forked from baron/baron-sso
테넌트 접근 제한 안내화면 개선
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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')),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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']),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user