import 'dart:async'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import '../../../core/constants/error_whitelist.dart'; import '../../../core/i18n/locale_utils.dart'; import '../../../core/services/auth_proxy_service.dart'; import '../../../core/services/logout_service.dart'; import '../../../core/widgets/theme_toggle_button.dart'; import 'package:userfront/i18n.dart'; class ErrorScreen extends StatefulWidget { final String? errorId; final String? errorCode; final String? description; final bool? isProdOverride; final Future> Function()? sessionProfileLoader; final Map? tenantAccessDetails; const ErrorScreen({ super.key, this.errorId, this.errorCode, this.description, this.isProdOverride, this.sessionProfileLoader, this.tenantAccessDetails, }); @override State createState() => _ErrorScreenState(); } class _ErrorScreenState extends State { Map? _sessionProfile; bool _isLoadingSessionProfile = false; String? _sessionProfileError; bool get _isTenantAccessBlocked => (widget.errorCode ?? '').trim() == 'tenant_not_allowed'; @override void initState() { super.initState(); if (_isTenantAccessBlocked && _shouldLoadSessionProfile()) { unawaited(_loadSessionProfile()); } } Map? get _tenantAccessDetails => widget.tenantAccessDetails; bool _shouldLoadSessionProfile() { final details = _tenantAccessDetails; if (details == null) { return true; } final hasAccount = _extractAccountEmail(details).isNotEmpty; final hasTenant = _extractCurrentTenantLabel(details).isNotEmpty; return !hasAccount || !hasTenant; } Future _loadSessionProfile() async { if (_isLoadingSessionProfile) { return; } setState(() { _isLoadingSessionProfile = true; _sessionProfileError = null; }); try { final loader = widget.sessionProfileLoader ?? AuthProxyService.getMe; final profile = await loader(); if (!mounted) { return; } setState(() { _sessionProfile = profile; }); } catch (error) { if (!mounted) { return; } setState(() { _sessionProfileError = error.toString(); }); } finally { if (mounted) { setState(() { _isLoadingSessionProfile = false; }); } } } String _extractTenantLabel(Map? profile) { if (profile == null) { return ''; } final tenant = profile['tenant']; if (tenant is Map) { final name = tenant['name']?.toString().trim() ?? ''; if (name.isNotEmpty) { return name; } final slug = tenant['slug']?.toString().trim() ?? ''; if (slug.isNotEmpty) { return slug; } } final joinedTenants = profile['joinedTenants']; if (joinedTenants is List) { for (final item in joinedTenants) { if (item is Map) { final name = item['name']?.toString().trim() ?? ''; if (name.isNotEmpty) { return name; } final slug = item['slug']?.toString().trim() ?? ''; if (slug.isNotEmpty) { return slug; } } } } final companyCode = profile['companyCode']?.toString().trim() ?? ''; if (companyCode.isNotEmpty) { return companyCode; } return ''; } String _extractCurrentTenantLabel(Map? details) { if (details == null) { return ''; } final tenant = details['current_tenant']; if (tenant is! Map) { return ''; } final name = tenant['name']?.toString().trim() ?? ''; if (name.isNotEmpty) { return name; } final slug = tenant['slug']?.toString().trim() ?? ''; if (slug.isNotEmpty) { return slug; } final identifier = tenant['identifier']?.toString().trim() ?? ''; if (identifier.isNotEmpty) { return identifier; } final id = tenant['id']?.toString().trim() ?? ''; return id; } String _extractAccountEmail(Map? details) { if (details == null) { return ''; } final account = details['account']; if (account is! Map) { return ''; } return account['email']?.toString().trim() ?? ''; } List _extractAllowedTenantLabels(Map? details) { if (details == null) { return const []; } final raw = details['allowed_tenants']; if (raw is! List) { return const []; } final labels = []; for (final item in raw) { if (item is! Map) { continue; } final label = item['name']?.toString().trim() ?? item['slug']?.toString().trim() ?? item['identifier']?.toString().trim() ?? item['id']?.toString().trim() ?? ''; if (label.isNotEmpty) { labels.add(label); } } return labels; } List _extractAffiliatedTenantLabelsFromDetails( Map? details, ) { if (details == null) { return const []; } final raw = details['affiliated_tenants']; if (raw is! List) { return const []; } final labels = []; for (final item in raw) { if (item is! Map) { continue; } final label = item['name']?.toString().trim() ?? item['slug']?.toString().trim() ?? item['identifier']?.toString().trim() ?? item['id']?.toString().trim() ?? ''; if (label.isNotEmpty) { labels.add(label); } } return labels; } List _extractAffiliatedTenantLabelsFromProfile( Map? profile, ) { if (profile == null) { return const []; } final labels = []; final seen = {}; void appendLabel(String value) { final trimmed = value.trim(); if (trimmed.isEmpty || seen.contains(trimmed)) { return; } seen.add(trimmed); labels.add(trimmed); } final joinedTenants = profile['joinedTenants']; if (joinedTenants is List) { for (final item in joinedTenants) { if (item is Map) { appendLabel(item['name']?.toString() ?? ''); appendLabel(item['slug']?.toString() ?? ''); } } } final tenant = _extractTenantLabel(profile); if (tenant.isNotEmpty) { appendLabel(tenant); } final companyCode = profile['companyCode']?.toString().trim() ?? ''; if (companyCode.isNotEmpty) { appendLabel(companyCode); } return labels; } Future _switchAccount() async { await LogoutService().logout(); if (!mounted) { return; } context.go(buildLocalizedSigninPath(Uri.base)); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; final isProd = widget.isProdOverride ?? AuthProxyService.isProdEnv; final normalizedCode = (widget.errorCode ?? '').trim(); final hasCode = normalizedCode.isNotEmpty; final internalWhitelistKey = internalErrorWhitelistMessageKeys[normalizedCode]; final isInternalWhitelisted = internalWhitelistKey != null; final isOryBypass = hasCode && oryBypassErrorCodes.contains(normalizedCode); final isKnownProdCode = hasCode && (isInternalWhitelisted || isOryBypass); final isTenantAccessBlocked = normalizedCode == 'tenant_not_allowed'; final errorType = isProd ? (isKnownProdCode ? normalizedCode : 'unknown_error') : (hasCode ? normalizedCode : 'unknown_error'); final title = isTenantAccessBlocked ? tr( 'msg.userfront.error.tenant.page_title', fallback: 'Application access is restricted', ) : isProd ? tr('msg.userfront.error.title') : (hasCode ? tr( 'msg.userfront.error.title_with_code', params: {'code': normalizedCode}, ) : tr('msg.userfront.error.title_generic')); final tenantLabelFromDetails = _extractCurrentTenantLabel( _tenantAccessDetails, ); final tenantLabel = tenantLabelFromDetails.isNotEmpty ? tenantLabelFromDetails : _extractTenantLabel(_sessionProfile); final emailFromDetails = _extractAccountEmail(_tenantAccessDetails); final emailLabel = emailFromDetails.isNotEmpty ? emailFromDetails : (_sessionProfile?['email']?.toString().trim() ?? ''); final affiliatedTenantLabels = _extractAffiliatedTenantLabelsFromDetails( _tenantAccessDetails, ).isNotEmpty ? _extractAffiliatedTenantLabelsFromDetails(_tenantAccessDetails) : _extractAffiliatedTenantLabelsFromProfile(_sessionProfile); final allowedTenantLabels = _extractAllowedTenantLabels( _tenantAccessDetails, ); final isLoadingTenantContext = _isLoadingSessionProfile && _tenantAccessDetails == null; final hasTenantLookupFailure = _sessionProfileError != null && _sessionProfileError!.isNotEmpty && _tenantAccessDetails == null; final showTenantLookupFallback = _tenantAccessDetails == null && (emailLabel.isEmpty || tenantLabel.isEmpty); final detail = isTenantAccessBlocked ? tr( 'msg.userfront.error.tenant.detail', fallback: 'The current signed-in account cannot access this application.', ) : isProd ? (isInternalWhitelisted ? tr(internalWhitelistKey!) : (isOryBypass ? tr( 'msg.userfront.error.ory.$normalizedCode', fallback: (widget.description?.isNotEmpty == true) ? widget.description : tr('msg.userfront.error.detail_request'), ) : tr('msg.userfront.error.detail_contact'))) : ((widget.description?.isNotEmpty == true) ? widget.description! : (hasCode ? tr('msg.userfront.error.detail_generic') : tr('msg.userfront.error.detail_request'))); return Scaffold( backgroundColor: colorScheme.surfaceContainerLowest, body: SafeArea( child: LayoutBuilder( builder: (context, constraints) => SingleChildScrollView( padding: const EdgeInsets.all(24), child: ConstrainedBox( constraints: BoxConstraints( minHeight: constraints.maxHeight - 48, ), child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 560), child: Card( margin: EdgeInsets.zero, elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), side: BorderSide(color: colorScheme.outlineVariant), ), child: Padding( padding: const EdgeInsets.fromLTRB(28, 28, 28, 24), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text( title, style: theme.textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w700, color: colorScheme.onSurface, ), ), ), const ThemeToggleButton(compact: true), ], ), const SizedBox(height: 12), Text( detail, style: theme.textTheme.bodyMedium?.copyWith( color: colorScheme.onSurfaceVariant, height: 1.5, ), ), if (isTenantAccessBlocked) ...[ const SizedBox(height: 16), Container( width: double.infinity, padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: colorScheme.surface, borderRadius: BorderRadius.circular(12), border: Border.all( color: colorScheme.outlineVariant, ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( tr( 'msg.userfront.error.tenant.title', fallback: 'Access restriction details', ), style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w700, ), ), const SizedBox(height: 12), if (isLoadingTenantContext) Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, color: colorScheme.primary, ), ), const SizedBox(width: 10), Flexible( child: Text( tr( 'msg.userfront.error.tenant.loading', fallback: 'Loading the current account details.', ), style: theme.textTheme.bodySmall ?.copyWith( color: colorScheme .onSurfaceVariant, ), ), ), ], ) else ...[ _InfoRow( label: tr( 'msg.userfront.error.tenant.account', fallback: 'Account', ), value: emailLabel.isNotEmpty ? emailLabel : tr( 'msg.userfront.error.tenant.account_unknown', fallback: 'Unknown', ), ), const SizedBox(height: 8), _InfoRow( label: tr( 'msg.userfront.error.tenant.primary_tenant', fallback: 'Primary affiliated tenant', ), value: tenantLabel.isNotEmpty ? tenantLabel : tr( 'msg.userfront.error.tenant.tenant_unknown', fallback: 'Unknown', ), ), const SizedBox(height: 8), _InfoRow( label: tr( 'msg.userfront.error.tenant.affiliated_tenants', fallback: 'All affiliated tenants', ), value: affiliatedTenantLabels.isNotEmpty ? affiliatedTenantLabels.join(', ') : tr( 'msg.userfront.error.tenant.tenant_unknown', fallback: 'Unknown', ), ), if (showTenantLookupFallback) ...[ const SizedBox(height: 12), Text( tr( 'msg.userfront.error.tenant.lookup_fallback', fallback: 'Some fields may be unavailable because there is not enough profile information to display.', ), style: theme.textTheme.bodySmall ?.copyWith( color: colorScheme.onSurfaceVariant, ), ), ], if (hasTenantLookupFailure) ...[ const SizedBox(height: 12), Text( tr( 'msg.userfront.error.tenant.load_failed', fallback: 'Failed to load account details. Please try again.', ), style: theme.textTheme.bodySmall ?.copyWith( color: colorScheme.onSurfaceVariant, ), ), ], ], ], ), ), const SizedBox(height: 12), Container( width: double.infinity, padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: colorScheme.surface, borderRadius: BorderRadius.circular(12), border: Border.all( color: colorScheme.outlineVariant, ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( tr( 'msg.userfront.error.tenant.allowed_box_title', fallback: 'Allowed tenants', ), style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w700, ), ), const SizedBox(height: 12), if (allowedTenantLabels.isNotEmpty) ...[ _InfoRow( label: tr( 'msg.userfront.error.tenant.allowed_tenants', fallback: 'Allowed tenants', ), value: allowedTenantLabels.join(', '), ), ] else ...[ _InfoRow( label: tr( 'msg.userfront.error.tenant.allowed_tenants', fallback: 'Allowed tenants', ), value: tr( 'msg.userfront.error.tenant.tenant_unknown', fallback: 'Unknown', ), ), ], ], ), ), ], const SizedBox(height: 12), Text( tr( 'msg.userfront.error.type', params: {'type': errorType}, ), style: theme.textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), ), if (widget.errorId != null && widget.errorId!.isNotEmpty) ...[ const SizedBox(height: 12), Text( tr( 'msg.userfront.error.id', params: {'id': widget.errorId!}, ), style: theme.textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), ), ], const SizedBox(height: 20), Wrap( spacing: 12, runSpacing: 12, children: [ ElevatedButton( onPressed: isTenantAccessBlocked ? _switchAccount : () => context.go('/login'), style: ElevatedButton.styleFrom( backgroundColor: colorScheme.primary, foregroundColor: colorScheme.onPrimary, padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 12, ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), ), child: Text( isTenantAccessBlocked ? tr('ui.userfront.error.switch_account') : tr('ui.userfront.error.go_login'), ), ), OutlinedButton( onPressed: () => context.go( buildLocalizedHomePath(Uri.base), ), style: OutlinedButton.styleFrom( foregroundColor: colorScheme.onSurface, padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 12, ), side: BorderSide(color: colorScheme.outline), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), ), child: Text(tr('ui.userfront.error.go_home')), ), ], ), ], ), ), ), ), ), ), ), ), ), ); } } class _InfoRow extends StatelessWidget { final String label; final String value; const _InfoRow({required this.label, required this.value}); @override Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 100, child: Text( label, style: theme.textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, fontWeight: FontWeight.w600, ), ), ), Expanded( child: Text( value, style: theme.textTheme.bodySmall?.copyWith( color: colorScheme.onSurface, ), ), ), ], ); } }