1
0
forked from baron/baron-sso
Files
baron-sso/userfront/lib/features/dashboard/presentation/dashboard_screen.dart

2456 lines
80 KiB
Dart

import 'dart:async';
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 '../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/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 'audit_device_utils.dart';
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();
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 ref.read(linkedRpsProvider.notifier).refresh();
}
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: '-');
}
if (userAgent == headlessServerUserAgentSentinel) {
return 'Headless(Server)';
}
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.trim().isNotEmpty ?? false)
? profile!.name
: (profile?.email.trim().isNotEmpty ?? false)
? profile!.email
: (profile?.phone.trim().isNotEmpty ?? false)
? 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 _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) {
if (userAgent == headlessServerUserAgentSentinel) {
return '';
}
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 '';
}
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),
autoLoginSupported: rp.autoLoginSupported,
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,
),
),
if (item.autoLoginSupported) ...[
const SizedBox(height: 8),
Text(
tr(
'msg.userfront.dashboard.auto_login_supported',
fallback: '연동앱 클릭 시 별도 로그인 없이 로그인할 수 있습니다.',
),
style: TextStyle(fontSize: 12, color: Colors.green[700]),
),
],
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,
),
)
: _singleLineText(
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 preferredUserAgent = preferredAuditLogUserAgent(
log,
);
final deviceLabel = _deviceLabelFromUserAgent(
preferredUserAgent,
);
final browserLabel = _sessionBrowserLabel(
preferredUserAgent,
);
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(
preferredAuditLogUserAgent(log),
),
},
),
),
_selectableText(
tr(
'msg.userfront.audit.browser',
params: {
'value':
_sessionBrowserLabel(
preferredAuditLogUserAgent(log),
).isEmpty
? tr('ui.common.hyphen', fallback: '-')
: _sessionBrowserLabel(
preferredAuditLogUserAgent(log),
),
},
),
),
_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;
}
} 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 bool autoLoginSupported;
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.autoLoginSupported = false,
this.isRevoked = false,
this.onRevoke,
this.lastAuthDateTime,
});
}