Merge branch 'dev' into feat/org-chart-rebac
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 25 KiB |
BIN
userfront/assets/baron.ico
Normal file
|
After Width: | Height: | Size: 17 KiB |
@@ -38,6 +38,7 @@ approved_device = "Approved Device"
|
||||
approved_ip = "Approve IP: {ip}"
|
||||
audit_empty = "Audit Empty"
|
||||
audit_load_error = "Audit Load Error"
|
||||
render_error = "Dashboard render error: {error}"
|
||||
auth_method = "Auth Method"
|
||||
client_id = "Client ID: {id}"
|
||||
client_id_missing = "Client Id Missing"
|
||||
@@ -557,4 +558,3 @@ verify = "Verify"
|
||||
|
||||
[ui.userfront.signup.success]
|
||||
action = "Action"
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ approved_device = "승인 기기: {device}"
|
||||
approved_ip = "승인 IP: {ip}"
|
||||
audit_empty = "최근 접속 이력이 없습니다."
|
||||
audit_load_error = "접속이력을 불러오지 못했습니다."
|
||||
render_error = "대시보드 렌더링 오류: {error}"
|
||||
auth_method = "인증수단: {method}"
|
||||
client_id = "Client ID: {id}"
|
||||
client_id_missing = "Client ID 없음"
|
||||
@@ -557,4 +558,3 @@ verify = "본인인증"
|
||||
|
||||
[ui.userfront.signup.success]
|
||||
action = "로그인하기"
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ approved_device = ""
|
||||
approved_ip = ""
|
||||
audit_empty = ""
|
||||
audit_load_error = ""
|
||||
render_error = ""
|
||||
auth_method = ""
|
||||
client_id = ""
|
||||
client_id_missing = ""
|
||||
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 236 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 954 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 21 KiB |
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import '../../../core/i18n/locale_utils.dart';
|
||||
|
||||
bool isPublicAuthPath(String path, Uri uri) {
|
||||
return path == '/signin' ||
|
||||
path == '/signup' ||
|
||||
path == '/login' ||
|
||||
path == '/registration' ||
|
||||
path == '/verify' ||
|
||||
path == '/verification' ||
|
||||
path.startsWith('/verify/') ||
|
||||
path.startsWith('/l/') ||
|
||||
path == '/approve' ||
|
||||
path.startsWith('/ql/') ||
|
||||
path == '/forgot-password' ||
|
||||
path == '/recovery' ||
|
||||
path == '/reset-password' ||
|
||||
path == '/error' ||
|
||||
path == '/settings' ||
|
||||
path == '/consent' ||
|
||||
path.startsWith('/consent/') ||
|
||||
uri.path.contains('/consent');
|
||||
}
|
||||
|
||||
String? extractLoginShortCode(Uri uri) {
|
||||
final normalizedPath = stripLocalePath(uri);
|
||||
final segments = normalizedPath
|
||||
.split('/')
|
||||
.where((segment) => segment.isNotEmpty)
|
||||
.toList();
|
||||
if (segments.length < 2 || segments.first != 'l') {
|
||||
return null;
|
||||
}
|
||||
return segments[1];
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -17,11 +18,15 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
|
||||
bool _success = false;
|
||||
bool _isCheckingSession = false;
|
||||
bool _redirectingToLogin = false;
|
||||
bool _autoApproveTriggered = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bootstrapCookieSession().then((_) => _redirectIfNotLoggedIn());
|
||||
_bootstrapCookieSession().then((_) {
|
||||
_redirectIfNotLoggedIn();
|
||||
_maybeAutoApprove();
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> _bootstrapCookieSession() async {
|
||||
@@ -47,18 +52,44 @@ 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');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _maybeAutoApprove() {
|
||||
if (!mounted || _autoApproveTriggered) return;
|
||||
if (widget.pendingRef == null || widget.pendingRef!.trim().isEmpty) {
|
||||
if (_message == null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_message = 'Error: pendingRef is missing.';
|
||||
});
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final hasStoredToken = AuthTokenStore.getToken()?.isNotEmpty ?? false;
|
||||
final usesCookie = AuthTokenStore.usesCookie();
|
||||
final isLoggedIn = hasStoredToken || usesCookie || _isCheckingSession;
|
||||
if (!isLoggedIn || _isLoading || _success) {
|
||||
return;
|
||||
}
|
||||
|
||||
_autoApproveTriggered = true;
|
||||
_handleApprove();
|
||||
}
|
||||
|
||||
Future<void> _handleApprove() async {
|
||||
if (widget.pendingRef == null) return;
|
||||
|
||||
@@ -70,7 +101,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 +126,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,13 +137,16 @@ 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;
|
||||
|
||||
if (!isLoggedIn && !_redirectingToLogin) {
|
||||
_redirectIfNotLoggedIn();
|
||||
}
|
||||
if (isLoggedIn && !_success && !_isLoading) {
|
||||
_maybeAutoApprove();
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text("QR Login Approval")),
|
||||
@@ -148,29 +183,44 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
|
||||
),
|
||||
),
|
||||
|
||||
if (!_success)
|
||||
FilledButton.icon(
|
||||
onPressed: _isLoading || !isLoggedIn ? null : _handleApprove,
|
||||
icon: const Icon(Icons.check_circle),
|
||||
label: const Text("Approve Login"),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(60),
|
||||
backgroundColor: Colors.blue,
|
||||
),
|
||||
if (_isLoading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(bottom: 16),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
|
||||
if (!_success && !_isLoading)
|
||||
Text(
|
||||
"Approving login request automatically...",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey.shade700),
|
||||
),
|
||||
|
||||
if (!isLoggedIn && !_success)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: TextButton(
|
||||
onPressed: () => context.go('/signin'),
|
||||
onPressed: () =>
|
||||
context.go(buildLocalizedSigninPath(Uri.base)),
|
||||
child: const Text("Login on this device first"),
|
||||
),
|
||||
),
|
||||
|
||||
if (!_success && !_isLoading && _message != null)
|
||||
FilledButton.icon(
|
||||
onPressed: !isLoggedIn
|
||||
? null
|
||||
: () {
|
||||
_autoApproveTriggered = false;
|
||||
_handleApprove();
|
||||
},
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text("Retry Approval"),
|
||||
),
|
||||
|
||||
if (_success)
|
||||
FilledButton(
|
||||
onPressed: () => context.go('/'),
|
||||
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,12 @@ 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 '../domain/login_link_route_policy.dart';
|
||||
import '../../profile/domain/notifiers/profile_notifier.dart';
|
||||
import '../../../core/services/web_window.dart';
|
||||
|
||||
@@ -65,6 +68,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(
|
||||
@@ -108,8 +112,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
final loginIdParam = uri.queryParameters['loginId'];
|
||||
final codeParam = uri.queryParameters['code'];
|
||||
final pendingRefParam = uri.queryParameters['pendingRef'];
|
||||
final hasShortCodePath =
|
||||
uri.pathSegments.length >= 2 && uri.pathSegments.first == 'l';
|
||||
final shortCodeFromPath = extractLoginShortCode(uri);
|
||||
final hasShortCodePath = shortCodeFromPath != null;
|
||||
final hasTokenParam = uri.queryParameters.containsKey('t');
|
||||
final hasVerificationToken =
|
||||
widget.verificationToken != null || hasTokenParam;
|
||||
@@ -119,13 +123,16 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
final notice = uri.queryParameters['notice'];
|
||||
|
||||
if (hasShortCodePath) {
|
||||
final shortCode = uri.pathSegments[1];
|
||||
_verifyShortCode(shortCode);
|
||||
_verifyShortCode(shortCodeFromPath);
|
||||
}
|
||||
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'),
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
enum QrCameraBootstrapStatus {
|
||||
ready,
|
||||
detectorUnsupported,
|
||||
permissionError,
|
||||
cameraError,
|
||||
}
|
||||
|
||||
class QrCameraBootstrapResult {
|
||||
const QrCameraBootstrapResult(this.status, {this.errorDetail = ''});
|
||||
|
||||
final QrCameraBootstrapStatus status;
|
||||
final String errorDetail;
|
||||
|
||||
bool get isReady => status == QrCameraBootstrapStatus.ready;
|
||||
}
|
||||
|
||||
typedef QrOpenCameraAndPlay = Future<void> Function();
|
||||
typedef QrStopCamera = Future<void> Function();
|
||||
|
||||
bool isQrPermissionError(Object error) {
|
||||
final raw = error.toString();
|
||||
return raw.contains('NotAllowedError') ||
|
||||
raw.contains('PermissionDeniedError') ||
|
||||
raw.contains('SecurityError');
|
||||
}
|
||||
|
||||
Future<QrCameraBootstrapResult> bootstrapQrCamera({
|
||||
required bool hasBarcodeDetector,
|
||||
required QrOpenCameraAndPlay openCameraAndPlay,
|
||||
required QrStopCamera stopCamera,
|
||||
}) async {
|
||||
try {
|
||||
await openCameraAndPlay();
|
||||
if (!hasBarcodeDetector) {
|
||||
await stopCamera();
|
||||
return const QrCameraBootstrapResult(
|
||||
QrCameraBootstrapStatus.detectorUnsupported,
|
||||
errorDetail: 'BarcodeDetector is not supported in this browser.',
|
||||
);
|
||||
}
|
||||
return const QrCameraBootstrapResult(QrCameraBootstrapStatus.ready);
|
||||
} catch (e) {
|
||||
if (isQrPermissionError(e)) {
|
||||
return QrCameraBootstrapResult(
|
||||
QrCameraBootstrapStatus.permissionError,
|
||||
errorDetail: e.toString(),
|
||||
);
|
||||
}
|
||||
return QrCameraBootstrapResult(
|
||||
QrCameraBootstrapStatus.cameraError,
|
||||
errorDetail: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
17
userfront/lib/features/auth/presentation/qr_scan_route.dart
Normal file
@@ -0,0 +1,17 @@
|
||||
import '../../../../core/i18n/locale_utils.dart';
|
||||
|
||||
String buildQrApprovePath(
|
||||
String scannedValue, {
|
||||
String? localeCode,
|
||||
Uri? currentUri,
|
||||
}) {
|
||||
final value = scannedValue.trim();
|
||||
final explicitLocale = localeCode?.trim();
|
||||
final uri = currentUri ?? Uri.base;
|
||||
final resolvedLocale = explicitLocale != null && explicitLocale.isNotEmpty
|
||||
? explicitLocale.toLowerCase().replaceAll('_', '-')
|
||||
: normalizeLocaleCode(
|
||||
extractLocaleFromPath(uri) ?? resolvePreferredLocaleCode(),
|
||||
);
|
||||
return '/$resolvedLocale/approve?ref=${Uri.encodeQueryComponent(value)}';
|
||||
}
|
||||
@@ -1,30 +1,2 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
|
||||
class QRScanScreen extends StatefulWidget {
|
||||
const QRScanScreen({super.key});
|
||||
|
||||
@override
|
||||
State<QRScanScreen> createState() => _QRScanScreenState();
|
||||
}
|
||||
|
||||
class _QRScanScreenState extends State<QRScanScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(tr('ui.userfront.qr.title', fallback: 'Scan QR Code')),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
),
|
||||
body: const Center(
|
||||
child: Text(
|
||||
'QR Scanner is temporarily disabled for WASM build stability.',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
export 'qr_scan_screen_stub.dart'
|
||||
if (dart.library.js_interop) 'qr_scan_screen_web.dart';
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
|
||||
import 'qr_scan_route.dart';
|
||||
|
||||
class QRScanScreen extends StatefulWidget {
|
||||
const QRScanScreen({super.key});
|
||||
|
||||
@override
|
||||
State<QRScanScreen> createState() => _QRScanScreenState();
|
||||
}
|
||||
|
||||
class _QRScanScreenState extends State<QRScanScreen> {
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
final raw = _controller.text.trim();
|
||||
if (raw.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
tr(
|
||||
'msg.userfront.qr.permission_required',
|
||||
fallback: '카메라 권한이 필요합니다.',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
context.go(buildQrApprovePath(raw));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(tr('ui.userfront.qr.title', fallback: 'Scan QR Code')),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
tr(
|
||||
'msg.userfront.qr.permission_error',
|
||||
fallback: '카메라 권한 요청에 실패했습니다. 브라우저/OS 설정을 확인해주세요.',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
key: const ValueKey('qr_scan_manual_input'),
|
||||
controller: _controller,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'QR Payload',
|
||||
hintText: 'https://.../ql/{ref} 또는 ref',
|
||||
),
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
key: const ValueKey('qr_scan_submit_button'),
|
||||
onPressed: _submit,
|
||||
icon: const Icon(Icons.check_circle),
|
||||
label: Text(
|
||||
tr('ui.userfront.qr.result_success', fallback: '승인 화면으로 이동'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
238
userfront/lib/features/auth/presentation/qr_scan_screen_web.dart
Normal file
@@ -0,0 +1,238 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
|
||||
import 'qr_scan_route.dart';
|
||||
|
||||
class QRScanScreen extends StatefulWidget {
|
||||
const QRScanScreen({super.key});
|
||||
|
||||
@override
|
||||
State<QRScanScreen> createState() => _QRScanScreenState();
|
||||
}
|
||||
|
||||
class _QRScanScreenState extends State<QRScanScreen> {
|
||||
final MobileScannerController _scannerController = MobileScannerController(
|
||||
autoStart: true,
|
||||
detectionSpeed: DetectionSpeed.noDuplicates,
|
||||
facing: CameraFacing.back,
|
||||
formats: const <BarcodeFormat>[BarcodeFormat.qrCode],
|
||||
);
|
||||
final TextEditingController _manualController = TextEditingController();
|
||||
|
||||
bool _isProcessing = false;
|
||||
String? _error;
|
||||
String? _status;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_status = tr(
|
||||
'msg.userfront.login.qr.scan_hint',
|
||||
fallback: 'QR 코드를 카메라 중앙에 맞춰주세요.',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_manualController.dispose();
|
||||
_scannerController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _navigateToApprove(String rawPayload) async {
|
||||
final payload = rawPayload.trim();
|
||||
if (payload.isEmpty || _isProcessing || !mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isProcessing = true;
|
||||
_error = null;
|
||||
_status = tr(
|
||||
'ui.userfront.qr.result_success',
|
||||
fallback: '승인 화면으로 이동 중...',
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
await _scannerController.stop();
|
||||
} catch (_) {}
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
context.go(buildQrApprovePath(payload));
|
||||
}
|
||||
|
||||
void _onDetect(BarcodeCapture capture) {
|
||||
for (final barcode in capture.barcodes) {
|
||||
final raw = barcode.rawValue?.trim();
|
||||
if (raw != null && raw.isNotEmpty) {
|
||||
unawaited(_navigateToApprove(raw));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _toScannerErrorMessage(MobileScannerException error) {
|
||||
switch (error.errorCode) {
|
||||
case MobileScannerErrorCode.permissionDenied:
|
||||
return tr(
|
||||
'msg.userfront.qr.permission_error',
|
||||
fallback: '카메라 권한 요청에 실패했습니다. 브라우저/OS 설정을 확인해주세요.',
|
||||
);
|
||||
case MobileScannerErrorCode.unsupported:
|
||||
return tr(
|
||||
'msg.userfront.qr.camera_error',
|
||||
fallback: '카메라 오류: {{error}}',
|
||||
params: {'error': 'QR scanner is not supported in this browser.'},
|
||||
);
|
||||
default:
|
||||
final detail = error.errorDetails?.message;
|
||||
return tr(
|
||||
'msg.userfront.qr.camera_error',
|
||||
fallback: '카메라 오류: {{error}}',
|
||||
params: {'error': detail ?? error.errorCode.message},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _submitManual() {
|
||||
unawaited(_navigateToApprove(_manualController.text));
|
||||
}
|
||||
|
||||
Future<void> _retry() async {
|
||||
setState(() {
|
||||
_isProcessing = false;
|
||||
_error = null;
|
||||
_status = tr(
|
||||
'msg.userfront.login.qr.scan_hint',
|
||||
fallback: 'QR 코드를 카메라 중앙에 맞춰주세요.',
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
await _scannerController.start();
|
||||
} catch (e) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_error = tr(
|
||||
'msg.userfront.qr.camera_error',
|
||||
fallback: '카메라 오류: {{error}}',
|
||||
params: {'error': '$e'},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(tr('ui.userfront.qr.title', fallback: 'Scan QR Code')),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 3 / 4,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
MobileScanner(
|
||||
controller: _scannerController,
|
||||
onDetect: _onDetect,
|
||||
errorBuilder: (context, error) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_error = _toScannerErrorMessage(error);
|
||||
});
|
||||
});
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
_toScannerErrorMessage(error),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (_isProcessing)
|
||||
Container(
|
||||
color: Colors.black45,
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_status != null) Text(_status!, textAlign: TextAlign.center),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_error!,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: _isProcessing ? null : _retry,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: Text(tr('ui.userfront.qr.rescan', fallback: '다시 스캔')),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
key: const ValueKey('qr_scan_manual_input'),
|
||||
controller: _manualController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'QR Payload',
|
||||
hintText: 'https://.../ql/{ref} 또는 ref',
|
||||
),
|
||||
onSubmitted: (_) => _submitManual(),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.icon(
|
||||
key: const ValueKey('qr_scan_submit_button'),
|
||||
onPressed: _isProcessing ? null : _submitManual,
|
||||
icon: const Icon(Icons.check_circle),
|
||||
label: Text(
|
||||
tr('ui.userfront.qr.result_success', fallback: '승인 화면으로 이동'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -1145,16 +1178,16 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor.withValues(alpha: 31),
|
||||
color: statusColor,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
item.status == 'active'
|
||||
? tr('ui.common.status.active')
|
||||
? tr('ui.userfront.dashboard.activity.linked')
|
||||
: tr('ui.userfront.dashboard.status.revoked'),
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: statusColor,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
@@ -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),
|
||||
|
||||
@@ -14,12 +14,15 @@ import 'features/auth/presentation/qr_scan_screen.dart';
|
||||
import 'features/auth/presentation/forgot_password_screen.dart';
|
||||
import 'features/auth/presentation/reset_password_screen.dart';
|
||||
import 'features/auth/presentation/error_screen.dart';
|
||||
import 'features/auth/domain/login_link_route_policy.dart';
|
||||
import 'features/dashboard/presentation/dashboard_screen.dart';
|
||||
import 'features/admin/presentation/user_management_screen.dart';
|
||||
import 'features/profile/presentation/pages/profile_page.dart';
|
||||
import 'core/services/auth_proxy_service.dart';
|
||||
import 'core/services/auth_token_store.dart';
|
||||
import 'core/services/logger_service.dart';
|
||||
import 'core/services/null_check_recovery.dart';
|
||||
import 'core/services/web_window.dart';
|
||||
import 'core/notifiers/auth_notifier.dart';
|
||||
import 'core/i18n/locale_gate.dart';
|
||||
import 'core/i18n/locale_registry.dart';
|
||||
@@ -31,6 +34,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 +83,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 +138,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 +156,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();
|
||||
},
|
||||
@@ -274,34 +329,23 @@ final _router = GoRouter(
|
||||
(token != null && token.isNotEmpty) || AuthTokenStore.usesCookie();
|
||||
final path = stripLocalePath(uri);
|
||||
|
||||
// Precise public path detection
|
||||
final isPublicPath =
|
||||
path == '/signin' ||
|
||||
path == '/signup' ||
|
||||
path == '/login' ||
|
||||
path == '/registration' ||
|
||||
path == '/verify' ||
|
||||
path == '/verification' ||
|
||||
path.startsWith('/verify/') ||
|
||||
path == '/approve' ||
|
||||
path.startsWith('/ql/') ||
|
||||
path == '/forgot-password' ||
|
||||
path == '/recovery' ||
|
||||
path == '/reset-password' ||
|
||||
path == '/error' ||
|
||||
path == '/settings' ||
|
||||
path == '/consent' ||
|
||||
path.startsWith('/consent/') ||
|
||||
uri.path.contains('/consent');
|
||||
final isPublicPath = isPublicAuthPath(path, uri);
|
||||
|
||||
if (isPublicPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isLoggedIn) {
|
||||
if (path == '/') {
|
||||
return '/$requestedLocale/signin';
|
||||
}
|
||||
return buildSigninRedirectPath(requestedLocale, uri);
|
||||
}
|
||||
|
||||
if (path == '/') {
|
||||
return '/$requestedLocale/dashboard';
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
);
|
||||
@@ -311,11 +355,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
|
||||
|
||||
@@ -45,10 +45,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
version: "1.4.0"
|
||||
cli_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -268,6 +268,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.5"
|
||||
js:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: js
|
||||
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.2"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -320,18 +328,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.18"
|
||||
version: "0.12.17"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.0"
|
||||
version: "0.11.1"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -348,6 +356,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
mobile_scanner:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: mobile_scanner
|
||||
sha256: c92c26bf2231695b6d3477c8dcf435f51e28f87b1745966b1fe4c47a286171ce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.2.0"
|
||||
node_preamble:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -637,26 +653,26 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test
|
||||
sha256: "54c516bbb7cee2754d327ad4fca637f78abfc3cbcc5ace83b3eda117e42cd71a"
|
||||
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.29.0"
|
||||
version: "1.26.3"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.9"
|
||||
version: "0.7.7"
|
||||
test_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_core
|
||||
sha256: "394f07d21f0f2255ec9e3989f21e54d3c7dc0e6e9dbce160e5a9c1a6be0e2943"
|
||||
sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.15"
|
||||
version: "0.6.12"
|
||||
toml:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@@ -44,6 +44,7 @@ dependencies:
|
||||
logging: ^1.2.0
|
||||
logger: ^2.0.0
|
||||
qr_flutter: ^4.1.0
|
||||
mobile_scanner: ^7.1.4
|
||||
easy_localization: ^3.0.7
|
||||
toml: ^0.15.0
|
||||
web: ^1.1.0
|
||||
|
||||
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
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
33
userfront/test/login_link_route_policy_test.dart
Normal file
@@ -0,0 +1,33 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:userfront/core/i18n/locale_registry.dart';
|
||||
import 'package:userfront/features/auth/domain/login_link_route_policy.dart';
|
||||
|
||||
void main() {
|
||||
group('login_link_route_policy', () {
|
||||
setUp(() {
|
||||
LocaleRegistry.setSupportedLocaleCodesForTest(['ko', 'en']);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
LocaleRegistry.resetForTest();
|
||||
});
|
||||
|
||||
test('extracts short code from plain short-code route', () {
|
||||
final shortCode = extractLoginShortCode(Uri.parse('/l/AB123456'));
|
||||
expect(shortCode, 'AB123456');
|
||||
});
|
||||
|
||||
test('extracts short code from localized short-code route', () {
|
||||
final shortCode = extractLoginShortCode(Uri.parse('/ko/l/AB123456'));
|
||||
expect(shortCode, 'AB123456');
|
||||
});
|
||||
|
||||
test('treats localized short-code route as public path', () {
|
||||
final isPublic = isPublicAuthPath(
|
||||
'/l/AB123456',
|
||||
Uri.parse('/ko/l/AB123456'),
|
||||
);
|
||||
expect(isPublic, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
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
@@ -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);
|
||||
});
|
||||
}
|
||||
67
userfront/test/qr_camera_bootstrap_policy_test.dart
Normal file
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:userfront/features/auth/presentation/qr_camera_bootstrap_policy.dart';
|
||||
|
||||
void main() {
|
||||
group('bootstrapQrCamera', () {
|
||||
test('권한 허용 후 카메라 실행 성공 시 ready 상태를 반환한다', () async {
|
||||
var stopCalled = false;
|
||||
|
||||
final result = await bootstrapQrCamera(
|
||||
hasBarcodeDetector: true,
|
||||
openCameraAndPlay: () async {},
|
||||
stopCamera: () async {
|
||||
stopCalled = true;
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status, QrCameraBootstrapStatus.ready);
|
||||
expect(stopCalled, isFalse);
|
||||
});
|
||||
|
||||
test('권한 허용 후 play 단계 오류는 cameraError로 분류한다', () async {
|
||||
var stopCalled = false;
|
||||
|
||||
final result = await bootstrapQrCamera(
|
||||
hasBarcodeDetector: true,
|
||||
openCameraAndPlay: () async {
|
||||
throw Exception('NotReadableError: Could not start video source');
|
||||
},
|
||||
stopCamera: () async {
|
||||
stopCalled = true;
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status, QrCameraBootstrapStatus.cameraError);
|
||||
expect(result.errorDetail, contains('NotReadableError'));
|
||||
expect(stopCalled, isFalse);
|
||||
});
|
||||
|
||||
test('권한 거부 오류는 permissionError로 분류한다', () async {
|
||||
final result = await bootstrapQrCamera(
|
||||
hasBarcodeDetector: true,
|
||||
openCameraAndPlay: () async {
|
||||
throw Exception('NotAllowedError: Permission denied');
|
||||
},
|
||||
stopCamera: () async {},
|
||||
);
|
||||
|
||||
expect(result.status, QrCameraBootstrapStatus.permissionError);
|
||||
expect(result.errorDetail, contains('NotAllowedError'));
|
||||
});
|
||||
|
||||
test('detector 미지원이면 카메라를 정리하고 detectorUnsupported를 반환한다', () async {
|
||||
var stopCalled = false;
|
||||
|
||||
final result = await bootstrapQrCamera(
|
||||
hasBarcodeDetector: false,
|
||||
openCameraAndPlay: () async {},
|
||||
stopCamera: () async {
|
||||
stopCalled = true;
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status, QrCameraBootstrapStatus.detectorUnsupported);
|
||||
expect(stopCalled, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
27
userfront/test/qr_scan_route_test.dart
Normal file
@@ -0,0 +1,27 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:userfront/features/auth/presentation/qr_scan_route.dart';
|
||||
|
||||
void main() {
|
||||
group('buildQrApprovePath', () {
|
||||
test('스캔 값을 trim/encode 해서 approve 경로를 만든다', () {
|
||||
final result = buildQrApprovePath(
|
||||
' https://sss.hmac.kr/ql/abc-123?x=1&y=2 ',
|
||||
localeCode: 'ko',
|
||||
);
|
||||
|
||||
expect(
|
||||
result,
|
||||
'/ko/approve?ref=https%3A%2F%2Fsss.hmac.kr%2Fql%2Fabc-123%3Fx%3D1%26y%3D2',
|
||||
);
|
||||
});
|
||||
|
||||
test('현재 URI에서 locale을 추출한다', () {
|
||||
final result = buildQrApprovePath(
|
||||
'abc123',
|
||||
currentUri: Uri.parse('https://sss.hmac.kr/en/dashboard'),
|
||||
);
|
||||
|
||||
expect(result, '/en/approve?ref=abc123');
|
||||
});
|
||||
});
|
||||
}
|
||||
16
userfront/test/qr_scan_screen_test.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:userfront/features/auth/presentation/qr_scan_screen.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('QR 스캔 화면은 비활성 문구 대신 입력/이동 UI를 노출한다', (tester) async {
|
||||
await tester.pumpWidget(const MaterialApp(home: QRScanScreen()));
|
||||
|
||||
expect(
|
||||
find.text('QR Scanner is temporarily disabled for WASM build stability.'),
|
||||
findsNothing,
|
||||
);
|
||||
expect(find.byKey(const ValueKey('qr_scan_manual_input')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('qr_scan_submit_button')), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
BIN
userfront/web/favicon.ico
Normal file
|
After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 917 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 89 KiB |
@@ -27,7 +27,7 @@
|
||||
<link rel="apple-touch-icon" href="icons/Icon-192.png" />
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="favicon.png" />
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||
|
||||
<title>Baron 로그인</title>
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
|
||||