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