1
0
forked from baron/baron-sso
Files
baron-sso/userfront/lib/features/auth/presentation/reset_password_screen.dart

293 lines
11 KiB
Dart

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;
String? _token;
bool _isPasswordObscured = true;
bool _isConfirmPasswordObscured = true;
Map<String, dynamic>? _policy;
bool _isPolicyLoading = false;
@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'];
}
// 토큰도 함께 읽어놓는다.
final uri = Uri.base;
_token = uri.queryParameters['token'];
_loadPolicy();
}
Future<void> _loadPolicy() async {
setState(() {
_isPolicyLoading = true;
});
try {
final policy = await AuthProxyService.fetchPasswordPolicy();
if (mounted) {
setState(() {
_policy = policy;
});
}
} catch (_) {
// 실패해도 기본 검증 로직 사용
} finally {
if (mounted) {
setState(() {
_isPolicyLoading = false;
});
}
}
}
Future<void> _handlePasswordReset() async {
if (_formKey.currentState?.validate() != true) return;
if ((_loginId == null || _loginId!.isEmpty) && (_token == null || _token!.isEmpty)) {
_showError("유효하지 않은 재설정 링크입니다. (loginId/token 누락)");
return;
}
setState(() => _isLoading = true);
try {
await AuthProxyService.completePasswordReset(
loginId: _loginId,
token: _token,
newPassword: _passwordController.text,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("비밀번호가 성공적으로 변경되었습니다. 다시 로그인해주세요."),
backgroundColor: Colors.green,
),
);
context.go('/signin');
}
} 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),
);
}
String _buildPolicyDescription() {
if (_isPolicyLoading) {
return "비밀번호 정책을 불러오는 중입니다...";
}
final minLength = (_policy?['minLength'] as int?) ?? 12;
final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0;
final requiresLower = _policy?['lowercase'] ?? true;
final requiresUpper = _policy?['uppercase'] ?? false;
final requiresNumber = _policy?['number'] ?? true;
final requiresSymbol = _policy?['nonAlphanumeric'] ?? true;
final parts = <String>["최소 ${minLength}자 이상"];
if (minTypes > 0) {
parts.add("영문 대/소문자/숫자/특수문자 중 ${minTypes}가지 이상");
}
if (requiresLower) parts.add("소문자 1개 이상");
if (requiresUpper) parts.add("대문자 1개 이상");
if (requiresNumber) parts.add("숫자 1개 이상");
if (requiresSymbol) parts.add("특수문자 1개 이상");
return parts.join(", ");
}
@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) && (_token == null || _token!.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),
Text(
_buildPolicyDescription(),
textAlign: TextAlign.center,
style: const 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) {
final val = value ?? "";
if (val.isEmpty) {
return '비밀번호를 입력해주세요.';
}
final minLength = (_policy?['minLength'] as int?) ?? 12;
if (val.length < minLength) {
return '비밀번호는 최소 $minLength자 이상이어야 합니다.';
}
final hasLower = RegExp(r'[a-z]').hasMatch(val);
final hasUpper = RegExp(r'[A-Z]').hasMatch(val);
final hasNumber = RegExp(r'[0-9]').hasMatch(val);
final hasSymbol = RegExp(r'[\W_]').hasMatch(val);
int typeCount = 0;
if (hasLower) typeCount++;
if (hasUpper) typeCount++;
if (hasNumber) typeCount++;
if (hasSymbol) typeCount++;
final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0;
if (minTypes > 0 && typeCount < minTypes) {
return '비밀번호는 영문 대/소문자/숫자/특수문자 중 $minTypes가지 이상 포함해야 합니다.';
}
if ((_policy?['lowercase'] ?? true) && !hasLower) {
return '최소 1개 이상의 소문자를 포함해야 합니다.';
}
if ((_policy?['uppercase'] ?? false) && !hasUpper) {
return '최소 1개 이상의 대문자를 포함해야 합니다.';
}
if ((_policy?['number'] ?? true) && !hasNumber) {
return '최소 1개 이상의 숫자를 포함해야 합니다.';
}
if ((_policy?['nonAlphanumeric'] ?? true) && !hasSymbol) {
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,
),
],
),
);
}
}