forked from baron/baron-sso
293 lines
11 KiB
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,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|