From d0340fc0620a8f28b2aa9f5aec2a3c56caf13980 Mon Sep 17 00:00:00 2001 From: kyy Date: Tue, 28 Apr 2026 10:57:44 +0900 Subject: [PATCH] =?UTF-8?q?=ED=85=8C=EB=84=8C=ED=8A=B8=20=EC=A0=91?= =?UTF-8?q?=EA=B7=BC=20=EC=A0=9C=ED=95=9C=20=EC=95=88=EB=82=B4=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lib/core/services/auth_proxy_service.dart | 25 +- .../auth/domain/consent_error_routing.dart | 5 + .../auth/presentation/consent_screen.dart | 46 +- .../auth/presentation/error_screen.dart | 671 ++++++++++++------ userfront/lib/i18n_data.dart | 21 +- userfront/lib/main.dart | 16 + 6 files changed, 574 insertions(+), 210 deletions(-) create mode 100644 userfront/lib/features/auth/domain/consent_error_routing.dart diff --git a/userfront/lib/core/services/auth_proxy_service.dart b/userfront/lib/core/services/auth_proxy_service.dart index 59f3f0d5..651ddd23 100644 --- a/userfront/lib/core/services/auth_proxy_service.dart +++ b/userfront/lib/core/services/auth_proxy_service.dart @@ -396,8 +396,14 @@ class AuthProxyService { return jsonDecode(response.body); } else { final errorBody = jsonDecode(response.body); - throw Exception( - errorBody['error'] ?? tr('err.userfront.auth_proxy.consent_fetch'), + final rawDetails = errorBody['details']; + throw AuthProxyException( + errorCode: (errorBody['code'] ?? '').toString(), + message: + (errorBody['error'] ?? + tr('err.userfront.auth_proxy.consent_fetch')) + .toString(), + details: rawDetails is Map ? rawDetails : null, ); } } finally { @@ -1105,3 +1111,18 @@ class AuthProxyService { } } } + +class AuthProxyException implements Exception { + final String errorCode; + final String message; + final Map? details; + + const AuthProxyException({ + required this.errorCode, + required this.message, + this.details, + }); + + @override + String toString() => message; +} diff --git a/userfront/lib/features/auth/domain/consent_error_routing.dart b/userfront/lib/features/auth/domain/consent_error_routing.dart new file mode 100644 index 00000000..3526352b --- /dev/null +++ b/userfront/lib/features/auth/domain/consent_error_routing.dart @@ -0,0 +1,5 @@ +import 'package:userfront/core/services/auth_proxy_service.dart'; + +bool shouldRouteConsentErrorToErrorScreen(Object error) { + return error is AuthProxyException && error.errorCode == 'tenant_not_allowed'; +} diff --git a/userfront/lib/features/auth/presentation/consent_screen.dart b/userfront/lib/features/auth/presentation/consent_screen.dart index 46a0ed82..cddf6c5a 100644 --- a/userfront/lib/features/auth/presentation/consent_screen.dart +++ b/userfront/lib/features/auth/presentation/consent_screen.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:userfront/i18n.dart'; @@ -5,11 +7,18 @@ import 'package:userfront/core/i18n/locale_utils.dart'; import 'package:userfront/core/services/auth_proxy_service.dart'; import 'package:userfront/core/services/web_window.dart'; import 'package:userfront/core/ui/toast_service.dart'; +import 'package:userfront/features/auth/domain/consent_error_routing.dart'; class ConsentScreen extends StatefulWidget { final String consentChallenge; + final Future> Function(String consentChallenge)? + consentInfoLoader; - const ConsentScreen({super.key, required this.consentChallenge}); + const ConsentScreen({ + super.key, + required this.consentChallenge, + this.consentInfoLoader, + }); @override State createState() => _ConsentScreenState(); @@ -93,9 +102,9 @@ class _ConsentScreenState extends State { Future _fetchConsentInfo() async { try { - final info = await AuthProxyService.getConsentInfo( - widget.consentChallenge, - ); + final loader = + widget.consentInfoLoader ?? AuthProxyService.getConsentInfo; + final info = await loader(widget.consentChallenge); // [Skip Logic] 백엔드에서 자동 승인되어 리다이렉트 URL이 온 경우 즉시 이동 if (info['redirectTo'] != null) { @@ -139,6 +148,35 @@ class _ConsentScreenState extends State { _consentInfo = info; _isLoading = false; }); + } on AuthProxyException catch (e) { + if (shouldRouteConsentErrorToErrorScreen(e)) { + if (!mounted) { + return; + } + final localeCode = + extractLocaleFromPath(Uri.base) ?? resolvePreferredLocaleCode(); + final target = buildLocalizedPath( + localeCode, + Uri( + path: '/error', + queryParameters: { + 'error': e.errorCode, + 'error_description': e.message, + if (e.details != null) 'details': jsonEncode(e.details), + }, + ), + ); + context.go(target); + return; + } + setState(() { + _error = tr( + 'msg.userfront.consent.load_error', + fallback: 'Failed to load consent information: {{error}}', + params: {'error': e.message}, + ); + _isLoading = false; + }); } catch (e) { setState(() { _error = tr( diff --git a/userfront/lib/features/auth/presentation/error_screen.dart b/userfront/lib/features/auth/presentation/error_screen.dart index fcdab116..016d3fe4 100644 --- a/userfront/lib/features/auth/presentation/error_screen.dart +++ b/userfront/lib/features/auth/presentation/error_screen.dart @@ -15,6 +15,7 @@ class ErrorScreen extends StatefulWidget { final String? description; final bool? isProdOverride; final Future> Function()? sessionProfileLoader; + final Map? tenantAccessDetails; const ErrorScreen({ super.key, @@ -23,6 +24,7 @@ class ErrorScreen extends StatefulWidget { this.description, this.isProdOverride, this.sessionProfileLoader, + this.tenantAccessDetails, }); @override @@ -40,11 +42,23 @@ class _ErrorScreenState extends State { @override void initState() { super.initState(); - if (_isTenantAccessBlocked) { + 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; @@ -119,6 +133,140 @@ class _ErrorScreenState extends State { 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) { @@ -143,7 +291,12 @@ class _ErrorScreenState extends State { final errorType = isProd ? (isKnownProdCode ? normalizedCode : 'unknown_error') : (hasCode ? normalizedCode : 'unknown_error'); - final title = isProd + final title = isTenantAccessBlocked + ? tr( + 'msg.userfront.error.tenant.page_title', + fallback: '애플리케이션 접근이 제한되었습니다', + ) + : isProd ? tr('msg.userfront.error.title') : (hasCode ? tr( @@ -151,234 +304,352 @@ class _ErrorScreenState extends State { 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: - '현재 로그인된 계정은 이 애플리케이션에 접근할 수 없습니다.', + 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() ?? ''; - + ? (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'))); return Scaffold( backgroundColor: colorScheme.surfaceContainerLowest, body: SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(vertical: 24), - child: Center( + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + padding: const EdgeInsets.all(24), 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, + 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), ), - ), - 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: Padding( + padding: const EdgeInsets.fromLTRB(28, 28, 28, 24), child: Column( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - tr( - 'msg.userfront.error.tenant.title', - fallback: '접근 제한 정보', - ), - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w700, - ), + 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), - if (_isLoadingSessionProfile) - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: colorScheme.primary, - ), + 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, ), - const SizedBox(width: 10), - Flexible( - child: Text( + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( tr( - 'msg.userfront.error.tenant.loading', - fallback: - '현재 계정 정보를 불러오는 중입니다.', + 'msg.userfront.error.tenant.title', + fallback: '접근 제한 정보', ), - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, ), ), - ), - ], - ) - 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: 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: '현재 계정 정보를 불러오는 중입니다.', + ), + 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: '알 수 없음', + const SizedBox(height: 8), + _InfoRow( + label: tr( + 'msg.userfront.error.tenant.primary_tenant', + fallback: '대표 소속 테넌트', + ), + value: tenantLabel.isNotEmpty + ? tenantLabel + : tr( + 'msg.userfront.error.tenant.tenant_unknown', + fallback: '알 수 없음', + ), ), + const SizedBox(height: 8), + _InfoRow( + label: tr( + 'msg.userfront.error.tenant.affiliated_tenants', + fallback: '전체 소속 테넌트', + ), + value: affiliatedTenantLabels.isNotEmpty + ? affiliatedTenantLabels.join(', ') + : tr( + 'msg.userfront.error.tenant.tenant_unknown', + fallback: '알 수 없음', + ), + ), + if (showTenantLookupFallback) ...[ + const SizedBox(height: 12), + Text( + tr( + 'msg.userfront.error.tenant.lookup_fallback', + fallback: + '표시 정보가 충분하지 않아 일부 항목은 확인되지 않을 수 있습니다.', + ), + style: theme.textTheme.bodySmall + ?.copyWith( + color: + colorScheme.onSurfaceVariant, + ), + ), + ], + if (hasTenantLookupFailure) ...[ + const SizedBox(height: 12), + Text( + tr( + 'msg.userfront.error.tenant.load_failed', + fallback: + '계정 정보를 확인하지 못했습니다. 다시 시도해 주세요.', + ), + style: theme.textTheme.bodySmall + ?.copyWith( + color: + colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ], + ), ), - if (_sessionProfileError != null && - _sessionProfileError!.isNotEmpty) ...[ - const SizedBox(height: 12), - Text( - tr( - 'msg.userfront.error.tenant.load_failed', - fallback: - '계정 정보를 확인하지 못했습니다. 다시 시도해 주세요.', + 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, ), - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + tr( + 'msg.userfront.error.tenant.allowed_box_title', + fallback: '접속 가능 테넌트', + ), + 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: '접속 가능 테넌트', + ), + value: allowedTenantLabels.join(', '), + ), + ] else ...[ + _InfoRow( + label: tr( + 'msg.userfront.error.tenant.allowed_tenants', + fallback: '접속 가능 테넌트', + ), + value: tr( + 'msg.userfront.error.tenant.tenant_unknown', + fallback: '알 수 없음', + ), + ), + ], + ], + ), + ), + ], + 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')), ), ], - ], + ), ], ), ), - ], - 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')), - ), - ], - ), - ], ), ), ), diff --git a/userfront/lib/i18n_data.dart b/userfront/lib/i18n_data.dart index 8cc55f76..1e94aa0e 100644 --- a/userfront/lib/i18n_data.dart +++ b/userfront/lib/i18n_data.dart @@ -505,11 +505,16 @@ const Map koStrings = { "msg.userfront.error.type": "오류 종류: {{type}}", "msg.userfront.error.tenant.account": "계정", "msg.userfront.error.tenant.account_unknown": "알 수 없음", - "msg.userfront.error.tenant.detail": - "현재 로그인된 계정은 이 애플리케이션에 접근할 수 없습니다.", - "msg.userfront.error.tenant.load_failed": - "계정 정보를 확인하지 못했습니다. 다시 시도해 주세요.", + "msg.userfront.error.tenant.affiliated_tenants": "전체 소속 테넌트", + "msg.userfront.error.tenant.allowed_tenants": "접속 가능 테넌트", + "msg.userfront.error.tenant.allowed_box_title": "접속 가능 테넌트", + "msg.userfront.error.tenant.detail": "현재 로그인된 계정은 이 애플리케이션에 접근할 수 없습니다.", + "msg.userfront.error.tenant.load_failed": "계정 정보를 확인하지 못했습니다. 다시 시도해 주세요.", + "msg.userfront.error.tenant.lookup_fallback": + "표시 정보가 충분하지 않아 일부 항목은 확인되지 않을 수 있습니다.", + "msg.userfront.error.tenant.page_title": "애플리케이션 접근이 제한되었습니다", "msg.userfront.error.tenant.loading": "현재 계정 정보를 불러오는 중입니다.", + "msg.userfront.error.tenant.primary_tenant": "대표 소속 테넌트", "msg.userfront.error.tenant.tenant": "소속 테넌트", "msg.userfront.error.tenant.tenant_unknown": "알 수 없음", "msg.userfront.error.tenant.title": "접근 제한 정보", @@ -2450,11 +2455,19 @@ const Map enStrings = { "msg.userfront.error.type": "Error type: {{type}}", "msg.userfront.error.tenant.account": "Account", "msg.userfront.error.tenant.account_unknown": "Unknown", + "msg.userfront.error.tenant.affiliated_tenants": "All affiliated tenants", + "msg.userfront.error.tenant.allowed_tenants": "Allowed tenants", + "msg.userfront.error.tenant.allowed_box_title": "Allowed tenants", "msg.userfront.error.tenant.detail": "The currently signed-in account cannot access this application.", "msg.userfront.error.tenant.load_failed": "We could not confirm the account details. Please try again.", + "msg.userfront.error.tenant.lookup_fallback": + "Some fields could not be verified because the access context was incomplete.", + "msg.userfront.error.tenant.page_title": + "Access to this application is restricted", "msg.userfront.error.tenant.loading": "Loading the current account details.", + "msg.userfront.error.tenant.primary_tenant": "Primary affiliated tenant", "msg.userfront.error.tenant.tenant": "Tenant", "msg.userfront.error.tenant.tenant_unknown": "Unknown", "msg.userfront.error.tenant.title": "Access restriction details", diff --git a/userfront/lib/main.dart b/userfront/lib/main.dart index f33ef9f0..ac1a801c 100644 --- a/userfront/lib/main.dart +++ b/userfront/lib/main.dart @@ -1,4 +1,6 @@ // ignore_for_file: avoid_print +import 'dart:convert'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -38,6 +40,19 @@ import 'i18n.dart'; final _log = Logger('Main'); +Map? _decodeErrorDetails(String? raw) { + if (raw == null || raw.trim().isEmpty) { + return null; + } + try { + final decoded = jsonDecode(raw); + if (decoded is Map) { + return decoded; + } + } catch (_) {} + return null; +} + void _attemptRecoveryFromNullCheck({ required Object exception, StackTrace? stackTrace, @@ -398,6 +413,7 @@ final _router = GoRouter( errorCode: params['error'], description: params['error_description'] ?? params['message'], + tenantAccessDetails: _decodeErrorDetails(params['details']), ), ); },