forked from baron/baron-sso
userfront gateway 분리.
This commit is contained in:
@@ -469,6 +469,9 @@ class AuthProxyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> sendLog(String level, String message, {Map<String, dynamic>? data}) async {
|
static Future<void> sendLog(String level, String message, {Map<String, dynamic>? data}) async {
|
||||||
|
if (!_canSendClientLog()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
final url = Uri.parse('$_baseUrl/api/v1/client-log');
|
final url = Uri.parse('$_baseUrl/api/v1/client-log');
|
||||||
try {
|
try {
|
||||||
await http.post(
|
await http.post(
|
||||||
@@ -480,7 +483,9 @@ class AuthProxyService {
|
|||||||
if (data != null) 'data': data,
|
if (data != null) 'data': data,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
_recordClientLogSuccess();
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
|
_recordClientLogFailure();
|
||||||
// Ignore logging errors to prevent loops
|
// Ignore logging errors to prevent loops
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -493,6 +498,34 @@ class AuthProxyService {
|
|||||||
await sendLog('ERROR', message, data: data);
|
await sendLog('ERROR', message, data: data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static int _clientLogFailureCount = 0;
|
||||||
|
static DateTime? _clientLogLastFailureAt;
|
||||||
|
static DateTime? _clientLogOpenUntil;
|
||||||
|
|
||||||
|
static bool _canSendClientLog() {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final openUntil = _clientLogOpenUntil;
|
||||||
|
if (openUntil != null && now.isBefore(openUntil)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _recordClientLogFailure() {
|
||||||
|
_clientLogFailureCount += 1;
|
||||||
|
_clientLogLastFailureAt = DateTime.now();
|
||||||
|
if (_clientLogFailureCount >= 3) {
|
||||||
|
_clientLogOpenUntil = DateTime.now().add(const Duration(minutes: 1));
|
||||||
|
_clientLogFailureCount = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _recordClientLogSuccess() {
|
||||||
|
_clientLogFailureCount = 0;
|
||||||
|
_clientLogLastFailureAt = null;
|
||||||
|
_clientLogOpenUntil = null;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Signup Methods ---
|
// --- Signup Methods ---
|
||||||
|
|
||||||
static Future<bool> checkEmailAvailability(String email) async {
|
static Future<bool> checkEmailAvailability(String email) async {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class LoggerService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 2. Configure Standard Logger (logging package)
|
// 2. Configure Standard Logger (logging package)
|
||||||
std_log.Logger.root.level = kReleaseMode ? std_log.Level.INFO : std_log.Level.ALL;
|
std_log.Logger.root.level = kReleaseMode ? std_log.Level.WARNING : std_log.Level.ALL;
|
||||||
|
|
||||||
std_log.Logger.root.onRecord.listen((record) {
|
std_log.Logger.root.onRecord.listen((record) {
|
||||||
if (kReleaseMode) {
|
if (kReleaseMode) {
|
||||||
@@ -71,7 +71,7 @@ class LoggerService {
|
|||||||
debugPrint(jsonEncode(logData));
|
debugPrint(jsonEncode(logData));
|
||||||
|
|
||||||
// 2. Relay to Backend (Docker Terminal)
|
// 2. Relay to Backend (Docker Terminal)
|
||||||
if (record.level >= std_log.Level.INFO) {
|
if (record.level >= std_log.Level.WARNING) {
|
||||||
AuthProxyService.sendLog(
|
AuthProxyService.sendLog(
|
||||||
record.level.name,
|
record.level.name,
|
||||||
record.message,
|
record.message,
|
||||||
|
|||||||
@@ -17,11 +17,12 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
|
|||||||
String? _message;
|
String? _message;
|
||||||
bool _success = false;
|
bool _success = false;
|
||||||
bool _isCheckingSession = false;
|
bool _isCheckingSession = false;
|
||||||
|
bool _redirectingToLogin = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_bootstrapCookieSession();
|
_bootstrapCookieSession().then((_) => _redirectIfNotLoggedIn());
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> _bootstrapCookieSession() async {
|
Future<bool> _bootstrapCookieSession() async {
|
||||||
@@ -45,6 +46,21 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _redirectIfNotLoggedIn() {
|
||||||
|
if (_redirectingToLogin || !mounted) return;
|
||||||
|
final hasStoredToken = AuthTokenStore.getToken() != null;
|
||||||
|
final hasDescopeSession = Descope.sessionManager.session?.refreshToken.isExpired == false;
|
||||||
|
final usesCookie = AuthTokenStore.usesCookie();
|
||||||
|
final isLoggedIn = hasStoredToken || hasDescopeSession || usesCookie;
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
_redirectingToLogin = true;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
context.go('/signin?notice=qr_login_required');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _handleApprove() async {
|
Future<void> _handleApprove() async {
|
||||||
if (widget.pendingRef == null) return;
|
if (widget.pendingRef == null) return;
|
||||||
|
|
||||||
@@ -56,8 +72,9 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
|
|||||||
hasCookie = await _bootstrapCookieSession();
|
hasCookie = await _bootstrapCookieSession();
|
||||||
}
|
}
|
||||||
if (storedToken == null && (session == null || session.refreshToken.isExpired) && !hasCookie) {
|
if (storedToken == null && (session == null || session.refreshToken.isExpired) && !hasCookie) {
|
||||||
setState(() => _message = "Please log in on your phone first.");
|
if (mounted) {
|
||||||
context.go('/signin'); // Redirect to login
|
context.go('/signin?notice=qr_login_required');
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +113,10 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
|
|||||||
final usesCookie = AuthTokenStore.usesCookie();
|
final usesCookie = AuthTokenStore.usesCookie();
|
||||||
final isLoggedIn = hasStoredToken || hasDescopeSession || usesCookie || _isCheckingSession;
|
final isLoggedIn = hasStoredToken || hasDescopeSession || usesCookie || _isCheckingSession;
|
||||||
|
|
||||||
|
if (!isLoggedIn && !_redirectingToLogin) {
|
||||||
|
_redirectIfNotLoggedIn();
|
||||||
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text("QR Login Approval")),
|
appBar: AppBar(title: const Text("QR Login Approval")),
|
||||||
body: Center(
|
body: Center(
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
bool _verificationOnly = false;
|
bool _verificationOnly = false;
|
||||||
bool _verificationApproved = false;
|
bool _verificationApproved = false;
|
||||||
String _verificationMessage = '';
|
String _verificationMessage = '';
|
||||||
|
String _verificationTitle = '승인 완료';
|
||||||
|
String _verificationPageTitle = '로그인 승인';
|
||||||
|
String _verificationActionLabel = '확인';
|
||||||
|
String _verificationActionPath = '/';
|
||||||
|
Timer? _verificationRedirectTimer;
|
||||||
|
bool _noticeHandled = false;
|
||||||
bool _drySendEnabled = false;
|
bool _drySendEnabled = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -69,6 +75,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
final hasVerificationToken = widget.verificationToken != null || hasTokenParam;
|
final hasVerificationToken = widget.verificationToken != null || hasTokenParam;
|
||||||
final hasLoginCode = loginIdParam != null && codeParam != null;
|
final hasLoginCode = loginIdParam != null && codeParam != null;
|
||||||
_verificationOnly = hasVerificationToken || hasLoginCode || hasShortCodePath;
|
_verificationOnly = hasVerificationToken || hasLoginCode || hasShortCodePath;
|
||||||
|
final notice = uri.queryParameters['notice'];
|
||||||
|
|
||||||
if (hasShortCodePath) {
|
if (hasShortCodePath) {
|
||||||
final shortCode = uri.pathSegments[1];
|
final shortCode = uri.pathSegments[1];
|
||||||
@@ -80,6 +87,11 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
_verifyToken(widget.verificationToken ?? uri.queryParameters['t']!);
|
_verifyToken(widget.verificationToken ?? uri.queryParameters['t']!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!_noticeHandled && notice == 'qr_login_required') {
|
||||||
|
_noticeHandled = true;
|
||||||
|
_showInfo('로그인 한 상태여야 QR 스캔으로 로그인 할 수 있습니다');
|
||||||
|
}
|
||||||
|
|
||||||
if (!_verificationOnly) {
|
if (!_verificationOnly) {
|
||||||
_tryCookieSession();
|
_tryCookieSession();
|
||||||
}
|
}
|
||||||
@@ -360,12 +372,31 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
return AuthTokenStore.usesCookie();
|
return AuthTokenStore.usesCookie();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _markVerificationApproved(String message) {
|
void _markVerificationApproved(
|
||||||
|
String message, {
|
||||||
|
String title = '승인 완료',
|
||||||
|
String pageTitle = '로그인 승인',
|
||||||
|
String actionLabel = '확인',
|
||||||
|
String actionPath = '/',
|
||||||
|
bool autoRedirect = false,
|
||||||
|
Duration redirectDelay = const Duration(seconds: 2),
|
||||||
|
}) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_verificationApproved = true;
|
_verificationApproved = true;
|
||||||
_verificationMessage = message;
|
_verificationMessage = message;
|
||||||
|
_verificationTitle = title;
|
||||||
|
_verificationPageTitle = pageTitle;
|
||||||
|
_verificationActionLabel = actionLabel;
|
||||||
|
_verificationActionPath = actionPath;
|
||||||
});
|
});
|
||||||
|
_verificationRedirectTimer?.cancel();
|
||||||
|
if (autoRedirect) {
|
||||||
|
_verificationRedirectTimer = Timer(redirectDelay, () {
|
||||||
|
if (!mounted) return;
|
||||||
|
context.go(actionPath);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildVerificationResultView() {
|
Widget _buildVerificationResultView() {
|
||||||
@@ -377,9 +408,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
children: [
|
children: [
|
||||||
const Icon(Icons.check_circle_outline, color: Colors.green, size: 72),
|
const Icon(Icons.check_circle_outline, color: Colors.green, size: 72),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text(
|
Text(
|
||||||
'승인 완료',
|
_verificationTitle,
|
||||||
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.green),
|
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.green),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
@@ -389,8 +420,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => context.go('/'),
|
onPressed: () => context.go(_verificationActionPath),
|
||||||
child: const Text('확인'),
|
child: Text(_verificationActionLabel),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -409,11 +440,20 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
final hasLocalSession = _hasLocalSession();
|
final hasLocalSession = _hasLocalSession();
|
||||||
|
|
||||||
if (jwt is String && jwt.isNotEmpty) {
|
if (jwt is String && jwt.isNotEmpty) {
|
||||||
if (hasLocalSession) {
|
if (hasLocalSession) {
|
||||||
_markVerificationApproved("승인되었습니다. 이미 로그인된 브라우저입니다.");
|
_markVerificationApproved(
|
||||||
|
"승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_completeLoginFromToken(jwt, provider: provider);
|
_markVerificationApproved(
|
||||||
|
"링크로 로그인 되었습니다. 잠시 후 로그인 화면으로 이동합니다.",
|
||||||
|
title: '링크 로그인 완료',
|
||||||
|
pageTitle: '링크 로그인',
|
||||||
|
actionLabel: '로그인 화면으로 이동',
|
||||||
|
actionPath: '/signin',
|
||||||
|
autoRedirect: true,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,7 +491,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
|
|
||||||
if (jwt is String && jwt.isNotEmpty) {
|
if (jwt is String && jwt.isNotEmpty) {
|
||||||
if (hasLocalSession) {
|
if (hasLocalSession) {
|
||||||
_markVerificationApproved("승인되었습니다. 이미 로그인된 브라우저입니다.");
|
_markVerificationApproved(
|
||||||
|
"승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_completeLoginFromToken(jwt, provider: res['provider'] as String?);
|
_completeLoginFromToken(jwt, provider: res['provider'] as String?);
|
||||||
@@ -489,7 +531,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
|
|
||||||
if (jwt is String && jwt.isNotEmpty) {
|
if (jwt is String && jwt.isNotEmpty) {
|
||||||
if (hasLocalSession) {
|
if (hasLocalSession) {
|
||||||
_markVerificationApproved("승인되었습니다. 이미 로그인된 브라우저입니다.");
|
_markVerificationApproved(
|
||||||
|
"승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_completeLoginFromToken(jwt, provider: res['provider'] as String?);
|
_completeLoginFromToken(jwt, provider: res['provider'] as String?);
|
||||||
@@ -510,6 +554,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_stopQrPolling();
|
_stopQrPolling();
|
||||||
|
_verificationRedirectTimer?.cancel();
|
||||||
_tabController.dispose();
|
_tabController.dispose();
|
||||||
_linkIdController.dispose();
|
_linkIdController.dispose();
|
||||||
_passwordLoginIdController.dispose();
|
_passwordLoginIdController.dispose();
|
||||||
@@ -851,7 +896,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
if (_verificationOnly && _verificationApproved) {
|
if (_verificationOnly && _verificationApproved) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('로그인 승인'),
|
title: Text(_verificationPageTitle),
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
onPressed: () => context.go('/'),
|
onPressed: () => context.go('/'),
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class _QRScanScreenState extends State<QRScanScreen> {
|
|||||||
bool _isScanned = false;
|
bool _isScanned = false;
|
||||||
bool _isCheckingSession = false;
|
bool _isCheckingSession = false;
|
||||||
bool _isProcessing = false;
|
bool _isProcessing = false;
|
||||||
|
bool _isRequestingCamera = false;
|
||||||
bool? _isSuccess;
|
bool? _isSuccess;
|
||||||
String? _resultMessage;
|
String? _resultMessage;
|
||||||
|
|
||||||
@@ -100,10 +101,7 @@ class _QRScanScreenState extends State<QRScanScreen> {
|
|||||||
}
|
}
|
||||||
if (sessionToken == null && !usesCookie) {
|
if (sessionToken == null && !usesCookie) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
context.go('/signin?notice=qr_login_required');
|
||||||
const SnackBar(content: Text('로그인이 필요합니다.'), backgroundColor: Colors.red),
|
|
||||||
);
|
|
||||||
context.pop();
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -148,6 +146,28 @@ class _QRScanScreenState extends State<QRScanScreen> {
|
|||||||
controller.start();
|
controller.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _requestCameraPermission() async {
|
||||||
|
if (_isRequestingCamera) return;
|
||||||
|
setState(() => _isRequestingCamera = true);
|
||||||
|
try {
|
||||||
|
await controller.start();
|
||||||
|
} catch (e) {
|
||||||
|
_log.warning('Camera permission request failed: $e');
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('카메라 권한 요청에 실패했습니다. 브라우저/OS 설정을 확인해주세요.'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isRequestingCamera = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildResultView() {
|
Widget _buildResultView() {
|
||||||
final success = _isSuccess == true;
|
final success = _isSuccess == true;
|
||||||
final icon = success ? Icons.check_circle_outline : Icons.error_outline;
|
final icon = success ? Icons.check_circle_outline : Icons.error_outline;
|
||||||
@@ -207,13 +227,30 @@ class _QRScanScreenState extends State<QRScanScreen> {
|
|||||||
controller: controller,
|
controller: controller,
|
||||||
onDetect: _onDetect,
|
onDetect: _onDetect,
|
||||||
errorBuilder: (context, error) {
|
errorBuilder: (context, error) {
|
||||||
|
final isPermissionDenied = error.errorCode ==
|
||||||
|
MobileScannerErrorCode.permissionDenied;
|
||||||
return Center(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.error, color: Colors.red, size: 50),
|
const Icon(Icons.error, color: Colors.red, size: 50),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
Text('Camera Error: ${error.errorCode}'),
|
Text(
|
||||||
|
isPermissionDenied
|
||||||
|
? '카메라 권한이 필요합니다.'
|
||||||
|
: '카메라 오류: ${error.errorCode}',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _isRequestingCamera
|
||||||
|
? null
|
||||||
|
: _requestCameraPermission,
|
||||||
|
child: Text(
|
||||||
|
_isRequestingCamera
|
||||||
|
? '요청 중...'
|
||||||
|
: '카메라 권한 요청하기',
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
final FocusNode _departmentFocus = FocusNode();
|
final FocusNode _departmentFocus = FocusNode();
|
||||||
final FocusNode _phoneFocus = FocusNode();
|
final FocusNode _phoneFocus = FocusNode();
|
||||||
final FocusNode _phoneCodeFocus = FocusNode();
|
final FocusNode _phoneCodeFocus = FocusNode();
|
||||||
|
bool _nameTouched = false;
|
||||||
|
bool _departmentTouched = false;
|
||||||
|
bool _phoneTouched = false;
|
||||||
|
bool _phoneCodeTouched = false;
|
||||||
|
bool _isSavingField = false;
|
||||||
|
|
||||||
String _initialPhone = '';
|
String _initialPhone = '';
|
||||||
bool _isPhoneChanged = false;
|
bool _isPhoneChanged = false;
|
||||||
@@ -102,6 +107,8 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
_isCodeSent = false;
|
_isCodeSent = false;
|
||||||
_isVerifying = false;
|
_isVerifying = false;
|
||||||
_codeController?.clear();
|
_codeController?.clear();
|
||||||
|
_phoneTouched = false;
|
||||||
|
_phoneCodeTouched = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _startEditing(String field, UserProfile profile) {
|
void _startEditing(String field, UserProfile profile) {
|
||||||
@@ -117,6 +124,16 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
_resetPhoneState();
|
_resetPhoneState();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
if (field == 'name') {
|
||||||
|
FocusScope.of(context).requestFocus(_nameFocus);
|
||||||
|
} else if (field == 'department') {
|
||||||
|
FocusScope.of(context).requestFocus(_departmentFocus);
|
||||||
|
} else if (field == 'phone') {
|
||||||
|
FocusScope.of(context).requestFocus(_phoneFocus);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _cancelEditing(UserProfile profile) {
|
void _cancelEditing(UserProfile profile) {
|
||||||
@@ -131,6 +148,8 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
_resetPhoneState();
|
_resetPhoneState();
|
||||||
}
|
}
|
||||||
_editingField = null;
|
_editingField = null;
|
||||||
|
_nameTouched = false;
|
||||||
|
_departmentTouched = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,18 +212,55 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
void _autoSaveIfEditing(UserProfile profile, String field) {
|
void _autoSaveIfEditing(UserProfile profile, String field) {
|
||||||
if (_editingField != field) return;
|
if (_editingField != field) return;
|
||||||
if (_isVerifying) return;
|
if (_isVerifying) return;
|
||||||
|
if (_isSavingField) return;
|
||||||
|
if (!_hasFieldChanged(profile, field)) {
|
||||||
|
setState(() {
|
||||||
|
if (field == 'phone') {
|
||||||
|
_resetPhoneState();
|
||||||
|
}
|
||||||
|
_editingField = null;
|
||||||
|
if (field == 'name') {
|
||||||
|
_nameTouched = false;
|
||||||
|
} else if (field == 'department') {
|
||||||
|
_departmentTouched = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
_saveField(profile);
|
_saveField(profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handlePhoneFocusChange(UserProfile profile) {
|
void _handlePhoneFocusChange(UserProfile profile) {
|
||||||
if (_editingField != 'phone') return;
|
if (_editingField != 'phone') return;
|
||||||
if (_isVerifying) return;
|
if (_isVerifying) return;
|
||||||
|
if (_isSavingField) return;
|
||||||
if (_phoneFocus.hasFocus || _phoneCodeFocus.hasFocus) return;
|
if (_phoneFocus.hasFocus || _phoneCodeFocus.hasFocus) return;
|
||||||
|
if (!_hasFieldChanged(profile, 'phone')) {
|
||||||
|
setState(() {
|
||||||
|
_resetPhoneState();
|
||||||
|
_editingField = null;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
_saveField(profile);
|
_saveField(profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _hasFieldChanged(UserProfile profile, String field) {
|
||||||
|
if (field == 'name') {
|
||||||
|
return (_nameController?.text.trim() ?? '') != profile.name;
|
||||||
|
}
|
||||||
|
if (field == 'department') {
|
||||||
|
return (_departmentController?.text.trim() ?? '') != profile.department;
|
||||||
|
}
|
||||||
|
if (field == 'phone') {
|
||||||
|
return (_phoneController?.text.trim() ?? '') != profile.phone;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _saveField(UserProfile profile) async {
|
Future<void> _saveField(UserProfile profile) async {
|
||||||
if (_editingField == null) return;
|
if (_editingField == null) return;
|
||||||
|
if (_isSavingField) return;
|
||||||
|
|
||||||
final nextName = _editingField == 'name'
|
final nextName = _editingField == 'name'
|
||||||
? _nameController!.text.trim()
|
? _nameController!.text.trim()
|
||||||
@@ -243,6 +299,20 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!_hasFieldChanged(profile, _editingField!)) {
|
||||||
|
setState(() {
|
||||||
|
if (_editingField == 'phone') {
|
||||||
|
_resetPhoneState();
|
||||||
|
}
|
||||||
|
_editingField = null;
|
||||||
|
_nameTouched = false;
|
||||||
|
_departmentTouched = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isSavingField = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ref.read(profileProvider.notifier).updateProfile(
|
await ref.read(profileProvider.notifier).updateProfile(
|
||||||
name: nextName,
|
name: nextName,
|
||||||
@@ -256,6 +326,8 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
_resetPhoneState();
|
_resetPhoneState();
|
||||||
}
|
}
|
||||||
_editingField = null;
|
_editingField = null;
|
||||||
|
_nameTouched = false;
|
||||||
|
_departmentTouched = false;
|
||||||
});
|
});
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('정보가 수정되었습니다.')),
|
const SnackBar(content: Text('정보가 수정되었습니다.')),
|
||||||
@@ -267,6 +339,8 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
SnackBar(content: Text('수정 실패: $e')),
|
SnackBar(content: Text('수정 실패: $e')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
_isSavingField = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,23 +528,53 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
children: [
|
children: [
|
||||||
Text(label, style: const TextStyle(fontWeight: FontWeight.w600)),
|
Text(label, style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Focus(
|
Row(
|
||||||
focusNode: field == 'name' ? _nameFocus : _departmentFocus,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
onFocusChange: (hasFocus) {
|
children: [
|
||||||
if (!hasFocus) {
|
Expanded(
|
||||||
_autoSaveIfEditing(profile, field);
|
child: Focus(
|
||||||
}
|
focusNode: field == 'name' ? _nameFocus : _departmentFocus,
|
||||||
},
|
onFocusChange: (hasFocus) {
|
||||||
child: TextField(
|
if (field == 'name') {
|
||||||
controller: controller,
|
if (hasFocus) {
|
||||||
focusNode: field == 'name' ? _nameFocus : _departmentFocus,
|
_nameTouched = true;
|
||||||
textInputAction: TextInputAction.done,
|
return;
|
||||||
onSubmitted: (_) => _autoSaveIfEditing(profile, field),
|
}
|
||||||
decoration: InputDecoration(
|
if (!_nameTouched) {
|
||||||
border: const OutlineInputBorder(),
|
return;
|
||||||
hintText: label,
|
}
|
||||||
|
}
|
||||||
|
if (field == 'department') {
|
||||||
|
if (hasFocus) {
|
||||||
|
_departmentTouched = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!_departmentTouched) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!hasFocus) {
|
||||||
|
_autoSaveIfEditing(profile, field);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: TextField(
|
||||||
|
controller: controller,
|
||||||
|
focusNode: field == 'name' ? _nameFocus : _departmentFocus,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onSubmitted: (_) => _autoSaveIfEditing(profile, field),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
hintText: label,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 12),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: isUpdating ? null : () => _cancelEditing(profile),
|
||||||
|
child: const Text('취소'),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -504,6 +608,13 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
child: Focus(
|
child: Focus(
|
||||||
focusNode: _phoneFocus,
|
focusNode: _phoneFocus,
|
||||||
onFocusChange: (hasFocus) {
|
onFocusChange: (hasFocus) {
|
||||||
|
if (hasFocus) {
|
||||||
|
_phoneTouched = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!_phoneTouched) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!hasFocus) {
|
if (!hasFocus) {
|
||||||
_handlePhoneFocusChange(profile);
|
_handlePhoneFocusChange(profile);
|
||||||
}
|
}
|
||||||
@@ -531,6 +642,11 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
onPressed: _isVerifying ? null : _sendCode,
|
onPressed: _isVerifying ? null : _sendCode,
|
||||||
child: Text(_isCodeSent ? '재전송' : '인증요청'),
|
child: Text(_isCodeSent ? '재전송' : '인증요청'),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: isUpdating ? null : () => _cancelEditing(profile),
|
||||||
|
child: const Text('취소'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (_isCodeSent && !_isPhoneVerified) ...[
|
if (_isCodeSent && !_isPhoneVerified) ...[
|
||||||
@@ -542,6 +658,13 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
child: Focus(
|
child: Focus(
|
||||||
focusNode: _phoneCodeFocus,
|
focusNode: _phoneCodeFocus,
|
||||||
onFocusChange: (hasFocus) {
|
onFocusChange: (hasFocus) {
|
||||||
|
if (hasFocus) {
|
||||||
|
_phoneCodeTouched = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!_phoneCodeTouched) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!hasFocus) {
|
if (!hasFocus) {
|
||||||
_handlePhoneFocusChange(profile);
|
_handlePhoneFocusChange(profile);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ final _routerLogger = Logger('Router');
|
|||||||
|
|
||||||
final _router = GoRouter(
|
final _router = GoRouter(
|
||||||
initialLocation: '/',
|
initialLocation: '/',
|
||||||
debugLogDiagnostics: true, // Enable diagnostic logs
|
debugLogDiagnostics: !kReleaseMode,
|
||||||
refreshListenable: AuthNotifier.instance,
|
refreshListenable: AuthNotifier.instance,
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# Map ISO8601 time to "YYYY-MM-DD HH:mm:ss" format
|
# ISO8601 시간을 "YYYY-MM-DD HH:mm:ss" 형식으로 변환
|
||||||
map $time_iso8601 $time_custom {
|
map $time_iso8601 $time_custom {
|
||||||
"~^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})" "$1-$2-$3 $4:$5:$6";
|
"~^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})" "$1-$2-$3 $4:$5:$6";
|
||||||
}
|
}
|
||||||
|
|
||||||
# Custom JSON Log Format matching Go slog
|
# Go slog 포맷과 맞춘 JSON 액세스 로그
|
||||||
log_format json_combined escape=json
|
log_format json_combined escape=json
|
||||||
'{'
|
'{'
|
||||||
'"time":"$time_custom",'
|
'"time":"$time_custom",'
|
||||||
@@ -23,66 +23,10 @@ server {
|
|||||||
listen 5000;
|
listen 5000;
|
||||||
include /etc/nginx/mime.types;
|
include /etc/nginx/mime.types;
|
||||||
|
|
||||||
|
error_log /dev/stderr warn;
|
||||||
access_log /var/log/nginx/access.log json_combined;
|
access_log /var/log/nginx/access.log json_combined;
|
||||||
|
|
||||||
# --- Backend API Proxy ---
|
# --- UserFront 정적 파일 ---
|
||||||
location /api {
|
|
||||||
proxy_pass http://baron_backend:3000;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Ory Stack Proxy (via Oathkeeper) ---
|
|
||||||
# Kratos Public API
|
|
||||||
location /auth {
|
|
||||||
proxy_pass http://oathkeeper:4455;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Hydra Public API
|
|
||||||
location /oidc {
|
|
||||||
proxy_pass http://oathkeeper:4455;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Internal Web Apps Proxy --- 초반에는 외부 오픈 없이 Private Net 내부에서만 운영
|
|
||||||
# AdminFront (Vite Dev Server or Nginx)
|
|
||||||
# location /admin {
|
|
||||||
# proxy_pass http://baron_adminfront:5173;
|
|
||||||
# proxy_set_header Host $host;
|
|
||||||
# proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
# proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
|
|
||||||
# # WebSocket support (for Vite HMR)
|
|
||||||
# proxy_http_version 1.1;
|
|
||||||
# proxy_set_header Upgrade $http_upgrade;
|
|
||||||
# proxy_set_header Connection "upgrade";
|
|
||||||
# }
|
|
||||||
|
|
||||||
# # DevFront (Vite Dev Server or Nginx)
|
|
||||||
# location /dev {
|
|
||||||
# proxy_pass http://baron_devfront:5173;
|
|
||||||
# proxy_set_header Host $host;
|
|
||||||
# proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
# proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
|
|
||||||
# # WebSocket support (for Vite HMR)
|
|
||||||
# proxy_http_version 1.1;
|
|
||||||
# proxy_set_header Upgrade $http_upgrade;
|
|
||||||
# proxy_set_header Connection "upgrade";
|
|
||||||
# }
|
|
||||||
|
|
||||||
# --- UserFront Static Files ---
|
|
||||||
location / {
|
location / {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|||||||
Reference in New Issue
Block a user