첫 커밋: 로컬 프로젝트 업로드

This commit is contained in:
2026-06-10 15:51:34 +09:00
commit 6a8dbeb2e9
1211 changed files with 312864 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
const Map<String, String> internalErrorWhitelistMessageKeys = {
'settings_disabled': 'msg.userfront.error.whitelist.settings_disabled',
'invalid_session': 'msg.userfront.error.whitelist.invalid_session',
'verification_required':
'msg.userfront.error.whitelist.verification_required',
'recovery_expired': 'msg.userfront.error.whitelist.recovery_expired',
'recovery_invalid': 'msg.userfront.error.whitelist.recovery_invalid',
'rate_limited': 'msg.userfront.error.whitelist.rate_limited',
'not_found': 'msg.userfront.error.whitelist.not_found',
'bad_request': 'msg.userfront.error.whitelist.bad_request',
'password_or_email_mismatch':
'msg.userfront.error.whitelist.password_or_email_mismatch',
'tenant_not_allowed': 'msg.userfront.error.whitelist.tenant_not_allowed',
};
const Set<String> oryBypassErrorCodes = {
'access_denied',
'consent_required',
'interaction_required',
'invalid_client',
'invalid_grant',
'invalid_request',
'invalid_scope',
'login_required',
'request_forbidden',
'server_error',
'temporarily_unavailable',
'unauthorized_client',
'unsupported_response_type',
};

View File

@@ -0,0 +1,75 @@
import 'dart:async';
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> {
bool _syncScheduled = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_scheduleLocaleSync();
}
@override
void didUpdateWidget(LocaleGate oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.localeCode != widget.localeCode) {
_scheduleLocaleSync();
}
}
void _scheduleLocaleSync() {
if (_syncScheduled) {
return;
}
_syncScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_syncScheduled = false;
if (!mounted) {
return;
}
unawaited(_applyLocale());
});
}
Future<void> _applyLocale() async {
if (!mounted) {
return;
}
final normalized = normalizeLocaleCode(widget.localeCode);
LocaleStorage.write(normalized);
final localization = EasyLocalization.of(context);
if (localization == null) {
return;
}
if (localization.currentLocale?.languageCode == normalized) {
webWindow.setTitle(tr('ui.userfront.app_title'));
return;
}
await localization.setLocale(Locale(normalized));
if (!mounted) {
return;
}
webWindow.setTitle(tr('ui.userfront.app_title'));
}
@override
Widget build(BuildContext context) => widget.child;
}

View File

@@ -0,0 +1,115 @@
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 void primeWithDefaults({
Iterable<String> localeCodes = const ['en', 'ko'],
}) {
if (_localeCodes.isNotEmpty) {
return;
}
_localeCodes.addAll(
localeCodes
.map((code) => code.toLowerCase().replaceAll('_', '-'))
.where(_isValidLocaleCode),
);
if (_localeCodes.isEmpty) {
_localeCodes.add(_safeFallbackLocaleCode);
}
}
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

@@ -0,0 +1,59 @@
import 'package:flutter/foundation.dart';
import 'locale_storage_backend.dart';
import 'locale_storage_stub.dart'
if (dart.library.js_interop) 'locale_storage_web.dart';
abstract class LocaleStorage {
static bool _forceMemory = false;
static bool _forceSession = false;
static void _syncTestMode() {
if (_forceMemory) {
localeStorage.setTestMode(LocaleStorageTestMode.memoryOnly);
return;
}
if (_forceSession) {
localeStorage.setTestMode(LocaleStorageTestMode.sessionOnly);
return;
}
localeStorage.setTestMode(LocaleStorageTestMode.normal);
}
static String? read() => localeStorage.read();
static void write(String locale) => localeStorage.write(locale);
@visibleForTesting
static void setTestModeForTests(LocaleStorageTestMode mode) {
_forceMemory = mode == LocaleStorageTestMode.memoryOnly;
_forceSession = mode == LocaleStorageTestMode.sessionOnly;
_syncTestMode();
}
@visibleForTesting
static void clearForTests() {
localeStorage.clearForTests();
_forceMemory = false;
_forceSession = false;
}
@visibleForTesting
static void seedLegacyForTests(String locale) {
localeStorage.seedLegacyForTests(locale);
}
@visibleForTesting
static LocaleStorageDebugState debugStateForTests() {
return localeStorage.debugStateForTests();
}
static void forceMemoryStorageForTests(bool value) {
_forceMemory = value;
_syncTestMode();
}
static void forceSessionStorageForTests(bool value) {
_forceSession = value;
_syncTestMode();
}
}

View File

@@ -0,0 +1,38 @@
import 'package:flutter/foundation.dart';
enum LocaleStorageTestMode { normal, sessionOnly, memoryOnly }
@immutable
class LocaleStorageDebugState {
const LocaleStorageDebugState({
required this.mode,
this.localCurrent,
this.localLegacy,
this.sessionCurrent,
this.sessionLegacy,
this.memoryCurrent,
this.memoryLegacy,
});
final LocaleStorageTestMode mode;
final String? localCurrent;
final String? localLegacy;
final String? sessionCurrent;
final String? sessionLegacy;
final String? memoryCurrent;
final String? memoryLegacy;
}
abstract interface class LocaleStorageBackend {
String? read();
void write(String locale);
void setTestMode(LocaleStorageTestMode mode);
void clearForTests();
void seedLegacyForTests(String locale);
LocaleStorageDebugState debugStateForTests();
}

View File

@@ -0,0 +1,245 @@
import 'locale_storage_backend.dart';
import 'locale_storage_policy.dart';
enum _StorageTarget { local, session, memory }
abstract interface class LocaleStorageTarget {
String? read(String key);
bool write(String key, String value);
bool remove(String key);
void clear();
}
class LocaleStorageNoopTarget implements LocaleStorageTarget {
const LocaleStorageNoopTarget();
@override
String? read(String key) => null;
@override
bool write(String key, String value) => false;
@override
bool remove(String key) => false;
@override
void clear() {}
}
class LocaleStorageCallbackTarget implements LocaleStorageTarget {
LocaleStorageCallbackTarget({
required this.readCallback,
required this.writeCallback,
required this.removeCallback,
required this.clearCallback,
});
final String? Function(String key) readCallback;
final void Function(String key, String value) writeCallback;
final void Function(String key) removeCallback;
final void Function() clearCallback;
@override
String? read(String key) => readCallback(key);
@override
bool write(String key, String value) {
writeCallback(key, value);
return true;
}
@override
bool remove(String key) {
removeCallback(key);
return true;
}
@override
void clear() => clearCallback();
}
class LocaleStorageEngine implements LocaleStorageBackend {
LocaleStorageEngine({
required LocaleStorageTarget localTarget,
required LocaleStorageTarget sessionTarget,
}) : _localTarget = localTarget,
_sessionTarget = sessionTarget;
final LocaleStorageTarget _localTarget;
final LocaleStorageTarget _sessionTarget;
final Map<String, String> _memory = {};
LocaleStorageTestMode _mode = LocaleStorageTestMode.normal;
List<_StorageTarget> _fallbackTargets() {
switch (_mode) {
case LocaleStorageTestMode.normal:
return [
_StorageTarget.local,
_StorageTarget.session,
_StorageTarget.memory,
];
case LocaleStorageTestMode.sessionOnly:
return [_StorageTarget.session, _StorageTarget.memory];
case LocaleStorageTestMode.memoryOnly:
return [_StorageTarget.memory];
}
}
String? _safeReadTarget(_StorageTarget target, String key) {
try {
switch (target) {
case _StorageTarget.local:
return _localTarget.read(key);
case _StorageTarget.session:
return _sessionTarget.read(key);
case _StorageTarget.memory:
return _memory[key];
}
} catch (_) {
return null;
}
}
bool _safeWriteTarget(_StorageTarget target, String key, String value) {
try {
switch (target) {
case _StorageTarget.local:
return _localTarget.write(key, value);
case _StorageTarget.session:
return _sessionTarget.write(key, value);
case _StorageTarget.memory:
_memory[key] = value;
return true;
}
} catch (_) {
return false;
}
}
bool _safeRemoveTarget(_StorageTarget target, String key) {
try {
switch (target) {
case _StorageTarget.local:
return _localTarget.remove(key);
case _StorageTarget.session:
return _sessionTarget.remove(key);
case _StorageTarget.memory:
_memory.remove(key);
return true;
}
} catch (_) {
return false;
}
}
void _safeClearTarget(_StorageTarget target) {
try {
switch (target) {
case _StorageTarget.local:
_localTarget.clear();
case _StorageTarget.session:
_sessionTarget.clear();
case _StorageTarget.memory:
_memory.clear();
}
} catch (_) {
// 테스트 정리 단계에서는 clear 예외를 무시합니다.
}
}
String? _readByKey(String key) {
for (final target in _fallbackTargets()) {
final value = _safeReadTarget(target, key);
if (value != null) {
return value;
}
}
return null;
}
void _writeByKey(String key, String value) {
for (final target in _fallbackTargets()) {
if (_safeWriteTarget(target, key, value)) {
return;
}
}
}
void _removeEverywhere(String key) {
_safeRemoveTarget(_StorageTarget.local, key);
_safeRemoveTarget(_StorageTarget.session, key);
_memory.remove(key);
}
@override
String? read() {
final current = _readByKey(LocaleStoragePolicy.currentKey);
if (LocaleStoragePolicy.hasValue(current)) {
return current;
}
final legacy = _readByKey(LocaleStoragePolicy.legacyKey);
if (LocaleStoragePolicy.shouldMigrateLegacy(
current: current,
legacy: legacy,
) &&
legacy != null) {
_writeByKey(LocaleStoragePolicy.currentKey, legacy);
_removeEverywhere(LocaleStoragePolicy.legacyKey);
return legacy;
}
return null;
}
@override
void write(String locale) {
_writeByKey(LocaleStoragePolicy.currentKey, locale);
}
@override
void setTestMode(LocaleStorageTestMode mode) {
_mode = mode;
}
@override
void clearForTests() {
_safeClearTarget(_StorageTarget.local);
_safeClearTarget(_StorageTarget.session);
_memory.clear();
_mode = LocaleStorageTestMode.normal;
}
@override
void seedLegacyForTests(String locale) {
_writeByKey(LocaleStoragePolicy.legacyKey, locale);
}
@override
LocaleStorageDebugState debugStateForTests() {
return LocaleStorageDebugState(
mode: _mode,
localCurrent: _safeReadTarget(
_StorageTarget.local,
LocaleStoragePolicy.currentKey,
),
localLegacy: _safeReadTarget(
_StorageTarget.local,
LocaleStoragePolicy.legacyKey,
),
sessionCurrent: _safeReadTarget(
_StorageTarget.session,
LocaleStoragePolicy.currentKey,
),
sessionLegacy: _safeReadTarget(
_StorageTarget.session,
LocaleStoragePolicy.legacyKey,
),
memoryCurrent: _memory[LocaleStoragePolicy.currentKey],
memoryLegacy: _memory[LocaleStoragePolicy.legacyKey],
);
}
}

View File

@@ -0,0 +1,13 @@
class LocaleStoragePolicy {
static const currentKey = 'locale';
static const legacyKey = 'baron_locale';
static bool hasValue(String? value) => value != null && value.isNotEmpty;
static bool shouldMigrateLegacy({
required String? current,
required String? legacy,
}) {
return !hasValue(current) && hasValue(legacy);
}
}

View File

@@ -0,0 +1,7 @@
import 'locale_storage_backend.dart';
import 'locale_storage_engine.dart';
final LocaleStorageBackend localeStorage = LocaleStorageEngine(
localTarget: const LocaleStorageNoopTarget(),
sessionTarget: const LocaleStorageNoopTarget(),
);

View File

@@ -0,0 +1,22 @@
// ignore_for_file: avoid_web_libraries_in_flutter
import 'package:web/web.dart' as web;
import 'locale_storage_backend.dart';
import 'locale_storage_engine.dart';
final LocaleStorageBackend localeStorage = LocaleStorageEngine(
localTarget: LocaleStorageCallbackTarget(
readCallback: (key) => web.window.localStorage.getItem(key),
writeCallback: (key, value) => web.window.localStorage.setItem(key, value),
removeCallback: (key) => web.window.localStorage.removeItem(key),
clearCallback: () => web.window.localStorage.clear(),
),
sessionTarget: LocaleStorageCallbackTarget(
readCallback: (key) => web.window.sessionStorage.getItem(key),
writeCallback: (key, value) =>
web.window.sessionStorage.setItem(key, value),
removeCallback: (key) => web.window.sessionStorage.removeItem(key),
clearCallback: () => web.window.sessionStorage.clear(),
),
);

View File

@@ -0,0 +1,117 @@
import 'dart:ui';
import 'locale_storage.dart';
import 'locale_registry.dart';
String get defaultLocaleCode => LocaleRegistry.fallbackLocaleCode;
String normalizeLocaleCode(String? code) {
final supportedLocaleCodes = LocaleRegistry.supportedLocaleCodes;
final fallbackLocaleCode = LocaleRegistry.fallbackLocaleCode;
if (code == null || code.isEmpty) {
return fallbackLocaleCode;
}
final normalized = code.toLowerCase().replaceAll('_', '-');
if (supportedLocaleCodes.contains(normalized)) {
return normalized;
}
final languageCode = normalized.split('-').first;
if (supportedLocaleCodes.contains(languageCode)) {
return languageCode;
}
return fallbackLocaleCode;
}
String resolvePreferredLocaleCode() {
final stored = LocaleStorage.read();
if (stored != null && stored.isNotEmpty) {
final normalizedStored = normalizeLocaleCode(stored);
if (LocaleRegistry.contains(normalizedStored)) {
return normalizedStored;
}
}
final deviceLocale = PlatformDispatcher.instance.locale;
final countryCode = deviceLocale.countryCode;
final languageTag = countryCode == null || countryCode.isEmpty
? deviceLocale.languageCode
: '${deviceLocale.languageCode}-$countryCode';
return normalizeLocaleCode(languageTag);
}
String? extractLocaleFromPath(Uri uri) {
final supportedLocaleCodes = LocaleRegistry.supportedLocaleCodes;
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 supportedLocaleCodes = LocaleRegistry.supportedLocaleCodes;
final segments = uri.pathSegments;
if (segments.isNotEmpty &&
supportedLocaleCodes.contains(segments.first.toLowerCase())) {
final rest = segments.skip(1).join('/');
if (rest.isEmpty) {
return '/';
}
return '/$rest';
}
return uri.path;
}
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)) {
restSegments = segments.skip(1);
}
}
final newPath = '/${[localeCode, ...restSegments].join('/')}';
// Return only the path and query part to avoid GoRouter confusion with full URLs
final newUri = uri.replace(path: newPath);
String result = newUri.path;
if (newUri.hasQuery) {
result += '?${newUri.query}';
}
if (newUri.hasFragment) {
result += '#${newUri.fragment}';
}
return result;
}
String buildSigninRedirectPath(String localeCode, Uri uri) {
final newPath = '/$localeCode/signin';
final newUri = uri.replace(path: newPath);
String result = newUri.path;
if (newUri.hasQuery) {
result += '?${newUri.query}';
}
if (newUri.hasFragment) {
result += '#${newUri.fragment}';
}
return result;
}
String buildLocalizedHomePath(Uri uri, {String? preferredLocaleCode}) {
final resolvedLocale =
extractLocaleFromPath(uri) ??
normalizeLocaleCode(preferredLocaleCode ?? resolvePreferredLocaleCode());
return '/$resolvedLocale/dashboard';
}
String buildLocalizedSigninPath(Uri uri, {String? preferredLocaleCode}) {
final resolvedLocale =
extractLocaleFromPath(uri) ??
normalizeLocaleCode(preferredLocaleCode ?? resolvePreferredLocaleCode());
return '/$resolvedLocale/signin';
}

View File

@@ -0,0 +1,54 @@
import 'dart:ui';
import 'package:easy_localization/easy_localization.dart';
import '../../i18n_data.dart';
class TomlAssetLoader extends AssetLoader {
const TomlAssetLoader();
@override
Future<Map<String, dynamic>> load(String path, Locale locale) async {
final languageCode = locale.languageCode.toLowerCase();
return switch (languageCode) {
'ko' => _normalizedKoStrings,
'en' => _normalizedEnStrings,
_ => _normalizedEnStrings,
};
}
}
final Map<String, dynamic> _normalizedKoStrings = _normalizeFlatTranslations(
koStrings,
);
final Map<String, dynamic> _normalizedEnStrings = _normalizeFlatTranslations(
enStrings,
);
Map<String, dynamic> _normalizeFlatTranslations(Map<String, String> flatMap) =>
Map.fromEntries(
flatMap.entries
.where((entry) => _isUserfrontTranslationKey(entry.key))
.map(
(entry) =>
MapEntry(entry.key, _normalizeLocalizationValue(entry.value)),
),
);
bool _isUserfrontTranslationKey(String key) {
return key.startsWith('domain.') ||
key.startsWith('err.userfront.') ||
key.startsWith('msg.userfront.') ||
key.startsWith('ui.userfront.') ||
key.startsWith('ui.common.');
}
String _normalizeLocalizationValue(String value) {
return value
.replaceAllMapped(
RegExp(r'\{\{\s*([a-zA-Z0-9_]+)\s*\}\}'),
(match) => '{${match.group(1)}}',
)
.replaceAll(r'\\n', '\n')
.replaceAll(r'\n', '\n');
}

View File

@@ -0,0 +1,16 @@
import 'package:flutter/foundation.dart';
import '../services/auth_token_store.dart';
class AuthNotifier extends ChangeNotifier {
static final AuthNotifier instance = AuthNotifier();
Future<void> onLoginSuccess(String token, {String? provider}) async {
AuthTokenStore.setToken(token, provider: provider);
AuthTokenStore.clearPendingProvider();
notifyListeners();
}
void notify() {
notifyListeners();
}
}

View File

@@ -0,0 +1,40 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'runtime_env.dart';
class AuditService {
static String get _baseUrl => runtimeBackendUrl();
static Future<void> logEvent({
required String userId,
required String eventType,
required String status,
String? details,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/audit');
try {
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'user_id': userId,
'event_type': eventType,
'status': status,
'details': details,
}),
);
if (response.statusCode >= 200 && response.statusCode < 300) {
debugPrint('Audit log sent successfully');
} else {
debugPrint(
'Failed to send audit log: ${response.statusCode} ${response.body}',
);
}
} catch (e) {
debugPrint('Error sending audit log: $e');
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,45 @@
import 'auth_token_store_stub.dart'
if (dart.library.js_interop) 'auth_token_store_web.dart';
class AuthTokenStore {
static bool hasToken() {
final token = getToken();
return token != null && token.isNotEmpty;
}
static String? getToken() => authTokenStore.getToken();
static String? getProvider() => authTokenStore.getProvider();
static bool usesCookie() => authTokenStore.usesCookie();
static void setToken(String token, {String? provider}) {
authTokenStore.setToken(token, provider: provider);
}
static void setCookieMode({String? provider}) {
authTokenStore.setCookieMode(provider: provider);
}
static String? getPendingProvider() => authTokenStore.getPendingProvider();
static void setPendingProvider(String? provider) {
authTokenStore.setPendingProvider(provider);
}
static void clearPendingProvider() {
authTokenStore.setPendingProvider(null);
}
static void skipNextCookieSessionCheck() {
authTokenStore.skipNextCookieSessionCheck();
}
static bool consumeSkipCookieSessionCheck() {
return authTokenStore.consumeSkipCookieSessionCheck();
}
static void clear() {
authTokenStore.clear();
}
}

View File

@@ -0,0 +1,124 @@
abstract class AuthTokenStorageTarget {
String? read(String key);
void write(String key, String value);
void remove(String key);
}
class AuthTokenStoreBackend {
AuthTokenStoreBackend({
required AuthTokenStorageTarget localTarget,
required AuthTokenStorageTarget sessionTarget,
}) : _targets = [localTarget, sessionTarget, _MemoryStorageTarget()];
static const _tokenKey = 'baron_auth_token';
static const _providerKey = 'baron_auth_provider';
static const _cookieModeKey = 'baron_auth_cookie_mode';
static const _pendingProviderKey = 'baron_auth_pending_provider';
static const _skipCookieSessionCheckKey =
'baron_auth_skip_cookie_session_check';
final List<AuthTokenStorageTarget> _targets;
String? getToken() => _readFirst(_tokenKey);
String? getProvider() => _readFirst(_providerKey);
bool usesCookie() => _readFirst(_cookieModeKey) == '1';
void setToken(String token, {String? provider}) {
_writeAll(_tokenKey, token);
_removeAll(_cookieModeKey);
if (provider != null) {
_writeAll(_providerKey, provider);
}
}
void setCookieMode({String? provider}) {
_writeAll(_cookieModeKey, '1');
_removeAll(_tokenKey);
if (provider != null) {
_writeAll(_providerKey, provider);
}
}
String? getPendingProvider() => _readFirst(_pendingProviderKey);
bool consumeSkipCookieSessionCheck() {
final shouldSkip = _readFirst(_skipCookieSessionCheckKey) == '1';
if (shouldSkip) {
_removeAll(_skipCookieSessionCheckKey);
}
return shouldSkip;
}
void setPendingProvider(String? provider) {
if (provider == null || provider.isEmpty) {
_removeAll(_pendingProviderKey);
return;
}
_writeAll(_pendingProviderKey, provider);
}
void clear() {
_removeAll(_tokenKey);
_removeAll(_providerKey);
_removeAll(_cookieModeKey);
_removeAll(_pendingProviderKey);
_removeAll(_skipCookieSessionCheckKey);
}
void skipNextCookieSessionCheck() {
_writeAll(_skipCookieSessionCheckKey, '1');
}
String? _readFirst(String key) {
for (final target in _targets) {
try {
final value = target.read(key);
if (value != null && value.isNotEmpty) {
return value;
}
} catch (_) {
continue;
}
}
return null;
}
void _writeAll(String key, String value) {
for (final target in _targets) {
try {
target.write(key, value);
} catch (_) {
continue;
}
}
}
void _removeAll(String key) {
for (final target in _targets) {
try {
target.remove(key);
} catch (_) {
continue;
}
}
}
}
class _MemoryStorageTarget implements AuthTokenStorageTarget {
final Map<String, String> _memory = {};
@override
String? read(String key) => _memory[key];
@override
void remove(String key) {
_memory.remove(key);
}
@override
void write(String key, String value) {
_memory[key] = value;
}
}

View File

@@ -0,0 +1,53 @@
class AuthTokenStore {
String? _token;
String? _provider;
bool _cookieMode = false;
String? _pendingProvider;
bool _skipCookieSessionCheck = false;
String? getToken() => _token;
String? getProvider() => _provider;
bool usesCookie() => _cookieMode;
void setToken(String token, {String? provider}) {
_token = token;
_cookieMode = false;
_provider = provider;
}
void setCookieMode({String? provider}) {
_cookieMode = true;
_token = null;
if (provider != null) {
_provider = provider;
}
}
String? getPendingProvider() => _pendingProvider;
bool consumeSkipCookieSessionCheck() {
final shouldSkip = _skipCookieSessionCheck;
_skipCookieSessionCheck = false;
return shouldSkip;
}
void setPendingProvider(String? provider) {
_pendingProvider = provider;
}
void skipNextCookieSessionCheck() {
_skipCookieSessionCheck = true;
}
void clear() {
_token = null;
_provider = null;
_cookieMode = false;
_pendingProvider = null;
_skipCookieSessionCheck = false;
}
}
final authTokenStore = AuthTokenStore();

View File

@@ -0,0 +1,48 @@
// ignore_for_file: avoid_web_libraries_in_flutter
import 'dart:js_interop';
import 'auth_token_store_backend.dart';
@JS('window.localStorage')
external _JSStorage get _localStorage;
@JS('window.sessionStorage')
external _JSStorage get _sessionStorage;
@JS()
extension type _JSStorage(JSObject _) implements JSObject {
external String? getItem(String key);
external void setItem(String key, String value);
external void removeItem(String key);
}
class AuthTokenStore extends AuthTokenStoreBackend {
AuthTokenStore()
: super(
localTarget: _JsStorageTarget(_localStorage),
sessionTarget: _JsStorageTarget(_sessionStorage),
);
}
class _JsStorageTarget implements AuthTokenStorageTarget {
_JsStorageTarget(this._storage);
final _JSStorage _storage;
@override
String? read(String key) {
return _storage.getItem(key);
}
@override
void remove(String key) {
_storage.removeItem(key);
}
@override
void write(String key, String value) {
_storage.setItem(key, value);
}
}
final authTokenStore = AuthTokenStore();

View File

@@ -0,0 +1,6 @@
import 'package:http/http.dart' as http;
import 'http_client_stub.dart' if (dart.library.html) 'http_client_web.dart';
http.Client createHttpClient({bool withCredentials = false}) {
return httpClientFactory.create(withCredentials: withCredentials);
}

View File

@@ -0,0 +1,9 @@
import 'package:http/http.dart' as http;
class HttpClientFactory {
http.Client create({bool withCredentials = false}) {
return http.Client();
}
}
final httpClientFactory = HttpClientFactory();

View File

@@ -0,0 +1,12 @@
import 'package:http/browser_client.dart';
import 'package:http/http.dart' as http;
class HttpClientFactory {
http.Client create({bool withCredentials = false}) {
final client = BrowserClient();
client.withCredentials = withCredentials;
return client;
}
}
final httpClientFactory = HttpClientFactory();

View File

@@ -0,0 +1,139 @@
class LogPolicy {
static const Set<String> _sensitiveKeys = {
'password',
'currentpassword',
'newpassword',
'oldpassword',
'token',
'accesstoken',
'refreshtoken',
'secret',
'clientsecret',
'authorization',
'cookie',
'setcookie',
'verificationcode',
'code',
'loginchallenge',
'loginverifier',
'sessionjwt',
'accessjwt',
'refreshjwt',
};
static bool isProductionEnv(String? appEnv) {
final env = (appEnv ?? '').trim().toLowerCase();
return env == 'prod' ||
env == 'production' ||
env == 'stage' ||
env == 'staging';
}
static ({bool enabled, bool specified}) parseOptionalBoolFlag(String? raw) {
final value = (raw ?? '').trim().toLowerCase();
if (value == '1' ||
value == 'true' ||
value == 'yes' ||
value == 'y' ||
value == 'on') {
return (enabled: true, specified: true);
}
if (value == '0' ||
value == 'false' ||
value == 'no' ||
value == 'n' ||
value == 'off') {
return (enabled: false, specified: true);
}
return (enabled: false, specified: false);
}
static bool debugEnabled({
required String? appEnv,
required String? productionDebugFlag,
}) {
if (!isProductionEnv(appEnv)) {
return true;
}
final flag = parseOptionalBoolFlag(productionDebugFlag);
return flag.specified && flag.enabled;
}
static bool shouldRelayClientLog({
required String level,
required String? appEnv,
required String? productionDebugFlag,
}) {
final flag = parseOptionalBoolFlag(productionDebugFlag);
final debugRelayEnabled = isProductionEnv(appEnv)
? flag.specified && flag.enabled
: !(flag.specified && !flag.enabled);
if (debugRelayEnabled) {
return true;
}
final normalized = level.trim().toUpperCase();
return normalized == 'SEVERE' ||
normalized == 'ERROR' ||
normalized == 'WARNING' ||
normalized == 'WARN';
}
static String sanitizeMessage(String message) {
if (message.trim().isEmpty) {
return message;
}
var sanitized = message.replaceAllMapped(
RegExp(
r'"(password|currentpassword|newpassword|oldpassword|token|accesstoken|refreshtoken|secret|clientsecret|authorization|cookie|setcookie|verificationcode|code|loginchallenge|loginverifier|sessionjwt|accessjwt|refreshjwt)"\s*:\s*"[^"]*"',
caseSensitive: false,
),
(match) {
final key = match.group(1) ?? 'sensitive';
return '"$key":"*****"';
},
);
sanitized = sanitized.replaceAllMapped(
RegExp(
r'\b(password|current_password|currentpassword|new_password|newpassword|old_password|oldpassword|token|access_token|accesstoken|refresh_token|refreshtoken|authorization|cookie|session_jwt|sessionjwt|access_jwt|accessjwt|refresh_jwt|refreshjwt)\b\s*[:=]\s*([^\s,;]+)',
caseSensitive: false,
),
(match) {
final key = match.group(1) ?? 'sensitive';
return '$key=*****';
},
);
return sanitized;
}
static Map<String, dynamic> sanitizeData(Map<String, dynamic> input) {
final output = <String, dynamic>{};
for (final entry in input.entries) {
if (_isSensitiveKey(entry.key)) {
output[entry.key] = '*****';
} else {
output[entry.key] = _sanitizeValue(entry.value);
}
}
return output;
}
static dynamic _sanitizeValue(dynamic value) {
if (value is Map<String, dynamic>) {
return sanitizeData(value);
}
if (value is List) {
return value.map(_sanitizeValue).toList(growable: false);
}
if (value is String) {
return sanitizeMessage(value);
}
return value;
}
static bool _isSensitiveKey(String key) {
var normalized = key.trim().toLowerCase();
normalized = normalized.replaceAll(RegExp(r'[-_.\s]'), '');
return _sensitiveKeys.contains(normalized);
}
}

View File

@@ -0,0 +1,111 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart' as std_log;
import 'package:logger/logger.dart' as pretty_log;
import 'auth_proxy_service.dart';
import 'log_policy.dart';
import 'runtime_env.dart';
/// Global Logger Service for Baron SSO Frontend
class LoggerService {
static final LoggerService _instance = LoggerService._internal();
factory LoggerService() => _instance;
late final pretty_log.Logger _prettyLogger;
late final String _appEnv;
late final String _productionDebugFlag;
LoggerService._internal() {
_appEnv = envOrDefault('APP_ENV', 'dev');
_productionDebugFlag = envOrDefault(
'CLIENT_LOG_DEBUG',
envOrDefault('USERFRONT_DEBUG_LOG', ''),
);
final debugEnabled = LogPolicy.debugEnabled(
appEnv: _appEnv,
productionDebugFlag: _productionDebugFlag,
);
// 1. Initialize Pretty Logger for Dev
_prettyLogger = pretty_log.Logger(
printer: pretty_log.PrettyPrinter(
methodCount: 0,
errorMethodCount: 8,
lineLength: 120,
colors: true,
printEmojis: true,
dateTimeFormat: pretty_log.DateTimeFormat.onlyTimeAndSinceStart,
),
);
// 2. Configure Standard Logger (logging package)
std_log.Logger.root.level = debugEnabled
? std_log.Level.ALL
: std_log.Level.WARNING;
std_log.Logger.root.onRecord.listen((record) {
if (kReleaseMode) {
// [Production] Log as JSON
_logJson(record);
} else {
// [Development] Log using Pretty Printer
_logPretty(record);
}
});
}
/// Initialize the logger. Call this in main.dart
static void init() {
// Accessing the instance triggers the constructor
LoggerService();
std_log.Logger('BaronSSO').info('Logger initialized');
}
void _logPretty(std_log.LogRecord record) {
if (record.level >= std_log.Level.SEVERE) {
_prettyLogger.e(
record.message,
error: record.error,
stackTrace: record.stackTrace,
);
} else if (record.level >= std_log.Level.WARNING) {
_prettyLogger.w(record.message);
} else if (record.level >= std_log.Level.INFO) {
_prettyLogger.i(record.message);
} else {
_prettyLogger.d(record.message);
}
}
void _logJson(std_log.LogRecord record) {
final sanitizedMessage = LogPolicy.sanitizeMessage(record.message);
final logData = {
'time': record.time.toUtc().toIso8601String(), // Use UTC for consistency
'level': record.level.name,
'msg': sanitizedMessage,
'svc': 'baron-userfront',
if (record.error != null) 'error': record.error.toString(),
if (record.stackTrace != null) 'stack': record.stackTrace.toString(),
};
// 1. Print to Browser Console (F12)
debugPrint(jsonEncode(logData));
// 2. Relay to Backend (Docker Terminal)
if (LogPolicy.shouldRelayClientLog(
level: record.level.name,
appEnv: _appEnv,
productionDebugFlag: _productionDebugFlag,
)) {
AuthProxyService.sendLog(
record.level.name,
sanitizedMessage,
data: {
'client_time': record.time.toUtc().toIso8601String(),
'logger': record.loggerName,
if (record.error != null) 'error': record.error.toString(),
},
);
}
}
}

View File

@@ -0,0 +1,4 @@
import 'login_challenge_loop_guard_stub.dart'
if (dart.library.js_interop) 'login_challenge_loop_guard_web.dart';
final loginChallengeLoopGuard = createLoginChallengeLoopGuard();

View File

@@ -0,0 +1,5 @@
abstract class LoginChallengeLoopGuard {
bool shouldAllowAutoAccept(String loginChallenge, {int cooldownMs = 15000});
void markAutoAcceptAttempt(String loginChallenge);
void clear(String loginChallenge);
}

View File

@@ -0,0 +1,37 @@
import 'login_challenge_loop_guard_base.dart';
class _InMemoryLoginChallengeLoopGuard implements LoginChallengeLoopGuard {
final Map<String, int> _lastAttemptAtMs = <String, int>{};
@override
bool shouldAllowAutoAccept(String loginChallenge, {int cooldownMs = 15000}) {
final challenge = loginChallenge.trim();
if (challenge.isEmpty) {
return false;
}
final nowMs = DateTime.now().millisecondsSinceEpoch;
final lastMs = _lastAttemptAtMs[challenge];
if (lastMs == null) {
return true;
}
return nowMs - lastMs > cooldownMs;
}
@override
void markAutoAcceptAttempt(String loginChallenge) {
final challenge = loginChallenge.trim();
if (challenge.isEmpty) {
return;
}
_lastAttemptAtMs[challenge] = DateTime.now().millisecondsSinceEpoch;
}
@override
void clear(String loginChallenge) {
_lastAttemptAtMs.remove(loginChallenge.trim());
}
}
LoginChallengeLoopGuard createLoginChallengeLoopGuard() {
return _InMemoryLoginChallengeLoopGuard();
}

View File

@@ -0,0 +1,69 @@
// ignore_for_file: avoid_web_libraries_in_flutter
import 'dart:js_interop';
import 'login_challenge_loop_guard_base.dart';
@JS('window.sessionStorage')
external _JSStorage get _sessionStorage;
@JS()
extension type _JSStorage(JSObject _) implements JSObject {
external String? getItem(String key);
external void setItem(String key, String value);
external void removeItem(String key);
}
class _WebLoginChallengeLoopGuard implements LoginChallengeLoopGuard {
static const String _keyPrefix = 'baron_oidc_auto_accept_last:';
String _key(String challenge) => '$_keyPrefix$challenge';
@override
bool shouldAllowAutoAccept(String loginChallenge, {int cooldownMs = 15000}) {
final challenge = loginChallenge.trim();
if (challenge.isEmpty) {
return false;
}
try {
final raw = _sessionStorage.getItem(_key(challenge));
if (raw == null || raw.isEmpty) {
return true;
}
final lastMs = int.tryParse(raw);
if (lastMs == null) {
return true;
}
final nowMs = DateTime.now().millisecondsSinceEpoch;
return nowMs - lastMs > cooldownMs;
} catch (_) {
return true;
}
}
@override
void markAutoAcceptAttempt(String loginChallenge) {
final challenge = loginChallenge.trim();
if (challenge.isEmpty) {
return;
}
try {
final nowMs = DateTime.now().millisecondsSinceEpoch;
_sessionStorage.setItem(_key(challenge), nowMs.toString());
} catch (_) {}
}
@override
void clear(String loginChallenge) {
final challenge = loginChallenge.trim();
if (challenge.isEmpty) {
return;
}
try {
_sessionStorage.removeItem(_key(challenge));
} catch (_) {}
}
}
LoginChallengeLoopGuard createLoginChallengeLoopGuard() {
return _WebLoginChallengeLoopGuard();
}

View File

@@ -0,0 +1,39 @@
import '../notifiers/auth_notifier.dart';
import 'auth_proxy_service.dart';
import 'auth_token_store.dart';
typedef CurrentSessionLoader = Future<String?> Function();
typedef SessionRevoker = Future<void> Function(String sessionId);
typedef LogoutCallback = void Function();
class LogoutService {
LogoutService({
CurrentSessionLoader? loadCurrentSessionId,
SessionRevoker? revokeSession,
LogoutCallback? clearAuth,
LogoutCallback? notifyAuthChanged,
}) : _loadCurrentSessionId =
loadCurrentSessionId ?? AuthProxyService.fetchCurrentSessionId,
_revokeSession = revokeSession ?? AuthProxyService.revokeSession,
_clearAuth = clearAuth ?? AuthTokenStore.clear,
_notifyAuthChanged = notifyAuthChanged ?? AuthNotifier.instance.notify;
final CurrentSessionLoader _loadCurrentSessionId;
final SessionRevoker _revokeSession;
final LogoutCallback _clearAuth;
final LogoutCallback _notifyAuthChanged;
Future<void> logout() async {
try {
final currentSessionId = await _loadCurrentSessionId();
if (currentSessionId != null && currentSessionId.isNotEmpty) {
await _revokeSession(currentSessionId);
}
} catch (_) {
// 서버 세션 종료는 best-effort로 처리하고, 로컬 로그아웃은 계속 진행합니다.
} finally {
_clearAuth();
_notifyAuthChanged();
}
}
}

View File

@@ -0,0 +1,26 @@
import '../i18n/locale_utils.dart';
String? computeNullCheckRecoveryTarget({
required Object exception,
required Uri uri,
required String preferredLocaleCode,
}) {
final message = exception.toString();
if (!message.contains('Null check operator used on a null value')) {
return null;
}
final localeCode =
extractLocaleFromPath(uri) ?? normalizeLocaleCode(preferredLocaleCode);
final path = uri.path;
final localeRootPath = '/$localeCode';
if (path != '/' && path != localeRootPath) {
return null;
}
final target = '/$localeCode/signin';
if (path == target) {
return null;
}
return target;
}

View File

@@ -0,0 +1,220 @@
class OidcRedirectCheckResult {
final Uri? uri;
final bool isValid;
final String reason;
final int length;
final String scheme;
final String host;
final String path;
final int queryParamCount;
final List<String> queryKeys;
final bool hasLoginVerifier;
final int loginVerifierLength;
final bool hasState;
final int stateLength;
final bool hasClientId;
final String clientId;
final bool hasCodeChallenge;
final int codeChallengeLength;
final String codeChallengeMethod;
final bool hasRedirectUri;
final int redirectUriLength;
final String redirectUriScheme;
final String redirectUriHost;
final int redirectUriPort;
final String redirectUriPath;
final String responseType;
final int scopeCount;
final bool isOidcAuthPath;
const OidcRedirectCheckResult({
required this.uri,
required this.isValid,
required this.reason,
required this.length,
required this.scheme,
required this.host,
required this.path,
required this.queryParamCount,
required this.queryKeys,
required this.hasLoginVerifier,
required this.loginVerifierLength,
required this.hasState,
required this.stateLength,
required this.hasClientId,
required this.clientId,
required this.hasCodeChallenge,
required this.codeChallengeLength,
required this.codeChallengeMethod,
required this.hasRedirectUri,
required this.redirectUriLength,
required this.redirectUriScheme,
required this.redirectUriHost,
required this.redirectUriPort,
required this.redirectUriPath,
required this.responseType,
required this.scopeCount,
required this.isOidcAuthPath,
});
Map<String, Object?> toDiagnostics() {
return {
'is_valid': isValid,
'reason': reason,
'length': length,
'scheme': scheme,
'host': host,
'path': path,
'is_oidc_auth_path': isOidcAuthPath,
'query_param_count': queryParamCount,
'query_keys': queryKeys,
'has_login_verifier': hasLoginVerifier,
'login_verifier_len': loginVerifierLength,
'has_state': hasState,
'state_len': stateLength,
'has_client_id': hasClientId,
'client_id': clientId,
'has_code_challenge': hasCodeChallenge,
'code_challenge_len': codeChallengeLength,
'code_challenge_method': codeChallengeMethod,
'has_redirect_uri': hasRedirectUri,
'redirect_uri_len': redirectUriLength,
'redirect_uri_scheme': redirectUriScheme,
'redirect_uri_host': redirectUriHost,
'redirect_uri_port': redirectUriPort,
'redirect_uri_path': redirectUriPath,
'response_type': responseType,
'scope_count': scopeCount,
};
}
}
OidcRedirectCheckResult validateOidcRedirectTarget(String redirectTo) {
final trimmed = redirectTo.trim();
if (trimmed.isEmpty) {
return const OidcRedirectCheckResult(
uri: null,
isValid: false,
reason: 'empty',
length: 0,
scheme: '',
host: '',
path: '',
queryParamCount: 0,
queryKeys: [],
hasLoginVerifier: false,
loginVerifierLength: 0,
hasState: false,
stateLength: 0,
hasClientId: false,
clientId: '',
hasCodeChallenge: false,
codeChallengeLength: 0,
codeChallengeMethod: '',
hasRedirectUri: false,
redirectUriLength: 0,
redirectUriScheme: '',
redirectUriHost: '',
redirectUriPort: 0,
redirectUriPath: '',
responseType: '',
scopeCount: 0,
isOidcAuthPath: false,
);
}
Uri parsed;
try {
parsed = Uri.parse(trimmed);
} catch (_) {
return OidcRedirectCheckResult(
uri: null,
isValid: false,
reason: 'parse_error',
length: trimmed.length,
scheme: '',
host: '',
path: '',
queryParamCount: 0,
queryKeys: [],
hasLoginVerifier: false,
loginVerifierLength: 0,
hasState: false,
stateLength: 0,
hasClientId: false,
clientId: '',
hasCodeChallenge: false,
codeChallengeLength: 0,
codeChallengeMethod: '',
hasRedirectUri: false,
redirectUriLength: 0,
redirectUriScheme: '',
redirectUriHost: '',
redirectUriPort: 0,
redirectUriPath: '',
responseType: '',
scopeCount: 0,
isOidcAuthPath: false,
);
}
final scheme = parsed.scheme.toLowerCase();
final isHttpScheme = scheme == 'http' || scheme == 'https';
final isAbsolute = parsed.hasScheme && parsed.host.isNotEmpty;
final isValid = isHttpScheme && isAbsolute;
final query = parsed.queryParameters;
final queryKeys = query.keys.toList()..sort();
final loginVerifier = query['login_verifier'] ?? '';
final state = query['state'] ?? '';
final clientId = query['client_id'] ?? '';
final codeChallenge = query['code_challenge'] ?? '';
final codeChallengeMethod = query['code_challenge_method'] ?? '';
final redirectUriValue = query['redirect_uri'] ?? query['redirect_url'] ?? '';
final responseType = query['response_type'] ?? '';
final scope = query['scope'] ?? '';
final Uri? redirectUriParsed = redirectUriValue.isEmpty
? null
: Uri.tryParse(redirectUriValue);
final redirectUriScheme = redirectUriParsed?.scheme ?? '';
final redirectUriHost = redirectUriParsed?.host ?? '';
final redirectUriPort = redirectUriParsed?.port ?? 0;
final redirectUriPath = redirectUriParsed?.path ?? '';
final scopeCount = scope.isEmpty
? 0
: scope.split(RegExp(r'\s+')).where((s) => s.isNotEmpty).length;
final reason = isValid
? 'ok'
: (isAbsolute ? 'unsupported_scheme' : 'not_absolute');
return OidcRedirectCheckResult(
uri: isValid ? parsed : null,
isValid: isValid,
reason: reason,
length: trimmed.length,
scheme: scheme,
host: parsed.host,
path: parsed.path,
queryParamCount: query.length,
queryKeys: queryKeys,
hasLoginVerifier: loginVerifier.isNotEmpty,
loginVerifierLength: loginVerifier.length,
hasState: state.isNotEmpty,
stateLength: state.length,
hasClientId: clientId.isNotEmpty,
clientId: clientId,
hasCodeChallenge: codeChallenge.isNotEmpty,
codeChallengeLength: codeChallenge.length,
codeChallengeMethod: codeChallengeMethod,
hasRedirectUri: redirectUriValue.isNotEmpty,
redirectUriLength: redirectUriValue.length,
redirectUriScheme: redirectUriScheme,
redirectUriHost: redirectUriHost,
redirectUriPort: redirectUriPort,
redirectUriPath: redirectUriPath,
responseType: responseType,
scopeCount: scopeCount,
isOidcAuthPath: parsed.path == '/oidc/oauth2/auth',
);
}

View File

@@ -0,0 +1,37 @@
const _compileTimeEnv = {
'APP_ENV': String.fromEnvironment('APP_ENV'),
'BACKEND_URL': String.fromEnvironment('BACKEND_URL'),
'CLIENT_LOG_DEBUG': String.fromEnvironment('CLIENT_LOG_DEBUG'),
'USERFRONT_DEBUG_LOG': String.fromEnvironment('USERFRONT_DEBUG_LOG'),
'USERFRONT_URL': String.fromEnvironment('USERFRONT_URL'),
};
String runtimeOriginFallback() {
try {
final origin = Uri.base.origin;
if (origin.isNotEmpty && origin != 'null') {
return origin;
}
} catch (_) {}
return '';
}
String envOrDefault(String key, String fallback) {
final compileTimeValue = _compileTimeEnv[key];
if (compileTimeValue != null && compileTimeValue.trim().isNotEmpty) {
return compileTimeValue;
}
return fallback;
}
String sanitizedUrl(String value) {
return value.replaceAll(r'$', '').trim().replaceAll(RegExp(r'/$'), '');
}
String runtimeBackendUrl() {
return sanitizedUrl(envOrDefault('BACKEND_URL', runtimeOriginFallback()));
}
String runtimeUserfrontUrl() {
return sanitizedUrl(envOrDefault('USERFRONT_URL', runtimeOriginFallback()));
}

View File

@@ -0,0 +1,13 @@
import 'web_auth_integration_stub.dart'
if (dart.library.js_interop) 'web_auth_integration_web.dart';
abstract class WebAuthIntegration {
static void sendLoginSuccess(String token) {
// Platform-specific implementation
implSendLoginSuccess(token);
}
static bool isPopup() {
return implIsPopup();
}
}

View File

@@ -0,0 +1,10 @@
import 'package:flutter/foundation.dart';
void implSendLoginSuccess(String token) {
// No-op on non-web platforms
debugPrint('Not on web: Login Success with token: $token');
}
bool implIsPopup() {
return false;
}

View File

@@ -0,0 +1,98 @@
// ignore_for_file: avoid_web_libraries_in_flutter, deprecated_member_use
import 'dart:async';
import 'dart:convert';
import 'package:web/web.dart' as web;
import 'package:flutter/foundation.dart';
import 'dart:js_interop';
import 'auth_token_store.dart';
import '../i18n/locale_utils.dart';
void implSendLoginSuccess(String token) {
var effectiveToken = token;
if (effectiveToken.isEmpty) {
effectiveToken = AuthTokenStore.getToken() ?? "";
}
final fullUrl = web.window.location.href;
final uri = Uri.base;
// Try to find redirect_uri from standard parsing first, then manual string search
String? redirectUri =
uri.queryParameters['redirect_uri'] ??
uri.queryParameters['redirect_url'];
if (redirectUri == null) {
// Manual fallback for cases where Uri.base misses params
final searchParams = web.window.location.search;
if (searchParams.isNotEmpty) {
final sUri = Uri.parse(
'?${searchParams.startsWith('?') ? searchParams.substring(1) : searchParams}',
);
redirectUri =
sUri.queryParameters['redirect_uri'] ??
sUri.queryParameters['redirect_url'];
}
}
// Final fallback: regex or manual search in fullUrl
if (redirectUri == null) {
for (final key in ['redirect_uri=', 'redirect_url=']) {
if (fullUrl.contains(key)) {
final start = fullUrl.indexOf(key) + key.length;
var end = fullUrl.indexOf('&', start);
if (end == -1) end = fullUrl.length;
final raw = fullUrl.substring(start, end);
try {
redirectUri = Uri.decodeComponent(raw);
break;
} catch (_) {}
}
}
}
if (redirectUri != null && redirectUri.isNotEmpty) {
// Redirection flow
final target = Uri.parse(redirectUri);
final query = Map<String, String>.from(target.queryParameters);
query['token'] = effectiveToken;
final finalUri = target.replace(queryParameters: query);
debugPrint('Redirecting to: ${finalUri.toString()}');
web.window.location.href = finalUri.toString();
return;
}
final message = {'type': 'LOGIN_SUCCESS', 'token': effectiveToken};
final opener = web.window.opener;
if (opener != null) {
try {
// Use JSON string for safer cross-origin/WASM messaging if direct object fails
final jsonMsg = jsonEncode(message);
(opener as web.Window).postMessage(jsonMsg.toJS, '*'.toJS);
debugPrint('Sent login success message to opener');
} catch (e) {
debugPrint('Failed to postMessage: $e');
}
// Close the popup after a short delay to ensure message sending
Timer(const Duration(milliseconds: 500), () {
try {
web.window.close();
} catch (e) {
debugPrint('Failed to close window: $e');
}
});
return;
}
// No opener and no redirect: fall back to local navigation
final fallbackTarget = buildLocalizedHomePath(Uri.base);
debugPrint('No opener found. Redirecting to $fallbackTarget.');
web.window.location.href = fallbackTarget;
}
bool implIsPopup() {
return web.window.opener != null;
}

View File

@@ -0,0 +1 @@
export 'web_window_web.dart';

View File

@@ -0,0 +1,27 @@
class WebWindow {
void setTitle(String title) {}
void redirectTo(String url) {}
String currentHref() {
return '';
}
String currentSearch() {
return '';
}
void alert(String message) {}
void close() {}
bool hasOpener() {
return false;
}
bool redirectOpenerTo(String url) {
return false;
}
}
final webWindow = WebWindow();

View File

@@ -0,0 +1,82 @@
// ignore_for_file: avoid_web_libraries_in_flutter, deprecated_member_use
import 'package:web/web.dart' as web;
import 'package:flutter/foundation.dart';
import 'dart:async';
class WebWindow {
void setTitle(String title) {
try {
web.document.title = title;
} catch (_) {}
}
void redirectTo(String url) {
final currentHref = web.window.location.href;
debugPrint(
"[WebWindow] redirectTo start: current=$currentHref, target=$url",
);
// Most direct and safe way for WASM: location.href assignment via package:web
Future.delayed(Duration.zero, () {
try {
web.window.location.href = url;
} catch (e) {
debugPrint("[WebWindow] CRITICAL JS ERROR: $e");
}
});
// Check after delay
Future<void>.delayed(const Duration(milliseconds: 800), () {
final nowHref = web.window.location.href;
if (nowHref == currentHref) {
debugPrint(
"[WebWindow] redirectTo no-op detected: current URL did not change",
);
}
});
}
String currentHref() {
return web.window.location.href;
}
String currentSearch() {
return web.window.location.search;
}
void alert(String message) {
try {
web.window.alert(message);
} catch (_) {}
}
void close() {
try {
web.window.close();
} catch (_) {}
}
bool hasOpener() {
try {
return web.window.opener != null;
} catch (_) {
return false;
}
}
bool redirectOpenerTo(String url) {
try {
final opener = web.window.opener;
if (opener == null) return false;
// In package:web, Window is not directly accessible from JSObject opener
// This is a known tricky part for WASM. We'll use a safer approach.
(opener as web.Window).location.href = url;
return true;
} catch (_) {
return false;
}
}
}
final webWindow = WebWindow();

View File

@@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
ThemeData buildLightTheme() {
final scheme =
ColorScheme.fromSeed(
seedColor: const Color(0xFF1A1F2C),
brightness: Brightness.light,
).copyWith(
surface: Colors.white,
surfaceContainerLowest: const Color(0xFFF7F8FA),
surfaceContainerLow: const Color(0xFFF3F4F6),
surfaceContainerHighest: const Color(0xFFE5E7EB),
outline: const Color(0xFFD1D5DB),
outlineVariant: const Color(0xFFE5E7EB),
primary: const Color(0xFF1A1F2C),
onPrimary: Colors.white,
onSurface: const Color(0xFF111827),
onSurfaceVariant: const Color(0xFF6B7280),
);
return _buildTheme(scheme);
}
ThemeData buildDarkTheme() {
final scheme =
ColorScheme.fromSeed(
seedColor: const Color(0xFF7DD3FC),
brightness: Brightness.dark,
).copyWith(
surface: const Color(0xFF0F172A),
surfaceContainerLowest: const Color(0xFF020617),
surfaceContainerLow: const Color(0xFF111827),
surfaceContainerHighest: const Color(0xFF1F2937),
outline: const Color(0xFF334155),
outlineVariant: const Color(0xFF1E293B),
primary: const Color(0xFFBAE6FD),
onPrimary: const Color(0xFF082F49),
onSurface: const Color(0xFFF8FAFC),
onSurfaceVariant: const Color(0xFF94A3B8),
);
return _buildTheme(scheme);
}
ThemeData _buildTheme(ColorScheme colorScheme) {
final isDark = colorScheme.brightness == Brightness.dark;
final base = ThemeData(useMaterial3: true, colorScheme: colorScheme);
return base.copyWith(
scaffoldBackgroundColor: colorScheme.surfaceContainerLowest,
pageTransitionsTheme: const PageTransitionsTheme(
builders: {
TargetPlatform.android: NoTransitionsBuilder(),
TargetPlatform.iOS: NoTransitionsBuilder(),
TargetPlatform.linux: NoTransitionsBuilder(),
TargetPlatform.macOS: NoTransitionsBuilder(),
TargetPlatform.windows: NoTransitionsBuilder(),
TargetPlatform.fuchsia: NoTransitionsBuilder(),
},
),
appBarTheme: AppBarTheme(
elevation: 0,
centerTitle: false,
backgroundColor: colorScheme.surface,
foregroundColor: colorScheme.onSurface,
surfaceTintColor: Colors.transparent,
),
cardTheme: CardThemeData(
color: colorScheme.surface,
elevation: 0,
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: colorScheme.outlineVariant),
),
),
dividerTheme: DividerThemeData(
color: colorScheme.outlineVariant,
thickness: 1,
),
drawerTheme: DrawerThemeData(
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
),
dialogTheme: DialogThemeData(
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: isDark ? colorScheme.surfaceContainerLow : colorScheme.surface,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: colorScheme.outline),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: colorScheme.outline),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: colorScheme.primary, width: 1.4),
),
labelStyle: TextStyle(color: colorScheme.onSurfaceVariant),
hintStyle: TextStyle(color: colorScheme.onSurfaceVariant),
prefixIconColor: colorScheme.onSurfaceVariant,
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(50),
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: colorScheme.onSurface,
side: BorderSide(color: colorScheme.outline),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
),
tabBarTheme: TabBarThemeData(
dividerColor: colorScheme.outlineVariant,
labelColor: colorScheme.onSurface,
unselectedLabelColor: colorScheme.onSurfaceVariant,
indicatorColor: colorScheme.primary,
),
);
}
class NoTransitionsBuilder extends PageTransitionsBuilder {
const NoTransitionsBuilder();
@override
Widget buildTransitions<T>(
PageRoute<T> route,
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
return child;
}
}

View File

@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
class ThemeController extends ValueNotifier<ThemeMode> {
ThemeController._(this.storageKey) : super(ThemeMode.light);
static const appStorageKey = 'userfront_theme';
static const authStorageKey = 'userfront_auth_theme';
static final ThemeController app = ThemeController._(appStorageKey);
static final ThemeController auth = ThemeController._(authStorageKey);
static final ThemeController instance = app;
final String storageKey;
bool get isDark => value == ThemeMode.dark;
Future<void> restore() async {
final prefs = await SharedPreferences.getInstance();
final stored = prefs.getString(storageKey);
value = stored == 'dark' ? ThemeMode.dark : ThemeMode.light;
}
Future<void> setThemeMode(ThemeMode mode) async {
if (value != mode) {
value = mode;
}
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
storageKey,
mode == ThemeMode.dark ? 'dark' : 'light',
);
}
Future<void> toggle() {
return setThemeMode(isDark ? ThemeMode.light : ThemeMode.dark);
}
}

View File

@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import 'app_theme.dart';
import 'theme_controller.dart';
class ThemeScope extends InheritedWidget {
const ThemeScope({super.key, required this.controller, required super.child});
final ThemeController controller;
static ThemeController of(BuildContext context) {
final scope = context.dependOnInheritedWidgetOfExactType<ThemeScope>();
return scope?.controller ?? ThemeController.app;
}
@override
bool updateShouldNotify(ThemeScope oldWidget) {
return oldWidget.controller != controller;
}
}
class ScopedTheme extends StatelessWidget {
const ScopedTheme({super.key, required this.controller, required this.child});
final ThemeController controller;
final Widget child;
@override
Widget build(BuildContext context) {
return ThemeScope(
controller: controller,
child: ValueListenableBuilder<ThemeMode>(
valueListenable: controller,
builder: (context, mode, _) {
return Theme(
data: mode == ThemeMode.dark ? buildDarkTheme() : buildLightTheme(),
child: child,
);
},
),
);
}
}

View File

@@ -0,0 +1 @@
const double sideMenuBreakpoint = 1400;

View File

@@ -0,0 +1,234 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
enum ToastType { success, error, info }
class _ToastItem {
const _ToastItem({
required this.id,
required this.message,
required this.type,
});
final String id;
final String message;
final ToastType type;
}
class ToastService {
static const Duration _displayDuration = Duration(milliseconds: 3000);
static final ValueNotifier<List<_ToastItem>> _toasts =
ValueNotifier<List<_ToastItem>>(<_ToastItem>[]);
static void success(String message) {
show(message, type: ToastType.success);
}
static void error(String message) {
show(message, type: ToastType.error);
}
static void info(String message) {
show(message, type: ToastType.info);
}
static void show(String message, {ToastType type = ToastType.success}) {
final trimmed = message.trim();
if (trimmed.isEmpty) {
return;
}
final item = _ToastItem(
id: '${DateTime.now().microsecondsSinceEpoch}-${_toasts.value.length}',
message: trimmed,
type: type,
);
_toasts.value = [..._toasts.value, item];
unawaited(
Future<void>.delayed(_displayDuration, () {
_remove(item.id);
}),
);
}
static void _remove(String id) {
final next = _toasts.value.where((toast) => toast.id != id).toList();
if (next.length == _toasts.value.length) {
return;
}
_toasts.value = next;
}
}
class ToastViewport extends StatelessWidget {
const ToastViewport({super.key});
@override
Widget build(BuildContext context) {
return IgnorePointer(
ignoring: true,
child: SafeArea(
child: ValueListenableBuilder<List<_ToastItem>>(
valueListenable: ToastService._toasts,
builder: (context, toasts, _) {
if (toasts.isEmpty) {
return const SizedBox.shrink();
}
final media = MediaQuery.of(context);
final width = math.min(320.0, media.size.width - 32);
return Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: const EdgeInsets.only(right: 16, bottom: 16),
child: SizedBox(
width: width > 0 ? width : 320,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (final toast in toasts)
Padding(
padding: const EdgeInsets.only(top: 8),
child: _ToastCard(item: toast),
),
],
),
),
),
);
},
),
),
);
}
}
class _ToastCard extends StatefulWidget {
const _ToastCard({required this.item});
final _ToastItem item;
@override
State<_ToastCard> createState() => _ToastCardState();
}
class _ToastCardState extends State<_ToastCard> {
bool _visible = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) {
return;
}
setState(() {
_visible = true;
});
});
}
@override
Widget build(BuildContext context) {
final scheme = _toastColorScheme(widget.item.type);
final icon = _toastIcon(widget.item.type);
return AnimatedSlide(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
offset: _visible ? Offset.zero : const Offset(1, 0),
child: AnimatedOpacity(
duration: const Duration(milliseconds: 220),
opacity: _visible ? 1 : 0,
child: DecoratedBox(
decoration: BoxDecoration(
color: scheme.background,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: scheme.border),
boxShadow: const [
BoxShadow(
color: Color(0x26000000),
blurRadius: 16,
offset: Offset(0, 6),
),
],
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(icon, size: 20, color: scheme.foreground),
const SizedBox(width: 12),
Expanded(
child: Text(
widget.item.message,
style: TextStyle(
color: scheme.foreground,
fontSize: 14,
fontWeight: FontWeight.w400,
height: 1.2,
decoration: TextDecoration.none,
),
),
),
],
),
),
),
),
);
}
_ToastColorScheme _toastColorScheme(ToastType type) {
switch (type) {
case ToastType.success:
return const _ToastColorScheme(
background: Color(0xFFECFDF5),
border: Color(0xFFA7F3D0),
foreground: Color(0xFF065F46),
);
case ToastType.error:
return const _ToastColorScheme(
background: Color(0xFFFFF1F2),
border: Color(0xFFFDA4AF),
foreground: Color(0xFF9F1239),
);
case ToastType.info:
return const _ToastColorScheme(
background: Color(0xFFEFF6FF),
border: Color(0xFFBFDBFE),
foreground: Color(0xFF1E40AF),
);
}
}
IconData _toastIcon(ToastType type) {
switch (type) {
case ToastType.success:
return Icons.check_circle_outline;
case ToastType.error:
return Icons.error_outline;
case ToastType.info:
return Icons.info_outline;
}
}
}
class _ToastColorScheme {
const _ToastColorScheme({
required this.background,
required this.border,
required this.foreground,
});
final Color background;
final Color border;
final Color foreground;
}

View File

@@ -0,0 +1,74 @@
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 localization = EasyLocalization.of(context);
final resolvedCurrent = normalizeLocaleCode(
localization?.currentLocale?.languageCode,
);
final current = (resolvedCurrent == 'ko' || resolvedCurrent == 'en')
? resolvedCurrent
: 'en';
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);
if (localization != null) {
await localization.setLocale(Locale(value));
}
if (!context.mounted) return;
Uri uri;
try {
uri = GoRouterState.of(context).uri;
} catch (_) {
uri = Uri.base;
}
final target = buildLocalizedPath(value, uri);
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,
],
),
),
);
}
}

View File

@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:userfront/i18n.dart';
import '../theme/theme_scope.dart';
class ThemeToggleButton extends StatelessWidget {
const ThemeToggleButton({super.key, this.compact = false});
final bool compact;
@override
Widget build(BuildContext context) {
Localizations.localeOf(context);
final controller = ThemeScope.of(context);
return ValueListenableBuilder<ThemeMode>(
valueListenable: controller,
builder: (context, mode, _) {
final isLight = mode == ThemeMode.light;
final icon = isLight
? Icons.light_mode_outlined
: Icons.dark_mode_outlined;
final label = isLight
? tr('ui.common.theme_light', fallback: 'Light')
: tr('ui.common.theme_dark', fallback: 'Dark');
final tooltip = tr('ui.common.theme_toggle', fallback: '테마 전환');
if (compact) {
return IconButton(
tooltip: tooltip,
onPressed: () => controller.toggle(),
icon: Icon(icon),
);
}
return OutlinedButton.icon(
onPressed: () => controller.toggle(),
icon: Icon(icon, size: 18),
label: Text(label),
);
},
);
}
}