forked from baron/baron-sso
390 lines
14 KiB
Dart
390 lines
14 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import '../../../core/services/auth_proxy_service.dart';
|
|
import 'package:userfront/i18n.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(
|
|
tr(
|
|
'msg.userfront.reset.invalid_link',
|
|
fallback: '유효하지 않은 재설정 링크입니다. (loginId/token 누락)',
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
setState(() => _isLoading = true);
|
|
|
|
try {
|
|
await AuthProxyService.completePasswordReset(
|
|
loginId: _loginId,
|
|
token: _token,
|
|
newPassword: _passwordController.text,
|
|
);
|
|
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
tr(
|
|
'msg.userfront.reset.success',
|
|
fallback: '비밀번호가 성공적으로 변경되었습니다. 다시 로그인해주세요.',
|
|
),
|
|
),
|
|
backgroundColor: Colors.green,
|
|
),
|
|
);
|
|
context.go('/signin');
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
_showError(
|
|
tr(
|
|
'msg.userfront.reset.error.generic',
|
|
fallback: '비밀번호 변경에 실패했습니다: {{error}}',
|
|
params: {'error': 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 tr(
|
|
'msg.userfront.reset.policy_loading',
|
|
fallback: '비밀번호 정책을 불러오는 중입니다...',
|
|
);
|
|
}
|
|
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>[
|
|
tr(
|
|
'msg.userfront.reset.policy.min_length',
|
|
fallback: '최소 {{count}}자 이상',
|
|
params: {'count': '$minLength'},
|
|
),
|
|
];
|
|
if (minTypes > 0) {
|
|
parts.add(
|
|
tr(
|
|
'msg.userfront.reset.policy.min_types',
|
|
fallback: '영문 대/소문자/숫자/특수문자 중 {{count}}가지 이상',
|
|
params: {'count': '$minTypes'},
|
|
),
|
|
);
|
|
}
|
|
if (requiresLower) {
|
|
parts.add(
|
|
tr('msg.userfront.reset.policy.lowercase', fallback: '소문자 1개 이상'),
|
|
);
|
|
}
|
|
if (requiresUpper) {
|
|
parts.add(
|
|
tr('msg.userfront.reset.policy.uppercase', fallback: '대문자 1개 이상'),
|
|
);
|
|
}
|
|
if (requiresNumber) {
|
|
parts.add(
|
|
tr('msg.userfront.reset.policy.number', fallback: '숫자 1개 이상'),
|
|
);
|
|
}
|
|
if (requiresSymbol) {
|
|
parts.add(
|
|
tr('msg.userfront.reset.policy.symbol', fallback: '특수문자 1개 이상'),
|
|
);
|
|
}
|
|
|
|
return parts.join(", ");
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(
|
|
tr('ui.userfront.reset.title', fallback: '새 비밀번호 설정'),
|
|
),
|
|
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(
|
|
tr(
|
|
'ui.userfront.reset.subtitle',
|
|
fallback: '새로운 비밀번호 설정',
|
|
),
|
|
style: TextStyle(
|
|
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: tr(
|
|
'ui.userfront.reset.new_password',
|
|
fallback: '새 비밀번호',
|
|
),
|
|
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 tr(
|
|
'msg.userfront.reset.error.empty_password',
|
|
fallback: '비밀번호를 입력해주세요.',
|
|
);
|
|
}
|
|
final minLength = (_policy?['minLength'] as int?) ?? 12;
|
|
if (val.length < minLength) {
|
|
return tr(
|
|
'msg.userfront.reset.error.min_length',
|
|
fallback: '비밀번호는 최소 {{count}}자 이상이어야 합니다.',
|
|
params: {'count': '$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 tr(
|
|
'msg.userfront.reset.error.min_types',
|
|
fallback:
|
|
'비밀번호는 영문 대/소문자/숫자/특수문자 중 {{count}}가지 이상 포함해야 합니다.',
|
|
params: {'count': '$minTypes'},
|
|
);
|
|
}
|
|
|
|
if ((_policy?['lowercase'] ?? true) && !hasLower) {
|
|
return tr(
|
|
'msg.userfront.reset.error.lowercase',
|
|
fallback: '최소 1개 이상의 소문자를 포함해야 합니다.',
|
|
);
|
|
}
|
|
if ((_policy?['uppercase'] ?? false) && !hasUpper) {
|
|
return tr(
|
|
'msg.userfront.reset.error.uppercase',
|
|
fallback: '최소 1개 이상의 대문자를 포함해야 합니다.',
|
|
);
|
|
}
|
|
if ((_policy?['number'] ?? true) && !hasNumber) {
|
|
return tr(
|
|
'msg.userfront.reset.error.number',
|
|
fallback: '최소 1개 이상의 숫자를 포함해야 합니다.',
|
|
);
|
|
}
|
|
if ((_policy?['nonAlphanumeric'] ?? true) && !hasSymbol) {
|
|
return tr(
|
|
'msg.userfront.reset.error.symbol',
|
|
fallback: '최소 1개 이상의 특수문자를 포함해야 합니다.',
|
|
);
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextFormField(
|
|
controller: _confirmPasswordController,
|
|
obscureText: _isConfirmPasswordObscured,
|
|
decoration: InputDecoration(
|
|
labelText: tr(
|
|
'ui.userfront.reset.confirm_password',
|
|
fallback: '새 비밀번호 확인',
|
|
),
|
|
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 tr(
|
|
'msg.userfront.reset.error.mismatch',
|
|
fallback: '비밀번호가 일치하지 않습니다.',
|
|
);
|
|
}
|
|
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,
|
|
),
|
|
)
|
|
: Text(
|
|
tr(
|
|
'ui.userfront.reset.submit',
|
|
fallback: '비밀번호 변경',
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildInvalidTokenView() {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const Icon(Icons.error_outline, color: Colors.red, size: 60),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
tr('msg.userfront.reset.invalid_title',
|
|
fallback: '유효하지 않은 링크입니다.'),
|
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
tr(
|
|
'msg.userfront.reset.invalid_body',
|
|
fallback: '비밀번호 재설정 링크가 만료되었거나 잘못되었습니다. 다시 시도해주세요.',
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|