diff --git a/userfront/assets/translations/en.toml b/userfront/assets/translations/en.toml index acc22ea8..26cf3641 100644 --- a/userfront/assets/translations/en.toml +++ b/userfront/assets/translations/en.toml @@ -137,6 +137,16 @@ title_generic = "An error occurred." title_with_code = "Error: {code}" type = "Error type: {type}" +[msg.userfront.error.tenant] +account = "Account" +account_unknown = "Unknown" +detail = "The currently signed-in account cannot access this application." +load_failed = "We could not confirm the account details. Please try again." +loading = "Loading the current account details." +tenant = "Tenant" +tenant_unknown = "Unknown" +title = "Access restriction details" + [msg.userfront.error.ory] "$normalizedCode" = "{error}" access_denied = "The user denied the consent request." @@ -506,6 +516,7 @@ windows = "Desktop(Windows)" [ui.userfront.error] go_home = "Go Home" go_login = "Go Login" +switch_account = "Sign in with another account" [ui.userfront.forgot] heading = "Forgot your password?" diff --git a/userfront/assets/translations/ko.toml b/userfront/assets/translations/ko.toml index 24f7ce3b..245ff0f3 100644 --- a/userfront/assets/translations/ko.toml +++ b/userfront/assets/translations/ko.toml @@ -78,6 +78,16 @@ title_generic = "오류가 발생했습니다" title_with_code = "오류: {code}" type = "오류 종류: {type}" +[msg.userfront.error.tenant] +account = "계정" +account_unknown = "알 수 없음" +detail = "현재 로그인된 계정은 이 애플리케이션에 접근할 수 없습니다." +load_failed = "계정 정보를 확인하지 못했습니다. 다시 시도해 주세요." +loading = "현재 계정 정보를 불러오는 중입니다." +tenant = "소속 테넌트" +tenant_unknown = "알 수 없음" +title = "접근 제한 정보" + [msg.userfront.forgot] description = "계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다." dry_send = "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다." @@ -190,6 +200,7 @@ windows = "Desktop(Windows)" [ui.userfront.error] go_home = "홈으로 이동" go_login = "로그인으로 이동" +switch_account = "다른 계정으로 로그인" [ui.userfront.forgot] heading = "비밀번호를 잊으셨나요?" diff --git a/userfront/assets/translations/template.toml b/userfront/assets/translations/template.toml index 8f8518a4..4418458f 100644 --- a/userfront/assets/translations/template.toml +++ b/userfront/assets/translations/template.toml @@ -50,6 +50,16 @@ title_generic = "" title_with_code = "" type = "" +[msg.userfront.error.tenant] +account = "" +account_unknown = "" +detail = "" +load_failed = "" +loading = "" +tenant = "" +tenant_unknown = "" +title = "" + [msg.userfront.forgot] description = "" dry_send = "" @@ -162,6 +172,7 @@ windows = "" [ui.userfront.error] go_home = "" go_login = "" +switch_account = "" [ui.userfront.forgot] heading = "" diff --git a/userfront/lib/core/constants/error_whitelist.dart b/userfront/lib/core/constants/error_whitelist.dart index 07a4ee87..af4e8558 100644 --- a/userfront/lib/core/constants/error_whitelist.dart +++ b/userfront/lib/core/constants/error_whitelist.dart @@ -8,6 +8,7 @@ const Map internalErrorWhitelistMessages = { 'not_found': '요청한 페이지를 찾을 수 없습니다.', 'bad_request': '입력값을 확인해 주세요.', 'password_or_email_mismatch': '이메일 혹은 비밀번호가 일치하지 않습니다.', + 'tenant_not_allowed': '허용되지 않은 테넌트입니다.', }; const Set oryBypassErrorCodes = { diff --git a/userfront/lib/features/auth/presentation/error_screen.dart b/userfront/lib/features/auth/presentation/error_screen.dart index 7977c0b6..fcdab116 100644 --- a/userfront/lib/features/auth/presentation/error_screen.dart +++ b/userfront/lib/features/auth/presentation/error_screen.dart @@ -1,16 +1,20 @@ +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 StatelessWidget { +class ErrorScreen extends StatefulWidget { final String? errorId; final String? errorCode; final String? description; final bool? isProdOverride; + final Future> Function()? sessionProfileLoader; const ErrorScreen({ super.key, @@ -18,20 +22,124 @@ class ErrorScreen extends StatelessWidget { this.errorCode, this.description, this.isProdOverride, + this.sessionProfileLoader, }); + @override + State createState() => _ErrorScreenState(); +} + +class _ErrorScreenState extends State { + Map? _sessionProfile; + bool _isLoadingSessionProfile = false; + String? _sessionProfileError; + + bool get _isTenantAccessBlocked => + (widget.errorCode ?? '').trim() == 'tenant_not_allowed'; + + @override + void initState() { + super.initState(); + if (_isTenantAccessBlocked) { + unawaited(_loadSessionProfile()); + } + } + + Future _loadSessionProfile() async { + if (_isLoadingSessionProfile) { + return; + } + setState(() { + _isLoadingSessionProfile = true; + _sessionProfileError = null; + }); + try { + final loader = widget.sessionProfileLoader ?? AuthProxyService.getMe; + final profile = await loader(); + if (!mounted) { + return; + } + setState(() { + _sessionProfile = profile; + }); + } catch (error) { + if (!mounted) { + return; + } + setState(() { + _sessionProfileError = error.toString(); + }); + } finally { + if (mounted) { + setState(() { + _isLoadingSessionProfile = false; + }); + } + } + } + + String _extractTenantLabel(Map? profile) { + if (profile == null) { + return ''; + } + + final tenant = profile['tenant']; + if (tenant is Map) { + final name = tenant['name']?.toString().trim() ?? ''; + if (name.isNotEmpty) { + return name; + } + final slug = tenant['slug']?.toString().trim() ?? ''; + if (slug.isNotEmpty) { + return slug; + } + } + + final joinedTenants = profile['joinedTenants']; + if (joinedTenants is List) { + for (final item in joinedTenants) { + if (item is Map) { + final name = item['name']?.toString().trim() ?? ''; + if (name.isNotEmpty) { + return name; + } + final slug = item['slug']?.toString().trim() ?? ''; + if (slug.isNotEmpty) { + return slug; + } + } + } + } + + final companyCode = profile['companyCode']?.toString().trim() ?? ''; + if (companyCode.isNotEmpty) { + return companyCode; + } + + return ''; + } + + Future _switchAccount() async { + await LogoutService().logout(); + if (!mounted) { + return; + } + context.go(buildLocalizedSigninPath(Uri.base)); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; - final isProd = isProdOverride ?? AuthProxyService.isProdEnv; - final normalizedCode = (errorCode ?? '').trim(); + final isProd = widget.isProdOverride ?? AuthProxyService.isProdEnv; + final normalizedCode = (widget.errorCode ?? '').trim(); final hasCode = normalizedCode.isNotEmpty; final internalWhitelistFallback = internalErrorWhitelistMessages[normalizedCode]; final isInternalWhitelisted = internalWhitelistFallback != null; final isOryBypass = hasCode && oryBypassErrorCodes.contains(normalizedCode); final isKnownProdCode = hasCode && (isInternalWhitelisted || isOryBypass); + final isTenantAccessBlocked = normalizedCode == 'tenant_not_allowed'; final errorType = isProd ? (isKnownProdCode ? normalizedCode : 'unknown_error') : (hasCode ? normalizedCode : 'unknown_error'); @@ -43,44 +151,55 @@ class ErrorScreen extends StatelessWidget { params: {'code': normalizedCode}, ) : tr('msg.userfront.error.title_generic')); - final detail = isProd - ? (isInternalWhitelisted - ? tr( - 'msg.userfront.error.whitelist.$normalizedCode', - fallback: internalWhitelistFallback, - ) - : (isOryBypass - ? tr( - 'msg.userfront.error.ory.$normalizedCode', - fallback: (description?.isNotEmpty == true) - ? description - : tr('msg.userfront.error.detail_request'), - ) - : tr('msg.userfront.error.detail_contact'))) - : ((description?.isNotEmpty == true) - ? description! - : (hasCode - ? tr('msg.userfront.error.detail_generic') - : tr('msg.userfront.error.detail_request'))); + final detail = isTenantAccessBlocked + ? tr( + 'msg.userfront.error.tenant.detail', + fallback: + '현재 로그인된 계정은 이 애플리케이션에 접근할 수 없습니다.', + ) + : isProd + ? (isInternalWhitelisted + ? tr( + 'msg.userfront.error.whitelist.$normalizedCode', + fallback: internalWhitelistFallback, + ) + : (isOryBypass + ? tr( + 'msg.userfront.error.ory.$normalizedCode', + fallback: (widget.description?.isNotEmpty == true) + ? widget.description + : tr('msg.userfront.error.detail_request'), + ) + : tr('msg.userfront.error.detail_contact'))) + : ((widget.description?.isNotEmpty == true) + ? widget.description! + : (hasCode + ? tr('msg.userfront.error.detail_generic') + : tr('msg.userfront.error.detail_request'))); + final tenantLabel = _extractTenantLabel(_sessionProfile); + final emailLabel = _sessionProfile?['email']?.toString().trim() ?? ''; return Scaffold( backgroundColor: colorScheme.surfaceContainerLowest, - body: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 560), - child: Card( - margin: const EdgeInsets.symmetric(horizontal: 24), - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: BorderSide(color: colorScheme.outlineVariant), - ), - child: Padding( - padding: const EdgeInsets.fromLTRB(28, 28, 28, 24), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: Card( + margin: const EdgeInsets.symmetric(horizontal: 24), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: colorScheme.outlineVariant), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(28, 28, 28, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ Row( children: [ Expanded( @@ -103,6 +222,101 @@ class ErrorScreen extends StatelessWidget { height: 1.5, ), ), + if (isTenantAccessBlocked) ...[ + const SizedBox(height: 16), + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: colorScheme.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + tr( + 'msg.userfront.error.tenant.title', + fallback: '접근 제한 정보', + ), + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 12), + if (_isLoadingSessionProfile) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.primary, + ), + ), + const SizedBox(width: 10), + Flexible( + child: Text( + tr( + 'msg.userfront.error.tenant.loading', + fallback: + '현재 계정 정보를 불러오는 중입니다.', + ), + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ) + else ...[ + _InfoRow( + label: tr( + 'msg.userfront.error.tenant.account', + fallback: '계정', + ), + value: emailLabel.isNotEmpty + ? emailLabel + : tr( + 'msg.userfront.error.tenant.account_unknown', + fallback: '알 수 없음', + ), + ), + const SizedBox(height: 8), + _InfoRow( + label: tr( + 'msg.userfront.error.tenant.tenant', + fallback: '소속 테넌트', + ), + value: tenantLabel.isNotEmpty + ? tenantLabel + : tr( + 'msg.userfront.error.tenant.tenant_unknown', + fallback: '알 수 없음', + ), + ), + if (_sessionProfileError != null && + _sessionProfileError!.isNotEmpty) ...[ + const SizedBox(height: 12), + Text( + tr( + 'msg.userfront.error.tenant.load_failed', + fallback: + '계정 정보를 확인하지 못했습니다. 다시 시도해 주세요.', + ), + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ], + ), + ), + ], const SizedBox(height: 12), Text( tr('msg.userfront.error.type', params: {'type': errorType}), @@ -110,10 +324,11 @@ class ErrorScreen extends StatelessWidget { color: colorScheme.onSurfaceVariant, ), ), - if (errorId != null && errorId!.isNotEmpty) ...[ + if (widget.errorId != null && widget.errorId!.isNotEmpty) ...[ const SizedBox(height: 12), Text( - tr('msg.userfront.error.id', params: {'id': errorId!}), + tr('msg.userfront.error.id', + params: {'id': widget.errorId!}), style: theme.textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -125,7 +340,9 @@ class ErrorScreen extends StatelessWidget { runSpacing: 12, children: [ ElevatedButton( - onPressed: () => context.go('/login'), + onPressed: isTenantAccessBlocked + ? _switchAccount + : () => context.go('/login'), style: ElevatedButton.styleFrom( backgroundColor: colorScheme.primary, foregroundColor: colorScheme.onPrimary, @@ -137,7 +354,11 @@ class ErrorScreen extends StatelessWidget { borderRadius: BorderRadius.circular(10), ), ), - child: Text(tr('ui.userfront.error.go_login')), + child: Text( + isTenantAccessBlocked + ? tr('ui.userfront.error.switch_account') + : tr('ui.userfront.error.go_login'), + ), ), OutlinedButton( onPressed: () => @@ -157,7 +378,9 @@ class ErrorScreen extends StatelessWidget { ), ], ), - ], + ], + ), + ), ), ), ), @@ -166,3 +389,39 @@ class ErrorScreen extends StatelessWidget { ); } } + +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, + ), + ), + ), + ], + ); + } +} diff --git a/userfront/lib/i18n_data.dart b/userfront/lib/i18n_data.dart index 8d7f7d8e..8cc55f76 100644 --- a/userfront/lib/i18n_data.dart +++ b/userfront/lib/i18n_data.dart @@ -503,6 +503,16 @@ const Map koStrings = { "msg.userfront.error.title_generic": "오류가 발생했습니다", "msg.userfront.error.title_with_code": "오류: {{code}}", "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.loading": "현재 계정 정보를 불러오는 중입니다.", + "msg.userfront.error.tenant.tenant": "소속 테넌트", + "msg.userfront.error.tenant.tenant_unknown": "알 수 없음", + "msg.userfront.error.tenant.title": "접근 제한 정보", "msg.userfront.error.whitelist.\"\$normalizedCode\"": "{{error}}", "msg.userfront.error.whitelist.bad_request": "입력값을 확인해 주세요.", "msg.userfront.error.whitelist.invalid_session": "세션이 만료되었습니다. 다시 로그인해 주세요.", @@ -514,6 +524,7 @@ const Map koStrings = { "재설정 링크가 만료되었습니다. 다시 요청해 주세요.", "msg.userfront.error.whitelist.recovery_invalid": "재설정 링크가 유효하지 않습니다.", "msg.userfront.error.whitelist.settings_disabled": "현재 계정 설정 화면은 준비 중입니다.", + "msg.userfront.error.whitelist.tenant_not_allowed": "허용되지 않은 테넌트입니다.", "msg.userfront.error.whitelist.verification_required": "추가 인증이 필요합니다. 안내에 따라 진행해 주세요.", "msg.userfront.forgot.description": @@ -1738,6 +1749,7 @@ const Map koStrings = { "ui.userfront.device.windows": "Desktop(Windows)", "ui.userfront.error.go_home": "홈으로 이동", "ui.userfront.error.go_login": "로그인으로 이동", + "ui.userfront.error.switch_account": "다른 계정으로 로그인", "ui.userfront.forgot.heading": "비밀번호를 잊으셨나요?", "ui.userfront.forgot.input_label": "이메일 또는 휴대폰 번호", "ui.userfront.forgot.submit": "재설정 링크 전송", @@ -2436,6 +2448,16 @@ const Map enStrings = { "msg.userfront.error.title_generic": "An error occurred.", "msg.userfront.error.title_with_code": "Error: {{code}}", "msg.userfront.error.type": "Error type: {{type}}", + "msg.userfront.error.tenant.account": "Account", + "msg.userfront.error.tenant.account_unknown": "Unknown", + "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.loading": "Loading the current account details.", + "msg.userfront.error.tenant.tenant": "Tenant", + "msg.userfront.error.tenant.tenant_unknown": "Unknown", + "msg.userfront.error.tenant.title": "Access restriction details", "msg.userfront.error.whitelist.\"\$normalizedCode\"": "{{error}}", "msg.userfront.error.whitelist.bad_request": "Please check your input.", "msg.userfront.error.whitelist.invalid_session": @@ -2452,6 +2474,8 @@ const Map enStrings = { "The recovery link is invalid.", "msg.userfront.error.whitelist.settings_disabled": "Account settings are currently unavailable.", + "msg.userfront.error.whitelist.tenant_not_allowed": + "This tenant is not allowed.", "msg.userfront.error.whitelist.verification_required": "Additional verification is required. Please follow the instructions.", "msg.userfront.forgot.description": @@ -3752,6 +3776,7 @@ const Map enStrings = { "ui.userfront.device.windows": "Desktop(Windows)", "ui.userfront.error.go_home": "Go Home", "ui.userfront.error.go_login": "Go Login", + "ui.userfront.error.switch_account": "Sign in with another account", "ui.userfront.forgot.heading": "Forgot your password?", "ui.userfront.forgot.input_label": "Email address or phone number", "ui.userfront.forgot.submit": "Send reset link", diff --git a/userfront/test/error_screen_test.dart b/userfront/test/error_screen_test.dart index e80c7454..036c5aa2 100644 --- a/userfront/test/error_screen_test.dart +++ b/userfront/test/error_screen_test.dart @@ -9,6 +9,7 @@ Future _pumpErrorScreen( String? errorCode, String? description, bool? isProdOverride, + Future> Function()? sessionProfileLoader, }) async { await tester.pumpWidget( MaterialApp( @@ -16,6 +17,7 @@ Future _pumpErrorScreen( errorCode: errorCode, description: description, isProdOverride: isProdOverride, + sessionProfileLoader: sessionProfileLoader, ), ), ); @@ -193,4 +195,50 @@ void main() { expect(find.text(type), findsOneWidget); expect(find.text('원문 메시지'), findsNothing); }); + + testWidgets('tenant_not_allowed는 전용 차단 정보를 노출한다', ( + WidgetTester tester, + ) async { + await _pumpErrorScreen( + tester, + errorCode: 'tenant_not_allowed', + description: '원문 메시지', + isProdOverride: true, + sessionProfileLoader: () async { + return { + 'email': 'employee@example.com', + 'tenant': {'name': 'Baron HQ', 'slug': 'baron-hq'}, + }; + }, + ); + + final title = tr( + 'msg.userfront.error.title', + fallback: '인증 과정에서 오류가 발생했습니다', + ); + final detail = tr( + 'msg.userfront.error.tenant.detail', + fallback: '현재 로그인된 계정은 이 애플리케이션에 접근할 수 없습니다.', + ); + final account = tr( + 'msg.userfront.error.tenant.account', + fallback: '계정', + ); + final tenant = tr( + 'msg.userfront.error.tenant.tenant', + fallback: '소속 테넌트', + ); + final switchAccount = tr( + 'ui.userfront.error.switch_account', + fallback: '다른 계정으로 로그인', + ); + + expect(find.text(title), findsOneWidget); + expect(find.text(detail), findsOneWidget); + expect(find.text(account), findsOneWidget); + expect(find.text('employee@example.com'), findsOneWidget); + expect(find.text(tenant), findsOneWidget); + expect(find.text('Baron HQ'), findsOneWidget); + expect(find.text(switchAccount), findsOneWidget); + }); }