1
0
forked from baron/baron-sso

#269 진행. 리다이렉트 등 파라미터 전체 전달

This commit is contained in:
Lectom C Han
2026-02-19 10:05:07 +09:00
parent 37e8fc4991
commit 7808d81bb4
7 changed files with 349 additions and 38 deletions

View File

@@ -0,0 +1,99 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
const _translationAssetPrefix = 'assets/translations/';
const _templateFileName = 'template.toml';
const _safeFallbackLocaleCode = 'en';
List<String> extractSupportedLocaleCodesFromAssets(Iterable<String> assets) {
final localeCodes = <String>{};
for (final asset in assets) {
if (!asset.startsWith(_translationAssetPrefix) ||
!asset.endsWith('.toml')) {
continue;
}
final fileName = asset.substring(_translationAssetPrefix.length);
if (fileName.contains('/') || fileName == _templateFileName) {
continue;
}
final rawCode = fileName.substring(0, fileName.length - '.toml'.length);
final normalized = rawCode.toLowerCase().replaceAll('_', '-');
if (_isValidLocaleCode(normalized)) {
localeCodes.add(normalized);
}
}
final sorted = localeCodes.toList()..sort();
return sorted;
}
class LocaleRegistry {
static final Set<String> _localeCodes = <String>{};
static bool _initialized = false;
static Future<void> initialize({AssetBundle? assetBundle}) async {
if (_initialized) {
return;
}
final bundle = assetBundle ?? rootBundle;
try {
final manifest = await AssetManifest.loadFromAssetBundle(bundle);
final extracted = extractSupportedLocaleCodesFromAssets(
manifest.listAssets(),
);
_localeCodes.addAll(extracted);
} catch (_) {
// manifest 로딩 실패 시 안전 fallback으로 계속 진행합니다.
}
if (_localeCodes.isEmpty) {
_localeCodes.add(_safeFallbackLocaleCode);
}
_initialized = true;
}
static List<String> get supportedLocaleCodes {
final sorted = _localeCodes.toList()..sort();
return List.unmodifiable(sorted);
}
static String get fallbackLocaleCode {
final supported = supportedLocaleCodes;
if (supported.isEmpty) {
return _safeFallbackLocaleCode;
}
if (supported.contains('en')) {
return 'en';
}
return supported.first;
}
static bool contains(String code) {
return _localeCodes.contains(code.toLowerCase());
}
@visibleForTesting
static void setSupportedLocaleCodesForTest(Iterable<String> localeCodes) {
_localeCodes
..clear()
..addAll(
localeCodes
.map((code) => code.toLowerCase().replaceAll('_', '-'))
.where(_isValidLocaleCode),
);
if (_localeCodes.isEmpty) {
_localeCodes.add(_safeFallbackLocaleCode);
}
_initialized = true;
}
@visibleForTesting
static void resetForTest() {
_localeCodes.clear();
_initialized = false;
}
}
bool _isValidLocaleCode(String value) {
return RegExp(r'^[a-z]{2,3}$').hasMatch(value);
}

View File

@@ -1,34 +1,46 @@
import 'dart:ui';
import 'locale_storage.dart';
import 'locale_registry.dart';
const supportedLocaleCodes = ['en', 'ko'];
const defaultLocaleCode = 'en';
String get defaultLocaleCode => LocaleRegistry.fallbackLocaleCode;
String normalizeLocaleCode(String? code) {
final supportedLocaleCodes = LocaleRegistry.supportedLocaleCodes;
final fallbackLocaleCode = LocaleRegistry.fallbackLocaleCode;
if (code == null || code.isEmpty) {
return defaultLocaleCode;
return fallbackLocaleCode;
}
final normalized = code.toLowerCase();
if (normalized == 'ko' || normalized.startsWith('ko-')) {
return 'ko';
final normalized = code.toLowerCase().replaceAll('_', '-');
if (supportedLocaleCodes.contains(normalized)) {
return normalized;
}
if (normalized == 'en' || normalized.startsWith('en-')) {
return 'en';
final languageCode = normalized.split('-').first;
if (supportedLocaleCodes.contains(languageCode)) {
return languageCode;
}
return defaultLocaleCode;
return fallbackLocaleCode;
}
String resolvePreferredLocaleCode() {
final stored = LocaleStorage.read();
if (stored != null && supportedLocaleCodes.contains(stored)) {
return stored;
if (stored != null && stored.isNotEmpty) {
final normalizedStored = normalizeLocaleCode(stored);
if (LocaleRegistry.contains(normalizedStored)) {
return normalizedStored;
}
}
final deviceLocale = PlatformDispatcher.instance.locale;
return normalizeLocaleCode(deviceLocale.languageCode);
final languageTag =
deviceLocale.countryCode == null || deviceLocale.countryCode!.isEmpty
? deviceLocale.languageCode
: '${deviceLocale.languageCode}-${deviceLocale.countryCode}';
return normalizeLocaleCode(languageTag);
}
String? extractLocaleFromPath(Uri uri) {
final supportedLocaleCodes = LocaleRegistry.supportedLocaleCodes;
if (uri.pathSegments.isEmpty) {
return null;
}
@@ -40,8 +52,10 @@ String? extractLocaleFromPath(Uri uri) {
}
String stripLocalePath(Uri uri) {
final supportedLocaleCodes = LocaleRegistry.supportedLocaleCodes;
final segments = uri.pathSegments;
if (segments.isNotEmpty && supportedLocaleCodes.contains(segments.first)) {
if (segments.isNotEmpty &&
supportedLocaleCodes.contains(segments.first.toLowerCase())) {
final rest = segments.skip(1).join('/');
if (rest.isEmpty) {
return '/';
@@ -52,18 +66,23 @@ String stripLocalePath(Uri uri) {
}
String buildLocalizedPath(String localeCode, Uri uri) {
final supportedLocaleCodes = LocaleRegistry.supportedLocaleCodes;
final segments = uri.pathSegments;
Iterable<String> restSegments = segments;
if (segments.isNotEmpty) {
final head = segments.first.toLowerCase();
if (supportedLocaleCodes.contains(head) || head.length == 2) {
if (supportedLocaleCodes.contains(head)) {
restSegments = segments.skip(1);
}
}
final newSegments = [localeCode, ...restSegments];
final path = '/${newSegments.join('/')}';
if (uri.queryParameters.isEmpty) {
return path;
}
return Uri(path: path, queryParameters: uri.queryParameters).toString();
final queryPart = uri.hasQuery ? '?${uri.query}' : '';
final fragmentPart = uri.fragment.isNotEmpty ? '#${uri.fragment}' : '';
return '$path$queryPart$fragmentPart';
}
String buildSigninRedirectPath(String localeCode, Uri uri) {
final queryPart = uri.hasQuery ? '?${uri.query}' : '';
return '/$localeCode/signin$queryPart';
}

View File

@@ -21,6 +21,7 @@ import 'core/services/auth_token_store.dart';
import 'core/services/logger_service.dart';
import 'core/notifiers/auth_notifier.dart';
import 'core/i18n/locale_gate.dart';
import 'core/i18n/locale_registry.dart';
import 'core/i18n/locale_utils.dart';
import 'core/i18n/toml_asset_loader.dart';
import 'package:logging/logging.dart';
@@ -45,6 +46,7 @@ void main() async {
WidgetsFlutterBinding.ensureInitialized();
usePathUrlStrategy();
await EasyLocalization.ensureInitialized();
await LocaleRegistry.initialize();
// 1. Global Error Handling
FlutterError.onError = (details) {
@@ -78,11 +80,16 @@ void main() async {
runApp(
// URL(/en, /ko)이 있으면 우선 적용해서 첫 렌더부터 올바른 언어로 시작합니다.
() {
final supportedLocaleCodes = LocaleRegistry.supportedLocaleCodes;
final supportedLocales = supportedLocaleCodes
.map((code) => Locale(code))
.toList(growable: false);
final fallbackLocaleCode = LocaleRegistry.fallbackLocaleCode;
final initialLocaleCode =
extractLocaleFromPath(Uri.base) ?? resolvePreferredLocaleCode();
return EasyLocalization(
supportedLocales: const [Locale('en'), Locale('ko')],
fallbackLocale: const Locale('en'),
supportedLocales: supportedLocales,
fallbackLocale: Locale(fallbackLocaleCode),
startLocale: Locale(initialLocaleCode),
saveLocale: false,
path: 'assets/translations',
@@ -143,8 +150,13 @@ final _router = GoRouter(
final redirectUrl =
state.uri.queryParameters['redirect_uri'] ??
state.uri.queryParameters['redirect_url'];
_routerLogger.info("Navigating to /login, redirect: $redirectUrl");
return LoginScreen(key: state.pageKey, redirectUrl: redirectUrl);
_routerLogger.info(
"Navigating to /login, redirect: $redirectUrl",
);
return LoginScreen(
key: state.pageKey,
redirectUrl: redirectUrl,
);
},
),
GoRoute(
@@ -153,7 +165,9 @@ final _router = GoRouter(
final consentChallenge =
state.uri.queryParameters['consent_challenge'];
if (consentChallenge == null) {
_routerLogger.warning("Consent screen loaded without a challenge.");
_routerLogger.warning(
"Consent screen loaded without a challenge.",
);
return const Scaffold(
body: Center(
child: Text('Error: Consent challenge is missing.'),
@@ -241,8 +255,7 @@ final _router = GoRouter(
return ErrorScreen(
errorId: params['id'],
errorCode: params['error'],
description:
params['error_description'] ?? params['message'],
description: params['error_description'] ?? params['message'],
);
},
),
@@ -252,9 +265,7 @@ final _router = GoRouter(
_routerLogger.info("Navigating to /settings (disabled)");
return ErrorScreen(
errorCode: 'settings_disabled',
description: tr(
'msg.userfront.settings.disabled',
),
description: tr('msg.userfront.settings.disabled'),
);
},
),
@@ -333,13 +344,7 @@ final _router = GoRouter(
// If not logged in and trying to access a protected page, redirect to /signin
if (!isLoggedIn) {
_routerLogger.info("Not logged in, redirecting to /signin");
// Preserve OIDC challenge if present
final loginChallenge = state.uri.queryParameters['login_challenge'];
final locale = requestedLocale;
if (loginChallenge != null) {
return '/$locale/signin?login_challenge=$loginChallenge';
}
return '/$locale/signin';
return buildSigninRedirectPath(requestedLocale, state.uri);
}
// If logged in and trying to access login page, redirect to root (dashboard)

View File

@@ -0,0 +1,36 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:userfront/core/i18n/locale_registry.dart';
void main() {
tearDown(() {
LocaleRegistry.resetForTest();
});
group('locale_registry', () {
test(
'extractSupportedLocaleCodesFromAssets excludes template and invalid',
() {
final locales = extractSupportedLocaleCodesFromAssets([
'assets/translations/template.toml',
'assets/translations/en.toml',
'assets/translations/ko.toml',
'assets/translations/pt_BR.toml',
'assets/translations/readme.txt',
'assets/translations/nested/ja.toml',
]);
expect(locales, ['en', 'ko']);
},
);
test('fallback locale prefers en when available', () {
LocaleRegistry.setSupportedLocaleCodesForTest(['ko', 'en']);
expect(LocaleRegistry.fallbackLocaleCode, 'en');
});
test('fallback locale uses first sorted code when en is absent', () {
LocaleRegistry.setSupportedLocaleCodesForTest(['ko', 'ja']);
expect(LocaleRegistry.fallbackLocaleCode, 'ja');
});
});
}

View File

@@ -1,7 +1,16 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:userfront/core/i18n/locale_registry.dart';
import 'package:userfront/core/i18n/locale_utils.dart';
void main() {
setUp(() {
LocaleRegistry.setSupportedLocaleCodesForTest(['en', 'ko']);
});
tearDown(() {
LocaleRegistry.resetForTest();
});
group('locale_utils', () {
test('normalizeLocaleCode handles supported locales', () {
expect(normalizeLocaleCode('ko'), 'ko');
@@ -50,7 +59,25 @@ void main() {
final uri = Uri.parse('/signin?redirect_uri=https://example.com');
expect(
buildLocalizedPath('ko', uri),
'/ko/signin?redirect_uri=https%3A%2F%2Fexample.com',
'/ko/signin?redirect_uri=https://example.com',
);
});
test('buildLocalizedPath preserves raw query order and duplicates', () {
final uri = Uri.parse(
'/signin?a=1&a=2&redirect_uri=https%3A%2F%2Fexample.com%2Fcb%3Fx%3D1%26y%3D2',
);
expect(
buildLocalizedPath('ko', uri),
'/ko/signin?a=1&a=2&redirect_uri=https%3A%2F%2Fexample.com%2Fcb%3Fx%3D1%26y%3D2',
);
});
test('buildLocalizedPath preserves fragment', () {
final uri = Uri.parse('/signin?notice=qr_login_required#auth');
expect(
buildLocalizedPath('ko', uri),
'/ko/signin?notice=qr_login_required#auth',
);
});
@@ -59,8 +86,28 @@ void main() {
expect(buildLocalizedPath('ko', Uri.parse('/en/profile')), '/ko/profile');
});
test('buildLocalizedPath drops unknown 2-letter prefix', () {
expect(buildLocalizedPath('ko', Uri.parse('/zz/signin')), '/ko/signin');
test('buildLocalizedPath keeps unknown 2-letter prefix as path', () {
expect(
buildLocalizedPath('ko', Uri.parse('/zz/signin')),
'/ko/zz/signin',
);
});
test('buildSigninRedirectPath keeps path without query', () {
expect(
buildSigninRedirectPath('ko', Uri.parse('/ko/profile')),
'/ko/signin',
);
});
test('buildSigninRedirectPath preserves full raw query', () {
final uri = Uri.parse(
'/ko/profile?a=1&a=2&redirect_uri=https%3A%2F%2Fexample.com%2Fcb%3Fx%3D1%26y%3D2&notice=qr_login_required',
);
expect(
buildSigninRedirectPath('ko', uri),
'/ko/signin?a=1&a=2&redirect_uri=https%3A%2F%2Fexample.com%2Fcb%3Fx%3D1%26y%3D2&notice=qr_login_required',
);
});
});
}