1
0
forked from baron/baron-sso

userfront 로그인 후 /dashboard로 이동하게 변경

This commit is contained in:
Lectom C Han
2026-02-23 22:06:00 +09:00
parent 19d3bade30
commit 2bdfc2eb51
37 changed files with 1504 additions and 222 deletions

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:easy_localization/easy_localization.dart' hide tr;
import 'package:flutter/material.dart';
@@ -17,28 +19,54 @@ class LocaleGate extends StatefulWidget {
}
class _LocaleGateState extends State<LocaleGate> {
bool _syncScheduled = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_applyLocale();
_scheduleLocaleSync();
}
@override
void didUpdateWidget(LocaleGate oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.localeCode != widget.localeCode) {
_applyLocale();
_scheduleLocaleSync();
}
}
Future<void> _applyLocale() async {
final normalized = normalizeLocaleCode(widget.localeCode);
LocaleStorage.write(normalized);
webWindow.setTitle(tr('ui.userfront.app_title'));
if (context.locale.languageCode == normalized) {
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;
}
await context.setLocale(Locale(normalized));
webWindow.setTitle(tr('ui.userfront.app_title'));
}

View File

@@ -183,10 +183,11 @@ class LocaleStorageEngine implements LocaleStorageBackend {
final legacy = _readByKey(LocaleStoragePolicy.legacyKey);
if (LocaleStoragePolicy.shouldMigrateLegacy(
current: current,
legacy: legacy,
)) {
_writeByKey(LocaleStoragePolicy.currentKey, legacy!);
current: current,
legacy: legacy,
) &&
legacy != null) {
_writeByKey(LocaleStoragePolicy.currentKey, legacy);
_removeEverywhere(LocaleStoragePolicy.legacyKey);
return legacy;
}

View File

@@ -32,10 +32,10 @@ String resolvePreferredLocaleCode() {
}
}
final deviceLocale = PlatformDispatcher.instance.locale;
final languageTag =
deviceLocale.countryCode == null || deviceLocale.countryCode!.isEmpty
final countryCode = deviceLocale.countryCode;
final languageTag = countryCode == null || countryCode.isEmpty
? deviceLocale.languageCode
: '${deviceLocale.languageCode}-${deviceLocale.countryCode}';
: '${deviceLocale.languageCode}-$countryCode';
return normalizeLocaleCode(languageTag);
}
@@ -101,3 +101,17 @@ String buildSigninRedirectPath(String localeCode, Uri uri) {
}
return result;
}
String buildLocalizedHomePath(Uri uri, {String? preferredLocaleCode}) {
final resolvedLocale =
extractLocaleFromPath(uri) ??
normalizeLocaleCode(preferredLocaleCode ?? resolvePreferredLocaleCode());
return '/$resolvedLocale/dashboard';
}
String buildLocalizedSigninPath(Uri uri, {String? preferredLocaleCode}) {
final resolvedLocale =
extractLocaleFromPath(uri) ??
normalizeLocaleCode(preferredLocaleCode ?? resolvePreferredLocaleCode());
return '/$resolvedLocale/signin';
}

View File

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

View File

@@ -6,6 +6,7 @@ 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;
@@ -87,8 +88,9 @@ void implSendLoginSuccess(String token) {
}
// No opener and no redirect: fall back to local navigation
debugPrint('No opener found. Redirecting to /.');
web.window.location.href = '/';
final fallbackTarget = buildLocalizedHomePath(Uri.base);
debugPrint('No opener found. Redirecting to $fallbackTarget.');
web.window.location.href = fallbackTarget;
}
bool implIsPopup() {

View File

@@ -13,7 +13,13 @@ class LanguageSelector extends StatelessWidget {
@override
Widget build(BuildContext context) {
final current = context.locale.languageCode;
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(
@@ -34,9 +40,16 @@ class LanguageSelector extends StatelessWidget {
return;
}
LocaleStorage.write(value);
await context.setLocale(Locale(value));
if (localization != null) {
await localization.setLocale(Locale(value));
}
if (!context.mounted) return;
final uri = GoRouterState.of(context).uri;
Uri uri;
try {
uri = GoRouterState.of(context).uri;
} catch (_) {
uri = Uri.base;
}
final target = buildLocalizedPath(value, uri);
context.go(target);
},

View File

@@ -1,6 +1,7 @@
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';
class CreateUserScreen extends StatefulWidget {
const CreateUserScreen({super.key});
@@ -67,7 +68,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
// If cancelled or empty
if (inputPassword == null || inputPassword.isEmpty) {
if (mounted) context.go('/'); // Kick out
if (mounted) context.go(buildLocalizedHomePath(Uri.base)); // Kick out
return;
}
@@ -91,7 +92,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
backgroundColor: Colors.red,
),
);
context.go('/'); // Kick out
context.go(buildLocalizedHomePath(Uri.base)); // Kick out
}
}
}
@@ -178,7 +179,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
title: const Text('Create User'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/'),
onPressed: () => context.go(buildLocalizedHomePath(Uri.base)),
),
),
body: Center(

View File

@@ -2,6 +2,7 @@ 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';
class UserManagementScreen extends StatefulWidget {
const UserManagementScreen({super.key});
@@ -89,7 +90,7 @@ class _UserManagementScreenState extends State<UserManagementScreen>
);
if (inputPassword == null || inputPassword.isEmpty) {
if (mounted) context.go('/');
if (mounted) context.go(buildLocalizedHomePath(Uri.base));
return;
}
@@ -113,7 +114,7 @@ class _UserManagementScreenState extends State<UserManagementScreen>
backgroundColor: Colors.red,
),
);
context.go('/');
context.go(buildLocalizedHomePath(Uri.base));
}
}
}
@@ -365,7 +366,7 @@ class _UserManagementScreenState extends State<UserManagementScreen>
title: const Text('User Management'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/'),
onPressed: () => context.go(buildLocalizedHomePath(Uri.base)),
),
bottom: TabBar(
controller: _tabController,

View File

@@ -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;
}

View File

@@ -1,5 +1,6 @@
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';
@@ -47,14 +48,15 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
void _redirectIfNotLoggedIn() {
if (_redirectingToLogin || !mounted) return;
final hasStoredToken = AuthTokenStore.getToken() != null;
final hasStoredToken = AuthTokenStore.getToken()?.isNotEmpty ?? false;
final usesCookie = AuthTokenStore.usesCookie();
final isLoggedIn = hasStoredToken || usesCookie;
if (!isLoggedIn) {
_redirectingToLogin = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
context.go('/signin?notice=qr_login_required');
final target = buildLocalizedSigninPath(Uri.base);
context.go('$target?notice=qr_login_required');
});
}
}
@@ -70,7 +72,8 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
}
if (storedToken == null && !hasCookie) {
if (mounted) {
context.go('/signin?notice=qr_login_required');
final target = buildLocalizedSigninPath(Uri.base);
context.go('$target?notice=qr_login_required');
}
return;
}
@@ -94,7 +97,7 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
// Automatically go to dashboard after a short delay
Future.delayed(const Duration(seconds: 1), () {
if (mounted) context.go('/');
if (mounted) context.go(buildLocalizedHomePath(Uri.base));
});
} catch (e) {
setState(() => _message = "Error: $e");
@@ -105,7 +108,7 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
@override
Widget build(BuildContext context) {
final hasStoredToken = AuthTokenStore.getToken() != null;
final hasStoredToken = AuthTokenStore.getToken()?.isNotEmpty ?? false;
final usesCookie = AuthTokenStore.usesCookie();
final isLoggedIn = hasStoredToken || usesCookie || _isCheckingSession;
@@ -163,14 +166,15 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
Padding(
padding: const EdgeInsets.only(top: 16),
child: TextButton(
onPressed: () => context.go('/signin'),
onPressed: () =>
context.go(buildLocalizedSigninPath(Uri.base)),
child: const Text("Login on this device first"),
),
),
if (_success)
FilledButton(
onPressed: () => context.go('/'),
onPressed: () => context.go(buildLocalizedHomePath(Uri.base)),
child: const Text("Go to My Dashboard"),
),
],

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.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';
@@ -153,7 +154,7 @@ class _ConsentScreenState extends State<ConsentScreen> {
if (redirectTo != null) {
webWindow.redirectTo(redirectTo);
} else {
if (mounted) context.go('/');
if (mounted) context.go(buildLocalizedHomePath(Uri.base));
}
} catch (e) {
setState(() => _isSubmitting = false);

View File

@@ -1,6 +1,7 @@
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 'package:userfront/i18n.dart';
@@ -130,7 +131,8 @@ class ErrorScreen extends StatelessWidget {
child: Text(tr('ui.userfront.error.go_login')),
),
OutlinedButton(
onPressed: () => context.go('/'),
onPressed: () =>
context.go(buildLocalizedHomePath(Uri.base)),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF111827),
padding: const EdgeInsets.symmetric(

View File

@@ -9,9 +9,11 @@ import '../../../core/widgets/language_selector.dart';
import '../../../core/services/web_auth_integration.dart';
import '../../../core/services/auth_proxy_service.dart';
import '../../../core/services/auth_token_store.dart';
import '../../../core/i18n/locale_utils.dart';
import '../../../core/services/oidc_redirect_guard.dart';
import '../../../core/notifiers/auth_notifier.dart';
import '../domain/login_challenge_resolver.dart';
import '../domain/cookie_session_policy.dart';
import '../../profile/domain/notifiers/profile_notifier.dart';
import '../../../core/services/web_window.dart';
@@ -65,6 +67,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
bool _verificationOnly = false;
bool _verificationApproved = false;
bool _dismissedOverlays = false;
bool _localNavigationCompleted = false;
String _verificationMessage = '';
String _verificationTitle = tr('ui.userfront.login.verification.title');
String _verificationPageTitle = tr(
@@ -125,7 +128,11 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (hasLoginCode) {
_verifyLoginCode(loginIdParam, codeParam, pendingRef: pendingRefParam);
} else if (hasVerificationToken) {
_verifyToken(widget.verificationToken ?? uri.queryParameters['t']!);
final verificationToken =
widget.verificationToken ?? uri.queryParameters['t'];
if (verificationToken != null && verificationToken.isNotEmpty) {
_verifyToken(verificationToken);
}
}
if (!_noticeHandled && notice == 'qr_login_required') {
@@ -142,8 +149,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}
Future<void> _tryCookieSession({bool silent = true}) async {
if (AuthTokenStore.getToken() != null &&
(_loginChallenge == null || _loginChallenge!.isEmpty)) {
final loginChallenge = _loginChallenge;
final token = AuthTokenStore.getToken();
if (!shouldPromoteCookieSession(
currentToken: token,
loginChallenge: loginChallenge,
)) {
return;
}
final pendingProvider = AuthTokenStore.getPendingProvider();
@@ -151,6 +162,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
try {
await AuthProxyService.checkCookieSession();
if (!shouldPromoteCookieSession(
currentToken: AuthTokenStore.getToken(),
loginChallenge: loginChallenge,
)) {
return;
}
AuthTokenStore.setCookieMode(provider: provider);
AuthTokenStore.clearPendingProvider();
if (mounted) {
@@ -171,7 +188,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
Future<void> _onCookieLoginSuccess(String provider) async {
debugPrint("[Auth] Cookie-based login success. Provider: $provider");
AuthNotifier.instance.notify();
if (_hasLoginChallenge) {
final accepted = await _acceptOidcLoginAndRedirect();
if (accepted) {
@@ -185,8 +201,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
final token = AuthTokenStore.getToken();
if (token != null && token.isNotEmpty) {
final redirectUrl = _redirectUrl;
if (WebAuthIntegration.isPopup() ||
(_redirectUrl != null && _redirectUrl!.isNotEmpty)) {
(redirectUrl != null && redirectUrl.isNotEmpty)) {
debugPrint(
"[Auth] Cookie session with external integration. Notifying...",
);
@@ -196,14 +213,23 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}
if (mounted) {
context.go('/');
_goLocalizedHomeOnce();
}
}
void _goLocalizedHomeOnce() {
if (!mounted || _localNavigationCompleted) {
return;
}
_localNavigationCompleted = true;
context.go(buildLocalizedHomePath(Uri.base));
}
Future<void> _attemptOidcAutoAccept() async {
if (_oidcAutoAcceptTried) return;
_oidcAutoAcceptTried = true;
if (_loginChallenge == null || _loginChallenge!.isEmpty) {
final loginChallenge = _loginChallenge;
if (loginChallenge == null || loginChallenge.isEmpty) {
return;
}
@@ -227,12 +253,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}
Future<bool> _acceptOidcLoginAndRedirect({String? token}) async {
if (_loginChallenge == null || _loginChallenge!.isEmpty) {
final loginChallenge = _loginChallenge;
if (loginChallenge == null || loginChallenge.isEmpty) {
return false;
}
try {
final res = await AuthProxyService.acceptOidcLogin(
_loginChallenge!,
loginChallenge,
token: token,
);
final redirectTo = res['redirectTo'] as String?;
@@ -274,8 +301,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}
}
bool get _hasLoginChallenge =>
_loginChallenge != null && _loginChallenge!.isNotEmpty;
bool get _hasLoginChallenge {
final loginChallenge = _loginChallenge;
return loginChallenge != null && loginChallenge.isNotEmpty;
}
LoginChallengeResolution _resolveLoginChallenge(Uri uri) {
return resolveLoginChallenge(
@@ -486,7 +515,11 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}
try {
final res = await AuthProxyService.pollQrStatus(_qrPendingRef!);
final pendingRef = _qrPendingRef;
if (pendingRef == null || pendingRef.isEmpty) {
return;
}
final res = await AuthProxyService.pollQrStatus(pendingRef);
if (res['error'] == 'slow_down') {
final interval = res['interval'];
if (interval is int && interval > 0) {
@@ -656,9 +689,11 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
FilledButton(
onPressed: () {
final hasLocalSession =
AuthTokenStore.getToken() != null ||
(AuthTokenStore.getToken()?.isNotEmpty ?? false) ||
AuthTokenStore.usesCookie();
final target = hasLocalSession ? '/' : '/signin';
final target = hasLocalSession
? buildLocalizedHomePath(Uri.base)
: buildLocalizedSigninPath(Uri.base);
if (mounted) {
setState(() {
_verificationOnly = false;
@@ -691,7 +726,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
final jwt = res['token'] ?? res['sessionJwt'] ?? res['sessionToken'];
final status = res['status']?.toString();
final hasLocalSession = await _hasValidLocalSession();
final actionPath = hasLocalSession ? '/' : '/signin';
final actionPath = hasLocalSession
? buildLocalizedHomePath(Uri.base)
: buildLocalizedSigninPath(Uri.base);
if (status == 'approved' || (jwt == null && _verificationOnly)) {
if (mounted) {
@@ -754,7 +791,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
"[Auth] Code verification successful for loginId: $sanitizedLoginId",
);
final hasLocalSession = await _hasValidLocalSession();
final actionPath = hasLocalSession ? '/' : '/signin';
final actionPath = hasLocalSession
? buildLocalizedHomePath(Uri.base)
: buildLocalizedSigninPath(Uri.base);
if (jwt == null && status == 'approved') {
if (mounted) {
@@ -814,7 +853,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
final status = res['status']?.toString();
debugPrint("[Auth] Short code verification successful");
final hasLocalSession = await _hasValidLocalSession();
final actionPath = hasLocalSession ? '/' : '/signin';
final actionPath = hasLocalSession
? buildLocalizedHomePath(Uri.base)
: buildLocalizedSigninPath(Uri.base);
if (jwt == null && status == 'approved') {
if (mounted) {
@@ -1147,14 +1188,15 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}
// [Priority 2] OIDC Challenge Handling
if (_loginChallenge != null && _loginChallenge!.isNotEmpty) {
final loginChallenge = _loginChallenge;
if (loginChallenge != null && loginChallenge.isNotEmpty) {
try {
// Save token first, it's needed for acceptance
final providerName = provider ?? AuthTokenStore.getProvider();
AuthTokenStore.setToken(token, provider: providerName);
final res = await AuthProxyService.acceptOidcLogin(
_loginChallenge!,
loginChallenge,
token: token,
);
final nextRedirectTo = res['redirectTo'] as String?;
@@ -1196,9 +1238,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
return;
}
AuthNotifier.instance.notify();
if (mounted) {
context.go('/');
_goLocalizedHomeOnce();
}
} catch (globalErr) {
// ignore
@@ -1237,7 +1278,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
title: Text(_verificationPageTitle),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/'),
onPressed: () => context.go(buildLocalizedHomePath(Uri.base)),
),
),
body: _buildVerificationResultView(),

View File

@@ -1,5 +1,6 @@
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 {
@@ -54,7 +55,7 @@ class LoginSuccessScreen extends StatelessWidget {
const SizedBox(height: 24),
TextButton(
onPressed: () {
context.go('/');
context.go(buildLocalizedHomePath(Uri.base));
},
child: Text(
tr('ui.userfront.login_success.later'),

View File

@@ -1,5 +1,6 @@
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 'package:userfront/i18n.dart';
@@ -89,7 +90,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
backgroundColor: Colors.green,
),
);
context.go('/signin');
context.go(buildLocalizedSigninPath(Uri.base));
}
} catch (e) {
if (mounted) {

View File

@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import 'package:userfront/i18n.dart';
import '../../../core/i18n/locale_utils.dart';
import '../../../core/services/auth_proxy_service.dart';
class SignupScreen extends StatefulWidget {
@@ -345,7 +346,7 @@ class _SignupScreenState extends State<SignupScreen> {
content: Text(tr('msg.userfront.signup.success.body')),
actions: [
TextButton(
onPressed: () => context.go('/signin'),
onPressed: () => context.go(buildLocalizedSigninPath(Uri.base)),
child: Text(tr('ui.userfront.signup.success.action')),
),
],

View File

@@ -133,7 +133,8 @@ class AuthTimelineNotifier extends Notifier<AuthTimelineState> {
if (state.isLoading || state.isLoadingMore) {
return;
}
if (state.nextCursor == null || state.nextCursor!.isEmpty) {
final nextCursor = state.nextCursor;
if (nextCursor == null || nextCursor.isEmpty) {
return;
}
state = state.copyWith(isLoadingMore: true, error: null);

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -7,6 +8,7 @@ import 'package:url_launcher/url_launcher.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import '../domain/providers/linked_rps_provider.dart';
import '../../../../core/notifiers/auth_notifier.dart';
import '../../../../core/services/auth_proxy_service.dart';
import '../../../../core/services/auth_token_store.dart';
import '../../../../core/services/http_client.dart';
import '../../../../core/i18n/locale_utils.dart';
@@ -38,6 +40,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
bool _auditLoadingMore = false;
bool _isRevoking = false;
bool _redirectingToSignin = false;
bool _authBootstrapInProgress = false;
bool _showAllActivities = false;
final Set<String> _revokedClientIds = {};
@@ -47,11 +50,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
super.initState();
_pageScrollController.addListener(_onPageScroll);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_isLoggedIn()) {
_redirectToSignin();
if (!mounted) {
return;
}
_loadAuditLogs(reset: true);
unawaited(_bootstrapAuthAndLoad());
});
}
@@ -254,7 +256,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
if (closeOnTap) {
Navigator.of(context).pop();
}
context.go('/');
context.go(buildLocalizedHomePath(Uri.base));
},
),
ListTile(
@@ -302,8 +304,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Future<void> _refreshAll() async {
if (!_isLoggedIn()) {
_redirectToSignin();
return;
final recovered = await _recoverSessionFromCookie();
if (!recovered) {
_redirectToSignin();
return;
}
}
await ref.read(profileProvider.notifier).loadProfile();
setState(() {
@@ -372,7 +377,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
if (_auditLoading || _auditLoadingMore) {
return;
}
if (!reset && (_auditNextCursor == null || _auditNextCursor!.isEmpty)) {
final nextCursor = _auditNextCursor;
if (!reset && (nextCursor == null || nextCursor.isEmpty)) {
return;
}
@@ -706,109 +712,133 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
@override
Widget build(BuildContext context) {
if (!_isLoggedIn()) {
_redirectToSignin();
return const SizedBox.shrink();
}
final isWide = MediaQuery.of(context).size.width >= sideMenuBreakpoint;
final profileState = ref.watch(profileProvider);
final profile = profileState.value;
final timelineState = ref.watch(authTimelineProvider);
final userName =
profile?.name ??
profile?.email ??
profile?.phone ??
tr('ui.userfront.profile.user_fallback', fallback: 'User');
final department = profile?.department.isNotEmpty == true
? profile!.department
: tr('ui.userfront.profile.department_empty');
final sessionIssuedAt = _getJwtIssuedAt();
try {
if (!_isLoggedIn()) {
_redirectToSignin();
return const SizedBox.shrink();
}
final isWide = MediaQuery.of(context).size.width >= sideMenuBreakpoint;
final profileState = ref.watch(profileProvider);
final profile = profileState.value;
final timelineState = ref.watch(authTimelineProvider);
final userName =
profile?.name ??
profile?.email ??
profile?.phone ??
tr('ui.userfront.profile.user_fallback', fallback: 'User');
final departmentValue = profile?.department ?? '';
final department = departmentValue.isNotEmpty
? departmentValue
: tr('ui.userfront.profile.department_empty');
final sessionIssuedAt = _getJwtIssuedAt();
return Scaffold(
backgroundColor: _subtle,
appBar: AppBar(
title: Text(
tr('ui.userfront.app_title'),
style: TextStyle(fontWeight: FontWeight.bold),
),
elevation: 0,
backgroundColor: _surface,
foregroundColor: Colors.black,
actions: [
IconButton(
icon: const Icon(Icons.person_outline),
tooltip: tr('ui.userfront.nav.profile'),
onPressed: () => context.push('/profile'),
return Scaffold(
backgroundColor: _subtle,
appBar: AppBar(
title: Text(
tr('ui.userfront.app_title'),
style: const TextStyle(fontWeight: FontWeight.bold),
),
IconButton(
icon: const Icon(Icons.qr_code_scanner),
tooltip: tr('ui.userfront.nav.qr_scan'),
onPressed: _onScanQR,
),
IconButton(
icon: const Icon(Icons.logout),
tooltip: tr('ui.userfront.nav.logout'),
onPressed: _logout,
),
],
),
drawer: isWide
? null
: Drawer(child: _buildSideMenu(context, closeOnTap: true)),
body: Row(
children: [
if (isWide)
SizedBox(
width: 240,
child: _buildSideMenu(context, closeOnTap: false),
elevation: 0,
backgroundColor: _surface,
foregroundColor: Colors.black,
actions: [
IconButton(
icon: const Icon(Icons.person_outline),
tooltip: tr('ui.userfront.nav.profile'),
onPressed: () => context.push('/profile'),
),
Expanded(
child: RefreshIndicator(
onRefresh: _refreshAll,
child: LayoutBuilder(
builder: (context, constraints) {
final timelineWide = constraints.maxWidth >= 900;
final isMobile = constraints.maxWidth < 600;
return SingleChildScrollView(
controller: _pageScrollController,
physics: const AlwaysScrollableScrollPhysics(),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isMobile) ...[
_buildHeaderCard(
userName,
department,
sessionIssuedAt,
IconButton(
icon: const Icon(Icons.qr_code_scanner),
tooltip: tr('ui.userfront.nav.qr_scan'),
onPressed: _onScanQR,
),
IconButton(
icon: const Icon(Icons.logout),
tooltip: tr('ui.userfront.nav.logout'),
onPressed: _logout,
),
],
),
drawer: isWide
? null
: Drawer(child: _buildSideMenu(context, closeOnTap: true)),
body: Row(
children: [
if (isWide)
SizedBox(
width: 240,
child: _buildSideMenu(context, closeOnTap: false),
),
Expanded(
child: RefreshIndicator(
onRefresh: _refreshAll,
child: LayoutBuilder(
builder: (context, constraints) {
final timelineWide = constraints.maxWidth >= 900;
final isMobile = constraints.maxWidth < 600;
return SingleChildScrollView(
controller: _pageScrollController,
physics: const AlwaysScrollableScrollPhysics(),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isMobile) ...[
_buildHeaderCard(
userName,
department,
sessionIssuedAt,
),
const SizedBox(height: 28),
],
_buildSectionTitle(
tr('ui.userfront.sections.apps'),
tr('msg.userfront.sections.apps_subtitle'),
),
const SizedBox(height: 12),
_buildActivitySection(isMobile),
const SizedBox(height: 28),
_buildSectionTitle(
tr('ui.userfront.sections.audit'),
tr('msg.userfront.sections.audit_subtitle'),
),
const SizedBox(height: 12),
_buildAccessHistory(timelineState, timelineWide),
],
_buildSectionTitle(
tr('ui.userfront.sections.apps'),
tr('msg.userfront.sections.apps_subtitle'),
),
const SizedBox(height: 12),
_buildActivitySection(isMobile),
const SizedBox(height: 28),
_buildSectionTitle(
tr('ui.userfront.sections.audit'),
tr('msg.userfront.sections.audit_subtitle'),
),
const SizedBox(height: 12),
_buildAccessHistory(timelineState, timelineWide),
],
),
),
),
);
},
);
},
),
),
),
],
),
);
} catch (error, stackTrace) {
AuthProxyService.logError(
'DASHBOARD_RENDER_ERROR: $error\nuri=${Uri.base}',
error: error,
stackTrace: stackTrace,
);
return Scaffold(
backgroundColor: _subtle,
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
tr(
'msg.userfront.dashboard.render_error',
fallback: '대시보드 렌더링 중 오류가 발생했습니다. 다시 시도해 주세요.',
),
textAlign: TextAlign.center,
),
),
],
),
);
),
);
}
}
Widget _buildHeaderCard(
@@ -973,8 +1003,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
normalizedStatus == 'active' || normalizedStatus == '';
final isRevoked = !isActiveInApi;
final lastAuthLabel = rp.lastAuthenticatedAt != null
? _formatDateTime(rp.lastAuthenticatedAt!)
final lastAuthAt = rp.lastAuthenticatedAt;
final lastAuthLabel = lastAuthAt != null
? _formatDateTime(lastAuthAt)
: tr('ui.userfront.dashboard.activity.linked');
final statusCode = isRevoked ? 'revoked' : 'active';
@@ -1004,8 +1035,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
if (!aActive && bActive) return 1;
// 둘 다 활성이거나 둘 다 비활성인 경우 최근 인증순 내림차순
if (a.lastAuthDateTime != null && b.lastAuthDateTime != null) {
return b.lastAuthDateTime!.compareTo(a.lastAuthDateTime!);
final aLastAuth = a.lastAuthDateTime;
final bLastAuth = b.lastAuthDateTime;
if (aLastAuth != null && bLastAuth != null) {
return bLastAuth.compareTo(aLastAuth);
}
if (a.lastAuthDateTime != null) return -1;
if (b.lastAuthDateTime != null) return 1;
@@ -1045,7 +1078,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
}
// 카드의 너비를 화면 너비에 맞춰 계산 (여백 고려)
final double spacing = 12.0;
const spacing = 12.0;
final double cardWidth =
(maxWidth - (spacing * (crossAxisCount - 1))) / crossAxisCount;
@@ -1244,8 +1277,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
child: GestureDetector(
onTap: () async {
final messenger = ScaffoldMessenger.of(context);
if (item.url != null && item.url!.isNotEmpty) {
final uri = Uri.parse(item.url!);
final itemUrl = item.url;
if (itemUrl != null && itemUrl.isNotEmpty) {
final uri = Uri.parse(itemUrl);
final canOpen = await canLaunchUrl(uri);
if (!mounted) return;
if (canOpen) {
@@ -1568,7 +1602,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
),
);
}
if (state.nextCursor == null || state.nextCursor!.isEmpty) {
final nextCursor = state.nextCursor;
if (nextCursor == null || nextCursor.isEmpty) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
@@ -1581,7 +1616,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
}
bool _isLoggedIn() {
return AuthTokenStore.getToken() != null || AuthTokenStore.usesCookie();
final token = AuthTokenStore.getToken();
return (token != null && token.isNotEmpty) || AuthTokenStore.usesCookie();
}
void _redirectToSignin() {
@@ -1593,13 +1629,60 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
if (!mounted) {
return;
}
final uri = GoRouterState.of(context).uri;
Uri uri;
try {
uri = GoRouterState.of(context).uri;
} catch (_) {
uri = Uri.base;
}
final localeCode =
extractLocaleFromPath(uri) ?? resolvePreferredLocaleCode();
context.go('/$localeCode/signin');
_redirectingToSignin = false;
});
}
Future<void> _bootstrapAuthAndLoad() async {
if (!mounted || _authBootstrapInProgress) {
return;
}
_authBootstrapInProgress = true;
try {
var authenticated = _isLoggedIn();
if (!authenticated) {
authenticated = await _recoverSessionFromCookie();
}
if (!mounted) {
return;
}
if (!authenticated) {
_redirectToSignin();
return;
}
await _loadAuditLogs(reset: true);
} finally {
_authBootstrapInProgress = false;
}
}
Future<bool> _recoverSessionFromCookie() async {
try {
await AuthProxyService.checkCookieSession();
final provider =
AuthTokenStore.getProvider() ??
AuthTokenStore.getPendingProvider() ??
'ory';
AuthTokenStore.setCookieMode(provider: provider);
AuthTokenStore.clearPendingProvider();
AuthNotifier.instance.notify();
try {
await ref.read(profileProvider.notifier).loadProfile();
} catch (_) {}
return true;
} catch (_) {
return false;
}
}
}
class _ActivityItem {

View File

@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:userfront/i18n.dart';
import '../../../../core/notifiers/auth_notifier.dart';
import '../../../../core/i18n/locale_utils.dart';
import '../../../../core/services/auth_token_store.dart';
import '../../../../core/ui/layout_breakpoints.dart';
import '../../../../core/widgets/language_selector.dart';
@@ -509,7 +510,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
ListTile(
leading: const Icon(Icons.home_outlined),
title: Text(tr('ui.userfront.nav.dashboard')),
onTap: () => context.go('/'),
onTap: () => context.go(buildLocalizedHomePath(Uri.base)),
),
ListTile(
leading: const Icon(Icons.person_outline),
@@ -1092,7 +1093,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
IconButton(
icon: const Icon(Icons.home_outlined),
tooltip: tr('ui.userfront.nav.dashboard'),
onPressed: () => context.go('/'),
onPressed: () => context.go(buildLocalizedHomePath(Uri.base)),
),
IconButton(
icon: const Icon(Icons.qr_code_scanner),

View File

@@ -20,6 +20,8 @@ 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/i18n/locale_gate.dart';
import 'core/i18n/locale_registry.dart';
@@ -31,6 +33,29 @@ import 'i18n.dart';
final _log = Logger('Main');
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> _loadBundledFonts() async {
const family = 'NotoSansKR';
final loader = FontLoader(family);
@@ -57,11 +82,16 @@ void main() async {
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;
};
@@ -107,6 +137,15 @@ final _router = GoRouter(
debugLogDiagnostics: !kReleaseMode,
refreshListenable: AuthNotifier.instance,
routes: [
GoRoute(
path: '/',
redirect: (context, state) {
return buildLocalizedHomePath(
state.uri,
preferredLocaleCode: resolvePreferredLocaleCode(),
);
},
),
ShellRoute(
builder: (context, state, child) {
final localeCode =
@@ -116,10 +155,25 @@ final _router = GoRouter(
routes: [
GoRoute(
path: '/:locale',
// Note: Removed direct builder here to prevent interference with sub-routes
redirect: (context, state) {
// /{locale} 진입은 화면 렌더링 없이 단일 목적지로만 보냅니다.
if (state.uri.pathSegments.length != 1) {
return null;
}
final rawLocale = state.pathParameters['locale'];
final localeCode = normalizeLocaleCode(rawLocale);
final token = AuthTokenStore.getToken();
final isLoggedIn =
(token != null && token.isNotEmpty) ||
AuthTokenStore.usesCookie();
if (!isLoggedIn) {
return buildSigninRedirectPath(localeCode, state.uri);
}
return '/$localeCode/dashboard';
},
routes: [
GoRoute(
path: '', // Matches /:locale
path: 'dashboard',
builder: (context, state) {
return const DashboardScreen();
},
@@ -299,9 +353,16 @@ final _router = GoRouter(
}
if (!isLoggedIn) {
if (path == '/') {
return '/$requestedLocale/signin';
}
return buildSigninRedirectPath(requestedLocale, uri);
}
if (path == '/') {
return '/$requestedLocale/dashboard';
}
return null;
},
);
@@ -311,11 +372,21 @@ class BaronSSOApp extends StatelessWidget {
@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: context.localizationDelegates,
supportedLocales: context.supportedLocales,
locale: context.locale,
localizationsDelegates: delegates,
supportedLocales: supportedLocales,
locale: locale,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF1A1F2C), // Dark Navy/Black base

View File

@@ -0,0 +1,40 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:userfront/features/auth/domain/cookie_session_policy.dart';
void main() {
group('cookie_session_policy', () {
test('토큰이 없고 login_challenge도 없으면 cookie 승격 허용', () {
expect(
shouldPromoteCookieSession(currentToken: null, loginChallenge: null),
isTrue,
);
});
test('토큰이 이미 있으면 일반 로그인에서 cookie 승격 차단', () {
expect(
shouldPromoteCookieSession(
currentToken: 'existing-token',
loginChallenge: null,
),
isFalse,
);
});
test('OIDC login_challenge가 있으면 token 존재 시에도 cookie 승격 허용', () {
expect(
shouldPromoteCookieSession(
currentToken: 'existing-token',
loginChallenge: 'lc_123',
),
isTrue,
);
});
test('공백 토큰은 유효 토큰으로 간주하지 않음', () {
expect(
shouldPromoteCookieSession(currentToken: ' ', loginChallenge: null),
isTrue,
);
});
});
}

View File

@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:userfront/core/services/auth_token_store.dart';
import 'package:userfront/features/dashboard/presentation/dashboard_screen.dart';
void main() {
setUp(() {
AuthTokenStore.clear();
});
tearDown(() {
AuthTokenStore.clear();
});
testWidgets('대시보드는 로그인 토큰이 있으면 크래시 없이 기본 프레임을 렌더링한다', (tester) async {
final recordedErrors = <FlutterErrorDetails>[];
final previousOnError = FlutterError.onError;
FlutterError.onError = (details) {
final text = details.exceptionAsString();
if (text.contains('A RenderFlex overflowed')) {
return;
}
recordedErrors.add(details);
};
addTearDown(() {
FlutterError.onError = previousOnError;
});
tester.view.devicePixelRatio = 1.0;
tester.view.physicalSize = const Size(1920, 1080);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
AuthTokenStore.setToken('smoke-token', provider: 'ory');
await tester.pumpWidget(
const ProviderScope(child: MaterialApp(home: DashboardScreen())),
);
await tester.pump();
expect(find.byType(Scaffold), findsOneWidget);
final hasNullCheckCrash = recordedErrors.any(
(error) => error.exceptionAsString().contains(
'Null check operator used on a null value',
),
);
expect(hasNullCheckCrash, isFalse);
});
}

View File

@@ -127,5 +127,32 @@ void main() {
'/ko/signin?redirect_url=https%3A%2F%2Fa.example.com%2Fcb&redirect_uri=https%3A%2F%2Fb.example.com%2Fcb',
);
});
test('buildLocalizedHomePath keeps locale from uri', () {
expect(buildLocalizedHomePath(Uri.parse('/ko/signin')), '/ko/dashboard');
expect(buildLocalizedHomePath(Uri.parse('/en/profile')), '/en/dashboard');
});
test('buildLocalizedHomePath falls back to preferred locale', () {
expect(
buildLocalizedHomePath(Uri.parse('/signin'), preferredLocaleCode: 'ko'),
'/ko/dashboard',
);
});
test('buildLocalizedSigninPath keeps locale from uri', () {
expect(buildLocalizedSigninPath(Uri.parse('/ko')), '/ko/signin');
expect(buildLocalizedSigninPath(Uri.parse('/en/profile')), '/en/signin');
});
test('buildLocalizedSigninPath falls back to preferred locale', () {
expect(
buildLocalizedSigninPath(
Uri.parse('/profile'),
preferredLocaleCode: 'ko',
),
'/ko/signin',
);
});
});
}

View File

@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:userfront/core/i18n/locale_registry.dart';
import 'package:userfront/core/i18n/locale_utils.dart';
import 'package:userfront/core/services/auth_token_store.dart';
class _AuthRefreshNotifier extends ChangeNotifier {
void refresh() => notifyListeners();
}
Widget _buildRaceTestApp(_AuthRefreshNotifier notifier) {
final router = GoRouter(
initialLocation: '/ko/signin',
refreshListenable: notifier,
routes: [
GoRoute(
path: '/:locale',
builder: (context, state) => const Scaffold(body: Text('locale-root')),
routes: [
GoRoute(
path: 'dashboard',
builder: (context, state) => const Scaffold(body: Text('home')),
),
GoRoute(
path: 'signin',
builder: (context, state) {
return Scaffold(
body: Center(
child: FilledButton(
onPressed: () {
AuthTokenStore.setToken('race-token', provider: 'ory');
notifier.refresh();
context.go('/ko/dashboard');
},
child: const Text('login'),
),
),
);
},
),
],
),
],
redirect: (context, state) {
final requestedLocale = extractLocaleFromPath(state.uri);
if (requestedLocale == null) {
return buildLocalizedPath(resolvePreferredLocaleCode(), state.uri);
}
final token = AuthTokenStore.getToken();
final isLoggedIn =
(token != null && token.isNotEmpty) || AuthTokenStore.usesCookie();
final path = stripLocalePath(state.uri);
if (path == '/signin') {
return null;
}
if (!isLoggedIn) {
return buildSigninRedirectPath(requestedLocale, state.uri);
}
if (path == '/') {
return '/$requestedLocale/dashboard';
}
return null;
},
);
return MaterialApp.router(routerConfig: router);
}
void main() {
setUp(() {
LocaleRegistry.setSupportedLocaleCodesForTest(['en', 'ko']);
AuthTokenStore.clear();
});
tearDown(() {
AuthTokenStore.clear();
LocaleRegistry.resetForTest();
});
testWidgets('로그인 성공 이벤트(notify + go) 동시 호출 시 홈으로 안정적으로 이동', (tester) async {
final notifier = _AuthRefreshNotifier();
await tester.pumpWidget(_buildRaceTestApp(notifier));
await tester.pumpAndSettle();
expect(find.text('login'), findsOneWidget);
await tester.tap(find.text('login'));
await tester.pumpAndSettle();
expect(find.text('home'), findsOneWidget);
expect(tester.takeException(), isNull);
});
}

View File

@@ -0,0 +1,63 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:userfront/core/i18n/locale_registry.dart';
import 'package:userfront/core/services/null_check_recovery.dart';
void main() {
setUp(() {
LocaleRegistry.setSupportedLocaleCodesForTest(['en', 'ko']);
});
tearDown(() {
LocaleRegistry.resetForTest();
});
test('Null check 오류 + 루트(/)면 선호 로케일 signin으로 복구', () {
final target = computeNullCheckRecoveryTarget(
exception: Exception('Null check operator used on a null value'),
uri: Uri.parse('https://sss.hmac.kr/'),
preferredLocaleCode: 'ko',
);
expect(target, '/ko/signin');
});
test('Null check 오류 + /ko면 /ko/signin으로 복구', () {
final target = computeNullCheckRecoveryTarget(
exception: Exception('Null check operator used on a null value'),
uri: Uri.parse('https://sss.hmac.kr/ko'),
preferredLocaleCode: 'en',
);
expect(target, '/ko/signin');
});
test('이미 /ko/signin이면 복구 이동하지 않음', () {
final target = computeNullCheckRecoveryTarget(
exception: Exception('Null check operator used on a null value'),
uri: Uri.parse('https://sss.hmac.kr/ko/signin'),
preferredLocaleCode: 'ko',
);
expect(target, isNull);
});
test('Null check 오류여도 /ko/profile에서는 복구 이동하지 않음', () {
final target = computeNullCheckRecoveryTarget(
exception: Exception('Null check operator used on a null value'),
uri: Uri.parse('https://sss.hmac.kr/ko/profile'),
preferredLocaleCode: 'ko',
);
expect(target, isNull);
});
test('다른 오류 메시지면 복구 이동하지 않음', () {
final target = computeNullCheckRecoveryTarget(
exception: Exception('Some other error'),
uri: Uri.parse('https://sss.hmac.kr/ko'),
preferredLocaleCode: 'ko',
);
expect(target, isNull);
});
}

View File

@@ -11,8 +11,28 @@ Widget _buildTestApp(String initialLocation) {
routes: [
GoRoute(
path: '/:locale',
builder: (context, state) => const Scaffold(body: Text('root')),
redirect: (context, state) {
if (state.uri.pathSegments.length != 1) {
return null;
}
final localeCode = normalizeLocaleCode(
state.pathParameters['locale'],
);
final token = AuthTokenStore.getToken();
final isLoggedIn =
(token != null && token.isNotEmpty) ||
AuthTokenStore.usesCookie();
if (!isLoggedIn) {
return buildSigninRedirectPath(localeCode, state.uri);
}
return '/$localeCode/dashboard';
},
routes: [
GoRoute(
path: 'dashboard',
builder: (context, state) =>
const Scaffold(body: Text('dashboard-page')),
),
GoRoute(
path: 'signin',
builder: (context, state) {
@@ -57,8 +77,9 @@ Widget _buildTestApp(String initialLocation) {
return buildLocalizedPath(resolvePreferredLocaleCode(), state.uri);
}
final token = AuthTokenStore.getToken();
final isLoggedIn =
AuthTokenStore.getToken() != null || AuthTokenStore.usesCookie();
(token != null && token.isNotEmpty) || AuthTokenStore.usesCookie();
final path = stripLocalePath(state.uri);
final isPublicPath = path == '/signin' || path == '/login';
if (isPublicPath) {
@@ -85,6 +106,25 @@ void main() {
LocaleRegistry.resetForTest();
});
testWidgets(
'루트 경로: /{locale} 로 접근 시 /{locale}/signin 으로 리다이렉트되어야 한다 (버그: 화면 렌더링 안됨)',
(tester) async {
await tester.pumpWidget(_buildTestApp('/ko'));
await tester.pumpAndSettle();
expect(find.textContaining('signin|'), findsOneWidget);
},
);
testWidgets('로그인 상태에서 /{locale} 접근 시 dashboard로 이동', (tester) async {
AuthTokenStore.setToken('root-token', provider: 'ory');
await tester.pumpWidget(_buildTestApp('/ko'));
await tester.pumpAndSettle();
expect(find.text('dashboard-page'), findsOneWidget);
expect(find.textContaining('signin|'), findsNothing);
});
testWidgets('/login: login_challenge와 redirect_uri를 전달', (tester) async {
final encodedRedirectUri = Uri.encodeComponent(
'https://rp.example.com/callback?x=1',
@@ -153,6 +193,15 @@ void main() {
expect(find.textContaining('signin|'), findsNothing);
});
testWidgets('빈 토큰은 로그인으로 간주하지 않고 signin으로 리다이렉트', (tester) async {
AuthTokenStore.setToken('', provider: 'ory');
await tester.pumpWidget(_buildTestApp('/ko/profile'));
await tester.pumpAndSettle();
expect(find.textContaining('signin|'), findsOneWidget);
expect(find.text('profile-page'), findsNothing);
});
testWidgets('로그인 후 같은 브라우저 새 창/팝업에서도 세션이 유지된다', (tester) async {
await tester.pumpWidget(_buildTestApp('/en/signin'));
await tester.pumpAndSettle();