1
0
forked from baron/baron-sso

비밀번호 변경 중간 저장2

This commit is contained in:
2026-01-26 20:29:35 +09:00
parent d922de5df6
commit 739da39a61
19 changed files with 1668 additions and 164 deletions

View File

@@ -86,6 +86,38 @@ class AuthProxyService {
}
}
static Future<Map<String, dynamic>> initiatePasswordReset(String loginId) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/password/reset/initiate');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'loginId': loginId}),
);
if (response.statusCode == 200) {
return jsonDecode(response.body);
} else {
final errorBody = jsonDecode(response.body);
throw Exception(errorBody['error'] ?? 'Failed to initiate password reset');
}
}
static Future<Map<String, dynamic>> completePasswordReset(String loginId, String newPassword) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/password/reset/complete?loginId=${Uri.encodeComponent(loginId)}');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'newPassword': newPassword}),
);
if (response.statusCode == 200) {
return jsonDecode(response.body);
} else {
final errorBody = jsonDecode(response.body);
throw Exception(errorBody['error'] ?? 'Failed to complete password reset');
}
}
static Future<void> sendSms(String phoneNumber) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/sms');

View File

@@ -0,0 +1,111 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../core/services/auth_proxy_service.dart';
class ForgotPasswordScreen extends StatefulWidget {
const ForgotPasswordScreen({super.key});
@override
State<ForgotPasswordScreen> createState() => _ForgotPasswordScreenState();
}
class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
final TextEditingController _loginIdController = TextEditingController();
bool _isLoading = false;
Future<void> _handlePasswordReset() async {
if (_loginIdController.text.trim().isEmpty) {
_showError("이메일 또는 휴대폰 번호를 입력해주세요.");
return;
}
setState(() => _isLoading = true);
try {
await AuthProxyService.initiatePasswordReset(_loginIdController.text.trim());
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("비밀번호 재설정 링크가 전송되었습니다. 이메일 또는 SMS를 확인해주세요."),
backgroundColor: Colors.green,
),
);
Navigator.of(context).pop();
}
} catch (e) {
if (mounted) {
_showError("전송에 실패했습니다: ${e.toString()}");
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
void _showError(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), backgroundColor: Colors.red),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("비밀번호 재설정"),
centerTitle: true,
),
body: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 400),
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
"비밀번호를 잊으셨나요?",
style: GoogleFonts.outfit(
fontSize: 28,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
const Text(
"계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다.",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
const SizedBox(height: 40),
TextField(
controller: _loginIdController,
decoration: const InputDecoration(
labelText: "이메일 또는 휴대폰 번호",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person_outline),
),
onSubmitted: (_) => _handlePasswordReset(),
),
const SizedBox(height: 24),
FilledButton(
onPressed: _isLoading ? null : _handlePasswordReset,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(50),
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Text("재설정 링크 전송"),
),
],
),
),
),
);
}
}

View File

@@ -12,6 +12,7 @@ import '../../../core/services/audit_service.dart';
import '../../../core/services/web_auth_integration.dart';
import '../../../core/services/auth_proxy_service.dart';
import '../../../core/notifiers/auth_notifier.dart';
import './forgot_password_screen.dart';
class LoginScreen extends ConsumerStatefulWidget {
final String? verificationToken;
@@ -546,7 +547,11 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
const SizedBox(height: 16),
TextButton(
onPressed: () {
_showError("비밀번호 재설정은 아직 구현되지 않았습니다.");
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const ForgotPasswordScreen(),
),
);
},
child: const Text("비밀번호를 잊으셨나요?"),
)

View File

@@ -0,0 +1,220 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:go_router/go_router.dart';
import '../../../core/services/auth_proxy_service.dart';
class ResetPasswordScreen extends StatefulWidget {
final String? loginId; // Now receiving loginId
const ResetPasswordScreen({super.key, this.loginId});
@override
State<ResetPasswordScreen> createState() => _ResetPasswordScreenState();
}
class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
final TextEditingController _passwordController = TextEditingController();
final TextEditingController _confirmPasswordController = TextEditingController();
final _formKey = GlobalKey<FormState>();
bool _isLoading = false;
String? _loginId;
bool _isPasswordObscured = true;
bool _isConfirmPasswordObscured = true;
@override
void initState() {
super.initState();
// 1. Get loginId from GoRouter state if available
_loginId = widget.loginId;
// 2. Fallback to URI query parameter if not available via router
if (_loginId == null || _loginId!.isEmpty) {
final uri = Uri.base;
_loginId = uri.queryParameters['loginId'];
}
}
Future<void> _handlePasswordReset() async {
if (_formKey.currentState?.validate() != true) return;
if (_loginId == null || _loginId!.isEmpty) {
_showError("유효하지 않은 재설정 링크입니다. (loginId 누락)");
return;
}
setState(() => _isLoading = true);
try {
await AuthProxyService.completePasswordReset(
_loginId!,
_passwordController.text,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("비밀번호가 성공적으로 변경되었습니다. 다시 로그인해주세요."),
backgroundColor: Colors.green,
),
);
context.go('/login');
}
} catch (e) {
if (mounted) {
_showError("비밀번호 변경에 실패했습니다: ${e.toString()}");
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
void _showError(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), backgroundColor: Colors.red),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("새 비밀번호 설정"),
centerTitle: true,
),
body: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 400),
padding: const EdgeInsets.all(24),
child: _loginId == null || _loginId!.isEmpty
? _buildInvalidTokenView()
: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
"새로운 비밀번호 설정",
style: GoogleFonts.outfit(
fontSize: 28,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
const Text(
"비밀번호는 최소 8자 이상이어야 하며,\n대소문자, 숫자, 특수문자를 모두 포함해야 합니다.",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
const SizedBox(height: 40),
TextFormField(
controller: _passwordController,
obscureText: _isPasswordObscured,
decoration: InputDecoration(
labelText: "새 비밀번호",
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(
_isPasswordObscured ? Icons.visibility_off : Icons.visibility,
),
onPressed: () {
setState(() {
_isPasswordObscured = !_isPasswordObscured;
});
},
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return '비밀번호를 입력해주세요.';
}
if (value.length < 8) {
return '비밀번호는 8자 이상이어야 합니다.';
}
if (!RegExp(r'(?=.*[a-z])').hasMatch(value)) {
return '최소 1개 이상의 소문자를 포함해야 합니다.';
}
if (!RegExp(r'(?=.*[A-Z])').hasMatch(value)) {
return '최소 1개 이상의 대문자를 포함해야 합니다.';
}
if (!RegExp(r'(?=.*\d)').hasMatch(value)) {
return '최소 1개 이상의 숫자를 포함해야 합니다.';
}
if (!RegExp(r'(?=.*[\W_])').hasMatch(value)) {
return '최소 1개 이상의 특수문자를 포함해야 합니다.';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _confirmPasswordController,
obscureText: _isConfirmPasswordObscured,
decoration: InputDecoration(
labelText: "새 비밀번호 확인",
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(
_isConfirmPasswordObscured ? Icons.visibility_off : Icons.visibility,
),
onPressed: () {
setState(() {
_isConfirmPasswordObscured = !_isConfirmPasswordObscured;
});
},
),
),
validator: (value) {
if (value != _passwordController.text) {
return '비밀번호가 일치하지 않습니다.';
}
return null;
},
),
const SizedBox(height: 24),
FilledButton(
onPressed: _isLoading ? null : _handlePasswordReset,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(50),
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Text("비밀번호 변경"),
),
],
),
),
),
),
);
}
Widget _buildInvalidTokenView() {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, color: Colors.red, size: 60),
SizedBox(height: 16),
Text(
"유효하지 않은 링크입니다.",
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
SizedBox(height: 8),
Text(
"비밀번호 재설정 링크가 만료되었거나 잘못되었습니다. 다시 시도해주세요.",
textAlign: TextAlign.center,
),
],
),
);
}
}

View File

@@ -10,6 +10,8 @@ import 'features/auth/presentation/login_screen.dart';
import 'features/auth/presentation/signup_screen.dart';
import 'features/auth/presentation/approve_qr_screen.dart';
import 'features/auth/presentation/qr_scan_screen.dart';
import 'features/auth/presentation/forgot_password_screen.dart';
import 'features/auth/presentation/reset_password_screen.dart';
import 'features/dashboard/presentation/dashboard_screen.dart';
import 'features/admin/presentation/user_management_screen.dart';
import 'core/services/auth_proxy_service.dart';
@@ -99,6 +101,23 @@ final _router = GoRouter(
return LoginScreen(verificationToken: token);
},
),
GoRoute(
path: '/forgot-password',
builder: (context, state) {
_routerLogger.info("Navigating to /forgot-password");
return const ForgotPasswordScreen();
},
),
GoRoute(
// Supports both /reset-password and /reset-password?token=...
path: '/reset-password',
builder: (context, state) {
// For deep linking, you might pass the token in the path, e.g., /reset-password/:token
// final token = state.pathParameters['token'];
_routerLogger.info("Navigating to /reset-password");
return const ResetPasswordScreen();
},
),
GoRoute(
path: '/approve',
builder: (context, state) {
@@ -131,26 +150,29 @@ final _router = GoRouter(
final isPublicPath = path == '/login' ||
path == '/signup' ||
path.startsWith('/verify/') ||
path == '/approve';
path == '/approve' ||
path == '/forgot-password' ||
path == '/reset-password';
_routerLogger.fine("Redirect check - Path: $path, IsLoggedIn: $isLoggedIn");
// 0. ALWAYS allow /verify/ to proceed so it can signal the backend
if (path.startsWith('/verify/')) {
// 0. ALWAYS allow public paths to proceed so they can function
if (isPublicPath) {
return null;
}
// If not logged in and trying to access a protected page, redirect to /login
if (!isLoggedIn && !isPublicPath) {
if (!isLoggedIn) {
_routerLogger.info("Not logged in, redirecting to /login");
return '/login';
}
// If logged in and trying to access login page, redirect to root (dashboard)
if (isLoggedIn && path == '/login') {
_routerLogger.info("Logged in, redirecting to /");
return '/';
}
// This is now implicitly handled by the isPublicPath check, but kept for clarity.
// if (isLoggedIn && path == '/login') {
// _routerLogger.info("Logged in, redirecting to /");
// return '/';
// }
return null;
},