1
0
forked from baron/baron-sso

Merge feature/i18n into dev (userfront only)

This commit is contained in:
Lectom C Han
2026-02-12 21:53:42 +09:00
55 changed files with 3982 additions and 1104 deletions

View File

@@ -0,0 +1,51 @@
import 'package:easy_localization/easy_localization.dart' hide tr;
import 'package:flutter/material.dart';
import 'package:userfront/i18n.dart';
import '../services/web_window.dart';
import 'locale_storage.dart';
import 'locale_utils.dart';
class LocaleGate extends StatefulWidget {
const LocaleGate({super.key, required this.localeCode, required this.child});
final String localeCode;
final Widget child;
@override
State<LocaleGate> createState() => _LocaleGateState();
}
class _LocaleGateState extends State<LocaleGate> {
@override
void didChangeDependencies() {
super.didChangeDependencies();
_applyLocale();
}
@override
void didUpdateWidget(LocaleGate oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.localeCode != widget.localeCode) {
_applyLocale();
}
}
Future<void> _applyLocale() async {
final normalized = normalizeLocaleCode(widget.localeCode);
LocaleStorage.write(normalized);
webWindow.setTitle(
tr('ui.userfront.app_title'),
);
if (context.locale.languageCode == normalized) {
return;
}
await context.setLocale(Locale(normalized));
webWindow.setTitle(
tr('ui.userfront.app_title'),
);
}
@override
Widget build(BuildContext context) => widget.child;
}

View File

@@ -0,0 +1,7 @@
import 'locale_storage_stub.dart'
if (dart.library.html) 'locale_storage_web.dart';
abstract class LocaleStorage {
static String? read() => localeStorage.read();
static void write(String locale) => localeStorage.write(locale);
}

View File

@@ -0,0 +1,11 @@
class LocaleStorageImpl {
String? _locale;
String? read() => _locale;
void write(String locale) {
_locale = locale;
}
}
final localeStorage = LocaleStorageImpl();

View File

@@ -0,0 +1,120 @@
// ignore_for_file: avoid_web_libraries_in_flutter
import 'dart:html' as html;
import 'package:flutter/foundation.dart';
class LocaleStorageImpl {
static const _key = 'locale';
static const _legacyKey = 'baron_locale';
static final Map<String, String> _memory = {};
static bool _forceMemory = false;
static bool _forceSession = false;
@visibleForTesting
static void forceMemoryStorageForTests(bool value) {
_forceMemory = value;
if (!value) {
_memory.clear();
}
}
@visibleForTesting
static void forceSessionStorageForTests(bool value) {
_forceSession = value;
}
String? _read(String key) {
if (!_forceMemory && !_forceSession) {
try {
return html.window.localStorage[key];
} catch (_) {
// localStorage 접근이 차단된 경우 sessionStorage로 fallback.
try {
return html.window.sessionStorage[key];
} catch (_) {
// sessionStorage도 차단된 경우 메모리 fallback 사용.
}
}
}
if (!_forceMemory) {
try {
return html.window.sessionStorage[key];
} catch (_) {
// sessionStorage도 차단된 경우 메모리 fallback 사용.
}
}
return _memory[key];
}
void _write(String key, String value) {
if (!_forceMemory && !_forceSession) {
try {
html.window.localStorage[key] = value;
return;
} catch (_) {
// localStorage 접근이 차단된 경우 sessionStorage로 fallback.
try {
html.window.sessionStorage[key] = value;
return;
} catch (_) {
// sessionStorage도 차단된 경우 메모리 fallback 사용.
}
}
}
if (!_forceMemory) {
try {
html.window.sessionStorage[key] = value;
return;
} catch (_) {
// sessionStorage도 차단된 경우 메모리 fallback 사용.
}
}
_memory[key] = value;
}
void _remove(String key) {
if (!_forceMemory && !_forceSession) {
try {
html.window.localStorage.remove(key);
return;
} catch (_) {
// localStorage 접근이 차단된 경우 sessionStorage로 fallback.
try {
html.window.sessionStorage.remove(key);
return;
} catch (_) {
// sessionStorage도 차단된 경우 메모리 fallback 사용.
}
}
}
if (!_forceMemory) {
try {
html.window.sessionStorage.remove(key);
return;
} catch (_) {
// sessionStorage도 차단된 경우 메모리 fallback 사용.
}
}
_memory.remove(key);
}
String? read() {
final current = _read(_key);
if (current != null && current.isNotEmpty) {
return current;
}
final legacy = _read(_legacyKey);
if (legacy != null && legacy.isNotEmpty) {
_write(_key, legacy);
_remove(_legacyKey);
return legacy;
}
return null;
}
void write(String locale) {
_write(_key, locale);
}
}
final localeStorage = LocaleStorageImpl();

View File

@@ -0,0 +1,69 @@
import 'dart:ui';
import 'locale_storage.dart';
const supportedLocaleCodes = ['en', 'ko'];
const defaultLocaleCode = 'en';
String normalizeLocaleCode(String? code) {
if (code == null || code.isEmpty) {
return defaultLocaleCode;
}
final normalized = code.toLowerCase();
if (normalized == 'ko' || normalized.startsWith('ko-')) {
return 'ko';
}
if (normalized == 'en' || normalized.startsWith('en-')) {
return 'en';
}
return defaultLocaleCode;
}
String resolvePreferredLocaleCode() {
final stored = LocaleStorage.read();
if (stored != null && supportedLocaleCodes.contains(stored)) {
return stored;
}
final deviceLocale = PlatformDispatcher.instance.locale;
return normalizeLocaleCode(deviceLocale.languageCode);
}
String? extractLocaleFromPath(Uri uri) {
if (uri.pathSegments.isEmpty) {
return null;
}
final code = uri.pathSegments.first.toLowerCase();
if (supportedLocaleCodes.contains(code)) {
return code;
}
return null;
}
String stripLocalePath(Uri uri) {
final segments = uri.pathSegments;
if (segments.isNotEmpty && supportedLocaleCodes.contains(segments.first)) {
final rest = segments.skip(1).join('/');
if (rest.isEmpty) {
return '/';
}
return '/$rest';
}
return uri.path;
}
String buildLocalizedPath(String localeCode, Uri uri) {
final segments = uri.pathSegments;
Iterable<String> restSegments = segments;
if (segments.isNotEmpty) {
final head = segments.first.toLowerCase();
if (supportedLocaleCodes.contains(head) || head.length == 2) {
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();
}

View File

@@ -0,0 +1,22 @@
import 'dart:ui';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart';
import 'package:toml/toml.dart';
class TomlAssetLoader extends AssetLoader {
const TomlAssetLoader();
@override
Future<Map<String, dynamic>> load(String path, Locale locale) async {
final assetPath = '$path/${locale.languageCode}.toml';
try {
final content = await rootBundle.loadString(assetPath);
final document = TomlDocument.parse(content);
return document.toMap();
} catch (e) {
// 로딩 실패 시 빈 맵을 반환해 렌더링을 지속합니다.
return {};
}
}
}

View File

@@ -11,11 +11,25 @@ class AuthProxyService {
if (!dotenv.isInitialized) {
return fallback;
}
return dotenv.env[key] ?? fallback;
final value = dotenv.env[key];
if (value == null || value.trim().isEmpty) {
return fallback;
}
return value;
}
static String _fallbackOrigin() {
try {
final origin = Uri.base.origin;
if (origin.isNotEmpty && origin != 'null') {
return origin;
}
} catch (_) {}
return 'http://sso.hmac.kr';
}
static String get _baseUrl {
final rawUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
final rawUrl = _envOrDefault('BACKEND_URL', _fallbackOrigin());
// 배포 환경에서 $ 기호나 공백이 섞여 들어오는 경우를 방지하기 위해 정제합니다.
return rawUrl.replaceAll(r'$', '').trim().replaceAll(RegExp(r'/$'), '');
}
@@ -103,7 +117,7 @@ class AuthProxyService {
bool? drySend,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/enchanted-link/init');
final userfrontUrl = _envOrDefault('USERFRONT_URL', 'http://sso.hmac.kr');
final userfrontUrl = _envOrDefault('USERFRONT_URL', _fallbackOrigin());
final body = <String, dynamic>{'loginId': loginId, 'uri': userfrontUrl};
if (_shouldSendDrySend(drySend)) {
@@ -269,7 +283,6 @@ class AuthProxyService {
errorBody['error'] ??
tr(
'err.userfront.auth_proxy.login_failed',
fallback: '로그인에 실패했습니다.',
),
);
}
@@ -294,7 +307,6 @@ class AuthProxyService {
errorBody['error'] ??
tr(
'err.userfront.auth_proxy.consent_fetch',
fallback: '동의 정보를 가져오지 못했습니다.',
),
);
}
@@ -324,7 +336,6 @@ class AuthProxyService {
errorBody['error'] ??
tr(
'err.userfront.auth_proxy.consent_accept',
fallback: '동의 처리에 실패했습니다.',
),
);
}
@@ -350,7 +361,6 @@ class AuthProxyService {
errorBody['error'] ??
tr(
'err.userfront.auth_proxy.consent_reject',
fallback: '동의 거부에 실패했습니다.',
),
);
}
@@ -381,7 +391,6 @@ class AuthProxyService {
errorBody['error'] ??
tr(
'err.userfront.auth_proxy.oidc_accept',
fallback: 'OIDC 로그인 승인에 실패했습니다.',
),
);
}
@@ -412,7 +421,6 @@ class AuthProxyService {
errorBody['error'] ??
tr(
'err.userfront.auth_proxy.password_reset_init',
fallback: '비밀번호 재설정을 시작하지 못했습니다.',
),
);
}
@@ -447,7 +455,6 @@ class AuthProxyService {
errorBody['error'] ??
tr(
'err.userfront.auth_proxy.password_reset_complete',
fallback: '비밀번호 재설정에 실패했습니다.',
),
);
}
@@ -780,7 +787,6 @@ class AuthProxyService {
errorBody['error'] ??
tr(
'err.userfront.auth_proxy.linked_app_revoke',
fallback: '연동 해지에 실패했습니다.',
),
);
}

View File

@@ -1,4 +1,6 @@
class WebWindow {
void setTitle(String title) {}
void redirectTo(String url) {}
void alert(String message) {}

View File

@@ -3,6 +3,10 @@
import 'dart:html' as html;
class WebWindow {
void setTitle(String title) {
html.document.title = title;
}
void redirectTo(String url) {
html.window.location.href = url;
}

View File

@@ -0,0 +1,65 @@
import 'package:easy_localization/easy_localization.dart' hide tr;
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:userfront/i18n.dart';
import '../i18n/locale_storage.dart';
import '../i18n/locale_utils.dart';
class LanguageSelector extends StatelessWidget {
const LanguageSelector({super.key, this.compact = false});
final bool compact;
@override
Widget build(BuildContext context) {
final current = context.locale.languageCode;
final items = [
DropdownMenuItem(
value: 'ko',
child: Text(tr('ui.common.language_ko')),
),
DropdownMenuItem(
value: 'en',
child: Text(tr('ui.common.language_en', fallback: 'English')),
),
];
final iconSize = compact ? 16.0 : 18.0;
final dropdown = DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: current,
items: items,
isDense: true,
icon: Icon(Icons.arrow_drop_down, size: compact ? 18 : 20),
onChanged: (value) async {
if (value == null || value == current) {
return;
}
LocaleStorage.write(value);
await context.setLocale(Locale(value));
final uri = GoRouterState.of(context).uri;
final target = buildLocalizedPath(value, uri);
if (context.mounted) {
context.go(target);
}
},
),
);
return Padding(
padding: EdgeInsets.only(top: compact ? 0 : 2),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: compact ? 24 : 28),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.language, size: iconSize),
const SizedBox(width: 6),
dropdown,
],
),
),
);
}
}