첫 커밋: 로컬 프로젝트 업로드
This commit is contained in:
30
baron-sso/userfront/lib/core/constants/error_whitelist.dart
Normal file
30
baron-sso/userfront/lib/core/constants/error_whitelist.dart
Normal 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',
|
||||
};
|
||||
75
baron-sso/userfront/lib/core/i18n/locale_gate.dart
Normal file
75
baron-sso/userfront/lib/core/i18n/locale_gate.dart
Normal 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;
|
||||
}
|
||||
115
baron-sso/userfront/lib/core/i18n/locale_registry.dart
Normal file
115
baron-sso/userfront/lib/core/i18n/locale_registry.dart
Normal 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);
|
||||
}
|
||||
59
baron-sso/userfront/lib/core/i18n/locale_storage.dart
Normal file
59
baron-sso/userfront/lib/core/i18n/locale_storage.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
245
baron-sso/userfront/lib/core/i18n/locale_storage_engine.dart
Normal file
245
baron-sso/userfront/lib/core/i18n/locale_storage_engine.dart
Normal 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],
|
||||
);
|
||||
}
|
||||
}
|
||||
13
baron-sso/userfront/lib/core/i18n/locale_storage_policy.dart
Normal file
13
baron-sso/userfront/lib/core/i18n/locale_storage_policy.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import 'locale_storage_backend.dart';
|
||||
import 'locale_storage_engine.dart';
|
||||
|
||||
final LocaleStorageBackend localeStorage = LocaleStorageEngine(
|
||||
localTarget: const LocaleStorageNoopTarget(),
|
||||
sessionTarget: const LocaleStorageNoopTarget(),
|
||||
);
|
||||
22
baron-sso/userfront/lib/core/i18n/locale_storage_web.dart
Normal file
22
baron-sso/userfront/lib/core/i18n/locale_storage_web.dart
Normal 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(),
|
||||
),
|
||||
);
|
||||
117
baron-sso/userfront/lib/core/i18n/locale_utils.dart
Normal file
117
baron-sso/userfront/lib/core/i18n/locale_utils.dart
Normal 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';
|
||||
}
|
||||
54
baron-sso/userfront/lib/core/i18n/toml_asset_loader.dart
Normal file
54
baron-sso/userfront/lib/core/i18n/toml_asset_loader.dart
Normal 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');
|
||||
}
|
||||
16
baron-sso/userfront/lib/core/notifiers/auth_notifier.dart
Normal file
16
baron-sso/userfront/lib/core/notifiers/auth_notifier.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
40
baron-sso/userfront/lib/core/services/audit_service.dart
Normal file
40
baron-sso/userfront/lib/core/services/audit_service.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
1140
baron-sso/userfront/lib/core/services/auth_proxy_service.dart
Normal file
1140
baron-sso/userfront/lib/core/services/auth_proxy_service.dart
Normal file
File diff suppressed because it is too large
Load Diff
45
baron-sso/userfront/lib/core/services/auth_token_store.dart
Normal file
45
baron-sso/userfront/lib/core/services/auth_token_store.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
6
baron-sso/userfront/lib/core/services/http_client.dart
Normal file
6
baron-sso/userfront/lib/core/services/http_client.dart
Normal 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);
|
||||
}
|
||||
@@ -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();
|
||||
12
baron-sso/userfront/lib/core/services/http_client_web.dart
Normal file
12
baron-sso/userfront/lib/core/services/http_client_web.dart
Normal 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();
|
||||
139
baron-sso/userfront/lib/core/services/log_policy.dart
Normal file
139
baron-sso/userfront/lib/core/services/log_policy.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
111
baron-sso/userfront/lib/core/services/logger_service.dart
Normal file
111
baron-sso/userfront/lib/core/services/logger_service.dart
Normal 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(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -0,0 +1,5 @@
|
||||
abstract class LoginChallengeLoopGuard {
|
||||
bool shouldAllowAutoAccept(String loginChallenge, {int cooldownMs = 15000});
|
||||
void markAutoAcceptAttempt(String loginChallenge);
|
||||
void clear(String loginChallenge);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
39
baron-sso/userfront/lib/core/services/logout_service.dart
Normal file
39
baron-sso/userfront/lib/core/services/logout_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
220
baron-sso/userfront/lib/core/services/oidc_redirect_guard.dart
Normal file
220
baron-sso/userfront/lib/core/services/oidc_redirect_guard.dart
Normal 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',
|
||||
);
|
||||
}
|
||||
37
baron-sso/userfront/lib/core/services/runtime_env.dart
Normal file
37
baron-sso/userfront/lib/core/services/runtime_env.dart
Normal 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()));
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
1
baron-sso/userfront/lib/core/services/web_window.dart
Normal file
1
baron-sso/userfront/lib/core/services/web_window.dart
Normal file
@@ -0,0 +1 @@
|
||||
export 'web_window_web.dart';
|
||||
27
baron-sso/userfront/lib/core/services/web_window_stub.dart
Normal file
27
baron-sso/userfront/lib/core/services/web_window_stub.dart
Normal 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();
|
||||
82
baron-sso/userfront/lib/core/services/web_window_web.dart
Normal file
82
baron-sso/userfront/lib/core/services/web_window_web.dart
Normal 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();
|
||||
144
baron-sso/userfront/lib/core/theme/app_theme.dart
Normal file
144
baron-sso/userfront/lib/core/theme/app_theme.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
37
baron-sso/userfront/lib/core/theme/theme_controller.dart
Normal file
37
baron-sso/userfront/lib/core/theme/theme_controller.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
43
baron-sso/userfront/lib/core/theme/theme_scope.dart
Normal file
43
baron-sso/userfront/lib/core/theme/theme_scope.dart
Normal 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,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1
baron-sso/userfront/lib/core/ui/layout_breakpoints.dart
Normal file
1
baron-sso/userfront/lib/core/ui/layout_breakpoints.dart
Normal file
@@ -0,0 +1 @@
|
||||
const double sideMenuBreakpoint = 1400;
|
||||
234
baron-sso/userfront/lib/core/ui/toast_service.dart
Normal file
234
baron-sso/userfront/lib/core/ui/toast_service.dart
Normal 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;
|
||||
}
|
||||
74
baron-sso/userfront/lib/core/widgets/language_selector.dart
Normal file
74
baron-sso/userfront/lib/core/widgets/language_selector.dart
Normal 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,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../../core/services/auth_proxy_service.dart';
|
||||
import '../../../../core/i18n/locale_utils.dart';
|
||||
import '../../../../core/ui/toast_service.dart';
|
||||
|
||||
class CreateUserScreen extends StatefulWidget {
|
||||
const CreateUserScreen({super.key});
|
||||
|
||||
@override
|
||||
State<CreateUserScreen> createState() => _CreateUserScreenState();
|
||||
}
|
||||
|
||||
class _CreateUserScreenState extends State<CreateUserScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final TextEditingController _loginIdController = TextEditingController();
|
||||
final TextEditingController _emailController = TextEditingController();
|
||||
final TextEditingController _phoneController = TextEditingController();
|
||||
final TextEditingController _nameController = TextEditingController();
|
||||
|
||||
bool _isLoading = false;
|
||||
bool _isAuthorized = false;
|
||||
String? _verifiedAdminPassword;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _verifyAccess());
|
||||
}
|
||||
|
||||
Future<void> _verifyAccess() async {
|
||||
final passwordController = TextEditingController();
|
||||
|
||||
// Show blocking dialog
|
||||
final String? inputPassword = await showDialog<String>(
|
||||
context: context,
|
||||
barrierDismissible: false, // User must enter password or leave
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text("Admin Authentication Required"),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text("Please enter the admin password to access this page."),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: passwordController,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Password",
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
autofocus: true,
|
||||
onSubmitted: (value) => Navigator.pop(context, value),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, null), // Cancel
|
||||
child: const Text("Cancel"),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, passwordController.text),
|
||||
child: const Text("Enter"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// If cancelled or empty
|
||||
if (inputPassword == null || inputPassword.isEmpty) {
|
||||
if (mounted) context.go(buildLocalizedHomePath(Uri.base)); // Kick out
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify against Backend
|
||||
setState(() => _isLoading = true);
|
||||
final isValid = await AuthProxyService.checkAdminAuth(inputPassword);
|
||||
setState(() => _isLoading = false);
|
||||
|
||||
if (isValid) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isAuthorized = true;
|
||||
_verifiedAdminPassword = inputPassword;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
ToastService.error('Invalid Password. Access Denied.');
|
||||
context.go(buildLocalizedHomePath(Uri.base)); // Kick out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_loginIdController.dispose();
|
||||
_emailController.dispose();
|
||||
_phoneController.dispose();
|
||||
_nameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
if (_verifiedAdminPassword == null) return; // Should not happen
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
String loginId = _loginIdController.text.trim();
|
||||
if (!loginId.contains('@')) {
|
||||
loginId = loginId.replaceAll(RegExp(r'[-\s]'), '');
|
||||
if (loginId.startsWith('010')) {
|
||||
loginId = '+82${loginId.substring(1)}';
|
||||
}
|
||||
}
|
||||
|
||||
String? phone = _phoneController.text.trim().isEmpty
|
||||
? null
|
||||
: _phoneController.text.trim();
|
||||
if (phone != null && !phone.contains('@')) {
|
||||
phone = phone.replaceAll(RegExp(r'[-\s]'), '');
|
||||
if (phone.startsWith('010')) {
|
||||
phone = '+82${phone.substring(1)}';
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await AuthProxyService.createUser(
|
||||
loginId: loginId,
|
||||
adminPassword: _verifiedAdminPassword!,
|
||||
email: _emailController.text.trim().isEmpty
|
||||
? null
|
||||
: _emailController.text.trim(),
|
||||
phone: phone,
|
||||
displayName: _nameController.text.trim().isEmpty
|
||||
? null
|
||||
: _nameController.text.trim(),
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
ToastService.success('User created successfully!');
|
||||
_formKey.currentState!.reset();
|
||||
_loginIdController.clear();
|
||||
_emailController.clear();
|
||||
_phoneController.clear();
|
||||
_nameController.clear();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ToastService.error('Error: $e');
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Hide content until authorized
|
||||
if (!_isAuthorized) {
|
||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Create User'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.go(buildLocalizedHomePath(Uri.base)),
|
||||
),
|
||||
),
|
||||
body: Center(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 600),
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
"Create New User",
|
||||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
TextFormField(
|
||||
controller: _loginIdController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Login ID (Required)",
|
||||
border: OutlineInputBorder(),
|
||||
helperText: "Unique identifier (Email or Phone)",
|
||||
),
|
||||
validator: (value) => value == null || value.isEmpty
|
||||
? 'Please enter Login ID'
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Display Name",
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.person),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Email",
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.email),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
TextFormField(
|
||||
controller: _phoneController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Phone Number",
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.phone),
|
||||
helperText: "Start with 010 (e.g., 010-1234-5678)",
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
FilledButton(
|
||||
onPressed: _isLoading ? null : _submit,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
child: _isLoading
|
||||
? const CircularProgressIndicator(color: Colors.white)
|
||||
: const Text("Create User"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'dart:async';
|
||||
import '../../../../core/services/auth_proxy_service.dart';
|
||||
import '../../../../core/i18n/locale_utils.dart';
|
||||
import '../../../../core/ui/toast_service.dart';
|
||||
|
||||
class UserManagementScreen extends StatefulWidget {
|
||||
const UserManagementScreen({super.key});
|
||||
|
||||
@override
|
||||
State<UserManagementScreen> createState() => _UserManagementScreenState();
|
||||
}
|
||||
|
||||
class _UserManagementScreenState extends State<UserManagementScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
bool _isAuthorized = false;
|
||||
String? _verifiedAdminPassword;
|
||||
bool _isLoading = false;
|
||||
|
||||
// --- List Tab Variables ---
|
||||
List<dynamic> _users = [];
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
Timer? _debounce;
|
||||
|
||||
// --- Create Tab Controllers ---
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final TextEditingController _createLoginIdController =
|
||||
TextEditingController();
|
||||
final TextEditingController _createEmailController = TextEditingController();
|
||||
final TextEditingController _createPhoneController = TextEditingController();
|
||||
final TextEditingController _createNameController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _verifyAccess());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
_searchController.dispose();
|
||||
_debounce?.cancel();
|
||||
_createLoginIdController.dispose();
|
||||
_createEmailController.dispose();
|
||||
_createPhoneController.dispose();
|
||||
_createNameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// --- Authentication ---
|
||||
Future<void> _verifyAccess() async {
|
||||
final passwordController = TextEditingController();
|
||||
|
||||
final String? inputPassword = await showDialog<String>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text("Admin Authentication Required"),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text("Please enter the admin password."),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: passwordController,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Password",
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
autofocus: true,
|
||||
onSubmitted: (value) => Navigator.pop(context, value),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, null),
|
||||
child: const Text("Cancel"),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, passwordController.text),
|
||||
child: const Text("Enter"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (inputPassword == null || inputPassword.isEmpty) {
|
||||
if (mounted) context.go(buildLocalizedHomePath(Uri.base));
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
final isValid = await AuthProxyService.checkAdminAuth(inputPassword);
|
||||
setState(() => _isLoading = false);
|
||||
|
||||
if (isValid) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isAuthorized = true;
|
||||
_verifiedAdminPassword = inputPassword;
|
||||
});
|
||||
_loadUsers();
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
ToastService.error('Invalid Password');
|
||||
context.go(buildLocalizedHomePath(Uri.base));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- User List Logic ---
|
||||
Future<void> _loadUsers({String? query}) async {
|
||||
if (_verifiedAdminPassword == null) return;
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final users = await AuthProxyService.listUsers(
|
||||
_verifiedAdminPassword!,
|
||||
query: query,
|
||||
);
|
||||
setState(() => _users = users);
|
||||
} catch (e) {
|
||||
_showError("Failed to load users: $e");
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _onSearchChanged(String query) {
|
||||
if (_debounce?.isActive ?? false) _debounce!.cancel();
|
||||
_debounce = Timer(const Duration(milliseconds: 500), () {
|
||||
_loadUsers(query: query);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _deleteUser(String loginId) async {
|
||||
if (_verifiedAdminPassword == null) return;
|
||||
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text("Delete User"),
|
||||
content: Text(
|
||||
"Are you sure you want to delete $loginId? This cannot be undone.",
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text("Cancel"),
|
||||
),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(backgroundColor: Colors.red),
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text("Delete"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirm != true) return;
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
await AuthProxyService.deleteUser(_verifiedAdminPassword!, loginId);
|
||||
_showSuccess("User deleted");
|
||||
_loadUsers(query: _searchController.text);
|
||||
} catch (e) {
|
||||
_showError("Failed to delete: $e");
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleStatus(String loginId, String currentStatus) async {
|
||||
if (_verifiedAdminPassword == null) return;
|
||||
final newStatus = (currentStatus == "enabled" || currentStatus == "active")
|
||||
? "disabled"
|
||||
: "enabled";
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
await AuthProxyService.updateUserStatus(
|
||||
_verifiedAdminPassword!,
|
||||
loginId,
|
||||
newStatus,
|
||||
);
|
||||
_showSuccess("User status updated to $newStatus");
|
||||
_loadUsers(query: _searchController.text);
|
||||
} catch (e) {
|
||||
_showError("Failed to update status: $e");
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _editUser(Map user) async {
|
||||
if (_verifiedAdminPassword == null) return;
|
||||
|
||||
final loginIDs = (user['loginIds'] as List?) ?? [];
|
||||
final loginId = loginIDs.isNotEmpty ? loginIDs.first.toString() : "";
|
||||
if (loginId.isEmpty) return;
|
||||
|
||||
final nameController = TextEditingController(
|
||||
text: user['name'] ?? user['user']?['name'] ?? "",
|
||||
);
|
||||
final emailController = TextEditingController(
|
||||
text: user['user']?['email'] ?? "",
|
||||
);
|
||||
final phoneController = TextEditingController(
|
||||
text: user['user']?['phone'] ?? "",
|
||||
);
|
||||
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text("Edit User: $loginId"),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: nameController,
|
||||
decoration: const InputDecoration(labelText: "Name"),
|
||||
),
|
||||
TextField(
|
||||
controller: emailController,
|
||||
decoration: const InputDecoration(labelText: "Email"),
|
||||
),
|
||||
TextField(
|
||||
controller: phoneController,
|
||||
decoration: const InputDecoration(labelText: "Phone"),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text("Cancel"),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text("Save"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirm != true) return;
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
String? phone = phoneController.text.trim().isEmpty
|
||||
? null
|
||||
: phoneController.text.trim();
|
||||
if (phone != null && !phone.contains('@')) {
|
||||
phone = phone.replaceAll(RegExp(r'[-\s]'), '');
|
||||
if (phone.startsWith('010')) {
|
||||
phone = '+82${phone.substring(1)}';
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await AuthProxyService.updateUserDetails(
|
||||
adminPassword: _verifiedAdminPassword!,
|
||||
loginId: loginId,
|
||||
displayName: nameController.text.trim(),
|
||||
email: emailController.text.trim(),
|
||||
phone: phone,
|
||||
);
|
||||
_showSuccess("User updated successfully");
|
||||
_loadUsers(query: _searchController.text);
|
||||
} catch (e) {
|
||||
_showError("Update failed: $e");
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Create User Logic ---
|
||||
Future<void> _createUserSubmit() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
if (_verifiedAdminPassword == null) return;
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
String loginId = _createLoginIdController.text.trim();
|
||||
if (!loginId.contains('@')) {
|
||||
loginId = loginId.replaceAll(RegExp(r'[-\s]'), '');
|
||||
if (loginId.startsWith('010')) {
|
||||
loginId = '+82${loginId.substring(1)}';
|
||||
}
|
||||
}
|
||||
|
||||
String? phone = _createPhoneController.text.trim().isEmpty
|
||||
? null
|
||||
: _createPhoneController.text.trim();
|
||||
if (phone != null && !phone.contains('@')) {
|
||||
phone = phone.replaceAll(RegExp(r'[-\s]'), '');
|
||||
if (phone.startsWith('010')) {
|
||||
phone = '+82${phone.substring(1)}';
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await AuthProxyService.createUser(
|
||||
loginId: loginId,
|
||||
adminPassword: _verifiedAdminPassword!,
|
||||
email: _createEmailController.text.trim().isEmpty
|
||||
? null
|
||||
: _createEmailController.text.trim(),
|
||||
phone: phone,
|
||||
displayName: _createNameController.text.trim().isEmpty
|
||||
? null
|
||||
: _createNameController.text.trim(),
|
||||
);
|
||||
|
||||
_showSuccess("User created successfully");
|
||||
_formKey.currentState!.reset();
|
||||
_createLoginIdController.clear();
|
||||
_createEmailController.clear();
|
||||
_createPhoneController.clear();
|
||||
_createNameController.clear();
|
||||
|
||||
// Switch to list tab and reload
|
||||
_tabController.animateTo(0);
|
||||
_loadUsers();
|
||||
} catch (e) {
|
||||
_showError("Error: $e");
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
// --- UI Helpers ---
|
||||
void _showError(String msg) {
|
||||
if (!mounted) return;
|
||||
ToastService.error(msg);
|
||||
}
|
||||
|
||||
void _showSuccess(String msg) {
|
||||
if (!mounted) return;
|
||||
ToastService.success(msg);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_isAuthorized) {
|
||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('User Management'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.go(buildLocalizedHomePath(Uri.base)),
|
||||
),
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
tabs: const [
|
||||
Tab(icon: Icon(Icons.list), text: "User List"),
|
||||
Tab(icon: Icon(Icons.person_add), text: "Create User"),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [_buildUserListTab(), _buildCreateUserTab()],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUserListTab() {
|
||||
return Column(
|
||||
children: [
|
||||
if (_isLoading) const LinearProgressIndicator(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Search Users",
|
||||
prefixIcon: Icon(Icons.search),
|
||||
border: OutlineInputBorder(),
|
||||
hintText: "Email, Phone, or Name",
|
||||
),
|
||||
onChanged: _onSearchChanged,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _users.isEmpty
|
||||
? const Center(child: Text("No users found."))
|
||||
: ListView.separated(
|
||||
itemCount: _users.length,
|
||||
separatorBuilder: (context, index) => const Divider(),
|
||||
itemBuilder: (context, index) {
|
||||
final user = _users[index];
|
||||
// 일부 응답은 최상위 또는 user 하위에 필드를 포함합니다.
|
||||
|
||||
final loginIDs = (user['loginIds'] as List?) ?? [];
|
||||
final loginId = loginIDs.isNotEmpty
|
||||
? loginIDs.first.toString()
|
||||
: "Unknown ID";
|
||||
final name =
|
||||
user['name'] ?? user['user']?['name'] ?? "No Name";
|
||||
final status = user['status'] ?? "unknown";
|
||||
final isEnabled = status == "enabled" || status == "active";
|
||||
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: isEnabled
|
||||
? Colors.green.shade100
|
||||
: Colors.grey.shade300,
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
color: isEnabled ? Colors.green : Colors.grey,
|
||||
),
|
||||
),
|
||||
title: Text(name),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
loginId,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
"Status: $status",
|
||||
style: TextStyle(
|
||||
color: isEnabled ? Colors.green : Colors.red,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit, color: Colors.blue),
|
||||
tooltip: "Edit User",
|
||||
onPressed: () => _editUser(user),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
isEnabled ? Icons.block : Icons.check_circle,
|
||||
color: isEnabled ? Colors.orange : Colors.green,
|
||||
),
|
||||
tooltip: isEnabled ? "Disable User" : "Enable User",
|
||||
onPressed: () => _toggleStatus(loginId, status),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete, color: Colors.red),
|
||||
tooltip: "Delete User",
|
||||
onPressed: () => _deleteUser(loginId),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCreateUserTab() {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 600),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (_isLoading) const LinearProgressIndicator(),
|
||||
const SizedBox(height: 20),
|
||||
TextFormField(
|
||||
controller: _createLoginIdController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Login ID (Required)",
|
||||
border: OutlineInputBorder(),
|
||||
helperText: "Unique identifier (Email or Phone)",
|
||||
),
|
||||
validator: (value) => value == null || value.isEmpty
|
||||
? 'Please enter Login ID'
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _createNameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Display Name",
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.person),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _createEmailController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Email",
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.email),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _createPhoneController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Phone Number",
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.phone),
|
||||
helperText: "010-xxxx-xxxx",
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
FilledButton(
|
||||
onPressed: _isLoading ? null : _createUserSubmit,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
child: const Text("Create User"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:userfront/core/i18n/locale_utils.dart';
|
||||
import 'package:userfront/core/services/auth_proxy_service.dart';
|
||||
|
||||
bool shouldRouteTenantAccessErrorToErrorScreen(Object error) {
|
||||
return error is AuthProxyException && error.errorCode == 'tenant_not_allowed';
|
||||
}
|
||||
|
||||
bool shouldRouteConsentErrorToErrorScreen(Object error) {
|
||||
return shouldRouteTenantAccessErrorToErrorScreen(error);
|
||||
}
|
||||
|
||||
String buildTenantAccessErrorPath(Object error, Uri baseUri) {
|
||||
final authError = error as AuthProxyException;
|
||||
final localeCode =
|
||||
extractLocaleFromPath(baseUri) ?? resolvePreferredLocaleCode();
|
||||
return buildLocalizedPath(
|
||||
localeCode,
|
||||
Uri(
|
||||
path: '/error',
|
||||
queryParameters: {
|
||||
'error': authError.errorCode,
|
||||
'error_description': authError.message,
|
||||
if (authError.details != null) 'details': jsonEncode(authError.details),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
bool shouldPromoteCookieSession({
|
||||
required String? currentToken,
|
||||
required String? loginChallenge,
|
||||
}) {
|
||||
final hasToken = currentToken != null && currentToken.trim().isNotEmpty;
|
||||
final hasChallenge =
|
||||
loginChallenge != null && loginChallenge.trim().isNotEmpty;
|
||||
|
||||
// 토큰 기반 세션이 이미 확보된 일반 로그인 흐름에서는
|
||||
// 뒤늦은 쿠키 세션 승격이 토큰을 덮어쓰지 않도록 차단합니다.
|
||||
if (hasToken && !hasChallenge) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
enum LoginChallengeSource { widget, uriQuery, rawSearch, rawHref, missing }
|
||||
|
||||
class LoginChallengeResolution {
|
||||
final String? value;
|
||||
final LoginChallengeSource source;
|
||||
final bool uriHasLoginChallenge;
|
||||
final bool rawSearchHasLoginChallenge;
|
||||
final bool rawHrefHasLoginChallenge;
|
||||
|
||||
const LoginChallengeResolution({
|
||||
required this.value,
|
||||
required this.source,
|
||||
required this.uriHasLoginChallenge,
|
||||
required this.rawSearchHasLoginChallenge,
|
||||
required this.rawHrefHasLoginChallenge,
|
||||
});
|
||||
|
||||
Map<String, Object?> toDiagnostics() {
|
||||
return {
|
||||
'resolved_value_len': value?.length ?? 0,
|
||||
'resolved_source': source.name,
|
||||
'uri_has_login_challenge': uriHasLoginChallenge,
|
||||
'raw_search_has_login_challenge': rawSearchHasLoginChallenge,
|
||||
'raw_href_has_login_challenge': rawHrefHasLoginChallenge,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
LoginChallengeResolution resolveLoginChallenge({
|
||||
String? widgetLoginChallenge,
|
||||
required Uri uri,
|
||||
String? rawSearch,
|
||||
String? rawHref,
|
||||
}) {
|
||||
final widgetValue = _normalizeChallenge(widgetLoginChallenge);
|
||||
if (widgetValue != null) {
|
||||
return const LoginChallengeResolution(
|
||||
value: null,
|
||||
source: LoginChallengeSource.widget,
|
||||
uriHasLoginChallenge: false,
|
||||
rawSearchHasLoginChallenge: false,
|
||||
rawHrefHasLoginChallenge: false,
|
||||
).copyWith(value: widgetValue);
|
||||
}
|
||||
|
||||
final uriValue = _normalizeChallenge(uri.queryParameters['login_challenge']);
|
||||
if (uriValue != null) {
|
||||
return const LoginChallengeResolution(
|
||||
value: null,
|
||||
source: LoginChallengeSource.uriQuery,
|
||||
uriHasLoginChallenge: true,
|
||||
rawSearchHasLoginChallenge: false,
|
||||
rawHrefHasLoginChallenge: false,
|
||||
).copyWith(value: uriValue);
|
||||
}
|
||||
|
||||
final rawSearchValue = _normalizeChallenge(
|
||||
_extractQueryParamFromRawQuery(rawSearch, 'login_challenge'),
|
||||
);
|
||||
if (rawSearchValue != null) {
|
||||
return const LoginChallengeResolution(
|
||||
value: null,
|
||||
source: LoginChallengeSource.rawSearch,
|
||||
uriHasLoginChallenge: false,
|
||||
rawSearchHasLoginChallenge: true,
|
||||
rawHrefHasLoginChallenge: false,
|
||||
).copyWith(value: rawSearchValue);
|
||||
}
|
||||
|
||||
final rawHrefValue = _normalizeChallenge(
|
||||
_extractQueryParamFromRawHref(rawHref, 'login_challenge'),
|
||||
);
|
||||
if (rawHrefValue != null) {
|
||||
return const LoginChallengeResolution(
|
||||
value: null,
|
||||
source: LoginChallengeSource.rawHref,
|
||||
uriHasLoginChallenge: false,
|
||||
rawSearchHasLoginChallenge: false,
|
||||
rawHrefHasLoginChallenge: true,
|
||||
).copyWith(value: rawHrefValue);
|
||||
}
|
||||
|
||||
return const LoginChallengeResolution(
|
||||
value: null,
|
||||
source: LoginChallengeSource.missing,
|
||||
uriHasLoginChallenge: false,
|
||||
rawSearchHasLoginChallenge: false,
|
||||
rawHrefHasLoginChallenge: false,
|
||||
);
|
||||
}
|
||||
|
||||
String? _normalizeChallenge(String? value) {
|
||||
final trimmed = value?.trim();
|
||||
if (trimmed == null || trimmed.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
String? _extractQueryParamFromRawHref(String? rawHref, String key) {
|
||||
final href = rawHref?.trim();
|
||||
if (href == null || href.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final parsed = Uri.tryParse(href);
|
||||
final fromParsed = parsed?.queryParameters[key];
|
||||
final normalizedParsed = _normalizeChallenge(fromParsed);
|
||||
if (normalizedParsed != null) {
|
||||
return normalizedParsed;
|
||||
}
|
||||
|
||||
final question = href.indexOf('?');
|
||||
if (question < 0) {
|
||||
return null;
|
||||
}
|
||||
final hash = href.indexOf('#', question + 1);
|
||||
final rawQuery = hash < 0
|
||||
? href.substring(question + 1)
|
||||
: href.substring(question + 1, hash);
|
||||
return _extractQueryParamFromRawQuery(rawQuery, key);
|
||||
}
|
||||
|
||||
String? _extractQueryParamFromRawQuery(String? rawQuery, String key) {
|
||||
final query = rawQuery?.trim();
|
||||
if (query == null || query.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final normalizedQuery = query.startsWith('?') ? query.substring(1) : query;
|
||||
if (normalizedQuery.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
final parsed = Uri.splitQueryString(normalizedQuery);
|
||||
final value = _normalizeChallenge(parsed[key]);
|
||||
if (value != null) {
|
||||
return value;
|
||||
}
|
||||
} catch (_) {
|
||||
// URI 파싱이 실패하면 수동 파싱으로 보완합니다.
|
||||
}
|
||||
|
||||
for (final pair in normalizedQuery.split('&')) {
|
||||
if (pair.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
final equalIndex = pair.indexOf('=');
|
||||
final rawKey = equalIndex < 0 ? pair : pair.substring(0, equalIndex);
|
||||
final decodedKey = _decodeQueryComponentSafe(rawKey);
|
||||
if (decodedKey != key) {
|
||||
continue;
|
||||
}
|
||||
if (equalIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
final rawValue = pair.substring(equalIndex + 1);
|
||||
final decodedValue = _normalizeChallenge(
|
||||
_decodeQueryComponentSafe(rawValue),
|
||||
);
|
||||
if (decodedValue != null) {
|
||||
return decodedValue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _decodeQueryComponentSafe(String value) {
|
||||
try {
|
||||
return Uri.decodeQueryComponent(value);
|
||||
} catch (_) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
extension on LoginChallengeResolution {
|
||||
LoginChallengeResolution copyWith({
|
||||
String? value,
|
||||
LoginChallengeSource? source,
|
||||
bool? uriHasLoginChallenge,
|
||||
bool? rawSearchHasLoginChallenge,
|
||||
bool? rawHrefHasLoginChallenge,
|
||||
}) {
|
||||
return LoginChallengeResolution(
|
||||
value: value ?? this.value,
|
||||
source: source ?? this.source,
|
||||
uriHasLoginChallenge: uriHasLoginChallenge ?? this.uriHasLoginChallenge,
|
||||
rawSearchHasLoginChallenge:
|
||||
rawSearchHasLoginChallenge ?? this.rawSearchHasLoginChallenge,
|
||||
rawHrefHasLoginChallenge:
|
||||
rawHrefHasLoginChallenge ?? this.rawHrefHasLoginChallenge,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import '../../../core/i18n/locale_utils.dart';
|
||||
|
||||
bool isPublicAuthPath(String path, Uri uri) {
|
||||
return path == '/signin' ||
|
||||
path == '/signup' ||
|
||||
path == '/login' ||
|
||||
path == '/registration' ||
|
||||
path == '/verify' ||
|
||||
path == '/verification' ||
|
||||
path == '/verify-complete' ||
|
||||
path.startsWith('/verify/') ||
|
||||
path.startsWith('/l/') ||
|
||||
path == '/approve' ||
|
||||
path.startsWith('/ql/') ||
|
||||
path == '/forgot-password' ||
|
||||
path == '/recovery' ||
|
||||
path == '/reset-password' ||
|
||||
path == '/error' ||
|
||||
path == '/settings' ||
|
||||
path == '/consent' ||
|
||||
path.startsWith('/consent/') ||
|
||||
uri.path.contains('/consent');
|
||||
}
|
||||
|
||||
String? extractLoginShortCode(Uri uri) {
|
||||
final normalizedPath = stripLocalePath(uri);
|
||||
final segments = normalizedPath
|
||||
.split('/')
|
||||
.where((segment) => segment.isNotEmpty)
|
||||
.toList();
|
||||
if (segments.length < 2 || segments.first != 'l') {
|
||||
return null;
|
||||
}
|
||||
return segments[1];
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
enum PasswordLoginNextAction { redirectToOidc, acceptOidc, localLogin, invalid }
|
||||
|
||||
PasswordLoginNextAction decidePasswordLoginNextAction({
|
||||
required bool hasLoginChallenge,
|
||||
required String? redirectTo,
|
||||
required String? jwt,
|
||||
}) {
|
||||
final hasRedirectTo = redirectTo != null && redirectTo.isNotEmpty;
|
||||
if (hasRedirectTo) {
|
||||
return PasswordLoginNextAction.redirectToOidc;
|
||||
}
|
||||
|
||||
if (hasLoginChallenge) {
|
||||
return PasswordLoginNextAction.acceptOidc;
|
||||
}
|
||||
|
||||
final hasJwt = jwt != null && jwt.isNotEmpty;
|
||||
if (hasJwt) {
|
||||
return PasswordLoginNextAction.localLogin;
|
||||
}
|
||||
|
||||
return PasswordLoginNextAction.invalid;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import '../../../core/i18n/locale_utils.dart';
|
||||
|
||||
const verificationRoutePath = '/verify';
|
||||
const verificationCompletionRoutePath = '/verify-complete';
|
||||
const verificationCompletionRouteName = 'verify-complete';
|
||||
|
||||
String buildLocalizedVerificationCompletePath(String localeCode) {
|
||||
return '/$localeCode$verificationCompletionRoutePath';
|
||||
}
|
||||
|
||||
bool isDedicatedVerificationRoute(Uri uri) {
|
||||
final path = stripLocalePath(uri);
|
||||
return path == verificationRoutePath ||
|
||||
path == '/verification' ||
|
||||
path.startsWith('/verify/') ||
|
||||
path.startsWith('/l/');
|
||||
}
|
||||
|
||||
bool hasVerificationPayload(Uri uri) {
|
||||
final query = uri.queryParameters;
|
||||
final token = query['t'];
|
||||
final loginId = query['loginId'];
|
||||
final code = query['code'];
|
||||
return (token != null && token.isNotEmpty) ||
|
||||
(loginId != null &&
|
||||
loginId.isNotEmpty &&
|
||||
code != null &&
|
||||
code.isNotEmpty);
|
||||
}
|
||||
|
||||
String? buildDedicatedVerificationRedirect(
|
||||
Uri uri, {
|
||||
required String localeCode,
|
||||
}) {
|
||||
if (isDedicatedVerificationRoute(uri)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final query = uri.queryParameters;
|
||||
final token = query['t'];
|
||||
final loginId = query['loginId'];
|
||||
final code = query['code'];
|
||||
final pendingRef = query['pendingRef'];
|
||||
final sanitizedQuery = <String, String>{};
|
||||
|
||||
if (token != null && token.isNotEmpty) {
|
||||
sanitizedQuery['t'] = token;
|
||||
} else if (loginId != null &&
|
||||
loginId.isNotEmpty &&
|
||||
code != null &&
|
||||
code.isNotEmpty) {
|
||||
sanitizedQuery['loginId'] = loginId;
|
||||
sanitizedQuery['code'] = code;
|
||||
if (pendingRef != null && pendingRef.isNotEmpty) {
|
||||
sanitizedQuery['pendingRef'] = pendingRef;
|
||||
}
|
||||
}
|
||||
|
||||
if (sanitizedQuery.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Uri(
|
||||
path: '/$localeCode$verificationRoutePath',
|
||||
queryParameters: sanitizedQuery,
|
||||
).toString();
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../../core/i18n/locale_utils.dart';
|
||||
import '../../../../core/services/auth_proxy_service.dart';
|
||||
import '../../../../core/services/auth_token_store.dart';
|
||||
import '../../../../core/services/web_window.dart';
|
||||
|
||||
class ApproveQrScreen extends StatefulWidget {
|
||||
final String? pendingRef;
|
||||
const ApproveQrScreen({super.key, this.pendingRef});
|
||||
|
||||
@override
|
||||
State<ApproveQrScreen> createState() => _ApproveQrScreenState();
|
||||
}
|
||||
|
||||
class _ApproveQrScreenState extends State<ApproveQrScreen> {
|
||||
bool _isLoading = false;
|
||||
String? _message;
|
||||
bool _success = false;
|
||||
bool _isCheckingSession = false;
|
||||
bool _redirectingToLogin = false;
|
||||
bool _autoApproveTriggered = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bootstrapCookieSession().then((_) {
|
||||
_redirectIfNotLoggedIn();
|
||||
_maybeAutoApprove();
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> _bootstrapCookieSession() async {
|
||||
if (AuthTokenStore.usesCookie()) {
|
||||
return true;
|
||||
}
|
||||
if (_isCheckingSession) {
|
||||
return false;
|
||||
}
|
||||
setState(() => _isCheckingSession = true);
|
||||
try {
|
||||
await AuthProxyService.checkCookieSession();
|
||||
AuthTokenStore.setCookieMode(provider: 'ory');
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isCheckingSession = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _redirectIfNotLoggedIn() {
|
||||
if (_redirectingToLogin || !mounted) return;
|
||||
final hasStoredToken = AuthTokenStore.getToken()?.isNotEmpty ?? false;
|
||||
final usesCookie = AuthTokenStore.usesCookie();
|
||||
final isLoggedIn = hasStoredToken || usesCookie;
|
||||
if (!isLoggedIn) {
|
||||
_redirectingToLogin = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final target = buildLocalizedSigninPath(Uri.base);
|
||||
webWindow.redirectTo('$target?notice=qr_login_required');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _maybeAutoApprove() {
|
||||
if (!mounted || _autoApproveTriggered) return;
|
||||
if (widget.pendingRef == null || widget.pendingRef!.trim().isEmpty) {
|
||||
if (_message == null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_message = 'Error: pendingRef is missing.';
|
||||
});
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final hasStoredToken = AuthTokenStore.getToken()?.isNotEmpty ?? false;
|
||||
final usesCookie = AuthTokenStore.usesCookie();
|
||||
final isLoggedIn = hasStoredToken || usesCookie || _isCheckingSession;
|
||||
if (!isLoggedIn || _isLoading || _success) {
|
||||
return;
|
||||
}
|
||||
|
||||
_autoApproveTriggered = true;
|
||||
_handleApprove();
|
||||
}
|
||||
|
||||
Future<void> _handleApprove() async {
|
||||
if (widget.pendingRef == null) return;
|
||||
|
||||
final storedToken = AuthTokenStore.getToken();
|
||||
final usesCookie = AuthTokenStore.usesCookie();
|
||||
var hasCookie = usesCookie;
|
||||
if (storedToken == null && !hasCookie) {
|
||||
hasCookie = await _bootstrapCookieSession();
|
||||
}
|
||||
if (storedToken == null && !hasCookie) {
|
||||
if (mounted) {
|
||||
final target = buildLocalizedSigninPath(Uri.base);
|
||||
webWindow.redirectTo('$target?notice=qr_login_required');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_message = null;
|
||||
});
|
||||
// jwt 유효성 확인
|
||||
try {
|
||||
final token = storedToken ?? '';
|
||||
await AuthProxyService.approveQrLogin(
|
||||
widget.pendingRef!,
|
||||
token: token,
|
||||
withCredentials: hasCookie,
|
||||
);
|
||||
setState(() {
|
||||
_success = true;
|
||||
_message = "Login Approved! Your browser should now be logged in.";
|
||||
});
|
||||
|
||||
// Automatically go to dashboard after a short delay
|
||||
Future.delayed(const Duration(seconds: 1), () {
|
||||
if (mounted) context.go(buildLocalizedHomePath(Uri.base));
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() => _message = "Error: $e");
|
||||
} finally {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasStoredToken = AuthTokenStore.getToken()?.isNotEmpty ?? false;
|
||||
final usesCookie = AuthTokenStore.usesCookie();
|
||||
final isLoggedIn = hasStoredToken || usesCookie || _isCheckingSession;
|
||||
|
||||
if (!isLoggedIn && !_redirectingToLogin) {
|
||||
_redirectIfNotLoggedIn();
|
||||
}
|
||||
if (isLoggedIn && !_success && !_isLoading) {
|
||||
_maybeAutoApprove();
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text("QR Login Approval")),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.phonelink_lock, size: 80, color: Colors.blue),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
"Web Login Request",
|
||||
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
"A computer is trying to log in using this QR code.",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey.shade600),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
if (_message != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 20),
|
||||
child: Text(
|
||||
_message!,
|
||||
style: TextStyle(
|
||||
color: _success ? Colors.green : Colors.red,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
|
||||
if (_isLoading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(bottom: 16),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
|
||||
if (!_success && !_isLoading)
|
||||
Text(
|
||||
"Approving login request automatically...",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey.shade700),
|
||||
),
|
||||
|
||||
if (!isLoggedIn && !_success)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: TextButton(
|
||||
onPressed: () =>
|
||||
context.go(buildLocalizedSigninPath(Uri.base)),
|
||||
child: const Text("Login on this device first"),
|
||||
),
|
||||
),
|
||||
|
||||
if (!_success && !_isLoading && _message != null)
|
||||
FilledButton.icon(
|
||||
onPressed: !isLoggedIn
|
||||
? null
|
||||
: () {
|
||||
_autoApproveTriggered = false;
|
||||
_handleApprove();
|
||||
},
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text("Retry Approval"),
|
||||
),
|
||||
|
||||
if (_success)
|
||||
FilledButton(
|
||||
onPressed: () => context.go(buildLocalizedHomePath(Uri.base)),
|
||||
child: const Text("Go to My Dashboard"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
import 'package:userfront/core/i18n/locale_utils.dart';
|
||||
import 'package:userfront/core/services/auth_proxy_service.dart';
|
||||
import 'package:userfront/core/services/web_window.dart';
|
||||
import 'package:userfront/core/ui/toast_service.dart';
|
||||
import 'package:userfront/features/auth/domain/consent_error_routing.dart';
|
||||
|
||||
class ConsentScreen extends StatefulWidget {
|
||||
final String consentChallenge;
|
||||
final Future<Map<String, dynamic>> Function(String consentChallenge)?
|
||||
consentInfoLoader;
|
||||
|
||||
const ConsentScreen({
|
||||
super.key,
|
||||
required this.consentChallenge,
|
||||
this.consentInfoLoader,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ConsentScreen> createState() => _ConsentScreenState();
|
||||
}
|
||||
|
||||
class _ConsentScreenState extends State<ConsentScreen> {
|
||||
Map<String, dynamic>? _consentInfo;
|
||||
bool _isLoading = true;
|
||||
bool _isSubmitting = false;
|
||||
String? _error;
|
||||
|
||||
final Set<String> _selectedScopes = {};
|
||||
final Map<String, String> _scopeDescriptions = {};
|
||||
final Set<String> _mandatoryScopes = {'openid'};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scopeDescriptions.addAll(_defaultScopeDescriptions());
|
||||
_fetchConsentInfo();
|
||||
}
|
||||
|
||||
Map<String, String> _defaultScopeDescriptions() {
|
||||
return {
|
||||
'openid': tr(
|
||||
'msg.userfront.consent.scope.openid',
|
||||
fallback: 'OpenID authentication information (signin session check)',
|
||||
),
|
||||
'profile': tr(
|
||||
'msg.userfront.consent.scope.profile',
|
||||
fallback: 'Basic profile information (name, user identifier)',
|
||||
),
|
||||
'email': tr(
|
||||
'msg.userfront.consent.scope.email',
|
||||
fallback: 'Email address (account identification and notifications)',
|
||||
),
|
||||
'offline_access': tr(
|
||||
'msg.userfront.consent.scope.offline_access',
|
||||
fallback: 'Offline access (keep signed in)',
|
||||
),
|
||||
'phone': tr(
|
||||
'msg.userfront.consent.scope.phone',
|
||||
fallback: 'Phone number (identity verification and notifications)',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
String _renderConsentText(String key, {String? fallback}) {
|
||||
return tr(
|
||||
key,
|
||||
fallback: fallback,
|
||||
).replaceAll(r'\\n', '\n').replaceAll(r'\n', '\n').replaceAll('\\\n', '\n');
|
||||
}
|
||||
|
||||
String _renderScopeCountLabel(int count) {
|
||||
return tr(
|
||||
'msg.userfront.consent.scope_count',
|
||||
fallback: 'Total {{count}}',
|
||||
params: {'count': '$count'},
|
||||
).replaceAll('{$count}', '$count');
|
||||
}
|
||||
|
||||
String _scopeDisplayLabel(String scope) {
|
||||
if (scope == 'offline_access') {
|
||||
return 'offline access';
|
||||
}
|
||||
return scope.replaceAll('_', ' ');
|
||||
}
|
||||
|
||||
String _renderClientIdLabel(String clientId) {
|
||||
final raw = tr(
|
||||
'msg.userfront.consent.client_id',
|
||||
fallback: 'Client ID: {{id}}',
|
||||
);
|
||||
final normalized = raw
|
||||
.replaceAll('{{id}}', '')
|
||||
.replaceAll('{id}', '')
|
||||
.trimRight();
|
||||
return '$normalized $clientId';
|
||||
}
|
||||
|
||||
Future<void> _fetchConsentInfo() async {
|
||||
try {
|
||||
final loader =
|
||||
widget.consentInfoLoader ?? AuthProxyService.getConsentInfo;
|
||||
final info = await loader(widget.consentChallenge);
|
||||
|
||||
// [Skip Logic] 백엔드에서 자동 승인되어 리다이렉트 URL이 온 경우 즉시 이동
|
||||
if (info['redirectTo'] != null) {
|
||||
webWindow.redirectTo(info['redirectTo']);
|
||||
return;
|
||||
}
|
||||
|
||||
// 백엔드에서 전달받은 커스텀 스코프 정보(scope_details) 적용
|
||||
if (info['scope_details'] != null) {
|
||||
final details = info['scope_details'] as Map<String, dynamic>;
|
||||
|
||||
details.forEach((scope, detail) {
|
||||
if (detail is Map<String, dynamic>) {
|
||||
// 설명 업데이트
|
||||
if (detail['description'] != null &&
|
||||
detail['description'].toString().isNotEmpty) {
|
||||
_scopeDescriptions[scope] = detail['description'].toString();
|
||||
}
|
||||
// 필수 여부 업데이트
|
||||
if (detail['mandatory'] == true) {
|
||||
_mandatoryScopes.add(scope);
|
||||
} else {
|
||||
// openid는 기본적으로 필수지만 설정에서 굳이 껐다면?
|
||||
// 안전을 위해 openid는 항상 필수로 유지하는 것이 좋지만,
|
||||
// 여기서는 서버 설정을 존중하되 openid는 예외처리 할 수도 있음.
|
||||
// 우선 서버 설정이 있으면 반영 (단, openid는 제거하지 않음)
|
||||
if (scope != 'openid') {
|
||||
_mandatoryScopes.remove(scope);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 초기 선택 상태 설정: 모든 요청된 스코프를 기본 선택
|
||||
final requestedScopes =
|
||||
(info['requested_scope'] as List<dynamic>?)?.cast<String>() ?? [];
|
||||
_selectedScopes.addAll(requestedScopes);
|
||||
|
||||
setState(() {
|
||||
_consentInfo = info;
|
||||
_isLoading = false;
|
||||
});
|
||||
} on AuthProxyException catch (e) {
|
||||
if (shouldRouteConsentErrorToErrorScreen(e)) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final target = buildTenantAccessErrorPath(e, Uri.base);
|
||||
context.go(target);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_error = tr(
|
||||
'msg.userfront.consent.load_error',
|
||||
fallback: 'Failed to load consent information: {{error}}',
|
||||
params: {'error': e.message},
|
||||
);
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = tr(
|
||||
'msg.userfront.consent.load_error',
|
||||
fallback: 'Failed to load consent information: {{error}}',
|
||||
params: {'error': '$e'},
|
||||
);
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _acceptConsent() async {
|
||||
setState(() {
|
||||
_isSubmitting = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
// 선택된 스코프만 리스트로 변환하여 전송
|
||||
final result = await AuthProxyService.acceptConsent(
|
||||
widget.consentChallenge,
|
||||
grantScope: _selectedScopes.toList(),
|
||||
);
|
||||
|
||||
if (result['redirectTo'] != null) {
|
||||
webWindow.redirectTo(result['redirectTo']);
|
||||
} else {
|
||||
setState(() {
|
||||
_error = tr(
|
||||
'msg.userfront.consent.missing_redirect',
|
||||
fallback:
|
||||
'Consent was processed, but the redirect URL was missing.',
|
||||
);
|
||||
_isSubmitting = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = tr(
|
||||
'msg.userfront.consent.accept_error',
|
||||
fallback: 'Failed to process consent: {{error}}',
|
||||
params: {'error': '$e'},
|
||||
);
|
||||
_isSubmitting = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onCancel() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(tr('ui.userfront.consent.cancel.title')),
|
||||
content: Text(tr('msg.userfront.consent.cancel.confirm')),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(tr('ui.common.cancel')),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: Text(tr('ui.userfront.consent.cancel.confirm_button')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
setState(() => _isSubmitting = true);
|
||||
try {
|
||||
final resp = await AuthProxyService.rejectConsent(
|
||||
widget.consentChallenge,
|
||||
);
|
||||
final redirectTo = resp['redirectTo'];
|
||||
if (redirectTo != null) {
|
||||
webWindow.redirectTo(redirectTo);
|
||||
} else {
|
||||
if (mounted) context.go(buildLocalizedHomePath(Uri.base));
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() => _isSubmitting = false);
|
||||
if (mounted) {
|
||||
ToastService.error(
|
||||
tr(
|
||||
'msg.userfront.consent.cancel.error',
|
||||
fallback: 'An error occurred while cancelling consent: {{error}}',
|
||||
params: {'error': '$e'},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 배경색을 약간 어둡게 처리하거나, 전체적인 테마 색상을 사용
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.grey[100],
|
||||
body: Center(
|
||||
child: _isLoading
|
||||
? const CircularProgressIndicator()
|
||||
: _error != null
|
||||
? _buildErrorCard()
|
||||
: _buildConsentCard(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorCard() {
|
||||
return Card(
|
||||
margin: const EdgeInsets.all(24),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Colors.red, size: 48),
|
||||
const SizedBox(height: 16),
|
||||
Text(_error!, textAlign: TextAlign.center),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildConsentCard(BuildContext context) {
|
||||
final clientRawName = _consentInfo?['client']?['client_name'] as String?;
|
||||
final clientId = _consentInfo?['client']?['client_id'] as String? ?? '-';
|
||||
final clientName = (clientRawName != null && clientRawName.isNotEmpty)
|
||||
? clientRawName
|
||||
: (clientId != '-'
|
||||
? clientId
|
||||
: tr('msg.userfront.consent.client_unknown'));
|
||||
final clientLogo = _consentInfo?['client']?['logo_uri'];
|
||||
final requestedScopes =
|
||||
(_consentInfo?['requested_scope'] as List<dynamic>?)?.cast<String>() ??
|
||||
[];
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 520),
|
||||
margin: const EdgeInsets.all(16),
|
||||
child: Card(
|
||||
elevation: 8,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 1. 헤더 영역
|
||||
Text(
|
||||
tr('ui.userfront.consent.title'),
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_renderConsentText('msg.userfront.consent.description'),
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// 2. 서비스 정보 영역
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[50],
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.grey[200]!),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (clientLogo != null &&
|
||||
clientLogo.toString().isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: CircleAvatar(
|
||||
radius: 24,
|
||||
backgroundImage: NetworkImage(clientLogo),
|
||||
backgroundColor: Colors.transparent,
|
||||
),
|
||||
)
|
||||
else
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 16),
|
||||
child: CircleAvatar(
|
||||
radius: 24,
|
||||
child: Icon(Icons.apps),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
clientName,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_renderClientIdLabel(clientId),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[500],
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// 3. 권한 선택 영역
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
tr('ui.userfront.consent.requested_scopes'),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_renderScopeCountLabel(requestedScopes.length),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Divider(),
|
||||
...requestedScopes.map((scope) {
|
||||
final isMandatory = _mandatoryScopes.contains(scope);
|
||||
final description = _scopeDescriptions[scope] ?? scope;
|
||||
final isSelected = _selectedScopes.contains(scope);
|
||||
|
||||
return CheckboxListTile(
|
||||
title: Text(
|
||||
_scopeDisplayLabel(scope),
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: Text(description),
|
||||
value: isSelected,
|
||||
onChanged: isMandatory
|
||||
? null // 필수 항목은 변경 불가 (비활성화 상태로 체크됨)
|
||||
: (bool? value) {
|
||||
setState(() {
|
||||
if (value == true) {
|
||||
_selectedScopes.add(scope);
|
||||
} else {
|
||||
_selectedScopes.remove(scope);
|
||||
}
|
||||
});
|
||||
},
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
activeColor: Theme.of(context).primaryColor,
|
||||
);
|
||||
}),
|
||||
const Divider(),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// 4. 버튼 영역
|
||||
ElevatedButton(
|
||||
onPressed: _isSubmitting ? null : _acceptConsent,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
backgroundColor: const Color(0xFF1A1F2C), // 브랜드 컬러
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
child: _isSubmitting
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
tr('ui.userfront.consent.accept'),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton(
|
||||
onPressed: _isSubmitting ? null : _onCancel,
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: Text(tr('ui.common.cancel')),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
tr('msg.userfront.consent.redirect_notice'),
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,690 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/constants/error_whitelist.dart';
|
||||
import '../../../core/i18n/locale_utils.dart';
|
||||
import '../../../core/services/auth_proxy_service.dart';
|
||||
import '../../../core/services/logout_service.dart';
|
||||
import '../../../core/widgets/theme_toggle_button.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
|
||||
class ErrorScreen extends StatefulWidget {
|
||||
final String? errorId;
|
||||
final String? errorCode;
|
||||
final String? description;
|
||||
final bool? isProdOverride;
|
||||
final Future<Map<String, dynamic>> Function()? sessionProfileLoader;
|
||||
final Map<String, dynamic>? tenantAccessDetails;
|
||||
|
||||
const ErrorScreen({
|
||||
super.key,
|
||||
this.errorId,
|
||||
this.errorCode,
|
||||
this.description,
|
||||
this.isProdOverride,
|
||||
this.sessionProfileLoader,
|
||||
this.tenantAccessDetails,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ErrorScreen> createState() => _ErrorScreenState();
|
||||
}
|
||||
|
||||
class _ErrorScreenState extends State<ErrorScreen> {
|
||||
Map<String, dynamic>? _sessionProfile;
|
||||
bool _isLoadingSessionProfile = false;
|
||||
String? _sessionProfileError;
|
||||
|
||||
bool get _isTenantAccessBlocked =>
|
||||
(widget.errorCode ?? '').trim() == 'tenant_not_allowed';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (_isTenantAccessBlocked && _shouldLoadSessionProfile()) {
|
||||
unawaited(_loadSessionProfile());
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic>? get _tenantAccessDetails => widget.tenantAccessDetails;
|
||||
|
||||
bool _shouldLoadSessionProfile() {
|
||||
final details = _tenantAccessDetails;
|
||||
if (details == null) {
|
||||
return true;
|
||||
}
|
||||
final hasAccount = _extractAccountEmail(details).isNotEmpty;
|
||||
final hasTenant = _extractCurrentTenantLabel(details).isNotEmpty;
|
||||
return !hasAccount || !hasTenant;
|
||||
}
|
||||
|
||||
Future<void> _loadSessionProfile() async {
|
||||
if (_isLoadingSessionProfile) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_isLoadingSessionProfile = true;
|
||||
_sessionProfileError = null;
|
||||
});
|
||||
try {
|
||||
final loader = widget.sessionProfileLoader ?? AuthProxyService.getMe;
|
||||
final profile = await loader();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_sessionProfile = profile;
|
||||
});
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_sessionProfileError = error.toString();
|
||||
});
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoadingSessionProfile = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _extractTenantLabel(Map<String, dynamic>? profile) {
|
||||
if (profile == null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
final tenant = profile['tenant'];
|
||||
if (tenant is Map) {
|
||||
final name = tenant['name']?.toString().trim() ?? '';
|
||||
if (name.isNotEmpty) {
|
||||
return name;
|
||||
}
|
||||
final slug = tenant['slug']?.toString().trim() ?? '';
|
||||
if (slug.isNotEmpty) {
|
||||
return slug;
|
||||
}
|
||||
}
|
||||
|
||||
final joinedTenants = profile['joinedTenants'];
|
||||
if (joinedTenants is List) {
|
||||
for (final item in joinedTenants) {
|
||||
if (item is Map) {
|
||||
final name = item['name']?.toString().trim() ?? '';
|
||||
if (name.isNotEmpty) {
|
||||
return name;
|
||||
}
|
||||
final slug = item['slug']?.toString().trim() ?? '';
|
||||
if (slug.isNotEmpty) {
|
||||
return slug;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
String _extractCurrentTenantLabel(Map<String, dynamic>? details) {
|
||||
if (details == null) {
|
||||
return '';
|
||||
}
|
||||
final tenant = details['current_tenant'];
|
||||
if (tenant is! Map) {
|
||||
return '';
|
||||
}
|
||||
|
||||
final name = tenant['name']?.toString().trim() ?? '';
|
||||
if (name.isNotEmpty) {
|
||||
return name;
|
||||
}
|
||||
final slug = tenant['slug']?.toString().trim() ?? '';
|
||||
if (slug.isNotEmpty) {
|
||||
return slug;
|
||||
}
|
||||
final identifier = tenant['identifier']?.toString().trim() ?? '';
|
||||
if (identifier.isNotEmpty) {
|
||||
return identifier;
|
||||
}
|
||||
final id = tenant['id']?.toString().trim() ?? '';
|
||||
return id;
|
||||
}
|
||||
|
||||
String _extractAccountEmail(Map<String, dynamic>? details) {
|
||||
if (details == null) {
|
||||
return '';
|
||||
}
|
||||
final account = details['account'];
|
||||
if (account is! Map) {
|
||||
return '';
|
||||
}
|
||||
return account['email']?.toString().trim() ?? '';
|
||||
}
|
||||
|
||||
List<String> _extractAllowedTenantLabels(Map<String, dynamic>? details) {
|
||||
if (details == null) {
|
||||
return const [];
|
||||
}
|
||||
final raw = details['allowed_tenants'];
|
||||
if (raw is! List) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
final labels = <String>[];
|
||||
for (final item in raw) {
|
||||
if (item is! Map) {
|
||||
continue;
|
||||
}
|
||||
final label =
|
||||
item['name']?.toString().trim() ??
|
||||
item['slug']?.toString().trim() ??
|
||||
item['identifier']?.toString().trim() ??
|
||||
item['id']?.toString().trim() ??
|
||||
'';
|
||||
if (label.isNotEmpty) {
|
||||
labels.add(label);
|
||||
}
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
List<String> _extractAffiliatedTenantLabelsFromDetails(
|
||||
Map<String, dynamic>? details,
|
||||
) {
|
||||
if (details == null) {
|
||||
return const [];
|
||||
}
|
||||
final raw = details['affiliated_tenants'];
|
||||
if (raw is! List) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
final labels = <String>[];
|
||||
for (final item in raw) {
|
||||
if (item is! Map) {
|
||||
continue;
|
||||
}
|
||||
final label =
|
||||
item['name']?.toString().trim() ??
|
||||
item['slug']?.toString().trim() ??
|
||||
item['identifier']?.toString().trim() ??
|
||||
item['id']?.toString().trim() ??
|
||||
'';
|
||||
if (label.isNotEmpty) {
|
||||
labels.add(label);
|
||||
}
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
List<String> _extractAffiliatedTenantLabelsFromProfile(
|
||||
Map<String, dynamic>? profile,
|
||||
) {
|
||||
if (profile == null) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
final labels = <String>[];
|
||||
final seen = <String>{};
|
||||
|
||||
void appendLabel(String value) {
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty || seen.contains(trimmed)) {
|
||||
return;
|
||||
}
|
||||
seen.add(trimmed);
|
||||
labels.add(trimmed);
|
||||
}
|
||||
|
||||
final joinedTenants = profile['joinedTenants'];
|
||||
if (joinedTenants is List) {
|
||||
for (final item in joinedTenants) {
|
||||
if (item is Map) {
|
||||
appendLabel(item['name']?.toString() ?? '');
|
||||
appendLabel(item['slug']?.toString() ?? '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final tenant = _extractTenantLabel(profile);
|
||||
if (tenant.isNotEmpty) {
|
||||
appendLabel(tenant);
|
||||
}
|
||||
|
||||
return labels;
|
||||
}
|
||||
|
||||
Future<void> _switchAccount() async {
|
||||
await LogoutService().logout();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
context.go(buildLocalizedSigninPath(Uri.base));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final isProd = widget.isProdOverride ?? AuthProxyService.isProdEnv;
|
||||
final normalizedCode = (widget.errorCode ?? '').trim();
|
||||
final hasCode = normalizedCode.isNotEmpty;
|
||||
final internalWhitelistKey =
|
||||
internalErrorWhitelistMessageKeys[normalizedCode];
|
||||
final isInternalWhitelisted = internalWhitelistKey != null;
|
||||
final isOryBypass = hasCode && oryBypassErrorCodes.contains(normalizedCode);
|
||||
final isKnownProdCode = hasCode && (isInternalWhitelisted || isOryBypass);
|
||||
final isTenantAccessBlocked = normalizedCode == 'tenant_not_allowed';
|
||||
final errorType = isProd
|
||||
? (isKnownProdCode ? normalizedCode : 'unknown_error')
|
||||
: (hasCode ? normalizedCode : 'unknown_error');
|
||||
final title = isTenantAccessBlocked
|
||||
? tr(
|
||||
'msg.userfront.error.tenant.page_title',
|
||||
fallback: 'Application access is restricted',
|
||||
)
|
||||
: isProd
|
||||
? tr('msg.userfront.error.title')
|
||||
: (hasCode
|
||||
? tr(
|
||||
'msg.userfront.error.title_with_code',
|
||||
params: {'code': normalizedCode},
|
||||
)
|
||||
: tr('msg.userfront.error.title_generic'));
|
||||
final tenantLabelFromDetails = _extractCurrentTenantLabel(
|
||||
_tenantAccessDetails,
|
||||
);
|
||||
final tenantLabel = tenantLabelFromDetails.isNotEmpty
|
||||
? tenantLabelFromDetails
|
||||
: _extractTenantLabel(_sessionProfile);
|
||||
final emailFromDetails = _extractAccountEmail(_tenantAccessDetails);
|
||||
final emailLabel = emailFromDetails.isNotEmpty
|
||||
? emailFromDetails
|
||||
: (_sessionProfile?['email']?.toString().trim() ?? '');
|
||||
final affiliatedTenantLabels =
|
||||
_extractAffiliatedTenantLabelsFromDetails(
|
||||
_tenantAccessDetails,
|
||||
).isNotEmpty
|
||||
? _extractAffiliatedTenantLabelsFromDetails(_tenantAccessDetails)
|
||||
: _extractAffiliatedTenantLabelsFromProfile(_sessionProfile);
|
||||
final allowedTenantLabels = _extractAllowedTenantLabels(
|
||||
_tenantAccessDetails,
|
||||
);
|
||||
final isLoadingTenantContext =
|
||||
_isLoadingSessionProfile && _tenantAccessDetails == null;
|
||||
final hasTenantLookupFailure =
|
||||
_sessionProfileError != null &&
|
||||
_sessionProfileError!.isNotEmpty &&
|
||||
_tenantAccessDetails == null;
|
||||
final showTenantLookupFallback =
|
||||
_tenantAccessDetails == null &&
|
||||
(emailLabel.isEmpty || tenantLabel.isEmpty);
|
||||
final internalWhitelistDetail = internalWhitelistKey == null
|
||||
? null
|
||||
: tr(internalWhitelistKey);
|
||||
final detail = isTenantAccessBlocked
|
||||
? tr(
|
||||
'msg.userfront.error.tenant.detail',
|
||||
fallback:
|
||||
'The current signed-in account cannot access this application.',
|
||||
)
|
||||
: isProd
|
||||
? (isInternalWhitelisted
|
||||
? internalWhitelistDetail!
|
||||
: (isOryBypass
|
||||
? tr(
|
||||
'msg.userfront.error.ory.$normalizedCode',
|
||||
fallback: (widget.description?.isNotEmpty == true)
|
||||
? widget.description
|
||||
: tr('msg.userfront.error.detail_request'),
|
||||
)
|
||||
: tr('msg.userfront.error.detail_contact')))
|
||||
: ((widget.description?.isNotEmpty == true)
|
||||
? widget.description!
|
||||
: (hasCode
|
||||
? tr('msg.userfront.error.detail_generic')
|
||||
: tr('msg.userfront.error.detail_request')));
|
||||
return Scaffold(
|
||||
backgroundColor: colorScheme.surfaceContainerLowest,
|
||||
body: SafeArea(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) => SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: constraints.maxHeight - 48,
|
||||
),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 560),
|
||||
child: Card(
|
||||
margin: EdgeInsets.zero,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(28, 28, 28, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
const ThemeToggleButton(compact: true),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
detail,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
if (isTenantAccessBlocked) ...[
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
tr(
|
||||
'msg.userfront.error.tenant.title',
|
||||
fallback: 'Access restriction details',
|
||||
),
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (isLoadingTenantContext)
|
||||
Row(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Flexible(
|
||||
child: Text(
|
||||
tr(
|
||||
'msg.userfront.error.tenant.loading',
|
||||
fallback:
|
||||
'Loading the current account details.',
|
||||
),
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else ...[
|
||||
_InfoRow(
|
||||
label: tr(
|
||||
'msg.userfront.error.tenant.account',
|
||||
fallback: 'Account',
|
||||
),
|
||||
value: emailLabel.isNotEmpty
|
||||
? emailLabel
|
||||
: tr(
|
||||
'msg.userfront.error.tenant.account_unknown',
|
||||
fallback: 'Unknown',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_InfoRow(
|
||||
label: tr(
|
||||
'msg.userfront.error.tenant.primary_tenant',
|
||||
fallback: 'Primary affiliated tenant',
|
||||
),
|
||||
value: tenantLabel.isNotEmpty
|
||||
? tenantLabel
|
||||
: tr(
|
||||
'msg.userfront.error.tenant.tenant_unknown',
|
||||
fallback: 'Unknown',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_InfoRow(
|
||||
label: tr(
|
||||
'msg.userfront.error.tenant.affiliated_tenants',
|
||||
fallback: 'All affiliated tenants',
|
||||
),
|
||||
value: affiliatedTenantLabels.isNotEmpty
|
||||
? affiliatedTenantLabels.join(', ')
|
||||
: tr(
|
||||
'msg.userfront.error.tenant.tenant_unknown',
|
||||
fallback: 'Unknown',
|
||||
),
|
||||
),
|
||||
if (showTenantLookupFallback) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
tr(
|
||||
'msg.userfront.error.tenant.lookup_fallback',
|
||||
fallback:
|
||||
'Some fields may be unavailable because there is not enough profile information to display.',
|
||||
),
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color:
|
||||
colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (hasTenantLookupFailure) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
tr(
|
||||
'msg.userfront.error.tenant.load_failed',
|
||||
fallback:
|
||||
'Failed to load account details. Please try again.',
|
||||
),
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color:
|
||||
colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
tr(
|
||||
'msg.userfront.error.tenant.allowed_box_title',
|
||||
fallback: 'Allowed tenants',
|
||||
),
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (allowedTenantLabels.isNotEmpty) ...[
|
||||
_InfoRow(
|
||||
label: tr(
|
||||
'msg.userfront.error.tenant.allowed_tenants',
|
||||
fallback: 'Allowed tenants',
|
||||
),
|
||||
value: allowedTenantLabels.join(', '),
|
||||
),
|
||||
] else ...[
|
||||
_InfoRow(
|
||||
label: tr(
|
||||
'msg.userfront.error.tenant.allowed_tenants',
|
||||
fallback: 'Allowed tenants',
|
||||
),
|
||||
value: tr(
|
||||
'msg.userfront.error.tenant.tenant_unknown',
|
||||
fallback: 'Unknown',
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
tr(
|
||||
'msg.userfront.error.type',
|
||||
params: {'type': errorType},
|
||||
),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (widget.errorId != null &&
|
||||
widget.errorId!.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
tr(
|
||||
'msg.userfront.error.id',
|
||||
params: {'id': widget.errorId!},
|
||||
),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: isTenantAccessBlocked
|
||||
? _switchAccount
|
||||
: () => context.go('/login'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
isTenantAccessBlocked
|
||||
? tr('ui.userfront.error.switch_account')
|
||||
: tr('ui.userfront.error.go_login'),
|
||||
),
|
||||
),
|
||||
OutlinedButton(
|
||||
onPressed: () => context.go(
|
||||
buildLocalizedHomePath(Uri.base),
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: colorScheme.onSurface,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
side: BorderSide(color: colorScheme.outline),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
child: Text(tr('ui.userfront.error.go_home')),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
const _InfoRow({required this.label, required this.value});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/services/auth_proxy_service.dart';
|
||||
import '../../../core/ui/toast_service.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
|
||||
class ForgotPasswordScreen extends StatefulWidget {
|
||||
const ForgotPasswordScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ForgotPasswordScreen> createState() => _ForgotPasswordScreenState();
|
||||
}
|
||||
|
||||
class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
||||
final TextEditingController _loginIdController = TextEditingController();
|
||||
bool _isLoading = false;
|
||||
bool _drySendEnabled = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_drySendEnabled =
|
||||
_parseBoolParam(Uri.base.queryParameters['drySend']) &&
|
||||
!AuthProxyService.isProdEnv;
|
||||
}
|
||||
|
||||
Future<void> _handlePasswordReset() async {
|
||||
final input = _loginIdController.text.trim();
|
||||
if (input.isEmpty) {
|
||||
_showError(tr('msg.userfront.forgot.input_required'));
|
||||
return;
|
||||
}
|
||||
|
||||
String loginId = input;
|
||||
if (!input.contains('@')) {
|
||||
// Format phone number if it's not an email
|
||||
loginId = input.replaceAll(RegExp(r'[-\s]'), '');
|
||||
if (loginId.startsWith('010')) {
|
||||
loginId = '+82${loginId.substring(1)}';
|
||||
}
|
||||
}
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
await AuthProxyService.initiatePasswordReset(
|
||||
loginId,
|
||||
drySend: _drySendEnabled,
|
||||
);
|
||||
if (mounted) {
|
||||
ToastService.success(tr('msg.userfront.forgot.sent'));
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
_showError(
|
||||
tr('msg.userfront.forgot.error', params: {'error': e.toString()}),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showError(String message) {
|
||||
ToastService.error(message);
|
||||
}
|
||||
|
||||
bool _parseBoolParam(String? value) {
|
||||
if (value == null) {
|
||||
return false;
|
||||
}
|
||||
final normalized = value.toLowerCase();
|
||||
return normalized == 'true' || normalized == '1' || normalized == 'yes';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(tr('ui.userfront.forgot.title')),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: Center(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
tr('ui.userfront.forgot.heading'),
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (_drySendEnabled) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF3CD),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: const Color(0xFFFFC107)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
color: Color(0xFF8A6D3B),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
tr('msg.userfront.forgot.dry_send'),
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF8A6D3B),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
tr('msg.userfront.forgot.description'),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
TextField(
|
||||
controller: _loginIdController,
|
||||
decoration: InputDecoration(
|
||||
labelText: tr('ui.userfront.forgot.input_label'),
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.person_outline),
|
||||
),
|
||||
onSubmitted: (_) => _handlePasswordReset(),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _isLoading ? null : _handlePasswordReset,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(50),
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Text(tr('ui.userfront.forgot.submit')),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
2427
baron-sso/userfront/lib/features/auth/presentation/login_screen.dart
Normal file
2427
baron-sso/userfront/lib/features/auth/presentation/login_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:userfront/core/i18n/locale_utils.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
|
||||
class LoginSuccessScreen extends StatelessWidget {
|
||||
const LoginSuccessScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.check_circle_outline,
|
||||
size: 80,
|
||||
color: Colors.green,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
tr('ui.userfront.login_success.title'),
|
||||
style: const TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
tr('msg.userfront.login_success.subtitle'),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
|
||||
// 이 버튼이 QR 카메라를 켜는 버튼입니다.
|
||||
FilledButton.icon(
|
||||
onPressed: () {
|
||||
context.push('/scan');
|
||||
},
|
||||
icon: const Icon(Icons.camera_alt, size: 28),
|
||||
label: Text(tr('ui.userfront.login_success.qr')),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(80), // 버튼 높이를 더 크게
|
||||
backgroundColor: Colors.blue.shade700,
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.go(buildLocalizedHomePath(Uri.base));
|
||||
},
|
||||
child: Text(
|
||||
tr('ui.userfront.login_success.later'),
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
enum QrCameraBootstrapStatus {
|
||||
ready,
|
||||
detectorUnsupported,
|
||||
permissionError,
|
||||
cameraError,
|
||||
}
|
||||
|
||||
class QrCameraBootstrapResult {
|
||||
const QrCameraBootstrapResult(this.status, {this.errorDetail = ''});
|
||||
|
||||
final QrCameraBootstrapStatus status;
|
||||
final String errorDetail;
|
||||
|
||||
bool get isReady => status == QrCameraBootstrapStatus.ready;
|
||||
}
|
||||
|
||||
typedef QrOpenCameraAndPlay = Future<void> Function();
|
||||
typedef QrStopCamera = Future<void> Function();
|
||||
|
||||
bool isQrPermissionError(Object error) {
|
||||
final raw = error.toString();
|
||||
return raw.contains('NotAllowedError') ||
|
||||
raw.contains('PermissionDeniedError') ||
|
||||
raw.contains('SecurityError');
|
||||
}
|
||||
|
||||
Future<QrCameraBootstrapResult> bootstrapQrCamera({
|
||||
required bool hasBarcodeDetector,
|
||||
required QrOpenCameraAndPlay openCameraAndPlay,
|
||||
required QrStopCamera stopCamera,
|
||||
}) async {
|
||||
try {
|
||||
await openCameraAndPlay();
|
||||
if (!hasBarcodeDetector) {
|
||||
await stopCamera();
|
||||
return const QrCameraBootstrapResult(
|
||||
QrCameraBootstrapStatus.detectorUnsupported,
|
||||
errorDetail: 'BarcodeDetector is not supported in this browser.',
|
||||
);
|
||||
}
|
||||
return const QrCameraBootstrapResult(QrCameraBootstrapStatus.ready);
|
||||
} catch (e) {
|
||||
if (isQrPermissionError(e)) {
|
||||
return QrCameraBootstrapResult(
|
||||
QrCameraBootstrapStatus.permissionError,
|
||||
errorDetail: e.toString(),
|
||||
);
|
||||
}
|
||||
return QrCameraBootstrapResult(
|
||||
QrCameraBootstrapStatus.cameraError,
|
||||
errorDetail: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import '../../../../core/i18n/locale_utils.dart';
|
||||
|
||||
String buildQrApprovePath(
|
||||
String scannedValue, {
|
||||
String? localeCode,
|
||||
Uri? currentUri,
|
||||
}) {
|
||||
final value = scannedValue.trim();
|
||||
final explicitLocale = localeCode?.trim();
|
||||
final uri = currentUri ?? Uri.base;
|
||||
final resolvedLocale = explicitLocale != null && explicitLocale.isNotEmpty
|
||||
? explicitLocale.toLowerCase().replaceAll('_', '-')
|
||||
: normalizeLocaleCode(
|
||||
extractLocaleFromPath(uri) ?? resolvePreferredLocaleCode(),
|
||||
);
|
||||
return '/$resolvedLocale/approve?ref=${Uri.encodeQueryComponent(value)}';
|
||||
}
|
||||
|
||||
String buildQrBackFallbackPath({String? localeCode, Uri? currentUri}) {
|
||||
final explicitLocale = localeCode?.trim();
|
||||
final uri = currentUri ?? Uri.base;
|
||||
final resolvedLocale = explicitLocale != null && explicitLocale.isNotEmpty
|
||||
? explicitLocale.toLowerCase().replaceAll('_', '-')
|
||||
: normalizeLocaleCode(
|
||||
extractLocaleFromPath(uri) ?? resolvePreferredLocaleCode(),
|
||||
);
|
||||
return '/$resolvedLocale/dashboard';
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export 'qr_scan_screen_stub.dart'
|
||||
if (dart.library.js_interop) 'qr_scan_screen_web.dart';
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
import 'package:userfront/core/ui/toast_service.dart';
|
||||
|
||||
import 'qr_scan_route.dart';
|
||||
|
||||
class QRScanScreen extends StatefulWidget {
|
||||
const QRScanScreen({super.key});
|
||||
|
||||
@override
|
||||
State<QRScanScreen> createState() => _QRScanScreenState();
|
||||
}
|
||||
|
||||
class _QRScanScreenState extends State<QRScanScreen> {
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
final raw = _controller.text.trim();
|
||||
if (raw.isEmpty) {
|
||||
ToastService.info(
|
||||
tr('msg.userfront.qr.permission_required', fallback: '카메라 권한이 필요합니다.'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
context.go(buildQrApprovePath(raw));
|
||||
}
|
||||
|
||||
void _handleBack() {
|
||||
final router = GoRouter.of(context);
|
||||
if (router.canPop()) {
|
||||
router.pop();
|
||||
return;
|
||||
}
|
||||
router.go(buildQrBackFallbackPath());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(tr('ui.userfront.qr.title', fallback: 'Scan QR Code')),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: _handleBack,
|
||||
),
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
tr(
|
||||
'msg.userfront.qr.permission_error',
|
||||
fallback: '카메라 권한 요청에 실패했습니다. 브라우저/OS 설정을 확인해주세요.',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
key: const ValueKey('qr_scan_manual_input'),
|
||||
controller: _controller,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'QR Payload',
|
||||
hintText: 'https://.../ql/{ref} 또는 ref',
|
||||
),
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
key: const ValueKey('qr_scan_submit_button'),
|
||||
onPressed: _submit,
|
||||
icon: const Icon(Icons.check_circle),
|
||||
label: Text(
|
||||
tr('ui.userfront.qr.result_success', fallback: '승인 화면으로 이동'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
|
||||
import 'qr_scan_route.dart';
|
||||
|
||||
class QRScanScreen extends StatefulWidget {
|
||||
const QRScanScreen({super.key});
|
||||
|
||||
@override
|
||||
State<QRScanScreen> createState() => _QRScanScreenState();
|
||||
}
|
||||
|
||||
class _QRScanScreenState extends State<QRScanScreen> {
|
||||
final MobileScannerController _scannerController = MobileScannerController(
|
||||
autoStart: true,
|
||||
detectionSpeed: DetectionSpeed.noDuplicates,
|
||||
facing: CameraFacing.back,
|
||||
formats: const <BarcodeFormat>[BarcodeFormat.qrCode],
|
||||
);
|
||||
final TextEditingController _manualController = TextEditingController();
|
||||
|
||||
bool _isProcessing = false;
|
||||
String? _error;
|
||||
String? _status;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_status = tr(
|
||||
'msg.userfront.login.qr.scan_hint',
|
||||
fallback: 'QR 코드를 카메라 중앙에 맞춰주세요.',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_manualController.dispose();
|
||||
_scannerController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _navigateToApprove(String rawPayload) async {
|
||||
final payload = rawPayload.trim();
|
||||
if (payload.isEmpty || _isProcessing || !mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isProcessing = true;
|
||||
_error = null;
|
||||
_status = tr(
|
||||
'ui.userfront.qr.result_success',
|
||||
fallback: '승인 화면으로 이동 중...',
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
await _scannerController.stop();
|
||||
} catch (_) {}
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
context.go(buildQrApprovePath(payload));
|
||||
}
|
||||
|
||||
void _onDetect(BarcodeCapture capture) {
|
||||
for (final barcode in capture.barcodes) {
|
||||
final raw = barcode.rawValue?.trim();
|
||||
if (raw != null && raw.isNotEmpty) {
|
||||
unawaited(_navigateToApprove(raw));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _toScannerErrorMessage(MobileScannerException error) {
|
||||
switch (error.errorCode) {
|
||||
case MobileScannerErrorCode.permissionDenied:
|
||||
return tr(
|
||||
'msg.userfront.qr.permission_error',
|
||||
fallback: '카메라 권한 요청에 실패했습니다. 브라우저/OS 설정을 확인해주세요.',
|
||||
);
|
||||
case MobileScannerErrorCode.unsupported:
|
||||
return tr(
|
||||
'msg.userfront.qr.camera_error',
|
||||
fallback: '카메라 오류: {{error}}',
|
||||
params: {'error': 'QR scanner is not supported in this browser.'},
|
||||
);
|
||||
default:
|
||||
final detail = error.errorDetails?.message;
|
||||
return tr(
|
||||
'msg.userfront.qr.camera_error',
|
||||
fallback: '카메라 오류: {{error}}',
|
||||
params: {'error': detail ?? error.errorCode.message},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _submitManual() {
|
||||
unawaited(_navigateToApprove(_manualController.text));
|
||||
}
|
||||
|
||||
Future<void> _retry() async {
|
||||
setState(() {
|
||||
_isProcessing = false;
|
||||
_error = null;
|
||||
_status = tr(
|
||||
'msg.userfront.login.qr.scan_hint',
|
||||
fallback: 'QR 코드를 카메라 중앙에 맞춰주세요.',
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
await _scannerController.start();
|
||||
} catch (e) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_error = tr(
|
||||
'msg.userfront.qr.camera_error',
|
||||
fallback: '카메라 오류: {{error}}',
|
||||
params: {'error': '$e'},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _handleBack() {
|
||||
final router = GoRouter.of(context);
|
||||
if (router.canPop()) {
|
||||
router.pop();
|
||||
return;
|
||||
}
|
||||
router.go(buildQrBackFallbackPath());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(tr('ui.userfront.qr.title', fallback: 'Scan QR Code')),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: _handleBack,
|
||||
),
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 3 / 4,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
MobileScanner(
|
||||
controller: _scannerController,
|
||||
onDetect: _onDetect,
|
||||
errorBuilder: (context, error) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_error = _toScannerErrorMessage(error);
|
||||
});
|
||||
});
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
_toScannerErrorMessage(error),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (_isProcessing)
|
||||
Container(
|
||||
color: Colors.black45,
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_status != null) Text(_status!, textAlign: TextAlign.center),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_error!,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: _isProcessing ? null : _retry,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: Text(tr('ui.userfront.qr.rescan', fallback: '다시 스캔')),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
key: const ValueKey('qr_scan_manual_input'),
|
||||
controller: _manualController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'QR Payload',
|
||||
hintText: 'https://.../ql/{ref} 또는 ref',
|
||||
),
|
||||
onSubmitted: (_) => _submitManual(),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.icon(
|
||||
key: const ValueKey('qr_scan_submit_button'),
|
||||
onPressed: _isProcessing ? null : _submitManual,
|
||||
icon: const Icon(Icons.check_circle),
|
||||
label: Text(
|
||||
tr('ui.userfront.qr.result_success', fallback: '승인 화면으로 이동'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/i18n/locale_utils.dart';
|
||||
import '../../../core/services/auth_proxy_service.dart';
|
||||
import '../../../core/ui/toast_service.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
|
||||
class ResetPasswordScreen extends StatefulWidget {
|
||||
final String? loginId; // Now receiving loginId
|
||||
const ResetPasswordScreen({super.key, this.loginId});
|
||||
|
||||
@override
|
||||
State<ResetPasswordScreen> createState() => _ResetPasswordScreenState();
|
||||
}
|
||||
|
||||
class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
||||
final TextEditingController _passwordController = TextEditingController();
|
||||
final TextEditingController _confirmPasswordController =
|
||||
TextEditingController();
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
bool _isLoading = false;
|
||||
String? _loginId;
|
||||
String? _token;
|
||||
bool _isPasswordObscured = true;
|
||||
bool _isConfirmPasswordObscured = true;
|
||||
Map<String, dynamic>? _policy;
|
||||
bool _isPolicyLoading = false;
|
||||
|
||||
String _renderTranslatedText(
|
||||
String key, {
|
||||
String? fallback,
|
||||
Map<String, String> values = const {},
|
||||
}) {
|
||||
var text = tr(key, fallback: fallback);
|
||||
values.forEach((name, value) {
|
||||
text = text.replaceAll('{{$name}}', value).replaceAll('{$name}', value);
|
||||
});
|
||||
return text;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 1. Get loginId from GoRouter state if available
|
||||
_loginId = widget.loginId;
|
||||
|
||||
// 2. Fallback to URI query parameter if not available via router
|
||||
if (_loginId == null || _loginId!.isEmpty) {
|
||||
final uri = Uri.base;
|
||||
_loginId = uri.queryParameters['loginId'];
|
||||
}
|
||||
|
||||
// 토큰도 함께 읽어놓는다.
|
||||
final uri = Uri.base;
|
||||
_token = uri.queryParameters['token'];
|
||||
|
||||
_loadPolicy();
|
||||
}
|
||||
|
||||
Future<void> _loadPolicy() async {
|
||||
setState(() {
|
||||
_isPolicyLoading = true;
|
||||
});
|
||||
try {
|
||||
final policy = await AuthProxyService.fetchPasswordPolicy();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_policy = policy;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
// 실패해도 기본 검증 로직 사용
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isPolicyLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handlePasswordReset() async {
|
||||
if (_isLoading) return;
|
||||
if (_formKey.currentState?.validate() != true) return;
|
||||
if ((_loginId == null || _loginId!.isEmpty) &&
|
||||
(_token == null || _token!.isEmpty)) {
|
||||
_showError(tr('msg.userfront.reset.invalid_link'));
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
bool isSuccess = false;
|
||||
|
||||
try {
|
||||
await AuthProxyService.completePasswordReset(
|
||||
loginId: _loginId,
|
||||
token: _token,
|
||||
newPassword: _passwordController.text,
|
||||
);
|
||||
|
||||
isSuccess = true;
|
||||
if (mounted) {
|
||||
ToastService.success(tr('msg.userfront.reset.success'));
|
||||
context.go(buildLocalizedSigninPath(Uri.base));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
_showError(
|
||||
tr(
|
||||
'msg.userfront.reset.error.generic',
|
||||
params: {'error': e.toString()},
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted && !isSuccess) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showError(String message) {
|
||||
ToastService.error(message);
|
||||
}
|
||||
|
||||
String _buildPolicyDescription() {
|
||||
if (_isPolicyLoading) {
|
||||
return tr('msg.userfront.reset.policy_loading');
|
||||
}
|
||||
final minLength = (_policy?['minLength'] as int?) ?? 12;
|
||||
final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0;
|
||||
final requiresLower = _policy?['lowercase'] ?? true;
|
||||
final requiresUpper = _policy?['uppercase'] ?? false;
|
||||
final requiresNumber = _policy?['number'] ?? true;
|
||||
final requiresSymbol = _policy?['nonAlphanumeric'] ?? true;
|
||||
|
||||
final parts = <String>[
|
||||
_renderTranslatedText(
|
||||
'msg.userfront.reset.policy.min_length',
|
||||
values: {'count': '$minLength'},
|
||||
),
|
||||
];
|
||||
if (minTypes > 0) {
|
||||
parts.add(
|
||||
_renderTranslatedText(
|
||||
'msg.userfront.reset.policy.min_types',
|
||||
values: {'count': '$minTypes'},
|
||||
),
|
||||
);
|
||||
}
|
||||
if (requiresLower) {
|
||||
parts.add(tr('msg.userfront.reset.policy.lowercase'));
|
||||
}
|
||||
if (requiresUpper) {
|
||||
parts.add(tr('msg.userfront.reset.policy.uppercase'));
|
||||
}
|
||||
if (requiresNumber) {
|
||||
parts.add(tr('msg.userfront.reset.policy.number'));
|
||||
}
|
||||
if (requiresSymbol) {
|
||||
parts.add(tr('msg.userfront.reset.policy.symbol'));
|
||||
}
|
||||
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(tr('ui.userfront.reset.title')),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: Center(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
padding: const EdgeInsets.all(24),
|
||||
child:
|
||||
(_loginId == null || _loginId!.isEmpty) &&
|
||||
(_token == null || _token!.isEmpty)
|
||||
? _buildInvalidTokenView()
|
||||
: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
tr('ui.userfront.reset.subtitle'),
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_buildPolicyDescription(),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
TextFormField(
|
||||
key: const ValueKey('reset_password_new_input'),
|
||||
controller: _passwordController,
|
||||
obscureText: _isPasswordObscured,
|
||||
decoration: InputDecoration(
|
||||
labelText: tr('ui.userfront.reset.new_password'),
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_isPasswordObscured
|
||||
? Icons.visibility_off
|
||||
: Icons.visibility,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isPasswordObscured = !_isPasswordObscured;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
final val = value ?? "";
|
||||
if (val.isEmpty) {
|
||||
return tr(
|
||||
'msg.userfront.reset.error.empty_password',
|
||||
);
|
||||
}
|
||||
final minLength =
|
||||
(_policy?['minLength'] as int?) ?? 12;
|
||||
if (val.length < minLength) {
|
||||
return tr(
|
||||
'msg.userfront.reset.error.min_length',
|
||||
params: {'count': '$minLength'},
|
||||
);
|
||||
}
|
||||
final hasLower = RegExp(r'[a-z]').hasMatch(val);
|
||||
final hasUpper = RegExp(r'[A-Z]').hasMatch(val);
|
||||
final hasNumber = RegExp(r'[0-9]').hasMatch(val);
|
||||
final hasSymbol = RegExp(r'[\W_]').hasMatch(val);
|
||||
int typeCount = 0;
|
||||
if (hasLower) typeCount++;
|
||||
if (hasUpper) typeCount++;
|
||||
if (hasNumber) typeCount++;
|
||||
if (hasSymbol) typeCount++;
|
||||
|
||||
final minTypes =
|
||||
(_policy?['minCharacterTypes'] as int?) ?? 0;
|
||||
if (minTypes > 0 && typeCount < minTypes) {
|
||||
return tr(
|
||||
'msg.userfront.reset.error.min_types',
|
||||
params: {'count': '$minTypes'},
|
||||
);
|
||||
}
|
||||
|
||||
if ((_policy?['lowercase'] ?? true) && !hasLower) {
|
||||
return tr('msg.userfront.reset.error.lowercase');
|
||||
}
|
||||
if ((_policy?['uppercase'] ?? false) && !hasUpper) {
|
||||
return tr('msg.userfront.reset.error.uppercase');
|
||||
}
|
||||
if ((_policy?['number'] ?? true) && !hasNumber) {
|
||||
return tr('msg.userfront.reset.error.number');
|
||||
}
|
||||
if ((_policy?['nonAlphanumeric'] ?? true) &&
|
||||
!hasSymbol) {
|
||||
return tr('msg.userfront.reset.error.symbol');
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
key: const ValueKey('reset_password_confirm_input'),
|
||||
controller: _confirmPasswordController,
|
||||
obscureText: _isConfirmPasswordObscured,
|
||||
decoration: InputDecoration(
|
||||
labelText: tr('ui.userfront.reset.confirm_password'),
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_isConfirmPasswordObscured
|
||||
? Icons.visibility_off
|
||||
: Icons.visibility,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isConfirmPasswordObscured =
|
||||
!_isConfirmPasswordObscured;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value != _passwordController.text) {
|
||||
return tr('msg.userfront.reset.error.mismatch');
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
key: const ValueKey('reset_password_submit_button'),
|
||||
onPressed: _isLoading ? null : _handlePasswordReset,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(50),
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Text(tr('ui.userfront.reset.submit')),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInvalidTokenView() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Colors.red, size: 60),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
tr('msg.userfront.reset.invalid_title'),
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
tr('msg.userfront.reset.invalid_body'),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,178 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/services/auth_token_store.dart';
|
||||
import '../../../../core/services/http_client.dart';
|
||||
import '../../../../core/services/runtime_env.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
import 'models.dart';
|
||||
|
||||
String get _baseUrl => runtimeBackendUrl();
|
||||
|
||||
Future<AuditPage> _fetchAuthTimelinePage({String? cursor}) async {
|
||||
final queryParameters = <String, String>{'limit': '20'};
|
||||
if (cursor != null && cursor.isNotEmpty) {
|
||||
queryParameters['cursor'] = cursor;
|
||||
}
|
||||
|
||||
final url = Uri.parse(
|
||||
'$_baseUrl/api/v1/audit/auth/timeline',
|
||||
).replace(queryParameters: queryParameters);
|
||||
final useCookie = AuthTokenStore.usesCookie();
|
||||
final token = AuthTokenStore.getToken();
|
||||
|
||||
final client = createHttpClient(withCredentials: useCookie);
|
||||
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||
if (!useCookie && token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await client.get(url, headers: headers);
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Failed to load audit logs');
|
||||
}
|
||||
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final items = (body['items'] as List?) ?? [];
|
||||
final nextCursor = body['next_cursor']?.toString();
|
||||
final logs = <AuditLogEntry>[];
|
||||
for (final item in items) {
|
||||
if (item is Map) {
|
||||
logs.add(AuditLogEntry.fromJson(Map<String, dynamic>.from(item)));
|
||||
}
|
||||
}
|
||||
|
||||
return AuditPage(items: logs, nextCursor: nextCursor);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
typedef AuthTimelineFetcher = Future<AuditPage> Function({String? cursor});
|
||||
|
||||
final authTimelineFetcherProvider = Provider<AuthTimelineFetcher>((ref) {
|
||||
return _fetchAuthTimelinePage;
|
||||
});
|
||||
|
||||
class AuthTimelineState {
|
||||
final List<AuditLogEntry> items;
|
||||
final String? nextCursor;
|
||||
final bool isLoading;
|
||||
final bool isLoadingMore;
|
||||
final String? error;
|
||||
|
||||
const AuthTimelineState({
|
||||
required this.items,
|
||||
this.nextCursor,
|
||||
this.isLoading = false,
|
||||
this.isLoadingMore = false,
|
||||
this.error,
|
||||
});
|
||||
|
||||
const AuthTimelineState.initial()
|
||||
: items = const [],
|
||||
nextCursor = null,
|
||||
isLoading = false,
|
||||
isLoadingMore = false,
|
||||
error = null;
|
||||
|
||||
AuthTimelineState copyWith({
|
||||
List<AuditLogEntry>? items,
|
||||
String? nextCursor,
|
||||
bool? isLoading,
|
||||
bool? isLoadingMore,
|
||||
String? error,
|
||||
}) {
|
||||
return AuthTimelineState(
|
||||
items: items ?? this.items,
|
||||
nextCursor: nextCursor ?? this.nextCursor,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AuthTimelineNotifier extends Notifier<AuthTimelineState> {
|
||||
late final AuthTimelineFetcher _fetchPage;
|
||||
bool _hasLoaded = false;
|
||||
|
||||
@override
|
||||
AuthTimelineState build() {
|
||||
_fetchPage = ref.watch(authTimelineFetcherProvider);
|
||||
if (!_hasLoaded) {
|
||||
_hasLoaded = true;
|
||||
Future.microtask(_loadInitial);
|
||||
}
|
||||
return const AuthTimelineState.initial();
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
if (state.isLoading) {
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(
|
||||
items: const [],
|
||||
nextCursor: null,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
);
|
||||
await _loadPage(reset: true);
|
||||
}
|
||||
|
||||
Future<void> loadMore() async {
|
||||
if (state.isLoading || state.isLoadingMore) {
|
||||
return;
|
||||
}
|
||||
final nextCursor = state.nextCursor;
|
||||
if (nextCursor == null || nextCursor.isEmpty) {
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(isLoadingMore: true, error: null);
|
||||
await _loadPage(reset: false);
|
||||
}
|
||||
|
||||
Future<void> _loadInitial() async {
|
||||
if (state.items.isNotEmpty || state.isLoading) {
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
await _loadPage(reset: true);
|
||||
}
|
||||
|
||||
Future<void> _loadPage({required bool reset}) async {
|
||||
try {
|
||||
final page = await _fetchPage(cursor: reset ? null : state.nextCursor);
|
||||
if (reset) {
|
||||
state = state.copyWith(
|
||||
items: page.items,
|
||||
nextCursor: page.nextCursor,
|
||||
isLoading: false,
|
||||
isLoadingMore: false,
|
||||
error: null,
|
||||
);
|
||||
} else {
|
||||
state = state.copyWith(
|
||||
items: [...state.items, ...page.items],
|
||||
nextCursor: page.nextCursor,
|
||||
isLoading: false,
|
||||
isLoadingMore: false,
|
||||
error: null,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
isLoadingMore: false,
|
||||
error: tr('msg.userfront.dashboard.timeline.load_error'),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final authTimelineProvider =
|
||||
NotifierProvider<AuthTimelineNotifier, AuthTimelineState>(
|
||||
AuthTimelineNotifier.new,
|
||||
);
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'providers/linked_rps_provider.dart';
|
||||
|
||||
String? resolveLinkedRpLaunchUrl(LinkedRp rp) {
|
||||
final normalizedStatus = rp.status.trim().toLowerCase();
|
||||
final isActive = normalizedStatus.isEmpty || normalizedStatus == 'active';
|
||||
if (!isActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (rp.autoLoginSupported) {
|
||||
final autoLoginUrl = rp.autoLoginUrl.trim();
|
||||
if (autoLoginUrl.isNotEmpty) {
|
||||
return autoLoginUrl;
|
||||
}
|
||||
final initUrl = rp.initUrl.trim();
|
||||
if (initUrl.isNotEmpty) {
|
||||
return initUrl;
|
||||
}
|
||||
}
|
||||
|
||||
final url = rp.url.trim();
|
||||
if (url.isNotEmpty) {
|
||||
return url;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
237
baron-sso/userfront/lib/features/dashboard/domain/models.dart
Normal file
237
baron-sso/userfront/lib/features/dashboard/domain/models.dart
Normal file
@@ -0,0 +1,237 @@
|
||||
import 'dart:convert';
|
||||
|
||||
class AuditLogEntry {
|
||||
final String eventId;
|
||||
final DateTime timestamp;
|
||||
final String userId;
|
||||
final String eventType;
|
||||
final String status;
|
||||
final String authMethod;
|
||||
final String ipAddress;
|
||||
final String userAgent;
|
||||
final String sessionId;
|
||||
final String details;
|
||||
final String source;
|
||||
final String clientId;
|
||||
final String appName;
|
||||
final String parentSessionId;
|
||||
|
||||
AuditLogEntry({
|
||||
required this.eventId,
|
||||
required this.timestamp,
|
||||
required this.userId,
|
||||
required this.eventType,
|
||||
required this.status,
|
||||
required this.authMethod,
|
||||
required this.ipAddress,
|
||||
required this.userAgent,
|
||||
required this.sessionId,
|
||||
required this.details,
|
||||
required this.source,
|
||||
required this.clientId,
|
||||
required this.appName,
|
||||
required this.parentSessionId,
|
||||
});
|
||||
|
||||
factory AuditLogEntry.fromJson(Map<String, dynamic> json) {
|
||||
final timestampRaw = json['timestamp']?.toString() ?? '';
|
||||
DateTime parsedTimestamp;
|
||||
try {
|
||||
parsedTimestamp = DateTime.parse(timestampRaw).toLocal();
|
||||
} catch (_) {
|
||||
parsedTimestamp = DateTime.now();
|
||||
}
|
||||
|
||||
return AuditLogEntry(
|
||||
eventId: json['event_id'] ?? '',
|
||||
timestamp: parsedTimestamp,
|
||||
userId: json['user_id'] ?? '',
|
||||
eventType: json['event_type'] ?? '',
|
||||
status: json['status'] ?? '',
|
||||
authMethod: json['auth_method'] ?? '',
|
||||
ipAddress: json['ip_address'] ?? '',
|
||||
userAgent: json['user_agent'] ?? '',
|
||||
sessionId: json['session_id'] ?? '',
|
||||
details: json['details'] ?? '',
|
||||
source: json['source'] ?? '',
|
||||
clientId: json['client_id'] ?? '',
|
||||
appName: json['app_name'] ?? '',
|
||||
parentSessionId: json['parent_session_id'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> get detailMap {
|
||||
if (details.isEmpty) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
return jsonDecode(details) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
String get path {
|
||||
final detailPath = detailMap['path']?.toString();
|
||||
if (detailPath != null && detailPath.isNotEmpty) {
|
||||
return detailPath;
|
||||
}
|
||||
final parts = eventType.split(' ');
|
||||
if (parts.length >= 2) {
|
||||
return parts.sublist(1).join(' ');
|
||||
}
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
|
||||
class AuditPage {
|
||||
final List<AuditLogEntry> items;
|
||||
final String? nextCursor;
|
||||
|
||||
const AuditPage({required this.items, this.nextCursor});
|
||||
}
|
||||
|
||||
class LinkedRp {
|
||||
final String id;
|
||||
final String name;
|
||||
final String logo;
|
||||
final String url;
|
||||
final String initUrl;
|
||||
final bool autoLoginSupported;
|
||||
final String autoLoginUrl;
|
||||
final String status;
|
||||
final List<String> scopes;
|
||||
final DateTime? lastAuthenticatedAt;
|
||||
|
||||
LinkedRp({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.logo,
|
||||
required this.url,
|
||||
required this.initUrl,
|
||||
required this.autoLoginSupported,
|
||||
required this.autoLoginUrl,
|
||||
required this.status,
|
||||
required this.scopes,
|
||||
this.lastAuthenticatedAt,
|
||||
});
|
||||
|
||||
factory LinkedRp.fromJson(Map<String, dynamic> json) {
|
||||
DateTime? parsedLastAuth;
|
||||
final rawLastAuth = json['lastAuthenticatedAt']?.toString();
|
||||
if (rawLastAuth != null && rawLastAuth.isNotEmpty) {
|
||||
try {
|
||||
parsedLastAuth = DateTime.parse(rawLastAuth).toLocal();
|
||||
} catch (_) {
|
||||
parsedLastAuth = null;
|
||||
}
|
||||
}
|
||||
|
||||
return LinkedRp(
|
||||
id: json['id']?.toString() ?? '',
|
||||
name: json['name']?.toString() ?? '',
|
||||
logo: json['logo']?.toString() ?? '',
|
||||
url: json['url']?.toString() ?? '',
|
||||
initUrl: json['init_url']?.toString() ?? '',
|
||||
autoLoginSupported: json['auto_login_supported'] == true,
|
||||
autoLoginUrl: json['auto_login_url']?.toString() ?? '',
|
||||
status: json['status']?.toString() ?? '',
|
||||
scopes: (json['scopes'] as List?)?.whereType<String>().toList() ?? [],
|
||||
lastAuthenticatedAt: parsedLastAuth,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RpHistoryItem {
|
||||
final String clientId;
|
||||
final String clientName;
|
||||
final List<String> scopes;
|
||||
final DateTime? lastApprovedAt;
|
||||
final DateTime? lastRevokedAt;
|
||||
final String status;
|
||||
|
||||
RpHistoryItem({
|
||||
required this.clientId,
|
||||
required this.clientName,
|
||||
required this.scopes,
|
||||
this.lastApprovedAt,
|
||||
this.lastRevokedAt,
|
||||
required this.status,
|
||||
});
|
||||
|
||||
factory RpHistoryItem.fromJson(Map<String, dynamic> json) {
|
||||
DateTime? parseDate(String? raw) {
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
try {
|
||||
return DateTime.parse(raw).toLocal();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return RpHistoryItem(
|
||||
clientId: json['client_id']?.toString() ?? '',
|
||||
clientName: json['client_name']?.toString() ?? '',
|
||||
scopes: (json['scopes'] as List?)?.whereType<String>().toList() ?? [],
|
||||
lastApprovedAt: parseDate(json['last_approved_at']?.toString()),
|
||||
lastRevokedAt: parseDate(json['last_revoked_at']?.toString()),
|
||||
status: json['status']?.toString() ?? 'unknown',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class UserSessionSummary {
|
||||
final String sessionId;
|
||||
final DateTime? authenticatedAt;
|
||||
final DateTime? expiresAt;
|
||||
final DateTime? issuedAt;
|
||||
final DateTime? lastSeenAt;
|
||||
final String ipAddress;
|
||||
final String userAgent;
|
||||
final String clientId;
|
||||
final String appName;
|
||||
final bool isCurrent;
|
||||
final bool isActive;
|
||||
|
||||
UserSessionSummary({
|
||||
required this.sessionId,
|
||||
this.authenticatedAt,
|
||||
this.expiresAt,
|
||||
this.issuedAt,
|
||||
this.lastSeenAt,
|
||||
required this.ipAddress,
|
||||
required this.userAgent,
|
||||
required this.clientId,
|
||||
required this.appName,
|
||||
required this.isCurrent,
|
||||
required this.isActive,
|
||||
});
|
||||
|
||||
factory UserSessionSummary.fromJson(Map<String, dynamic> json) {
|
||||
DateTime? parseDate(dynamic raw) {
|
||||
final value = raw?.toString();
|
||||
if (value == null || value.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return DateTime.parse(value).toLocal();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return UserSessionSummary(
|
||||
sessionId: json['session_id']?.toString() ?? '',
|
||||
authenticatedAt: parseDate(json['authenticated_at']),
|
||||
expiresAt: parseDate(json['expires_at']),
|
||||
issuedAt: parseDate(json['issued_at']),
|
||||
lastSeenAt: parseDate(json['last_seen_at']),
|
||||
ipAddress: json['ip_address']?.toString() ?? '',
|
||||
userAgent: json['user_agent']?.toString() ?? '',
|
||||
clientId: json['client_id']?.toString() ?? '',
|
||||
appName: json['app_name']?.toString() ?? '',
|
||||
isCurrent: json['is_current'] == true,
|
||||
isActive: json['is_active'] != false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:userfront/core/services/auth_proxy_service.dart';
|
||||
import 'package:userfront/core/services/auth_token_store.dart';
|
||||
import 'package:userfront/core/services/http_client.dart';
|
||||
import 'package:userfront/core/services/runtime_env.dart';
|
||||
|
||||
class LinkedRp {
|
||||
final String id;
|
||||
final String name;
|
||||
final String logo;
|
||||
final String url;
|
||||
final String initUrl;
|
||||
final bool autoLoginSupported;
|
||||
final String autoLoginUrl;
|
||||
final String status;
|
||||
final List<String> scopes;
|
||||
final DateTime? lastAuthenticatedAt;
|
||||
|
||||
LinkedRp({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.logo,
|
||||
required this.url,
|
||||
required this.initUrl,
|
||||
required this.autoLoginSupported,
|
||||
required this.autoLoginUrl,
|
||||
required this.status,
|
||||
required this.scopes,
|
||||
required this.lastAuthenticatedAt,
|
||||
});
|
||||
|
||||
factory LinkedRp.fromJson(Map<String, dynamic> json) {
|
||||
final rawLastAuth = json['lastAuthenticatedAt']?.toString() ?? '';
|
||||
DateTime? parsedLastAuth;
|
||||
if (rawLastAuth.isNotEmpty) {
|
||||
try {
|
||||
parsedLastAuth = DateTime.parse(rawLastAuth).toLocal();
|
||||
} catch (_) {
|
||||
parsedLastAuth = null;
|
||||
}
|
||||
}
|
||||
|
||||
return LinkedRp(
|
||||
id: json['id']?.toString() ?? '',
|
||||
name: json['name']?.toString() ?? '',
|
||||
logo: json['logo']?.toString() ?? '',
|
||||
url: json['url']?.toString() ?? '',
|
||||
initUrl: json['init_url']?.toString() ?? '',
|
||||
autoLoginSupported: json['auto_login_supported'] == true,
|
||||
autoLoginUrl: json['auto_login_url']?.toString() ?? '',
|
||||
status: json['status']?.toString() ?? 'unknown',
|
||||
scopes: (json['scopes'] as List?)?.whereType<String>().toList() ?? [],
|
||||
lastAuthenticatedAt: parsedLastAuth,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LinkedRpsNotifier extends AsyncNotifier<List<LinkedRp>> {
|
||||
@override
|
||||
Future<List<LinkedRp>> build() async {
|
||||
return _fetchLinkedRps();
|
||||
}
|
||||
|
||||
Future<List<LinkedRp>> _fetchLinkedRps() async {
|
||||
try {
|
||||
final baseUrl = runtimeBackendUrl();
|
||||
final url = Uri.parse('$baseUrl/api/v1/user/rp/linked');
|
||||
|
||||
final useCookie = AuthTokenStore.usesCookie();
|
||||
final token = AuthTokenStore.getToken();
|
||||
|
||||
final client = createHttpClient(withCredentials: useCookie);
|
||||
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||
if (!useCookie && token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
|
||||
final response = await client.get(url, headers: headers);
|
||||
client.close();
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Failed to load linked rps: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final items = (body['items'] as List?) ?? [];
|
||||
|
||||
return items
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(LinkedRp.fromJson)
|
||||
.toList();
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() => _fetchLinkedRps());
|
||||
}
|
||||
|
||||
Future<void> revokeRp(String clientId) async {
|
||||
await AuthProxyService.revokeLinkedRp(clientId);
|
||||
await refresh();
|
||||
}
|
||||
}
|
||||
|
||||
final linkedRpsProvider =
|
||||
AsyncNotifierProvider<LinkedRpsNotifier, List<LinkedRp>>(() {
|
||||
return LinkedRpsNotifier();
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/services/auth_proxy_service.dart';
|
||||
import '../../../../core/services/auth_token_store.dart';
|
||||
import '../../../../core/services/http_client.dart';
|
||||
import '../../../../core/services/runtime_env.dart';
|
||||
import '../models.dart';
|
||||
|
||||
class UserSessionsNotifier extends AsyncNotifier<List<UserSessionSummary>> {
|
||||
@override
|
||||
Future<List<UserSessionSummary>> build() async {
|
||||
return _fetchSessions();
|
||||
}
|
||||
|
||||
Future<List<UserSessionSummary>> _fetchSessions() async {
|
||||
final baseUrl = runtimeBackendUrl();
|
||||
final url = Uri.parse('$baseUrl/api/v1/user/sessions');
|
||||
|
||||
final useCookie = AuthTokenStore.usesCookie();
|
||||
final token = AuthTokenStore.getToken();
|
||||
|
||||
final client = createHttpClient(withCredentials: useCookie);
|
||||
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||
if (!useCookie && token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await client.get(url, headers: headers);
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Failed to load sessions: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final items = (body['items'] as List?) ?? const [];
|
||||
return items
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(UserSessionSummary.fromJson)
|
||||
.toList();
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(_fetchSessions);
|
||||
}
|
||||
|
||||
Future<void> revokeSession(String sessionId) async {
|
||||
await AuthProxyService.revokeSession(sessionId);
|
||||
await refresh();
|
||||
}
|
||||
}
|
||||
|
||||
final userSessionsProvider =
|
||||
AsyncNotifierProvider<UserSessionsNotifier, List<UserSessionSummary>>(() {
|
||||
return UserSessionsNotifier();
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../../profile/data/models/user_profile_model.dart';
|
||||
|
||||
DateTime? resolveDashboardSessionIssuedAt({
|
||||
String? token,
|
||||
UserProfile? profile,
|
||||
}) {
|
||||
final tokenIssuedAt = _getJwtIssuedAt(token);
|
||||
if (tokenIssuedAt != null) {
|
||||
return tokenIssuedAt;
|
||||
}
|
||||
return _parseSessionAuthenticatedAt(profile?.sessionAuthenticatedAt);
|
||||
}
|
||||
|
||||
DateTime? _getJwtIssuedAt(String? token) {
|
||||
if (token == null || token.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final parts = token.split('.');
|
||||
if (parts.length != 3) {
|
||||
return null;
|
||||
}
|
||||
final payload = utf8.decode(
|
||||
base64Url.decode(base64Url.normalize(parts[1])),
|
||||
);
|
||||
final data = json.decode(payload) as Map<String, dynamic>;
|
||||
final iatValue = data['iat'] ?? data['auth_time'];
|
||||
if (iatValue is num) {
|
||||
return DateTime.fromMillisecondsSinceEpoch(
|
||||
iatValue.toInt() * 1000,
|
||||
).toLocal();
|
||||
}
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
DateTime? _parseSessionAuthenticatedAt(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return DateTime.parse(value).toLocal();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:userfront/features/dashboard/domain/models.dart';
|
||||
|
||||
const headlessServerUserAgentSentinel = '__headless_server__';
|
||||
|
||||
bool looksLikeInternalAuditUserAgent(String userAgent) {
|
||||
final lower = userAgent.trim().toLowerCase();
|
||||
return lower.startsWith('go-http-client/') ||
|
||||
lower.startsWith('fasthttp') ||
|
||||
lower.startsWith('fiber') ||
|
||||
lower.startsWith('undici') ||
|
||||
lower.startsWith('node');
|
||||
}
|
||||
|
||||
String preferredAuditLogUserAgent(AuditLogEntry log) {
|
||||
final userAgent = log.userAgent.trim();
|
||||
final path = log.path.toLowerCase();
|
||||
|
||||
final isHeadlessLinkLog =
|
||||
path.contains('/api/v1/auth/magic-link/verify') ||
|
||||
path.contains('/api/v1/auth/login/code/verify');
|
||||
final isHeadlessPasswordLog = path.contains(
|
||||
'/api/v1/auth/headless/password/login',
|
||||
);
|
||||
|
||||
if ((isHeadlessLinkLog || isHeadlessPasswordLog) &&
|
||||
looksLikeInternalAuditUserAgent(userAgent)) {
|
||||
return headlessServerUserAgentSentinel;
|
||||
}
|
||||
|
||||
return userAgent;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,98 @@
|
||||
class Tenant {
|
||||
final String id;
|
||||
final String name;
|
||||
final String slug;
|
||||
final String description;
|
||||
|
||||
Tenant({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.slug,
|
||||
required this.description,
|
||||
});
|
||||
|
||||
factory Tenant.fromJson(Map<String, dynamic> json) {
|
||||
return Tenant(
|
||||
id: json['id'] ?? '',
|
||||
name: json['name'] ?? '',
|
||||
slug: json['slug'] ?? '',
|
||||
description: json['description'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {'id': id, 'name': name, 'slug': slug, 'description': description};
|
||||
}
|
||||
}
|
||||
|
||||
class UserProfile {
|
||||
final String id;
|
||||
final String email;
|
||||
final String name;
|
||||
final String phone;
|
||||
final String department;
|
||||
final String affiliationType;
|
||||
final String companyCode;
|
||||
final String? sessionAuthenticatedAt;
|
||||
final Map<String, dynamic>? metadata;
|
||||
final Tenant? tenant;
|
||||
|
||||
UserProfile({
|
||||
required this.id,
|
||||
required this.email,
|
||||
required this.name,
|
||||
required this.phone,
|
||||
required this.department,
|
||||
required this.affiliationType,
|
||||
required this.companyCode,
|
||||
this.sessionAuthenticatedAt,
|
||||
this.metadata,
|
||||
this.tenant,
|
||||
});
|
||||
|
||||
factory UserProfile.fromJson(Map<String, dynamic> json) {
|
||||
return UserProfile(
|
||||
id: json['id'] ?? '',
|
||||
email: json['email'] ?? '',
|
||||
name: json['name'] ?? '',
|
||||
phone: json['phone'] ?? '',
|
||||
department: json['department'] ?? '',
|
||||
affiliationType: json['affiliationType'] ?? '',
|
||||
companyCode: json['companyCode'] ?? '',
|
||||
sessionAuthenticatedAt: json['sessionAuthenticatedAt'] as String?,
|
||||
metadata: json['metadata'] != null
|
||||
? Map<String, dynamic>.from(json['metadata'])
|
||||
: null,
|
||||
tenant: json['tenant'] != null ? Tenant.fromJson(json['tenant']) : null,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'email': email,
|
||||
'name': name,
|
||||
'phone': phone,
|
||||
'department': department,
|
||||
'affiliationType': affiliationType,
|
||||
'companyCode': companyCode,
|
||||
'sessionAuthenticatedAt': sessionAuthenticatedAt,
|
||||
'metadata': metadata,
|
||||
'tenant': tenant?.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
UserProfile copyWith({String? name, String? phone, String? department}) {
|
||||
return UserProfile(
|
||||
id: id,
|
||||
email: email,
|
||||
name: name ?? this.name,
|
||||
phone: phone ?? this.phone,
|
||||
department: department ?? this.department,
|
||||
affiliationType: affiliationType,
|
||||
companyCode: companyCode,
|
||||
sessionAuthenticatedAt: sessionAuthenticatedAt,
|
||||
tenant: tenant,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import 'dart:convert';
|
||||
import 'package:userfront/i18n.dart';
|
||||
import '../models/user_profile_model.dart';
|
||||
import '../../../../core/services/auth_token_store.dart';
|
||||
import '../../../../core/services/http_client.dart';
|
||||
import '../../../../core/services/runtime_env.dart';
|
||||
|
||||
class ProfileRepository {
|
||||
static String get _baseUrl => runtimeBackendUrl();
|
||||
|
||||
// Helper to get session token
|
||||
static Future<String?> _getToken() async {
|
||||
return AuthTokenStore.getToken();
|
||||
}
|
||||
|
||||
Future<UserProfile> getMyProfile() async {
|
||||
final token = await _getToken();
|
||||
final useCookie = AuthTokenStore.usesCookie();
|
||||
if (token == null && !useCookie) {
|
||||
throw Exception(tr('err.userfront.session.missing'));
|
||||
}
|
||||
|
||||
final url = Uri.parse('$_baseUrl/api/v1/user/me');
|
||||
final client = createHttpClient(withCredentials: useCookie);
|
||||
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||
if (!useCookie && token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
final response = await client.get(url, headers: headers);
|
||||
client.close();
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return UserProfile.fromJson(jsonDecode(response.body));
|
||||
} else {
|
||||
throw Exception(
|
||||
tr(
|
||||
'err.userfront.profile.load_failed',
|
||||
params: {'error': response.body},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateMyProfile({
|
||||
required String name,
|
||||
required String phone,
|
||||
required String department,
|
||||
}) async {
|
||||
final token = await _getToken();
|
||||
final useCookie = AuthTokenStore.usesCookie();
|
||||
if (token == null && !useCookie) {
|
||||
throw Exception(tr('err.userfront.session.missing'));
|
||||
}
|
||||
|
||||
final url = Uri.parse('$_baseUrl/api/v1/user/me');
|
||||
final client = createHttpClient(withCredentials: useCookie);
|
||||
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||
if (!useCookie && token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
final response = await client.put(
|
||||
url,
|
||||
headers: headers,
|
||||
body: jsonEncode({
|
||||
'name': name,
|
||||
'phone': phone,
|
||||
'department': department,
|
||||
}),
|
||||
);
|
||||
client.close();
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception(
|
||||
tr(
|
||||
'err.userfront.profile.update_failed',
|
||||
params: {'error': response.body},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> sendUpdateCode(String phone) async {
|
||||
final token = await _getToken();
|
||||
final useCookie = AuthTokenStore.usesCookie();
|
||||
if (token == null && !useCookie) {
|
||||
throw Exception(tr('err.userfront.session.missing'));
|
||||
}
|
||||
|
||||
final url = Uri.parse('$_baseUrl/api/v1/user/me/send-code');
|
||||
final client = createHttpClient(withCredentials: useCookie);
|
||||
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||
if (!useCookie && token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
final response = await client.post(
|
||||
url,
|
||||
headers: headers,
|
||||
body: jsonEncode({'phone': phone}),
|
||||
);
|
||||
client.close();
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception(
|
||||
tr(
|
||||
'err.userfront.profile.send_code_failed',
|
||||
params: {'error': response.body},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> changePassword({
|
||||
required String currentPassword,
|
||||
required String newPassword,
|
||||
}) async {
|
||||
final token = await _getToken();
|
||||
final useCookie = AuthTokenStore.usesCookie();
|
||||
if (token == null && !useCookie) {
|
||||
throw Exception(tr('err.userfront.session.missing'));
|
||||
}
|
||||
|
||||
final url = Uri.parse('$_baseUrl/api/v1/user/me/password');
|
||||
final client = createHttpClient(withCredentials: useCookie);
|
||||
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||
if (!useCookie && token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
final response = await client.post(
|
||||
url,
|
||||
headers: headers,
|
||||
body: jsonEncode({
|
||||
'currentPassword': currentPassword,
|
||||
'newPassword': newPassword,
|
||||
}),
|
||||
);
|
||||
client.close();
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception(
|
||||
tr(
|
||||
'err.userfront.profile.password_change_failed',
|
||||
params: {'error': response.body},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> verifyUpdateCode(String phone, String code) async {
|
||||
final token = await _getToken();
|
||||
final useCookie = AuthTokenStore.usesCookie();
|
||||
if (token == null && !useCookie) {
|
||||
throw Exception(tr('err.userfront.session.missing'));
|
||||
}
|
||||
|
||||
final url = Uri.parse('$_baseUrl/api/v1/user/me/verify-code');
|
||||
final client = createHttpClient(withCredentials: useCookie);
|
||||
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||
if (!useCookie && token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
final response = await client.post(
|
||||
url,
|
||||
headers: headers,
|
||||
body: jsonEncode({'phone': phone, 'code': code}),
|
||||
);
|
||||
client.close();
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception(
|
||||
tr(
|
||||
'err.userfront.profile.verify_code_failed',
|
||||
params: {'error': response.body},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../data/models/user_profile_model.dart';
|
||||
import '../../data/repositories/profile_repository.dart';
|
||||
|
||||
// 1. Repository Provider
|
||||
final profileRepositoryProvider = Provider((ref) => ProfileRepository());
|
||||
|
||||
// 2. AsyncNotifier implementation (Modern Riverpod)
|
||||
class ProfileNotifier extends AsyncNotifier<UserProfile?> {
|
||||
@override
|
||||
FutureOr<UserProfile?> build() async {
|
||||
// Initial data fetch
|
||||
return _fetch();
|
||||
}
|
||||
|
||||
Future<UserProfile?> _fetch() async {
|
||||
return ref.read(profileRepositoryProvider).getMyProfile();
|
||||
}
|
||||
|
||||
Future<UserProfile?> loadProfile() async {
|
||||
state = const AsyncValue.loading();
|
||||
final profile = await _fetch();
|
||||
state = AsyncValue.data(profile);
|
||||
return profile;
|
||||
}
|
||||
|
||||
Future<void> updateProfile({
|
||||
required String name,
|
||||
required String phone,
|
||||
required String department,
|
||||
}) async {
|
||||
// Show loading state
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
// Perform update and then re-fetch profile
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref
|
||||
.read(profileRepositoryProvider)
|
||||
.updateMyProfile(name: name, phone: phone, department: department);
|
||||
return _fetch();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Provider definition
|
||||
final profileProvider = AsyncNotifierProvider<ProfileNotifier, UserProfile?>(
|
||||
() {
|
||||
return ProfileNotifier();
|
||||
},
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ProfileInfoRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
const ProfileInfoRow({super.key, required this.label, required this.value});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value.isEmpty ? '-' : value,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
18
baron-sso/userfront/lib/i18n.dart
Normal file
18
baron-sso/userfront/lib/i18n.dart
Normal file
@@ -0,0 +1,18 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
|
||||
final _koreanPattern = RegExp(r'[가-힣]');
|
||||
|
||||
String tr(String key, {String? fallback, Map<String, String>? params}) {
|
||||
try {
|
||||
if (fallback != null && _koreanPattern.hasMatch(fallback)) {
|
||||
fallback = null;
|
||||
}
|
||||
final translated = key.tr(namedArgs: params);
|
||||
if (translated == key && fallback != null && fallback.isNotEmpty) {
|
||||
return fallback;
|
||||
}
|
||||
return translated;
|
||||
} catch (_) {
|
||||
return fallback ?? key;
|
||||
}
|
||||
}
|
||||
4787
baron-sso/userfront/lib/i18n_data.dart
Normal file
4787
baron-sso/userfront/lib/i18n_data.dart
Normal file
File diff suppressed because it is too large
Load Diff
648
baron-sso/userfront/lib/main.dart
Normal file
648
baron-sso/userfront/lib/main.dart
Normal file
@@ -0,0 +1,648 @@
|
||||
// ignore_for_file: avoid_print
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:easy_localization/easy_localization.dart' hide tr;
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:flutter_web_plugins/flutter_web_plugins.dart';
|
||||
import 'features/auth/presentation/login_screen.dart';
|
||||
import 'features/auth/presentation/signup_screen.dart';
|
||||
import 'features/auth/presentation/approve_qr_screen.dart';
|
||||
import 'features/auth/presentation/qr_scan_screen.dart';
|
||||
import 'features/auth/presentation/forgot_password_screen.dart';
|
||||
import 'features/auth/presentation/reset_password_screen.dart';
|
||||
import 'features/auth/presentation/error_screen.dart';
|
||||
import 'features/auth/domain/login_link_route_policy.dart';
|
||||
import 'features/auth/domain/verification_completion_route.dart';
|
||||
import 'features/dashboard/presentation/dashboard_screen.dart';
|
||||
import 'features/admin/presentation/user_management_screen.dart';
|
||||
import 'features/profile/presentation/pages/profile_page.dart';
|
||||
import 'core/services/auth_proxy_service.dart';
|
||||
import 'core/services/auth_token_store.dart';
|
||||
import 'core/services/logger_service.dart';
|
||||
import 'core/services/null_check_recovery.dart';
|
||||
import 'core/services/web_window.dart';
|
||||
import 'core/notifiers/auth_notifier.dart';
|
||||
import 'core/theme/app_theme.dart';
|
||||
import 'core/theme/theme_controller.dart';
|
||||
import 'core/theme/theme_scope.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 'core/ui/toast_service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'features/auth/presentation/consent_screen.dart';
|
||||
import 'i18n.dart';
|
||||
|
||||
final _log = Logger('Main');
|
||||
|
||||
Map<String, dynamic>? _decodeErrorDetails(String? raw) {
|
||||
if (raw == null || raw.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
return decoded;
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool _hasActiveLocalSession() {
|
||||
final token = AuthTokenStore.getToken();
|
||||
return (token != null && token.isNotEmpty) || AuthTokenStore.usesCookie();
|
||||
}
|
||||
|
||||
String? _redirectPrivateLocaleRoute(GoRouterState state) {
|
||||
if (_hasActiveLocalSession()) {
|
||||
return null;
|
||||
}
|
||||
final localeCode =
|
||||
extractLocaleFromPath(state.uri) ?? resolvePreferredLocaleCode();
|
||||
return buildSigninRedirectPath(localeCode, state.uri);
|
||||
}
|
||||
|
||||
void _attemptRecoveryFromNullCheck({
|
||||
required Object exception,
|
||||
StackTrace? stackTrace,
|
||||
}) {
|
||||
final uri = Uri.base;
|
||||
final target = computeNullCheckRecoveryTarget(
|
||||
exception: exception,
|
||||
uri: uri,
|
||||
preferredLocaleCode: resolvePreferredLocaleCode(),
|
||||
);
|
||||
if (target == null) {
|
||||
return;
|
||||
}
|
||||
final path = uri.path;
|
||||
|
||||
AuthProxyService.logError(
|
||||
'RECOVERY_NAV_NULL_CHECK path=$path target=$target uri=$uri',
|
||||
error: exception,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
webWindow.redirectTo(target);
|
||||
}
|
||||
|
||||
Future<void> _silentSessionRecovery() async {
|
||||
_log.info("[SessionRecovery] Starting silent session recovery check...");
|
||||
|
||||
// 1. Local token check
|
||||
final hasLocalToken = AuthTokenStore.hasToken();
|
||||
if (hasLocalToken) {
|
||||
_log.info("[SessionRecovery] Local token found. Verifying session...");
|
||||
try {
|
||||
final status = await AuthProxyService.getSessionStatus(
|
||||
token: AuthTokenStore.getToken(),
|
||||
useCookie: false,
|
||||
);
|
||||
if (status == 401 || status == 403) {
|
||||
_log.warning(
|
||||
"[SessionRecovery] Local token is invalid. Clearing store.",
|
||||
);
|
||||
AuthTokenStore.clear();
|
||||
return;
|
||||
}
|
||||
_log.info(
|
||||
"[SessionRecovery] Local token is valid. Skipping cookie check.",
|
||||
);
|
||||
return;
|
||||
} catch (e) {
|
||||
_log.info("[SessionRecovery] Failed to verify local token: $e");
|
||||
// 만약 네트워크 에러 등이라면 당장 로그아웃 시키지 않고 일단 통과시킬 수도 있지만,
|
||||
// 보안과 확실한 상태 갱신을 위해 여기서는 실패 시 상태를 유지하거나 필요에 따라 처리합니다.
|
||||
// (현재는 401/403 확실한 인증 실패시에만 clear 처리)
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_log.info(
|
||||
"[SessionRecovery] Local token missing. Checking for browser cookies...",
|
||||
);
|
||||
|
||||
try {
|
||||
// 2. Try fetching user info (backend will use cookies if present)
|
||||
final userInfo = await AuthProxyService.getMe();
|
||||
final subject = userInfo['id'] ?? userInfo['identity_id'] ?? '';
|
||||
|
||||
if (subject.isNotEmpty) {
|
||||
_log.info(
|
||||
"[SessionRecovery] Valid session found via cookies. Recovering login state...",
|
||||
);
|
||||
// For cookie-based auth, we don't necessarily have a JWT in local storage,
|
||||
// but AuthNotifier needs to know we are logged in.
|
||||
final jwt =
|
||||
userInfo['sessionJwt'] ?? userInfo['token'] ?? 'cookie-session';
|
||||
await AuthNotifier.instance.onLoginSuccess(jwt);
|
||||
_log.info("[SessionRecovery] Recovery complete. Subject: $subject");
|
||||
} else {
|
||||
_log.warning("[SessionRecovery] Session found but subject is empty.");
|
||||
AuthTokenStore.clear();
|
||||
}
|
||||
} catch (e) {
|
||||
_log.info(
|
||||
"[SessionRecovery] No valid cookie session found or request failed: $e",
|
||||
);
|
||||
AuthTokenStore.clear();
|
||||
}
|
||||
}
|
||||
|
||||
bool _shouldRunStartupSessionRecovery(Uri uri) {
|
||||
final requestedLocale = extractLocaleFromPath(uri);
|
||||
final path = stripLocalePath(uri);
|
||||
final verificationPayloadRedirect = buildDedicatedVerificationRedirect(
|
||||
uri,
|
||||
localeCode: requestedLocale ?? resolvePreferredLocaleCode(),
|
||||
);
|
||||
if (verificationPayloadRedirect != null ||
|
||||
isDedicatedVerificationRoute(uri) ||
|
||||
path == verificationCompletionRoutePath) {
|
||||
return false;
|
||||
}
|
||||
if (requestedLocale == null) {
|
||||
return true;
|
||||
}
|
||||
return !isPublicAuthPath(path, uri);
|
||||
}
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
usePathUrlStrategy();
|
||||
await EasyLocalization.ensureInitialized();
|
||||
LocaleRegistry.primeWithDefaults();
|
||||
|
||||
// 1. Global Error Handling
|
||||
FlutterError.onError = (details) {
|
||||
FlutterError.presentError(details);
|
||||
_log.severe("FLUTTER_ERROR", details.exception, details.stack);
|
||||
// Also send to backend if needed
|
||||
AuthProxyService.logError(
|
||||
"FLUTTER_ERROR: ${details.exception}\n${details.stack}",
|
||||
);
|
||||
_attemptRecoveryFromNullCheck(
|
||||
exception: details.exception,
|
||||
stackTrace: details.stack,
|
||||
);
|
||||
};
|
||||
|
||||
PlatformDispatcher.instance.onError = (error, stack) {
|
||||
_log.severe("PLATFORM_ERROR", error, stack);
|
||||
AuthProxyService.logError("PLATFORM_ERROR: $error\n$stack");
|
||||
_attemptRecoveryFromNullCheck(exception: error, stackTrace: stack);
|
||||
return true;
|
||||
};
|
||||
|
||||
// 0. Initialize Logger
|
||||
LoggerService.init();
|
||||
|
||||
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: supportedLocales,
|
||||
fallbackLocale: Locale(fallbackLocaleCode),
|
||||
startLocale: Locale(initialLocaleCode),
|
||||
saveLocale: false,
|
||||
path: 'assets/translations',
|
||||
assetLoader: const TomlAssetLoader(),
|
||||
child: const ProviderScope(child: BaronSSOApp()),
|
||||
);
|
||||
}(),
|
||||
);
|
||||
}
|
||||
|
||||
// Router Configuration
|
||||
final _router = GoRouter(
|
||||
initialLocation: '/',
|
||||
debugLogDiagnostics: !kReleaseMode,
|
||||
refreshListenable: AuthNotifier.instance,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
redirect: (context, state) {
|
||||
return buildLocalizedHomePath(
|
||||
state.uri,
|
||||
preferredLocaleCode: resolvePreferredLocaleCode(),
|
||||
);
|
||||
},
|
||||
),
|
||||
ShellRoute(
|
||||
builder: (context, state, child) {
|
||||
final localeCode =
|
||||
extractLocaleFromPath(state.uri) ?? resolvePreferredLocaleCode();
|
||||
return LocaleGate(localeCode: localeCode, child: child);
|
||||
},
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/:locale',
|
||||
builder: (context, state) {
|
||||
final rawLocale = state.pathParameters['locale'];
|
||||
final localeCode = normalizeLocaleCode(rawLocale);
|
||||
return ScopedTheme(
|
||||
controller: ThemeController.auth,
|
||||
child: LocaleEntryRedirectScreen(localeCode: localeCode),
|
||||
);
|
||||
},
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'dashboard',
|
||||
redirect: (context, state) => _redirectPrivateLocaleRoute(state),
|
||||
builder: (context, state) {
|
||||
return ScopedTheme(
|
||||
controller: ThemeController.app,
|
||||
child: const DashboardScreen(),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'profile',
|
||||
redirect: (context, state) => _redirectPrivateLocaleRoute(state),
|
||||
builder: (context, state) => ScopedTheme(
|
||||
controller: ThemeController.app,
|
||||
child: const ProfilePage(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'signin',
|
||||
builder: (context, state) {
|
||||
final loginChallenge =
|
||||
state.uri.queryParameters['login_challenge'];
|
||||
final redirectUrl =
|
||||
state.uri.queryParameters['redirect_uri'] ??
|
||||
state.uri.queryParameters['redirect_url'];
|
||||
return ScopedTheme(
|
||||
controller: ThemeController.auth,
|
||||
child: LoginScreen(
|
||||
key: state.pageKey,
|
||||
loginChallenge: loginChallenge,
|
||||
redirectUrl: redirectUrl,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: verificationCompletionRouteName,
|
||||
builder: (context, state) {
|
||||
return ScopedTheme(
|
||||
controller: ThemeController.auth,
|
||||
child: LoginScreen(
|
||||
key: state.pageKey,
|
||||
verificationCompleteOnly: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'login',
|
||||
builder: (context, state) {
|
||||
// IMPORTANT: Match signin logic to handle OIDC challenges
|
||||
final loginChallenge =
|
||||
state.uri.queryParameters['login_challenge'];
|
||||
final redirectUrl =
|
||||
state.uri.queryParameters['redirect_uri'] ??
|
||||
state.uri.queryParameters['redirect_url'];
|
||||
return ScopedTheme(
|
||||
controller: ThemeController.auth,
|
||||
child: LoginScreen(
|
||||
key: state.pageKey,
|
||||
loginChallenge: loginChallenge,
|
||||
redirectUrl: redirectUrl,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'consent',
|
||||
builder: (BuildContext context, GoRouterState state) {
|
||||
final consentChallenge =
|
||||
state.uri.queryParameters['consent_challenge'];
|
||||
if (consentChallenge == null) {
|
||||
return const Scaffold(
|
||||
body: Center(
|
||||
child: Text('Error: Consent challenge is missing.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ScopedTheme(
|
||||
controller: ThemeController.auth,
|
||||
child: ConsentScreen(consentChallenge: consentChallenge),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'signup',
|
||||
builder: (context, state) => ScopedTheme(
|
||||
controller: ThemeController.auth,
|
||||
child: const SignupScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'registration',
|
||||
builder: (context, state) => ScopedTheme(
|
||||
controller: ThemeController.auth,
|
||||
child: const SignupScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'verify',
|
||||
builder: (context, state) => ScopedTheme(
|
||||
controller: ThemeController.auth,
|
||||
child: LoginScreen(key: state.pageKey),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'verify/:token',
|
||||
builder: (context, state) {
|
||||
final token = state.pathParameters['token'];
|
||||
return ScopedTheme(
|
||||
controller: ThemeController.auth,
|
||||
child: LoginScreen(
|
||||
key: state.pageKey,
|
||||
verificationToken: token,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'verification',
|
||||
builder: (context, state) => ScopedTheme(
|
||||
controller: ThemeController.auth,
|
||||
child: LoginScreen(key: state.pageKey),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'l/:shortCode',
|
||||
builder: (context, state) {
|
||||
return ScopedTheme(
|
||||
controller: ThemeController.auth,
|
||||
child: LoginScreen(key: state.pageKey),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'forgot-password',
|
||||
builder: (context, state) => ScopedTheme(
|
||||
controller: ThemeController.auth,
|
||||
child: const ForgotPasswordScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'recovery',
|
||||
builder: (context, state) => ScopedTheme(
|
||||
controller: ThemeController.auth,
|
||||
child: const ForgotPasswordScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'reset-password',
|
||||
builder: (context, state) => ScopedTheme(
|
||||
controller: ThemeController.auth,
|
||||
child: const ResetPasswordScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'error',
|
||||
builder: (context, state) {
|
||||
final params = state.uri.queryParameters;
|
||||
return ScopedTheme(
|
||||
controller: ThemeController.auth,
|
||||
child: ErrorScreen(
|
||||
errorId: params['id'],
|
||||
errorCode: params['error'],
|
||||
description:
|
||||
params['error_description'] ?? params['message'],
|
||||
tenantAccessDetails: _decodeErrorDetails(params['details']),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'settings',
|
||||
builder: (context, state) => ScopedTheme(
|
||||
controller: ThemeController.auth,
|
||||
child: ErrorScreen(
|
||||
errorCode: 'settings_disabled',
|
||||
description: tr('msg.userfront.settings.disabled'),
|
||||
),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'approve',
|
||||
redirect: (context, state) {
|
||||
final token = AuthTokenStore.getToken();
|
||||
final isLoggedIn =
|
||||
(token != null && token.isNotEmpty) ||
|
||||
AuthTokenStore.usesCookie();
|
||||
if (isLoggedIn) {
|
||||
return null;
|
||||
}
|
||||
final localeCode =
|
||||
extractLocaleFromPath(state.uri) ??
|
||||
resolvePreferredLocaleCode();
|
||||
return '/$localeCode/signin?notice=qr_login_required';
|
||||
},
|
||||
builder: (context, state) => ScopedTheme(
|
||||
controller: ThemeController.auth,
|
||||
child: ApproveQrScreen(
|
||||
pendingRef: state.uri.queryParameters['ref'],
|
||||
),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'ql/:ref',
|
||||
redirect: (context, state) {
|
||||
final localeCode =
|
||||
extractLocaleFromPath(state.uri) ??
|
||||
resolvePreferredLocaleCode();
|
||||
final pendingRef = state.pathParameters['ref'];
|
||||
if (pendingRef == null || pendingRef.isEmpty) {
|
||||
return '/$localeCode/approve';
|
||||
}
|
||||
final encodedRef = Uri.encodeQueryComponent(pendingRef);
|
||||
return '/$localeCode/approve?ref=$encodedRef';
|
||||
},
|
||||
builder: (context, state) => ScopedTheme(
|
||||
controller: ThemeController.auth,
|
||||
child: ApproveQrScreen(pendingRef: state.pathParameters['ref']),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'scan',
|
||||
redirect: (context, state) => _redirectPrivateLocaleRoute(state),
|
||||
builder: (context, state) => ScopedTheme(
|
||||
controller: ThemeController.auth,
|
||||
child: const QRScanScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'admin/users',
|
||||
redirect: (context, state) => _redirectPrivateLocaleRoute(state),
|
||||
builder: (context, state) => ScopedTheme(
|
||||
controller: ThemeController.app,
|
||||
child: const UserManagementScreen(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
redirect: (context, state) {
|
||||
final uri = state.uri;
|
||||
final requestedLocale = extractLocaleFromPath(uri);
|
||||
final preferredLocale = resolvePreferredLocaleCode();
|
||||
final verificationPayloadRedirect = buildDedicatedVerificationRedirect(
|
||||
uri,
|
||||
localeCode: requestedLocale ?? preferredLocale,
|
||||
);
|
||||
if (verificationPayloadRedirect != null) {
|
||||
return verificationPayloadRedirect;
|
||||
}
|
||||
|
||||
if (requestedLocale == null) {
|
||||
final localizedPath = buildLocalizedPath(preferredLocale, uri);
|
||||
return localizedPath;
|
||||
}
|
||||
|
||||
final token = AuthTokenStore.getToken();
|
||||
final isLoggedIn =
|
||||
(token != null && token.isNotEmpty) || AuthTokenStore.usesCookie();
|
||||
final path = stripLocalePath(uri);
|
||||
|
||||
if (!isLoggedIn && (path == '/approve' || path.startsWith('/ql/'))) {
|
||||
return '/$requestedLocale/signin?notice=qr_login_required';
|
||||
}
|
||||
|
||||
final isPublicPath = isPublicAuthPath(path, uri);
|
||||
|
||||
if (isPublicPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isLoggedIn) {
|
||||
if (path == '/') {
|
||||
return '/$requestedLocale/signin';
|
||||
}
|
||||
return buildSigninRedirectPath(requestedLocale, uri);
|
||||
}
|
||||
|
||||
if (path == '/') {
|
||||
return '/$requestedLocale/dashboard';
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
class BaronSSOApp extends StatefulWidget {
|
||||
const BaronSSOApp({super.key});
|
||||
|
||||
@override
|
||||
State<BaronSSOApp> createState() => _BaronSSOAppState();
|
||||
}
|
||||
|
||||
class _BaronSSOAppState extends State<BaronSSOApp> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// Re-run router redirects after the first frame so session-only web
|
||||
// storage state is reflected even when startup routing evaluated too early.
|
||||
AuthNotifier.instance.notify();
|
||||
unawaited(LocaleRegistry.initialize());
|
||||
unawaited(ThemeController.app.restore());
|
||||
unawaited(ThemeController.auth.restore());
|
||||
if (_shouldRunStartupSessionRecovery(Uri.base)) {
|
||||
unawaited(_silentSessionRecovery());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final localization = EasyLocalization.of(context);
|
||||
final supportedLocales =
|
||||
localization?.supportedLocales ??
|
||||
LocaleRegistry.supportedLocaleCodes
|
||||
.map((code) => Locale(code))
|
||||
.toList(growable: false);
|
||||
final delegates = localization?.delegates ?? const [];
|
||||
final locale =
|
||||
localization?.currentLocale ?? Locale(resolvePreferredLocaleCode());
|
||||
|
||||
return MaterialApp.router(
|
||||
title: tr('ui.userfront.app_title'),
|
||||
localizationsDelegates: delegates,
|
||||
supportedLocales: supportedLocales,
|
||||
locale: locale,
|
||||
builder: (context, child) {
|
||||
return Stack(
|
||||
children: [if (child != null) child, const ToastViewport()],
|
||||
);
|
||||
},
|
||||
theme: buildLightTheme(),
|
||||
darkTheme: buildDarkTheme(),
|
||||
themeMode: ThemeMode.light,
|
||||
routerConfig: _router,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LocaleEntryRedirectScreen extends StatefulWidget {
|
||||
const LocaleEntryRedirectScreen({super.key, required this.localeCode});
|
||||
|
||||
final String localeCode;
|
||||
|
||||
@override
|
||||
State<LocaleEntryRedirectScreen> createState() =>
|
||||
_LocaleEntryRedirectScreenState();
|
||||
}
|
||||
|
||||
class _LocaleEntryRedirectScreenState extends State<LocaleEntryRedirectScreen> {
|
||||
bool _redirected = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_redirect();
|
||||
});
|
||||
}
|
||||
|
||||
void _redirect() {
|
||||
if (!mounted || _redirected) {
|
||||
return;
|
||||
}
|
||||
|
||||
// This parent route is also built for nested locale routes, so only
|
||||
// redirect when the current location is exactly `/{locale}`.
|
||||
if (stripLocalePath(Uri.base) != '/') {
|
||||
return;
|
||||
}
|
||||
|
||||
_redirected = true;
|
||||
if (!_hasActiveLocalSession()) {
|
||||
context.go('/${widget.localeCode}/signin');
|
||||
return;
|
||||
}
|
||||
context.go('/${widget.localeCode}/dashboard');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user