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; const ErrorScreen({ super.key, this.errorId, this.errorCode, this.description, this.isProdOverride, this.sessionProfileLoader, }); @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) { unawaited(_loadSessionProfile()); } } 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 ''; } 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 internalWhitelistFallback = internalErrorWhitelistMessages[normalizedCode]; final isInternalWhitelisted = internalWhitelistFallback != 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 = isProd ? tr('msg.userfront.error.title') : (hasCode ? tr( 'msg.userfront.error.title_with_code', params: {'code': normalizedCode}, ) : tr('msg.userfront.error.title_generic')); final detail = isTenantAccessBlocked ? tr( 'msg.userfront.error.tenant.detail', fallback: '현재 로그인된 계정은 이 애플리케이션에 접근할 수 없습니다.', ) : isProd ? (isInternalWhitelisted ? tr( 'msg.userfront.error.whitelist.$normalizedCode', fallback: internalWhitelistFallback, ) : (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'))); final tenantLabel = _extractTenantLabel(_sessionProfile); final emailLabel = _sessionProfile?['email']?.toString().trim() ?? ''; return Scaffold( backgroundColor: colorScheme.surfaceContainerLowest, body: SafeArea( child: SingleChildScrollView( padding: const EdgeInsets.symmetric(vertical: 24), child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 560), child: Card( margin: const EdgeInsets.symmetric(horizontal: 24), 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: '접근 제한 정보', ), style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w700, ), ), const SizedBox(height: 12), if (_isLoadingSessionProfile) 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: '현재 계정 정보를 불러오는 중입니다.', ), style: theme.textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), ), ), ], ) else ...[ _InfoRow( label: tr( 'msg.userfront.error.tenant.account', fallback: '계정', ), value: emailLabel.isNotEmpty ? emailLabel : tr( 'msg.userfront.error.tenant.account_unknown', fallback: '알 수 없음', ), ), const SizedBox(height: 8), _InfoRow( label: tr( 'msg.userfront.error.tenant.tenant', fallback: '소속 테넌트', ), value: tenantLabel.isNotEmpty ? tenantLabel : tr( 'msg.userfront.error.tenant.tenant_unknown', fallback: '알 수 없음', ), ), if (_sessionProfileError != null && _sessionProfileError!.isNotEmpty) ...[ const SizedBox(height: 12), Text( tr( 'msg.userfront.error.tenant.load_failed', fallback: '계정 정보를 확인하지 못했습니다. 다시 시도해 주세요.', ), style: theme.textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), ), ], ], ], ), ), ], 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, ), ), ), ], ); } }