forked from baron/baron-sso
2578 lines
83 KiB
Dart
2578 lines
83 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:math' as math;
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_svg/flutter_svg.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
|
import '../domain/linked_rp_launch.dart';
|
|
import '../domain/session_time_resolver.dart';
|
|
import '../domain/providers/linked_rps_provider.dart';
|
|
import '../domain/providers/user_sessions_provider.dart';
|
|
import '../../../../core/notifiers/auth_notifier.dart';
|
|
import '../../../../core/services/auth_proxy_service.dart';
|
|
import '../../../../core/services/logout_service.dart';
|
|
import '../../../../core/services/auth_token_store.dart';
|
|
import '../../../../core/services/http_client.dart';
|
|
import '../../../../core/i18n/locale_utils.dart';
|
|
import '../../../../core/widgets/language_selector.dart';
|
|
import '../../../../core/widgets/theme_toggle_button.dart';
|
|
import '../../../../core/ui/layout_breakpoints.dart';
|
|
import '../../../../core/ui/toast_service.dart';
|
|
import '../../profile/domain/notifiers/profile_notifier.dart';
|
|
import '../domain/dashboard_providers.dart';
|
|
import '../domain/models.dart' hide LinkedRp;
|
|
import 'package:userfront/i18n.dart';
|
|
|
|
class DashboardScreen extends ConsumerStatefulWidget {
|
|
const DashboardScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<DashboardScreen> createState() => _DashboardScreenState();
|
|
}
|
|
|
|
class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|
static const double _dashboardCardSpacing = 12;
|
|
static const double _dashboardCardMaxWidth = 228;
|
|
static const double _activityDialogMaxWidth = 360;
|
|
static const double _historySessionMinWidth = 92;
|
|
static const double _historyOtherColumnsBaselineWidth = 780;
|
|
static const int _historySessionMinVisibleChars = 8;
|
|
static const double _historyDateColumnWidth = 132;
|
|
static const double _historyAppColumnWidth = 132;
|
|
static const double _historyIpColumnWidth = 118;
|
|
static const double _historyDeviceColumnWidth = 128;
|
|
static const double _historyBrowserColumnWidth = 112;
|
|
static const double _historyAuthMethodColumnWidth = 108;
|
|
static const double _historyResultColumnWidth = 88;
|
|
static const double _historyStatusColumnWidth = 92;
|
|
static const double _historyActionColumnWidth = 108;
|
|
|
|
final ScrollController _pageScrollController = ScrollController();
|
|
final ScrollController _rpScrollController = ScrollController();
|
|
final List<AuditLogEntry> _auditLogs = [];
|
|
String? _auditNextCursor;
|
|
bool _auditLoading = false;
|
|
bool _auditLoadingMore = false;
|
|
bool _isRevoking = false;
|
|
String? _revokingSessionId;
|
|
bool _redirectingToSignin = false;
|
|
bool _authBootstrapInProgress = false;
|
|
|
|
bool _showAllActivities = false;
|
|
bool _showActiveSessionsOnly = false;
|
|
bool _isDesktopSideMenuOpen = true;
|
|
final Set<String> _revokedClientIds = {};
|
|
|
|
Color get _ink => Theme.of(context).colorScheme.onSurface;
|
|
Color get _surface => Theme.of(context).colorScheme.surface;
|
|
Color get _border => Theme.of(context).colorScheme.outlineVariant;
|
|
Color get _subtle => Theme.of(context).colorScheme.surfaceContainerLowest;
|
|
|
|
String _renderTranslatedText(
|
|
String key, {
|
|
String? fallback,
|
|
Map<String, String> values = const {},
|
|
}) {
|
|
var text = tr(key, fallback: fallback);
|
|
values.forEach((name, value) {
|
|
text = text.replaceAll('{{$name}}', value).replaceAll('{$name}', value);
|
|
});
|
|
return text;
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_pageScrollController.addListener(_onPageScroll);
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
unawaited(_bootstrapAuthAndLoad());
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_pageScrollController.dispose();
|
|
_rpScrollController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _logout() async {
|
|
await LogoutService().logout();
|
|
}
|
|
|
|
Future<void> _onRevokeLink(String clientId, String appName) async {
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text(tr('ui.userfront.dashboard.revoke.title')),
|
|
content: Text(
|
|
tr(
|
|
'msg.userfront.dashboard.revoke.confirm',
|
|
params: {'app': appName},
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(false),
|
|
child: Text(tr('ui.common.cancel')),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(true),
|
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
|
child: Text(tr('ui.userfront.dashboard.revoke.confirm_button')),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (confirmed != true) return;
|
|
|
|
setState(() => _isRevoking = true);
|
|
try {
|
|
await ref.read(linkedRpsProvider.notifier).revokeRp(clientId);
|
|
if (mounted) {
|
|
ToastService.success(
|
|
tr(
|
|
'msg.userfront.dashboard.revoke.success',
|
|
params: {'app': appName},
|
|
),
|
|
);
|
|
setState(() {
|
|
_revokedClientIds.add(clientId);
|
|
});
|
|
ref.invalidate(linkedRpsProvider);
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ToastService.error(
|
|
tr('msg.userfront.dashboard.revoke.error', params: {'error': '$e'}),
|
|
);
|
|
}
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() => _isRevoking = false);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _onRevokeSession(UserSessionSummary session) async {
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text(tr('ui.userfront.dashboard.sessions.revoke.title')),
|
|
content: Text(
|
|
_renderTranslatedText(
|
|
'msg.userfront.dashboard.sessions.revoke.confirm',
|
|
values: {
|
|
'target': session.isCurrent
|
|
? tr('ui.userfront.dashboard.sessions.current_badge')
|
|
: _sessionDisplayLabel(session),
|
|
},
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(false),
|
|
child: Text(tr('ui.common.cancel')),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(true),
|
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
|
child: Text(tr('ui.userfront.dashboard.sessions.revoke.action')),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (confirmed != true) {
|
|
return;
|
|
}
|
|
|
|
setState(() => _revokingSessionId = session.sessionId);
|
|
try {
|
|
await ref
|
|
.read(userSessionsProvider.notifier)
|
|
.revokeSession(session.sessionId);
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
ToastService.success(
|
|
tr('msg.userfront.dashboard.sessions.revoke.success'),
|
|
);
|
|
} catch (e) {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
ToastService.error(
|
|
tr(
|
|
'msg.userfront.dashboard.sessions.revoke.error',
|
|
params: {'error': '$e'},
|
|
),
|
|
);
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() => _revokingSessionId = null);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _onScanQR() {
|
|
context.push('/scan');
|
|
}
|
|
|
|
void _onPageScroll() {
|
|
if (!_pageScrollController.hasClients) {
|
|
return;
|
|
}
|
|
if (_pageScrollController.position.extentAfter < 240) {
|
|
ref.read(authTimelineProvider.notifier).loadMore();
|
|
}
|
|
}
|
|
|
|
void _showRpDetails(_ActivityItem item) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => Consumer(
|
|
builder: (context, ref, _) {
|
|
final dialogWidth = math.min(
|
|
MediaQuery.sizeOf(context).width - 48,
|
|
_activityDialogMaxWidth,
|
|
);
|
|
final statusLabel = item.status == 'active'
|
|
? tr('ui.userfront.dashboard.activity.linked')
|
|
: tr('ui.userfront.dashboard.status.revoked');
|
|
final statusColor = _activityStatusColor(item.status);
|
|
|
|
return AlertDialog(
|
|
backgroundColor: _surface,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(24),
|
|
),
|
|
insetPadding: const EdgeInsets.symmetric(
|
|
horizontal: 24,
|
|
vertical: 24,
|
|
),
|
|
contentPadding: const EdgeInsets.fromLTRB(20, 20, 20, 8),
|
|
content: SizedBox(
|
|
width: dialogWidth,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: _subtle,
|
|
borderRadius: BorderRadius.circular(18),
|
|
border: Border.all(color: _border),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
item.appName,
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w700,
|
|
color: _ink,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
tr('ui.common.details'),
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.grey[600],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
_buildActivityDetailSection(
|
|
title: tr('ui.userfront.dashboard.status_history'),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: _buildActivityDetailField(
|
|
label: tr(
|
|
'ui.userfront.dashboard.link_status_label',
|
|
),
|
|
value: statusLabel,
|
|
valueColor: statusColor,
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: _buildActivityDetailField(
|
|
label: tr('ui.userfront.dashboard.last_auth_label'),
|
|
value: item.lastAuthAt,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
_buildActivityDetailSection(
|
|
title: tr('ui.userfront.dashboard.scopes.title'),
|
|
child: item.scopes.isEmpty
|
|
? Text(
|
|
tr('msg.userfront.dashboard.scopes.empty'),
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
color: Colors.grey[600],
|
|
),
|
|
)
|
|
: Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: item.scopes
|
|
.map(
|
|
(scope) => Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 10,
|
|
vertical: 8,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: _subtle,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: _border),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.shield_outlined,
|
|
size: 14,
|
|
color: _ink,
|
|
),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
scope,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
color: _ink,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
)
|
|
.toList(),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
|
actions: [
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: _ink,
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
backgroundColor: _subtle,
|
|
),
|
|
child: Text(
|
|
tr('ui.common.close'),
|
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildActivityDetailSection({
|
|
required String title,
|
|
required Widget child,
|
|
}) {
|
|
return Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(14),
|
|
decoration: BoxDecoration(
|
|
color: _surface,
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(color: _border),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
title,
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w700,
|
|
color: _ink,
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
child,
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildActivityDetailField({
|
|
required String label,
|
|
required String value,
|
|
Color? valueColor,
|
|
}) {
|
|
return Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: _subtle,
|
|
borderRadius: BorderRadius.circular(14),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.grey[600],
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
value,
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w700,
|
|
color: valueColor ?? _ink,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSideMenu(BuildContext context, {required bool closeOnTap}) {
|
|
return SafeArea(
|
|
child: Column(
|
|
children: [
|
|
Expanded(
|
|
child: ListView(
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
children: [
|
|
ListTile(
|
|
leading: const Icon(Icons.home_outlined),
|
|
title: Text(tr('ui.userfront.nav.dashboard')),
|
|
selected: true,
|
|
onTap: () {
|
|
if (closeOnTap) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
context.go(buildLocalizedHomePath(Uri.base));
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.person_outline),
|
|
title: Text(tr('ui.userfront.nav.profile')),
|
|
onTap: () {
|
|
if (closeOnTap) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
context.push('/profile');
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.qr_code_scanner),
|
|
title: Text(tr('ui.userfront.nav.qr_scan')),
|
|
onTap: () {
|
|
if (closeOnTap) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
_onScanQR();
|
|
},
|
|
),
|
|
const Divider(),
|
|
ListTile(
|
|
leading: const Icon(Icons.logout),
|
|
title: Text(tr('ui.userfront.nav.logout')),
|
|
onTap: () async {
|
|
if (closeOnTap) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
await _logout();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Padding(
|
|
padding: EdgeInsets.only(bottom: 16),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
ThemeToggleButton(),
|
|
SizedBox(height: 8),
|
|
LanguageSelector(compact: true),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _refreshAll() async {
|
|
if (!_isLoggedIn()) {
|
|
final recovered = await _recoverSessionFromCookie();
|
|
if (!recovered) {
|
|
_redirectToSignin();
|
|
return;
|
|
}
|
|
}
|
|
await ref.read(profileProvider.notifier).loadProfile();
|
|
setState(() {
|
|
_revokedClientIds.clear();
|
|
});
|
|
ref.invalidate(linkedRpsProvider);
|
|
ref.invalidate(userSessionsProvider);
|
|
|
|
await Future.wait([
|
|
ref.read(linkedRpsProvider.future),
|
|
ref.read(userSessionsProvider.future),
|
|
ref.read(authTimelineProvider.notifier).refresh(),
|
|
]);
|
|
|
|
await _loadAuditLogs(reset: true);
|
|
await ref.read(linkedRpsProvider.notifier).refresh();
|
|
}
|
|
|
|
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';
|
|
}
|
|
|
|
try {
|
|
final response = await client.get(url, headers: headers);
|
|
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);
|
|
} finally {
|
|
client.close();
|
|
}
|
|
}
|
|
|
|
Future<void> _loadAuditLogs({bool reset = false}) async {
|
|
if (!_isLoggedIn()) {
|
|
return;
|
|
}
|
|
if (_auditLoading || _auditLoadingMore) {
|
|
return;
|
|
}
|
|
final nextCursor = _auditNextCursor;
|
|
if (!reset && (nextCursor == null || nextCursor.isEmpty)) {
|
|
return;
|
|
}
|
|
|
|
if (reset) {
|
|
setState(() {
|
|
_auditLogs.clear();
|
|
_auditNextCursor = null;
|
|
_auditLoading = true;
|
|
});
|
|
} else {
|
|
setState(() {
|
|
_auditLoadingMore = true;
|
|
});
|
|
}
|
|
|
|
try {
|
|
final page = await _fetchAuditLogs(cursor: _auditNextCursor);
|
|
setState(() {
|
|
_auditLogs.addAll(page.items);
|
|
_auditNextCursor = page.nextCursor;
|
|
});
|
|
} catch (_) {
|
|
// 에러는 상위 UI에서 재시도 UX로 처리합니다.
|
|
} finally {
|
|
setState(() {
|
|
_auditLoading = false;
|
|
_auditLoadingMore = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
Widget _singleLineText(String text, {TextStyle? style}) {
|
|
return Text(
|
|
text,
|
|
style: style,
|
|
maxLines: 1,
|
|
softWrap: false,
|
|
overflow: TextOverflow.ellipsis,
|
|
textAlign: TextAlign.center,
|
|
);
|
|
}
|
|
|
|
String _authMethodLabel() {
|
|
if (AuthTokenStore.usesCookie()) {
|
|
return tr('ui.userfront.auth_method.ory');
|
|
}
|
|
final provider = AuthTokenStore.getProvider();
|
|
if (provider == null || provider.isEmpty) {
|
|
return tr('ui.userfront.auth_method.session');
|
|
}
|
|
final lower = provider.toLowerCase();
|
|
if (lower.contains('ory')) {
|
|
return tr('ui.userfront.auth_method.ory');
|
|
}
|
|
return provider;
|
|
}
|
|
|
|
String _deviceLabelFromUserAgent(String userAgent) {
|
|
if (userAgent.isEmpty) {
|
|
return tr('ui.common.hyphen', fallback: '-');
|
|
}
|
|
final ua = userAgent.toLowerCase();
|
|
if (ua.contains('iphone') || ua.contains('ipad') || ua.contains('ipod')) {
|
|
return tr('ui.userfront.device.ios', fallback: 'Mobile(iOS)');
|
|
}
|
|
if (ua.contains('android')) {
|
|
return tr('ui.userfront.device.android', fallback: 'Mobile(Android)');
|
|
}
|
|
if (ua.contains('windows')) {
|
|
return tr('ui.userfront.device.windows', fallback: 'Desktop(Windows)');
|
|
}
|
|
if (ua.contains('mac os x') || ua.contains('macintosh')) {
|
|
return tr('ui.userfront.device.macos', fallback: 'Desktop(macOS)');
|
|
}
|
|
if (ua.contains('linux')) {
|
|
return tr('ui.userfront.device.linux', fallback: 'Desktop(Linux)');
|
|
}
|
|
return tr('ui.common.unknown', fallback: '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 = [
|
|
tr(
|
|
'msg.userfront.dashboard.approved_device',
|
|
params: {'device': deviceLabel},
|
|
),
|
|
tr(
|
|
'msg.userfront.dashboard.approved_ip',
|
|
params: {'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
|
|
? tr('ui.userfront.dashboard.approved_session.userfront')
|
|
: tr('ui.userfront.dashboard.approved_session.default');
|
|
final tooltip = approvedSessionId.isEmpty
|
|
? tr(
|
|
'msg.userfront.dashboard.approved_session.none',
|
|
params: {'label': tooltipLabel},
|
|
)
|
|
: tr(
|
|
'msg.userfront.dashboard.approved_session.copy_click',
|
|
params: {'label': tooltipLabel, 'id': approvedSessionId},
|
|
);
|
|
return InkWell(
|
|
onTap: approvedSessionId.isEmpty
|
|
? null
|
|
: () async {
|
|
await Clipboard.setData(ClipboardData(text: approvedSessionId));
|
|
if (mounted) {
|
|
ToastService.info(
|
|
tr('msg.userfront.dashboard.session_id_copied'),
|
|
);
|
|
}
|
|
},
|
|
child: Tooltip(
|
|
message: tooltip,
|
|
child: Text(
|
|
isOidc ? authMethod : tr('ui.common.qr', fallback: '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(
|
|
tr(
|
|
'msg.userfront.dashboard.auth_method',
|
|
params: {'method': authMethod},
|
|
),
|
|
);
|
|
}
|
|
final deviceLabel = _deviceLabelFromUserAgent(approvedUserAgent);
|
|
final tooltip = [
|
|
tr(
|
|
'msg.userfront.dashboard.approved_device',
|
|
params: {'device': deviceLabel},
|
|
),
|
|
tr(
|
|
'msg.userfront.dashboard.approved_ip',
|
|
params: {'ip': approvedIp.isEmpty ? '-' : approvedIp},
|
|
),
|
|
].join('\n');
|
|
return Tooltip(
|
|
message: tooltip,
|
|
child: _selectableText(
|
|
tr(
|
|
'msg.userfront.dashboard.auth_method',
|
|
params: {'method': 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
|
|
? tr('ui.userfront.dashboard.approved_session.userfront')
|
|
: tr('ui.userfront.dashboard.approved_session.default');
|
|
return InkWell(
|
|
onTap: approvedSessionId.isEmpty
|
|
? null
|
|
: () async {
|
|
await Clipboard.setData(ClipboardData(text: approvedSessionId));
|
|
if (mounted) {
|
|
ToastService.info(
|
|
tr('msg.userfront.dashboard.session_id_copied'),
|
|
);
|
|
}
|
|
},
|
|
child: Tooltip(
|
|
message: approvedSessionId.isEmpty
|
|
? tr(
|
|
'msg.userfront.dashboard.approved_session.none',
|
|
params: {'label': tooltipLabel},
|
|
)
|
|
: tr(
|
|
'msg.userfront.dashboard.approved_session.copy_tap',
|
|
params: {'label': tooltipLabel, 'id': approvedSessionId},
|
|
),
|
|
child: Text(
|
|
tr(
|
|
'msg.userfront.dashboard.auth_method',
|
|
params: {
|
|
'method': isOidc
|
|
? authMethod
|
|
: tr('ui.common.qr', fallback: '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
|
|
? tr('msg.userfront.dashboard.client_id_missing')
|
|
: _renderTranslatedText(
|
|
'msg.userfront.dashboard.client_id',
|
|
fallback: 'Client ID: {{id}}',
|
|
values: {'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 tr('ui.userfront.app_label.baron');
|
|
}
|
|
if (path.startsWith('/api/v1/user')) {
|
|
return tr('ui.userfront.app_label.baron');
|
|
}
|
|
if (path.startsWith('/api/v1/dev')) {
|
|
return tr('ui.userfront.app_label.dev_console', fallback: 'Dev Console');
|
|
}
|
|
if (path.startsWith('/api/v1/admin')) {
|
|
return tr(
|
|
'ui.userfront.app_label.admin_console',
|
|
fallback: 'Admin Console',
|
|
);
|
|
}
|
|
return tr('ui.userfront.app_label.baron');
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
try {
|
|
if (!_isLoggedIn()) {
|
|
_redirectToSignin();
|
|
return const SizedBox.shrink();
|
|
}
|
|
final isWide = MediaQuery.of(context).size.width >= sideMenuBreakpoint;
|
|
final profileState = ref.watch(profileProvider);
|
|
final profile = profileState.value;
|
|
final timelineState = ref.watch(authTimelineProvider);
|
|
final userName =
|
|
profile?.name ??
|
|
profile?.email ??
|
|
profile?.phone ??
|
|
tr('ui.userfront.profile.user_fallback', fallback: 'User');
|
|
final departmentValue =
|
|
profile?.tenant?.name ?? profile?.department ?? '';
|
|
final department = departmentValue.isNotEmpty
|
|
? departmentValue
|
|
: tr('ui.userfront.profile.department_empty');
|
|
final sessionIssuedAt = resolveDashboardSessionIssuedAt(
|
|
token: AuthTokenStore.getToken(),
|
|
profile: profile,
|
|
);
|
|
|
|
return Scaffold(
|
|
backgroundColor: _subtle,
|
|
appBar: AppBar(
|
|
leading: isWide
|
|
? IconButton(
|
|
icon: Icon(
|
|
_isDesktopSideMenuOpen ? Icons.menu_open : Icons.menu,
|
|
),
|
|
tooltip: _isDesktopSideMenuOpen
|
|
? tr('ui.common.collapse')
|
|
: '펼치기',
|
|
onPressed: () {
|
|
setState(() {
|
|
_isDesktopSideMenuOpen = !_isDesktopSideMenuOpen;
|
|
});
|
|
},
|
|
)
|
|
: Builder(
|
|
builder: (context) => IconButton(
|
|
icon: const Icon(Icons.menu),
|
|
tooltip: MaterialLocalizations.of(
|
|
context,
|
|
).openAppDrawerTooltip,
|
|
onPressed: () => Scaffold.of(context).openDrawer(),
|
|
),
|
|
),
|
|
title: Text(
|
|
tr('ui.userfront.app_title'),
|
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
actions: [
|
|
const ThemeToggleButton(compact: true),
|
|
IconButton(
|
|
icon: const Icon(Icons.person_outline),
|
|
tooltip: tr('ui.userfront.nav.profile'),
|
|
onPressed: () => context.push('/profile'),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.qr_code_scanner),
|
|
tooltip: tr('ui.userfront.nav.qr_scan'),
|
|
onPressed: _onScanQR,
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.logout),
|
|
tooltip: tr('ui.userfront.nav.logout'),
|
|
onPressed: _logout,
|
|
),
|
|
],
|
|
),
|
|
drawer: isWide
|
|
? null
|
|
: Drawer(child: _buildSideMenu(context, closeOnTap: true)),
|
|
body: Row(
|
|
children: [
|
|
if (isWide && _isDesktopSideMenuOpen)
|
|
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(
|
|
tr('ui.userfront.sections.apps'),
|
|
tr('msg.userfront.sections.apps_subtitle'),
|
|
),
|
|
const SizedBox(height: 12),
|
|
_buildActivitySection(isMobile),
|
|
const SizedBox(height: 28),
|
|
_buildSectionTitle(
|
|
tr('ui.userfront.sections.audit'),
|
|
tr('msg.userfront.sections.audit_subtitle'),
|
|
),
|
|
const SizedBox(height: 12),
|
|
_buildAccessHistory(timelineState, timelineWide),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
} catch (error, stackTrace) {
|
|
AuthProxyService.logError(
|
|
'DASHBOARD_RENDER_ERROR: $error\nuri=${Uri.base}',
|
|
error: error,
|
|
stackTrace: stackTrace,
|
|
);
|
|
return Scaffold(
|
|
backgroundColor: _subtle,
|
|
body: Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Text(
|
|
tr(
|
|
'msg.userfront.dashboard.render_error',
|
|
fallback: '대시보드 렌더링 중 오류가 발생했습니다. 다시 시도해 주세요.',
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Widget _buildHeaderCard(
|
|
String userName,
|
|
String department,
|
|
DateTime? issuedAt,
|
|
) {
|
|
final sessionLabel = issuedAt != null
|
|
? _formatDateTime(issuedAt)
|
|
: tr('ui.userfront.session.unknown');
|
|
final infoColumn = Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
_renderTranslatedText(
|
|
'msg.userfront.greeting',
|
|
fallback: 'Hello, {{name}}.',
|
|
values: {'name': userName},
|
|
),
|
|
style: 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,
|
|
tr('ui.userfront.session.active'),
|
|
),
|
|
_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.withValues(alpha: 10),
|
|
blurRadius: 18,
|
|
offset: const Offset(0, 8),
|
|
),
|
|
],
|
|
),
|
|
child: infoColumn,
|
|
);
|
|
}
|
|
|
|
Widget _buildSectionTitle(String title, String subtitle) {
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Text(
|
|
title,
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w700,
|
|
color: _ink,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Text(subtitle, style: TextStyle(fontSize: 13, color: Colors.grey[600])),
|
|
],
|
|
);
|
|
}
|
|
|
|
String _sessionDisplayLabel(UserSessionSummary session) {
|
|
if (session.userAgent.trim().isNotEmpty) {
|
|
return _sessionUserAgentLabel(session.userAgent);
|
|
}
|
|
return tr('ui.userfront.dashboard.sessions.unknown_device');
|
|
}
|
|
|
|
String _sessionPrimaryLabel(UserSessionSummary session) {
|
|
final appLabel = _sessionAppLabel(session);
|
|
if (appLabel.isNotEmpty) {
|
|
return appLabel;
|
|
}
|
|
if (session.isCurrent) {
|
|
return 'UserFront';
|
|
}
|
|
return tr('ui.userfront.dashboard.sessions.unknown_session');
|
|
}
|
|
|
|
String _sessionClientLabel(UserSessionSummary session) {
|
|
return '';
|
|
}
|
|
|
|
String _sessionAppLabel(UserSessionSummary session) {
|
|
final appName = session.appName.trim();
|
|
if (appName.isNotEmpty) {
|
|
return appName;
|
|
}
|
|
final clientId = session.clientId.trim().toLowerCase();
|
|
if (clientId.isEmpty) {
|
|
return session.isCurrent ? 'UserFront' : '';
|
|
}
|
|
if (clientId.contains('adminfront')) {
|
|
return 'AdminFront';
|
|
}
|
|
if (clientId.contains('devfront')) {
|
|
return 'DevFront';
|
|
}
|
|
if (clientId.contains('userfront')) {
|
|
return 'UserFront';
|
|
}
|
|
if (clientId.contains('baron')) {
|
|
return tr('ui.userfront.app_label.baron');
|
|
}
|
|
return session.clientId.trim();
|
|
}
|
|
|
|
String _sessionUserAgentLabel(String userAgent) {
|
|
final lower = userAgent.toLowerCase();
|
|
if (lower.isEmpty) {
|
|
return tr('ui.userfront.dashboard.sessions.unknown_device');
|
|
}
|
|
if (_looksLikeInternalUserAgent(lower)) {
|
|
return '';
|
|
}
|
|
if (lower.contains('iphone') || lower.contains('ios')) {
|
|
return tr('ui.userfront.device.ios');
|
|
}
|
|
if (lower.contains('android')) {
|
|
return tr('ui.userfront.device.android');
|
|
}
|
|
if (lower.contains('windows')) {
|
|
return tr('ui.userfront.device.windows', fallback: 'Desktop(Windows)');
|
|
}
|
|
if (lower.contains('mac os') || lower.contains('macintosh')) {
|
|
return tr('ui.userfront.device.macos', fallback: 'Desktop(macOS)');
|
|
}
|
|
if (lower.contains('linux')) {
|
|
return tr('ui.userfront.device.linux');
|
|
}
|
|
return userAgent;
|
|
}
|
|
|
|
String _sessionBrowserLabel(String userAgent) {
|
|
final lower = userAgent.toLowerCase();
|
|
if (lower.isEmpty || _looksLikeInternalUserAgent(lower)) {
|
|
return '';
|
|
}
|
|
if (lower.contains('edg/')) {
|
|
return 'Edge';
|
|
}
|
|
if (lower.contains('chrome/') && !lower.contains('edg/')) {
|
|
return 'Chrome';
|
|
}
|
|
if (lower.contains('firefox/')) {
|
|
return 'Firefox';
|
|
}
|
|
if (lower.contains('safari/') && !lower.contains('chrome/')) {
|
|
return 'Safari';
|
|
}
|
|
if (lower.contains('samsungbrowser/')) {
|
|
return 'Samsung Internet';
|
|
}
|
|
if (lower.contains('flutter')) {
|
|
return 'Flutter';
|
|
}
|
|
return '';
|
|
}
|
|
|
|
String _sessionOsLabel(String userAgent) {
|
|
final lower = userAgent.toLowerCase();
|
|
if (lower.isEmpty || _looksLikeInternalUserAgent(lower)) {
|
|
return '';
|
|
}
|
|
if (lower.contains('iphone') || lower.contains('ios')) {
|
|
return 'iOS';
|
|
}
|
|
if (lower.contains('android')) {
|
|
return 'Android';
|
|
}
|
|
if (lower.contains('windows')) {
|
|
return 'Windows';
|
|
}
|
|
if (lower.contains('mac os') || lower.contains('macintosh')) {
|
|
return 'macOS';
|
|
}
|
|
if (lower.contains('linux')) {
|
|
return 'Linux';
|
|
}
|
|
return '';
|
|
}
|
|
|
|
bool _looksLikeInternalUserAgent(String userAgent) {
|
|
return userAgent.startsWith('go-http-client/') ||
|
|
userAgent.startsWith('fasthttp') ||
|
|
userAgent.startsWith('fiber');
|
|
}
|
|
|
|
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: TextStyle(
|
|
fontSize: 12,
|
|
color: _ink,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildActivitySection(bool isMobile) {
|
|
final linkedRpsState = ref.watch(linkedRpsProvider);
|
|
|
|
return linkedRpsState.when(
|
|
data: (linkedRps) {
|
|
final activities = _buildActivityItems(linkedRps);
|
|
|
|
if (activities.isEmpty) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
tr('msg.userfront.dashboard.activities.empty'),
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
color: Colors.grey[700],
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
tr('msg.userfront.dashboard.activities.empty_detail'),
|
|
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
return _buildActivityGrid(activities, isMobile);
|
|
},
|
|
loading: () => const SizedBox(
|
|
height: 100,
|
|
child: Center(child: CircularProgressIndicator()),
|
|
),
|
|
error: (error, stack) => Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
tr('msg.userfront.dashboard.activities.error'),
|
|
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
|
),
|
|
const SizedBox(height: 8),
|
|
TextButton(
|
|
onPressed: () => ref.read(linkedRpsProvider.notifier).refresh(),
|
|
child: Text(tr('ui.common.retry')),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
List<_ActivityItem> _buildActivityItems(List<LinkedRp> linkedRps) {
|
|
final items = <_ActivityItem>[];
|
|
for (final rp in linkedRps) {
|
|
final normalizedStatus = rp.status.toLowerCase();
|
|
// status가 'inactive'로 내려올 수 있으므로 이를 반영
|
|
final isActiveInApi =
|
|
normalizedStatus == 'active' || normalizedStatus == '';
|
|
final isRevoked = !isActiveInApi;
|
|
|
|
final lastAuthAt = rp.lastAuthenticatedAt;
|
|
final lastAuthLabel = lastAuthAt != null
|
|
? _formatDateTime(lastAuthAt)
|
|
: tr('ui.userfront.dashboard.activity.linked');
|
|
|
|
final statusCode = isRevoked ? 'revoked' : 'active';
|
|
final name = rp.name.isNotEmpty ? rp.name : rp.id;
|
|
|
|
items.add(
|
|
_ActivityItem(
|
|
clientId: rp.id,
|
|
appName: name,
|
|
logo: rp.logo.trim(),
|
|
lastAuthAt: lastAuthLabel,
|
|
status: statusCode,
|
|
scopes: rp.scopes,
|
|
isRevoked: isRevoked,
|
|
onRevoke: isRevoked ? null : () => _onRevokeLink(rp.id, name),
|
|
url: rp.url,
|
|
launchUrl: resolveLinkedRpLaunchUrl(rp),
|
|
lastAuthDateTime: rp.lastAuthenticatedAt,
|
|
),
|
|
);
|
|
}
|
|
|
|
// 정렬 로직 적용: 활성 우선 -> 최근 인증 최신순 -> 비활성
|
|
items.sort((a, b) {
|
|
final aActive = a.status == 'active';
|
|
final bActive = b.status == 'active';
|
|
|
|
if (aActive && !bActive) return -1;
|
|
if (!aActive && bActive) return 1;
|
|
|
|
// 둘 다 활성이거나 둘 다 비활성인 경우 최근 인증순 내림차순
|
|
final aLastAuth = a.lastAuthDateTime;
|
|
final bLastAuth = b.lastAuthDateTime;
|
|
if (aLastAuth != null && bLastAuth != null) {
|
|
return bLastAuth.compareTo(aLastAuth);
|
|
}
|
|
if (a.lastAuthDateTime != null) return -1;
|
|
if (b.lastAuthDateTime != null) return 1;
|
|
|
|
return 0;
|
|
});
|
|
|
|
return items;
|
|
}
|
|
|
|
Widget _buildActivityGrid(List<_ActivityItem> activities, bool isMobile) {
|
|
if (activities.isEmpty) return const SizedBox.shrink();
|
|
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final maxWidth = constraints.maxWidth;
|
|
|
|
final crossAxisCount = _dashboardCardColumnCount(maxWidth);
|
|
|
|
// 초기 표시 개수는 한 줄에 표시되는 개수와 동일하게 설정 (요청에 따라 유동적 조절 가능)
|
|
final int initialVisibleCount = crossAxisCount;
|
|
final shouldShowToggle = activities.length > initialVisibleCount;
|
|
|
|
List<_ActivityItem> visibleActivities;
|
|
if (_showAllActivities) {
|
|
visibleActivities = activities;
|
|
} else {
|
|
visibleActivities = activities.take(initialVisibleCount).toList();
|
|
}
|
|
|
|
final cardWidth = _dashboardCardWidth(maxWidth, crossAxisCount);
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Wrap(
|
|
spacing: _dashboardCardSpacing,
|
|
runSpacing: _dashboardCardSpacing,
|
|
children: visibleActivities.map((item) {
|
|
return SizedBox(
|
|
width: cardWidth,
|
|
child: _buildActivityCard(item, cardWidth: cardWidth),
|
|
);
|
|
}).toList(),
|
|
),
|
|
if (shouldShowToggle)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 16),
|
|
child: Align(
|
|
alignment: Alignment.centerRight,
|
|
child: TextButton.icon(
|
|
onPressed: () => setState(
|
|
() => _showAllActivities = !_showAllActivities,
|
|
),
|
|
icon: Icon(
|
|
_showAllActivities ? Icons.keyboard_arrow_up : Icons.add,
|
|
size: 18,
|
|
color: _showAllActivities
|
|
? Colors.grey
|
|
: Colors.blueAccent,
|
|
),
|
|
label: Text(
|
|
_showAllActivities
|
|
? tr('ui.common.collapse')
|
|
: tr('ui.common.show_more'),
|
|
style: TextStyle(
|
|
color: _showAllActivities
|
|
? Colors.grey
|
|
: Colors.blueAccent,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildActivityCard(_ActivityItem item, {double? cardWidth}) {
|
|
final isActive = item.status == 'active';
|
|
final statusColor = _activityStatusColor(item.status);
|
|
final borderColor = isActive
|
|
? Colors.green.withValues(alpha: 128)
|
|
: _border;
|
|
final borderWidth = isActive ? 1.5 : 1.0;
|
|
|
|
// 활성 상태면 클릭 가능 (URL 유무와 관계없이)
|
|
final isClickable = isActive;
|
|
|
|
// 카드 컨텐츠
|
|
final cardContent = Container(
|
|
width: cardWidth ?? 260,
|
|
padding: const EdgeInsets.all(14),
|
|
decoration: BoxDecoration(
|
|
color: _surface,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: borderColor, width: borderWidth),
|
|
boxShadow: isActive
|
|
? [
|
|
BoxShadow(
|
|
color: Colors.green.withValues(alpha: 13),
|
|
blurRadius: 10,
|
|
offset: const Offset(0, 4),
|
|
),
|
|
]
|
|
: null,
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildActivityCardHeader(item, statusColor),
|
|
const SizedBox(height: 10),
|
|
Text(
|
|
tr('ui.userfront.dashboard.last_auth_label'),
|
|
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
item.lastAuthAt,
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
color: _ink,
|
|
),
|
|
),
|
|
const SizedBox(height: 14),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: OutlinedButton(
|
|
onPressed: () => _showRpDetails(item),
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: _ink,
|
|
side: BorderSide(color: _border),
|
|
padding: const EdgeInsets.symmetric(vertical: 7),
|
|
),
|
|
child: Text(
|
|
tr('ui.common.details'),
|
|
style: const TextStyle(fontSize: 13),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: OutlinedButton(
|
|
onPressed: (_isRevoking || item.isRevoked)
|
|
? null
|
|
: item.onRevoke,
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: item.isRevoked
|
|
? Colors.grey
|
|
: Colors.redAccent,
|
|
side: BorderSide(
|
|
color: item.isRevoked ? Colors.grey : Colors.redAccent,
|
|
width: 0.5,
|
|
),
|
|
padding: const EdgeInsets.symmetric(vertical: 7),
|
|
),
|
|
child: _isRevoking && !item.isRevoked
|
|
? const SizedBox(
|
|
width: 14,
|
|
height: 14,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: Colors.redAccent,
|
|
),
|
|
)
|
|
: Text(
|
|
item.isRevoked
|
|
? tr('ui.userfront.dashboard.status.revoked')
|
|
: tr('ui.userfront.dashboard.revoke.title'),
|
|
style: const TextStyle(fontSize: 13),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
// Opacity 적용
|
|
final opaqueCard = Opacity(
|
|
opacity: item.isRevoked ? 0.6 : 1.0,
|
|
child: cardContent,
|
|
);
|
|
|
|
// 클릭 가능한 경우 InkWell로 감싸기
|
|
if (isClickable) {
|
|
return MouseRegion(
|
|
cursor: SystemMouseCursors.click,
|
|
child: GestureDetector(
|
|
onTap: () async {
|
|
final itemUrl = item.launchUrl;
|
|
if (itemUrl != null && itemUrl.isNotEmpty) {
|
|
final uri = Uri.parse(itemUrl);
|
|
final canOpen = await canLaunchUrl(uri);
|
|
if (!mounted) return;
|
|
if (canOpen) {
|
|
await launchUrl(uri);
|
|
return;
|
|
}
|
|
ToastService.error(tr('msg.userfront.dashboard.link_open_error'));
|
|
} else {
|
|
if (!mounted) return;
|
|
ToastService.info(tr('msg.userfront.dashboard.link_missing'));
|
|
}
|
|
},
|
|
child: opaqueCard,
|
|
),
|
|
);
|
|
}
|
|
|
|
return opaqueCard;
|
|
}
|
|
|
|
Widget _buildActivityCardHeader(_ActivityItem item, Color statusColor) {
|
|
final statusBadge = Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: statusColor,
|
|
borderRadius: BorderRadius.circular(999),
|
|
),
|
|
child: Text(
|
|
item.status == 'active'
|
|
? tr('ui.userfront.dashboard.activity.linked')
|
|
: tr('ui.userfront.dashboard.status.revoked'),
|
|
style: const TextStyle(
|
|
fontSize: 11,
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
);
|
|
|
|
return SizedBox(
|
|
height: 40,
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
if (item.logo.isNotEmpty) ...[
|
|
_buildActivityLogo(item.logo),
|
|
const SizedBox(width: 10),
|
|
],
|
|
Expanded(
|
|
child: Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: Text(
|
|
item.appName,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w600,
|
|
color: _ink,
|
|
height: 1.25,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
statusBadge,
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildActivityLogo(String logoUrl) {
|
|
return SizedBox(
|
|
width: 40,
|
|
height: 40,
|
|
child: _buildActivityLogoImage(logoUrl),
|
|
);
|
|
}
|
|
|
|
Widget _buildActivityLogoImage(String logoUrl) {
|
|
final isSvg = _isSvgLogoUrl(logoUrl);
|
|
return isSvg
|
|
? SvgPicture.network(
|
|
logoUrl,
|
|
fit: BoxFit.contain,
|
|
placeholderBuilder: (context) => _buildActivityLogoLoading(),
|
|
)
|
|
: Image.network(
|
|
logoUrl,
|
|
fit: BoxFit.contain,
|
|
errorBuilder: (context, error, stackTrace) {
|
|
return _buildActivityLogoFallback();
|
|
},
|
|
loadingBuilder: (context, child, loadingProgress) {
|
|
if (loadingProgress == null) {
|
|
return child;
|
|
}
|
|
return _buildActivityLogoLoading();
|
|
},
|
|
);
|
|
}
|
|
|
|
bool _isSvgLogoUrl(String logoUrl) {
|
|
final normalized = logoUrl.trim().toLowerCase();
|
|
if (normalized.isEmpty) {
|
|
return false;
|
|
}
|
|
final uri = Uri.tryParse(normalized);
|
|
final path = uri?.path.toLowerCase() ?? normalized;
|
|
return path.endsWith('.svg');
|
|
}
|
|
|
|
Widget _buildActivityLogoLoading() {
|
|
return Center(
|
|
child: SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: Colors.grey[400],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildActivityLogoFallback() {
|
|
return Icon(Icons.apps_rounded, size: 20, color: Colors.grey[500]);
|
|
}
|
|
|
|
Widget _buildAccessHistory(AuthTimelineState state, bool isWide) {
|
|
final sessionsState = ref.watch(userSessionsProvider);
|
|
if (state.isLoading && state.items.isEmpty) {
|
|
return _buildHistoryContainer(
|
|
child: const SizedBox(
|
|
height: 120,
|
|
child: Center(child: CircularProgressIndicator()),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (state.error != null && state.items.isEmpty) {
|
|
return _buildHistoryContainer(
|
|
child: SizedBox(
|
|
height: 120,
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(tr('msg.userfront.dashboard.audit_load_error')),
|
|
const SizedBox(height: 8),
|
|
TextButton(
|
|
onPressed: () =>
|
|
ref.read(authTimelineProvider.notifier).refresh(),
|
|
child: Text(tr('ui.common.retry')),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (sessionsState.isLoading && !sessionsState.hasValue) {
|
|
return _buildHistoryContainer(
|
|
child: const SizedBox(
|
|
height: 120,
|
|
child: Center(child: CircularProgressIndicator()),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (sessionsState.hasError && !sessionsState.hasValue) {
|
|
return _buildHistoryContainer(
|
|
child: SizedBox(
|
|
height: 120,
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(tr('msg.userfront.dashboard.sessions.error')),
|
|
const SizedBox(height: 8),
|
|
TextButton(
|
|
onPressed: () =>
|
|
ref.read(userSessionsProvider.notifier).refresh(),
|
|
child: Text(tr('ui.common.retry')),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
final sessions = sessionsState is AsyncData<List<UserSessionSummary>>
|
|
? sessionsState.value
|
|
: const <UserSessionSummary>[];
|
|
final Map<String, UserSessionSummary> sessionById = {
|
|
for (final session in sessions) session.sessionId.trim(): session,
|
|
};
|
|
final filteredItems = state.items.where((log) {
|
|
if (!_showActiveSessionsOnly) {
|
|
return true;
|
|
}
|
|
final status = _historySessionStatusForLog(log, sessionById);
|
|
return status != _HistorySessionStatus.inactive;
|
|
}).toList();
|
|
|
|
if (filteredItems.isEmpty) {
|
|
return _buildHistoryContainer(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildHistoryHeader(),
|
|
const SizedBox(height: 20),
|
|
Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
child: Text(
|
|
_showActiveSessionsOnly
|
|
? tr('msg.userfront.audit.filtered_empty')
|
|
: tr('msg.userfront.dashboard.audit_empty'),
|
|
style: TextStyle(color: Colors.grey[600]),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
if (isWide) {
|
|
return _buildHistoryTable(state, filteredItems, sessionById);
|
|
}
|
|
return _buildHistoryList(state, filteredItems, sessionById);
|
|
}
|
|
|
|
Widget _buildHistoryHeader() {
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
tr('ui.userfront.audit.filter.title'),
|
|
style: TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w700,
|
|
color: _ink,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
tr('msg.userfront.audit.filter.description'),
|
|
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
tr('ui.userfront.audit.filter.toggle_label'),
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
color: _ink,
|
|
),
|
|
),
|
|
const SizedBox(width: 2),
|
|
Transform.scale(
|
|
scale: 0.84,
|
|
alignment: Alignment.centerRight,
|
|
child: Switch(
|
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
value: _showActiveSessionsOnly,
|
|
onChanged: (value) {
|
|
setState(() {
|
|
_showActiveSessionsOnly = value;
|
|
});
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
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,
|
|
);
|
|
}
|
|
|
|
_HistorySessionStatus _historySessionStatusForLog(
|
|
AuditLogEntry log,
|
|
Map<String, UserSessionSummary> sessionById,
|
|
) {
|
|
final sessionId = log.sessionId.trim();
|
|
if (sessionId.isEmpty) {
|
|
return _HistorySessionStatus.inactive;
|
|
}
|
|
final session = sessionById[sessionId];
|
|
if (session == null) {
|
|
return _HistorySessionStatus.inactive;
|
|
}
|
|
if (session.isCurrent) {
|
|
return _HistorySessionStatus.current;
|
|
}
|
|
if (session.isActive) {
|
|
return _HistorySessionStatus.active;
|
|
}
|
|
return _HistorySessionStatus.inactive;
|
|
}
|
|
|
|
String _historySessionStatusLabel(_HistorySessionStatus status) {
|
|
switch (status) {
|
|
case _HistorySessionStatus.current:
|
|
return tr('ui.userfront.dashboard.sessions.current_badge');
|
|
case _HistorySessionStatus.active:
|
|
return tr('ui.userfront.dashboard.sessions.active_badge');
|
|
case _HistorySessionStatus.inactive:
|
|
return tr('ui.common.status.inactive');
|
|
}
|
|
}
|
|
|
|
Color _historySessionStatusColor(_HistorySessionStatus status) {
|
|
switch (status) {
|
|
case _HistorySessionStatus.current:
|
|
return Colors.blueGrey;
|
|
case _HistorySessionStatus.active:
|
|
return Colors.green;
|
|
case _HistorySessionStatus.inactive:
|
|
return Colors.grey;
|
|
}
|
|
}
|
|
|
|
Widget _buildHistoryStatusBadge(_HistorySessionStatus status) {
|
|
return SizedBox(
|
|
width: _historyStatusColumnWidth,
|
|
child: Center(
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: _historySessionStatusColor(status),
|
|
borderRadius: BorderRadius.circular(999),
|
|
),
|
|
child: Text(
|
|
_historySessionStatusLabel(status),
|
|
style: const TextStyle(
|
|
fontSize: 11,
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildHistorySessionActionCell(UserSessionSummary? session) {
|
|
if (session == null) {
|
|
return SizedBox(
|
|
width: _historyActionColumnWidth,
|
|
child: Center(
|
|
child: _selectableText(tr('ui.common.hyphen', fallback: '-')),
|
|
),
|
|
);
|
|
}
|
|
final isCurrent = session.isCurrent;
|
|
final canRevoke =
|
|
!isCurrent && _revokingSessionId == null && session.isActive;
|
|
return SizedBox(
|
|
width: _historyActionColumnWidth,
|
|
child: OutlinedButton(
|
|
onPressed: canRevoke ? () => _onRevokeSession(session) : null,
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: canRevoke ? Colors.redAccent : Colors.grey,
|
|
side: BorderSide(
|
|
color: canRevoke ? Colors.redAccent : Colors.grey,
|
|
width: 0.6,
|
|
),
|
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
|
),
|
|
child: _revokingSessionId == session.sessionId
|
|
? const SizedBox(
|
|
width: 14,
|
|
height: 14,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: Colors.redAccent,
|
|
),
|
|
)
|
|
: Text(
|
|
isCurrent
|
|
? tr('ui.userfront.dashboard.sessions.current_disabled')
|
|
: session.isActive
|
|
? tr('ui.userfront.dashboard.sessions.revoke.action')
|
|
: tr('ui.common.hyphen', fallback: '-'),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
int _dashboardCardColumnCount(double maxWidth) {
|
|
if (maxWidth > 1200) {
|
|
return 4;
|
|
}
|
|
if (maxWidth > 800) {
|
|
return 3;
|
|
}
|
|
return 2;
|
|
}
|
|
|
|
double _dashboardCardWidth(double maxWidth, int crossAxisCount) {
|
|
return math.min(
|
|
(maxWidth - (_dashboardCardSpacing * (crossAxisCount - 1))) /
|
|
crossAxisCount,
|
|
_dashboardCardMaxWidth,
|
|
);
|
|
}
|
|
|
|
Color _activityStatusColor(String status) {
|
|
return status == 'active' ? Colors.green : Colors.grey;
|
|
}
|
|
|
|
Widget _buildCenteredHistoryHeader(String label, {double? width}) {
|
|
return SizedBox(
|
|
width: width,
|
|
child: Center(child: Text(label, textAlign: TextAlign.center)),
|
|
);
|
|
}
|
|
|
|
Widget _buildCenteredHistoryCell(Widget child, {double? width}) {
|
|
return SizedBox(
|
|
width: width,
|
|
child: Center(child: child),
|
|
);
|
|
}
|
|
|
|
Widget _buildHistoryTable(
|
|
AuthTimelineState state,
|
|
List<AuditLogEntry> items,
|
|
Map<String, UserSessionSummary> sessionById,
|
|
) {
|
|
return _buildHistoryContainer(
|
|
child: Column(
|
|
children: [
|
|
_buildHistoryHeader(),
|
|
const SizedBox(height: 16),
|
|
LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final sessionColumnWidth = _historySessionColumnWidth(
|
|
constraints.maxWidth,
|
|
);
|
|
return SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(minWidth: constraints.maxWidth),
|
|
child: DataTable(
|
|
columnSpacing: 16,
|
|
horizontalMargin: 12,
|
|
columns: [
|
|
DataColumn(
|
|
label: _buildCenteredHistoryHeader(
|
|
tr(
|
|
'ui.userfront.audit.table.session_id',
|
|
fallback: 'Session ID',
|
|
),
|
|
width: sessionColumnWidth,
|
|
),
|
|
),
|
|
DataColumn(
|
|
label: _buildCenteredHistoryHeader(
|
|
tr('ui.userfront.audit.table.date'),
|
|
width: _historyDateColumnWidth,
|
|
),
|
|
),
|
|
DataColumn(
|
|
label: _buildCenteredHistoryHeader(
|
|
tr('ui.userfront.audit.table.app'),
|
|
width: _historyAppColumnWidth,
|
|
),
|
|
),
|
|
DataColumn(
|
|
label: _buildCenteredHistoryHeader(
|
|
tr('ui.userfront.audit.table.ip', fallback: 'IP'),
|
|
width: _historyIpColumnWidth,
|
|
),
|
|
),
|
|
DataColumn(
|
|
label: _buildCenteredHistoryHeader(
|
|
tr('ui.userfront.audit.table.device'),
|
|
width: _historyDeviceColumnWidth,
|
|
),
|
|
),
|
|
DataColumn(
|
|
label: _buildCenteredHistoryHeader(
|
|
tr('ui.userfront.audit.table.browser'),
|
|
width: _historyBrowserColumnWidth,
|
|
),
|
|
),
|
|
DataColumn(
|
|
label: _buildCenteredHistoryHeader(
|
|
tr('ui.userfront.audit.table.auth_method'),
|
|
width: _historyAuthMethodColumnWidth,
|
|
),
|
|
),
|
|
DataColumn(
|
|
label: _buildCenteredHistoryHeader(
|
|
tr('ui.userfront.audit.table.result'),
|
|
width: _historyResultColumnWidth,
|
|
),
|
|
),
|
|
DataColumn(
|
|
label: _buildCenteredHistoryHeader(
|
|
tr('ui.userfront.audit.table.status'),
|
|
width: _historyStatusColumnWidth,
|
|
),
|
|
),
|
|
DataColumn(
|
|
label: _buildCenteredHistoryHeader(
|
|
tr('ui.userfront.audit.table.action'),
|
|
width: _historyActionColumnWidth,
|
|
),
|
|
),
|
|
],
|
|
rows: items.map((log) {
|
|
final matchedSession = sessionById[log.sessionId.trim()];
|
|
final sessionStatus = _historySessionStatusForLog(
|
|
log,
|
|
sessionById,
|
|
);
|
|
final statusLabel = log.status == 'success'
|
|
? tr('ui.common.status.success')
|
|
: tr('ui.common.status.failure');
|
|
final statusColor = log.status == 'success'
|
|
? Colors.green
|
|
: Colors.redAccent;
|
|
final authMethod = log.authMethod.isNotEmpty
|
|
? log.authMethod
|
|
: _authMethodLabel();
|
|
final deviceLabel = _deviceLabelFromUserAgent(
|
|
log.userAgent,
|
|
);
|
|
final browserLabel = _sessionBrowserLabel(log.userAgent);
|
|
return DataRow(
|
|
cells: [
|
|
DataCell(
|
|
_buildCenteredHistoryCell(
|
|
_buildHistorySessionIdCell(
|
|
log.sessionId.isEmpty
|
|
? tr('ui.common.hyphen', fallback: '-')
|
|
: log.sessionId,
|
|
sessionColumnWidth,
|
|
),
|
|
width: sessionColumnWidth,
|
|
),
|
|
),
|
|
DataCell(
|
|
_buildCenteredHistoryCell(
|
|
_selectableText(_formatDateTime(log.timestamp)),
|
|
width: _historyDateColumnWidth,
|
|
),
|
|
),
|
|
DataCell(
|
|
_buildCenteredHistoryCell(
|
|
_buildAppCell(log),
|
|
width: _historyAppColumnWidth,
|
|
),
|
|
),
|
|
DataCell(
|
|
_buildCenteredHistoryCell(
|
|
_selectableText(
|
|
log.ipAddress.isEmpty
|
|
? tr('ui.common.hyphen', fallback: '-')
|
|
: log.ipAddress,
|
|
),
|
|
width: _historyIpColumnWidth,
|
|
),
|
|
),
|
|
DataCell(
|
|
_buildCenteredHistoryCell(
|
|
_singleLineText(deviceLabel),
|
|
width: _historyDeviceColumnWidth,
|
|
),
|
|
),
|
|
DataCell(
|
|
_buildCenteredHistoryCell(
|
|
_selectableText(
|
|
browserLabel.isEmpty
|
|
? tr('ui.common.hyphen', fallback: '-')
|
|
: browserLabel,
|
|
),
|
|
width: _historyBrowserColumnWidth,
|
|
),
|
|
),
|
|
DataCell(
|
|
_buildCenteredHistoryCell(
|
|
_buildAuthMethodCell(log, authMethod),
|
|
width: _historyAuthMethodColumnWidth,
|
|
),
|
|
),
|
|
DataCell(
|
|
_buildCenteredHistoryCell(
|
|
_selectableText(
|
|
statusLabel,
|
|
style: TextStyle(
|
|
color: statusColor,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
width: _historyResultColumnWidth,
|
|
),
|
|
),
|
|
DataCell(
|
|
_buildCenteredHistoryCell(
|
|
_buildHistoryStatusBadge(sessionStatus),
|
|
width: _historyStatusColumnWidth,
|
|
),
|
|
),
|
|
DataCell(
|
|
_buildCenteredHistoryCell(
|
|
_buildHistorySessionActionCell(matchedSession),
|
|
width: _historyActionColumnWidth,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}).toList(),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
_buildHistoryFooter(state),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
double _historySessionColumnWidth(double maxWidth) {
|
|
return math.min(
|
|
200.0,
|
|
math.max(
|
|
_historySessionMinWidth,
|
|
maxWidth - _historyOtherColumnsBaselineWidth,
|
|
),
|
|
);
|
|
}
|
|
|
|
String _compactSessionId(String sessionId) {
|
|
final parts = sessionId.split('-');
|
|
if (parts.length >= 4) {
|
|
return '${parts.take(3).join('-')}-...';
|
|
}
|
|
if (sessionId.length <= _historySessionMinVisibleChars) {
|
|
return sessionId;
|
|
}
|
|
return '${sessionId.substring(0, _historySessionMinVisibleChars)}...';
|
|
}
|
|
|
|
Widget _buildHistorySessionIdCell(String sessionId, double columnWidth) {
|
|
final displayText = _compactSessionId(sessionId);
|
|
final textWidget = Text(
|
|
displayText,
|
|
maxLines: 1,
|
|
softWrap: false,
|
|
overflow: TextOverflow.ellipsis,
|
|
textAlign: TextAlign.center,
|
|
);
|
|
|
|
if (displayText == sessionId || sessionId.isEmpty) {
|
|
return textWidget;
|
|
}
|
|
return Tooltip(message: sessionId, child: textWidget);
|
|
}
|
|
|
|
Widget _buildHistoryList(
|
|
AuthTimelineState state,
|
|
List<AuditLogEntry> items,
|
|
Map<String, UserSessionSummary> sessionById,
|
|
) {
|
|
return _buildHistoryContainer(
|
|
child: Column(
|
|
children: [
|
|
_buildHistoryHeader(),
|
|
const SizedBox(height: 16),
|
|
for (final log in items)
|
|
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: [
|
|
_buildHistoryStatusBadge(
|
|
_historySessionStatusForLog(log, sessionById),
|
|
),
|
|
const Spacer(),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _buildAppCell(
|
|
log,
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w600,
|
|
color: _ink,
|
|
),
|
|
),
|
|
),
|
|
_selectableText(
|
|
log.status == 'success'
|
|
? tr('ui.common.status.success')
|
|
: tr('ui.common.status.failure'),
|
|
style: TextStyle(
|
|
color: log.status == 'success'
|
|
? Colors.green
|
|
: Colors.redAccent,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 6),
|
|
_selectableText(
|
|
_renderTranslatedText(
|
|
'msg.userfront.audit.session_id',
|
|
fallback: 'Session ID: {{value}}',
|
|
values: {
|
|
'value': log.sessionId.isEmpty
|
|
? tr('ui.common.hyphen', fallback: '-')
|
|
: log.sessionId,
|
|
},
|
|
),
|
|
),
|
|
_selectableText(
|
|
tr(
|
|
'msg.userfront.audit.date',
|
|
params: {'value': _formatDateTime(log.timestamp)},
|
|
),
|
|
),
|
|
_selectableText(
|
|
tr(
|
|
'msg.userfront.audit.ip',
|
|
params: {
|
|
'value': log.ipAddress.isEmpty
|
|
? tr('ui.common.hyphen', fallback: '-')
|
|
: log.ipAddress,
|
|
},
|
|
),
|
|
),
|
|
_selectableText(
|
|
tr(
|
|
'msg.userfront.audit.device',
|
|
params: {
|
|
'value': _deviceLabelFromUserAgent(log.userAgent),
|
|
},
|
|
),
|
|
),
|
|
_selectableText(
|
|
tr(
|
|
'msg.userfront.audit.browser',
|
|
params: {
|
|
'value': _sessionBrowserLabel(log.userAgent).isEmpty
|
|
? tr('ui.common.hyphen', fallback: '-')
|
|
: _sessionBrowserLabel(log.userAgent),
|
|
},
|
|
),
|
|
),
|
|
_buildAuthMethodLine(
|
|
log,
|
|
log.authMethod.isNotEmpty
|
|
? log.authMethod
|
|
: _authMethodLabel(),
|
|
),
|
|
_selectableText(
|
|
tr(
|
|
'msg.userfront.audit.result',
|
|
params: {
|
|
'value': log.status == 'success'
|
|
? tr('ui.common.status.success')
|
|
: tr('ui.common.status.failure'),
|
|
},
|
|
),
|
|
),
|
|
_selectableText(
|
|
tr(
|
|
'msg.userfront.audit.status',
|
|
params: {
|
|
'value': _historySessionStatusLabel(
|
|
_historySessionStatusForLog(log, sessionById),
|
|
),
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
_buildHistorySessionActionCell(
|
|
sessionById[log.sessionId.trim()],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
_buildHistoryFooter(state),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildHistoryFooter(AuthTimelineState state) {
|
|
if (state.isLoadingMore) {
|
|
return const Padding(
|
|
padding: EdgeInsets.only(top: 8),
|
|
child: Center(child: CircularProgressIndicator()),
|
|
);
|
|
}
|
|
if (state.error != null) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(top: 8),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(tr('msg.userfront.audit.load_more_error')),
|
|
TextButton(
|
|
onPressed: () =>
|
|
ref.read(authTimelineProvider.notifier).loadMore(),
|
|
child: Text(tr('ui.common.retry')),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
final nextCursor = state.nextCursor;
|
|
if (nextCursor == null || nextCursor.isEmpty) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(top: 8),
|
|
child: Text(
|
|
tr('msg.userfront.audit.end'),
|
|
style: TextStyle(color: Colors.grey[600], fontSize: 12),
|
|
),
|
|
);
|
|
}
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
bool _isLoggedIn() {
|
|
final token = AuthTokenStore.getToken();
|
|
return (token != null && token.isNotEmpty) || AuthTokenStore.usesCookie();
|
|
}
|
|
|
|
void _redirectToSignin() {
|
|
if (!mounted || _redirectingToSignin) {
|
|
return;
|
|
}
|
|
_redirectingToSignin = true;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
Uri uri;
|
|
try {
|
|
uri = GoRouterState.of(context).uri;
|
|
} catch (_) {
|
|
uri = Uri.base;
|
|
}
|
|
final localeCode =
|
|
extractLocaleFromPath(uri) ?? resolvePreferredLocaleCode();
|
|
context.go('/$localeCode/signin');
|
|
_redirectingToSignin = false;
|
|
});
|
|
}
|
|
|
|
Future<void> _bootstrapAuthAndLoad() async {
|
|
if (!mounted || _authBootstrapInProgress) {
|
|
return;
|
|
}
|
|
_authBootstrapInProgress = true;
|
|
try {
|
|
var authenticated = _isLoggedIn();
|
|
if (!authenticated) {
|
|
authenticated = await _recoverSessionFromCookie();
|
|
}
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
if (!authenticated) {
|
|
_redirectToSignin();
|
|
return;
|
|
}
|
|
await _loadAuditLogs(reset: true);
|
|
} finally {
|
|
_authBootstrapInProgress = false;
|
|
}
|
|
}
|
|
|
|
Future<bool> _recoverSessionFromCookie() async {
|
|
try {
|
|
await AuthProxyService.checkCookieSession();
|
|
final provider =
|
|
AuthTokenStore.getProvider() ??
|
|
AuthTokenStore.getPendingProvider() ??
|
|
'ory';
|
|
AuthTokenStore.setCookieMode(provider: provider);
|
|
AuthTokenStore.clearPendingProvider();
|
|
AuthNotifier.instance.notify();
|
|
try {
|
|
await ref.read(profileProvider.notifier).loadProfile();
|
|
} catch (_) {}
|
|
return true;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
enum _HistorySessionStatus { current, active, inactive }
|
|
|
|
class _ActivityItem {
|
|
final String clientId;
|
|
final String appName;
|
|
final String logo;
|
|
final String lastAuthAt;
|
|
final String status;
|
|
final String? url;
|
|
final String? launchUrl;
|
|
final List<String> scopes;
|
|
final bool isRevoked;
|
|
final VoidCallback? onRevoke;
|
|
final DateTime? lastAuthDateTime;
|
|
|
|
_ActivityItem({
|
|
required this.clientId,
|
|
required this.appName,
|
|
required this.logo,
|
|
required this.lastAuthAt,
|
|
required this.status,
|
|
required this.scopes,
|
|
this.url,
|
|
this.launchUrl,
|
|
this.isRevoked = false,
|
|
this.onRevoke,
|
|
this.lastAuthDateTime,
|
|
});
|
|
}
|