diff --git a/userfront/lib/features/dashboard/presentation/audit_device_utils.dart b/userfront/lib/features/dashboard/presentation/audit_device_utils.dart new file mode 100644 index 00000000..415eb865 --- /dev/null +++ b/userfront/lib/features/dashboard/presentation/audit_device_utils.dart @@ -0,0 +1,31 @@ +import 'package:userfront/features/dashboard/domain/models.dart'; + +const headlessServerUserAgentSentinel = '__headless_server__'; + +bool looksLikeInternalAuditUserAgent(String userAgent) { + final lower = userAgent.trim().toLowerCase(); + return lower.startsWith('go-http-client/') || + lower.startsWith('fasthttp') || + lower.startsWith('fiber') || + lower.startsWith('undici') || + lower.startsWith('node'); +} + +String preferredAuditLogUserAgent(AuditLogEntry log) { + final userAgent = log.userAgent.trim(); + final path = log.path.toLowerCase(); + + final isHeadlessLinkLog = + path.contains('/api/v1/auth/magic-link/verify') || + path.contains('/api/v1/auth/login/code/verify'); + final isHeadlessPasswordLog = path.contains( + '/api/v1/auth/headless/password/login', + ); + + if ((isHeadlessLinkLog || isHeadlessPasswordLog) && + looksLikeInternalAuditUserAgent(userAgent)) { + return headlessServerUserAgentSentinel; + } + + return userAgent; +} diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index 09649860..d9d7f8fb 100644 --- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart @@ -25,6 +25,7 @@ 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 { @@ -690,6 +691,9 @@ class _DashboardScreenState extends ConsumerState { 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)'); @@ -1234,6 +1238,9 @@ class _DashboardScreenState extends ConsumerState { } String _sessionBrowserLabel(String userAgent) { + if (userAgent == headlessServerUserAgentSentinel) { + return ''; + } final lower = userAgent.toLowerCase(); if (lower.isEmpty || _looksLikeInternalUserAgent(lower)) { return ''; @@ -2164,10 +2171,15 @@ class _DashboardScreenState extends ConsumerState { final authMethod = log.authMethod.isNotEmpty ? log.authMethod : _authMethodLabel(); - final deviceLabel = _deviceLabelFromUserAgent( - log.userAgent, + final preferredUserAgent = preferredAuditLogUserAgent( + log, + ); + final deviceLabel = _deviceLabelFromUserAgent( + preferredUserAgent, + ); + final browserLabel = _sessionBrowserLabel( + preferredUserAgent, ); - final browserLabel = _sessionBrowserLabel(log.userAgent); return DataRow( cells: [ DataCell( @@ -2387,7 +2399,9 @@ class _DashboardScreenState extends ConsumerState { tr( 'msg.userfront.audit.device', params: { - 'value': _deviceLabelFromUserAgent(log.userAgent), + 'value': _deviceLabelFromUserAgent( + preferredAuditLogUserAgent(log), + ), }, ), ), @@ -2395,9 +2409,14 @@ class _DashboardScreenState extends ConsumerState { tr( 'msg.userfront.audit.browser', params: { - 'value': _sessionBrowserLabel(log.userAgent).isEmpty + 'value': + _sessionBrowserLabel( + preferredAuditLogUserAgent(log), + ).isEmpty ? tr('ui.common.hyphen', fallback: '-') - : _sessionBrowserLabel(log.userAgent), + : _sessionBrowserLabel( + preferredAuditLogUserAgent(log), + ), }, ), ), diff --git a/userfront/test/audit_device_utils_test.dart b/userfront/test/audit_device_utils_test.dart new file mode 100644 index 00000000..a8ed345e --- /dev/null +++ b/userfront/test/audit_device_utils_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:userfront/features/dashboard/domain/models.dart'; +import 'package:userfront/features/dashboard/presentation/audit_device_utils.dart'; + +AuditLogEntry _log({ + required String eventType, + String userAgent = '', + Map? details, +}) { + return AuditLogEntry.fromJson({ + 'event_id': 'audit-1', + 'timestamp': '2026-04-14T00:00:00Z', + 'user_id': 'user-123', + 'event_type': eventType, + 'status': 'success', + 'user_agent': userAgent, + 'details': details == null ? '' : details.toString(), + }); +} + +void main() { + test('headless link login maps internal client user agent to sentinel', () { + final log = AuditLogEntry.fromJson({ + 'event_id': 'audit-1', + 'timestamp': '2026-04-14T00:00:00Z', + 'user_id': 'user-123', + 'event_type': 'POST /api/v1/auth/login/code/verify', + 'status': 'success', + 'user_agent': 'undici', + 'details': + '{"approved_user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/146.0.0.0 Safari/537.36"}', + }); + + expect(preferredAuditLogUserAgent(log), headlessServerUserAgentSentinel); + }); + + test( + 'headless password login maps internal client user agent to sentinel', + () { + final log = _log( + eventType: 'POST /api/v1/auth/headless/password/login', + userAgent: 'undici', + ); + + expect(preferredAuditLogUserAgent(log), headlessServerUserAgentSentinel); + }, + ); + + test('non-headless login preserves original browser user agent', () { + const browserUa = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/146.0.0.0 Safari/537.36'; + final log = _log( + eventType: 'POST /api/v1/auth/password/login', + userAgent: browserUa, + ); + + expect(preferredAuditLogUserAgent(log), browserUa); + }); +}