forked from baron/baron-sso
377 lines
13 KiB
Dart
377 lines
13 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',
|
|
),
|
|
);
|
|
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',
|
|
),
|
|
),
|
|
backgroundColor: Colors.green,
|
|
),
|
|
);
|
|
context.go('/signin');
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
_showError(
|
|
tr(
|
|
'msg.userfront.reset.error.generic',
|
|
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',
|
|
);
|
|
}
|
|
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',
|
|
params: {'count': '$minLength'},
|
|
),
|
|
];
|
|
if (minTypes > 0) {
|
|
parts.add(
|
|
tr(
|
|
'msg.userfront.reset.policy.min_types',
|
|
params: {'count': '$minTypes'},
|
|
),
|
|
);
|
|
}
|
|
if (requiresLower) {
|
|
parts.add(
|
|
tr('msg.userfront.reset.policy.lowercase'),
|
|
);
|
|
}
|
|
if (requiresUpper) {
|
|
parts.add(
|
|
tr('msg.userfront.reset.policy.uppercase'),
|
|
);
|
|
}
|
|
if (requiresNumber) {
|
|
parts.add(tr('msg.userfront.reset.policy.number'));
|
|
}
|
|
if (requiresSymbol) {
|
|
parts.add(
|
|
tr('msg.userfront.reset.policy.symbol'),
|
|
);
|
|
}
|
|
|
|
return parts.join(", ");
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(tr('ui.userfront.reset.title')),
|
|
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',
|
|
),
|
|
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',
|
|
),
|
|
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',
|
|
);
|
|
}
|
|
final minLength =
|
|
(_policy?['minLength'] as int?) ?? 12;
|
|
if (val.length < minLength) {
|
|
return tr(
|
|
'msg.userfront.reset.error.min_length',
|
|
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',
|
|
params: {'count': '$minTypes'},
|
|
);
|
|
}
|
|
|
|
if ((_policy?['lowercase'] ?? true) && !hasLower) {
|
|
return tr(
|
|
'msg.userfront.reset.error.lowercase',
|
|
);
|
|
}
|
|
if ((_policy?['uppercase'] ?? false) && !hasUpper) {
|
|
return tr(
|
|
'msg.userfront.reset.error.uppercase',
|
|
);
|
|
}
|
|
if ((_policy?['number'] ?? true) && !hasNumber) {
|
|
return tr(
|
|
'msg.userfront.reset.error.number',
|
|
);
|
|
}
|
|
if ((_policy?['nonAlphanumeric'] ?? true) &&
|
|
!hasSymbol) {
|
|
return tr(
|
|
'msg.userfront.reset.error.symbol',
|
|
);
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextFormField(
|
|
controller: _confirmPasswordController,
|
|
obscureText: _isConfirmPasswordObscured,
|
|
decoration: InputDecoration(
|
|
labelText: tr(
|
|
'ui.userfront.reset.confirm_password',
|
|
),
|
|
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',
|
|
);
|
|
}
|
|
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',
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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'),
|
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
tr(
|
|
'msg.userfront.reset.invalid_body',
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|