// 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? _decodeErrorDetails(String? raw) { if (raw == null || raw.trim().isEmpty) { return null; } try { final decoded = jsonDecode(raw); if (decoded is Map) { 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 _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 createState() => _BaronSSOAppState(); } class _BaronSSOAppState extends State { @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 createState() => _LocaleEntryRedirectScreenState(); } class _LocaleEntryRedirectScreenState extends State { 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())); } }