forked from baron/baron-sso
userfront 로그인 후 /dashboard로 이동하게 변경
This commit is contained in:
@@ -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'));
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
26
userfront/lib/core/services/null_check_recovery.dart
Normal file
26
userfront/lib/core/services/null_check_recovery.dart
Normal 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;
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
40
userfront/test/cookie_session_policy_test.dart
Normal file
40
userfront/test/cookie_session_policy_test.dart
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
50
userfront/test/dashboard_screen_smoke_test.dart
Normal file
50
userfront/test/dashboard_screen_smoke_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
94
userfront/test/login_navigation_race_test.dart
Normal file
94
userfront/test/login_navigation_race_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
63
userfront/test/null_check_recovery_test.dart
Normal file
63
userfront/test/null_check_recovery_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user