1
0
forked from baron/baron-sso

테넌트 접근 제한 안내화면 개선

This commit is contained in:
2026-04-28 10:57:44 +09:00
parent 955128a25a
commit d0340fc062
6 changed files with 574 additions and 210 deletions

View File

@@ -396,8 +396,14 @@ class AuthProxyService {
return jsonDecode(response.body); return jsonDecode(response.body);
} else { } else {
final errorBody = jsonDecode(response.body); final errorBody = jsonDecode(response.body);
throw Exception( final rawDetails = errorBody['details'];
errorBody['error'] ?? tr('err.userfront.auth_proxy.consent_fetch'), throw AuthProxyException(
errorCode: (errorBody['code'] ?? '').toString(),
message:
(errorBody['error'] ??
tr('err.userfront.auth_proxy.consent_fetch'))
.toString(),
details: rawDetails is Map<String, dynamic> ? rawDetails : null,
); );
} }
} finally { } finally {
@@ -1105,3 +1111,18 @@ class AuthProxyService {
} }
} }
} }
class AuthProxyException implements Exception {
final String errorCode;
final String message;
final Map<String, dynamic>? details;
const AuthProxyException({
required this.errorCode,
required this.message,
this.details,
});
@override
String toString() => message;
}

View File

@@ -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';
}

View File

@@ -1,3 +1,5 @@
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:userfront/i18n.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/auth_proxy_service.dart';
import 'package:userfront/core/services/web_window.dart'; import 'package:userfront/core/services/web_window.dart';
import 'package:userfront/core/ui/toast_service.dart'; import 'package:userfront/core/ui/toast_service.dart';
import 'package:userfront/features/auth/domain/consent_error_routing.dart';
class ConsentScreen extends StatefulWidget { class ConsentScreen extends StatefulWidget {
final String consentChallenge; final String consentChallenge;
final Future<Map<String, dynamic>> Function(String consentChallenge)?
consentInfoLoader;
const ConsentScreen({super.key, required this.consentChallenge}); const ConsentScreen({
super.key,
required this.consentChallenge,
this.consentInfoLoader,
});
@override @override
State<ConsentScreen> createState() => _ConsentScreenState(); State<ConsentScreen> createState() => _ConsentScreenState();
@@ -93,9 +102,9 @@ class _ConsentScreenState extends State<ConsentScreen> {
Future<void> _fetchConsentInfo() async { Future<void> _fetchConsentInfo() async {
try { try {
final info = await AuthProxyService.getConsentInfo( final loader =
widget.consentChallenge, widget.consentInfoLoader ?? AuthProxyService.getConsentInfo;
); final info = await loader(widget.consentChallenge);
// [Skip Logic] 백엔드에서 자동 승인되어 리다이렉트 URL이 온 경우 즉시 이동 // [Skip Logic] 백엔드에서 자동 승인되어 리다이렉트 URL이 온 경우 즉시 이동
if (info['redirectTo'] != null) { if (info['redirectTo'] != null) {
@@ -139,6 +148,35 @@ class _ConsentScreenState extends State<ConsentScreen> {
_consentInfo = info; _consentInfo = info;
_isLoading = false; _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) { } catch (e) {
setState(() { setState(() {
_error = tr( _error = tr(

View File

@@ -15,6 +15,7 @@ class ErrorScreen extends StatefulWidget {
final String? description; final String? description;
final bool? isProdOverride; final bool? isProdOverride;
final Future<Map<String, dynamic>> Function()? sessionProfileLoader; final Future<Map<String, dynamic>> Function()? sessionProfileLoader;
final Map<String, dynamic>? tenantAccessDetails;
const ErrorScreen({ const ErrorScreen({
super.key, super.key,
@@ -23,6 +24,7 @@ class ErrorScreen extends StatefulWidget {
this.description, this.description,
this.isProdOverride, this.isProdOverride,
this.sessionProfileLoader, this.sessionProfileLoader,
this.tenantAccessDetails,
}); });
@override @override
@@ -40,11 +42,23 @@ class _ErrorScreenState extends State<ErrorScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
if (_isTenantAccessBlocked) { if (_isTenantAccessBlocked && _shouldLoadSessionProfile()) {
unawaited(_loadSessionProfile()); unawaited(_loadSessionProfile());
} }
} }
Map<String, dynamic>? 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<void> _loadSessionProfile() async { Future<void> _loadSessionProfile() async {
if (_isLoadingSessionProfile) { if (_isLoadingSessionProfile) {
return; return;
@@ -119,6 +133,140 @@ class _ErrorScreenState extends State<ErrorScreen> {
return ''; return '';
} }
String _extractCurrentTenantLabel(Map<String, dynamic>? 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<String, dynamic>? details) {
if (details == null) {
return '';
}
final account = details['account'];
if (account is! Map) {
return '';
}
return account['email']?.toString().trim() ?? '';
}
List<String> _extractAllowedTenantLabels(Map<String, dynamic>? details) {
if (details == null) {
return const [];
}
final raw = details['allowed_tenants'];
if (raw is! List) {
return const [];
}
final labels = <String>[];
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<String> _extractAffiliatedTenantLabelsFromDetails(
Map<String, dynamic>? details,
) {
if (details == null) {
return const [];
}
final raw = details['affiliated_tenants'];
if (raw is! List) {
return const [];
}
final labels = <String>[];
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<String> _extractAffiliatedTenantLabelsFromProfile(
Map<String, dynamic>? profile,
) {
if (profile == null) {
return const [];
}
final labels = <String>[];
final seen = <String>{};
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<void> _switchAccount() async { Future<void> _switchAccount() async {
await LogoutService().logout(); await LogoutService().logout();
if (!mounted) { if (!mounted) {
@@ -143,7 +291,12 @@ class _ErrorScreenState extends State<ErrorScreen> {
final errorType = isProd final errorType = isProd
? (isKnownProdCode ? normalizedCode : 'unknown_error') ? (isKnownProdCode ? normalizedCode : 'unknown_error')
: (hasCode ? 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') ? tr('msg.userfront.error.title')
: (hasCode : (hasCode
? tr( ? tr(
@@ -151,234 +304,352 @@ class _ErrorScreenState extends State<ErrorScreen> {
params: {'code': normalizedCode}, params: {'code': normalizedCode},
) )
: tr('msg.userfront.error.title_generic')); : 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 final detail = isTenantAccessBlocked
? tr( ? tr(
'msg.userfront.error.tenant.detail', 'msg.userfront.error.tenant.detail',
fallback: fallback: '현재 로그인된 계정은 이 애플리케이션에 접근할 수 없습니다.',
'현재 로그인된 계정은 이 애플리케이션에 접근할 수 없습니다.',
) )
: isProd : isProd
? (isInternalWhitelisted ? (isInternalWhitelisted
? tr( ? tr(
'msg.userfront.error.whitelist.$normalizedCode', 'msg.userfront.error.whitelist.$normalizedCode',
fallback: internalWhitelistFallback, fallback: internalWhitelistFallback,
) )
: (isOryBypass : (isOryBypass
? tr( ? tr(
'msg.userfront.error.ory.$normalizedCode', 'msg.userfront.error.ory.$normalizedCode',
fallback: (widget.description?.isNotEmpty == true) fallback: (widget.description?.isNotEmpty == true)
? widget.description ? widget.description
: tr('msg.userfront.error.detail_request'), : tr('msg.userfront.error.detail_request'),
) )
: tr('msg.userfront.error.detail_contact'))) : tr('msg.userfront.error.detail_contact')))
: ((widget.description?.isNotEmpty == true) : ((widget.description?.isNotEmpty == true)
? widget.description! ? widget.description!
: (hasCode : (hasCode
? tr('msg.userfront.error.detail_generic') ? tr('msg.userfront.error.detail_generic')
: tr('msg.userfront.error.detail_request'))); : tr('msg.userfront.error.detail_request')));
final tenantLabel = _extractTenantLabel(_sessionProfile);
final emailLabel = _sessionProfile?['email']?.toString().trim() ?? '';
return Scaffold( return Scaffold(
backgroundColor: colorScheme.surfaceContainerLowest, backgroundColor: colorScheme.surfaceContainerLowest,
body: SafeArea( body: SafeArea(
child: SingleChildScrollView( child: LayoutBuilder(
padding: const EdgeInsets.symmetric(vertical: 24), builder: (context, constraints) => SingleChildScrollView(
child: Center( padding: const EdgeInsets.all(24),
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560), constraints: BoxConstraints(
child: Card( minHeight: constraints.maxHeight - 48,
margin: const EdgeInsets.symmetric(horizontal: 24), ),
elevation: 0, child: Center(
shape: RoundedRectangleBorder( child: ConstrainedBox(
borderRadius: BorderRadius.circular(16), constraints: const BoxConstraints(maxWidth: 560),
side: BorderSide(color: colorScheme.outlineVariant), child: Card(
), margin: EdgeInsets.zero,
child: Padding( elevation: 0,
padding: const EdgeInsets.fromLTRB(28, 28, 28, 24), shape: RoundedRectangleBorder(
child: Column( borderRadius: BorderRadius.circular(16),
mainAxisSize: MainAxisSize.min, side: BorderSide(color: colorScheme.outlineVariant),
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,
), ),
), child: Padding(
if (isTenantAccessBlocked) ...[ padding: const EdgeInsets.fromLTRB(28, 28, 28, 24),
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( child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Row(
tr( children: [
'msg.userfront.error.tenant.title', Expanded(
fallback: '접근 제한 정보', child: Text(
), title,
style: theme.textTheme.titleSmall?.copyWith( style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), color: colorScheme.onSurface,
),
),
),
const ThemeToggleButton(compact: true),
],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
if (_isLoadingSessionProfile) Text(
Row( detail,
crossAxisAlignment: CrossAxisAlignment.start, style: theme.textTheme.bodyMedium?.copyWith(
children: [ color: colorScheme.onSurfaceVariant,
SizedBox( height: 1.5,
width: 16, ),
height: 16, ),
child: CircularProgressIndicator( if (isTenantAccessBlocked) ...[
strokeWidth: 2, const SizedBox(height: 16),
color: colorScheme.primary, 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: Column(
child: Text( crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
tr( tr(
'msg.userfront.error.tenant.loading', 'msg.userfront.error.tenant.title',
fallback: fallback: '접근 제한 정보',
'현재 계정 정보를 불러오는 중입니다.',
), ),
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant, fontWeight: FontWeight.w700,
), ),
), ),
), const SizedBox(height: 12),
], if (isLoadingTenantContext)
) Row(
else ...[ crossAxisAlignment:
_InfoRow( CrossAxisAlignment.start,
label: tr( children: [
'msg.userfront.error.tenant.account', SizedBox(
fallback: '계정', width: 16,
), height: 16,
value: emailLabel.isNotEmpty child: CircularProgressIndicator(
? emailLabel strokeWidth: 2,
: tr( color: colorScheme.primary,
'msg.userfront.error.tenant.account_unknown', ),
fallback: '알 수 없음', ),
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),
const SizedBox(height: 8), _InfoRow(
_InfoRow( label: tr(
label: tr( 'msg.userfront.error.tenant.primary_tenant',
'msg.userfront.error.tenant.tenant', fallback: '대표 소속 테넌트',
fallback: '소속 테넌트', ),
), value: tenantLabel.isNotEmpty
value: tenantLabel.isNotEmpty ? tenantLabel
? tenantLabel : tr(
: tr( 'msg.userfront.error.tenant.tenant_unknown',
'msg.userfront.error.tenant.tenant_unknown', fallback: '알 수 없음',
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 && const SizedBox(height: 12),
_sessionProfileError!.isNotEmpty) ...[ Container(
const SizedBox(height: 12), width: double.infinity,
Text( padding: const EdgeInsets.all(16),
tr( decoration: BoxDecoration(
'msg.userfront.error.tenant.load_failed', color: colorScheme.surface,
fallback: 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')),
),
],
),
],
), ),
), ),
), ),

View File

@@ -505,11 +505,16 @@ const Map<String, String> koStrings = {
"msg.userfront.error.type": "오류 종류: {{type}}", "msg.userfront.error.type": "오류 종류: {{type}}",
"msg.userfront.error.tenant.account": "계정", "msg.userfront.error.tenant.account": "계정",
"msg.userfront.error.tenant.account_unknown": "알 수 없음", "msg.userfront.error.tenant.account_unknown": "알 수 없음",
"msg.userfront.error.tenant.detail": "msg.userfront.error.tenant.affiliated_tenants": "전체 소속 테넌트",
"현재 로그인된 계정은 이 애플리케이션에 접근할 수 없습니다.", "msg.userfront.error.tenant.allowed_tenants": "접속 가능 테넌트",
"msg.userfront.error.tenant.load_failed": "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.loading": "현재 계정 정보를 불러오는 중입니다.",
"msg.userfront.error.tenant.primary_tenant": "대표 소속 테넌트",
"msg.userfront.error.tenant.tenant": "소속 테넌트", "msg.userfront.error.tenant.tenant": "소속 테넌트",
"msg.userfront.error.tenant.tenant_unknown": "알 수 없음", "msg.userfront.error.tenant.tenant_unknown": "알 수 없음",
"msg.userfront.error.tenant.title": "접근 제한 정보", "msg.userfront.error.tenant.title": "접근 제한 정보",
@@ -2450,11 +2455,19 @@ const Map<String, String> enStrings = {
"msg.userfront.error.type": "Error type: {{type}}", "msg.userfront.error.type": "Error type: {{type}}",
"msg.userfront.error.tenant.account": "Account", "msg.userfront.error.tenant.account": "Account",
"msg.userfront.error.tenant.account_unknown": "Unknown", "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": "msg.userfront.error.tenant.detail":
"The currently signed-in account cannot access this application.", "The currently signed-in account cannot access this application.",
"msg.userfront.error.tenant.load_failed": "msg.userfront.error.tenant.load_failed":
"We could not confirm the account details. Please try again.", "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.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": "Tenant",
"msg.userfront.error.tenant.tenant_unknown": "Unknown", "msg.userfront.error.tenant.tenant_unknown": "Unknown",
"msg.userfront.error.tenant.title": "Access restriction details", "msg.userfront.error.tenant.title": "Access restriction details",

View File

@@ -1,4 +1,6 @@
// ignore_for_file: avoid_print // ignore_for_file: avoid_print
import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -38,6 +40,19 @@ import 'i18n.dart';
final _log = Logger('Main'); final _log = Logger('Main');
Map<String, dynamic>? _decodeErrorDetails(String? raw) {
if (raw == null || raw.trim().isEmpty) {
return null;
}
try {
final decoded = jsonDecode(raw);
if (decoded is Map<String, dynamic>) {
return decoded;
}
} catch (_) {}
return null;
}
void _attemptRecoveryFromNullCheck({ void _attemptRecoveryFromNullCheck({
required Object exception, required Object exception,
StackTrace? stackTrace, StackTrace? stackTrace,
@@ -398,6 +413,7 @@ final _router = GoRouter(
errorCode: params['error'], errorCode: params['error'],
description: description:
params['error_description'] ?? params['message'], params['error_description'] ?? params['message'],
tenantAccessDetails: _decodeErrorDetails(params['details']),
), ),
); );
}, },