From 5d330c8ccc1ee9fe2fe2f91cb5ffa8dfabdbef48 Mon Sep 17 00:00:00 2001 From: Arseni Date: Tue, 13 Jan 2026 15:31:13 +0300 Subject: [PATCH] Email confirmation page after SignUp --- frontend/pshared/lib/provider/account.dart | 9 + frontend/pshared/lib/service/account.dart | 5 + frontend/pweb/lib/app/router/pages.dart | 3 +- frontend/pweb/lib/app/router/router.dart | 13 +- frontend/pweb/lib/l10n/en.arb | 24 +++ frontend/pweb/lib/l10n/ru.arb | 24 +++ frontend/pweb/lib/pages/login/form.dart | 14 +- .../lib/pages/signup/confirmation/args.dart | 5 + .../lib/pages/signup/confirmation/card.dart | 163 ++++++++++++++++++ .../signup/confirmation/login_prompt.dart | 31 ++++ .../lib/pages/signup/confirmation/page.dart | 49 ++++++ .../pweb/lib/pages/signup/form/state.dart | 12 +- 12 files changed, 343 insertions(+), 9 deletions(-) create mode 100644 frontend/pweb/lib/pages/signup/confirmation/args.dart create mode 100644 frontend/pweb/lib/pages/signup/confirmation/card.dart create mode 100644 frontend/pweb/lib/pages/signup/confirmation/login_prompt.dart create mode 100644 frontend/pweb/lib/pages/signup/confirmation/page.dart diff --git a/frontend/pshared/lib/provider/account.dart b/frontend/pshared/lib/provider/account.dart index 9b88dce..07f9dab 100644 --- a/frontend/pshared/lib/provider/account.dart +++ b/frontend/pshared/lib/provider/account.dart @@ -191,6 +191,15 @@ class AccountProvider extends ChangeNotifier { } } + Future resendVerificationEmail(String email) async { + try { + await AccountService.resendVerificationEmail(email); + } catch (e) { + _setResource(_resource.copyWith(error: toException(e))); + rethrow; + } + } + Future logout() async { _authState = AuthState.empty; _setResource(_resource.copyWith(isLoading: true, error: null)); diff --git a/frontend/pshared/lib/service/account.dart b/frontend/pshared/lib/service/account.dart index ec0b97d..c098a78 100644 --- a/frontend/pshared/lib/service/account.dart +++ b/frontend/pshared/lib/service/account.dart @@ -57,6 +57,11 @@ class AccountService { await getPUTResponse(_objectType, 'password', ForgotPasswordRequest.build(login: email).toJson()); } + static Future resendVerificationEmail(String email) async { + _logger.fine('Resending verification email'); + await getPUTResponse(_objectType, 'email', {'login': email}); + } + static Future resetPassword(String accountRef, String token, String newPassword) async { _logger.fine('Resetting password for account: $accountRef'); await getPOSTResponse(_objectType, 'password/reset/$accountRef/$token', ResetPasswordRequest.build(password: newPassword).toJson()); diff --git a/frontend/pweb/lib/app/router/pages.dart b/frontend/pweb/lib/app/router/pages.dart index a7e0005..c5e9ee8 100644 --- a/frontend/pweb/lib/app/router/pages.dart +++ b/frontend/pweb/lib/app/router/pages.dart @@ -9,6 +9,7 @@ enum Pages { methods, verify, signup, + signupConfirm, settings, dashboard, profile, @@ -57,4 +58,4 @@ void navigateNamedAndReplace(BuildContext context, Pages page, {String? objectRe void navigateNext(BuildContext context, Pages page, {Object? extra}) { WidgetsBinding.instance.addPostFrameCallback((_) => navigate(context, page, extra: extra)); -} \ No newline at end of file +} diff --git a/frontend/pweb/lib/app/router/router.dart b/frontend/pweb/lib/app/router/router.dart index c707d67..d77d3bf 100644 --- a/frontend/pweb/lib/app/router/router.dart +++ b/frontend/pweb/lib/app/router/router.dart @@ -8,6 +8,8 @@ import 'package:pweb/pages/2fa/page.dart'; import 'package:pweb/pages/errors/not_found.dart'; import 'package:pweb/pages/login/page.dart'; import 'package:pweb/pages/signup/page.dart'; +import 'package:pweb/pages/signup/confirmation/args.dart'; +import 'package:pweb/pages/signup/confirmation/page.dart'; import 'package:pweb/pages/verification/page.dart'; @@ -36,6 +38,15 @@ GoRouter createRouter() => GoRouter( path: routerPage(Pages.signup), builder: (_, __) => const SignUpPage(), ), + GoRoute( + name: Pages.signupConfirm.name, + path: routerPage(Pages.signupConfirm), + builder: (_, state) => SignUpConfirmationPage( + email: state.extra is SignupConfirmationArgs + ? (state.extra as SignupConfirmationArgs).email + : null, + ), + ), GoRoute( name: Pages.verify.name, path: '${routerPage(Pages.verify)}${routerAddParam(PageParams.token)}', @@ -46,4 +57,4 @@ GoRouter createRouter() => GoRouter( payoutShellRoute(), ], errorBuilder: (_, __) => const NotFoundPage(), -); \ No newline at end of file +); diff --git a/frontend/pweb/lib/l10n/en.arb b/frontend/pweb/lib/l10n/en.arb index 91d4d02..c9bb63d 100644 --- a/frontend/pweb/lib/l10n/en.arb +++ b/frontend/pweb/lib/l10n/en.arb @@ -41,6 +41,30 @@ "goToSignUp": "Go to Sign Up", "signupError": "Failed to signup: {error}", "signupSuccess": "Email confirmation message has been sent to {email}. Please, open it and click link to activate your account.", + "signupConfirmationTitle": "Confirm your email", + "signupConfirmationDescription": "We sent a confirmation link to {email}. Open it and click the link to activate your account.", + "@signupConfirmationDescription": { + "placeholders": { + "email": {} + } + }, + "signupConfirmationDescriptionNoEmail": "We sent a confirmation link to your email. Open it and click the link to activate your account.", + "signupConfirmationResend": "Resend email", + "signupConfirmationResendCooldown": "Resend in {time}", + "@signupConfirmationResendCooldown": { + "placeholders": { + "time": {} + } + }, + "signupConfirmationResent": "Verification email resent to {email}.", + "@signupConfirmationResent": { + "placeholders": { + "email": {} + } + }, + "signupConfirmationResendError": "Failed to resend verification email", + "signupConfirmationLoginTitle": "Log in after confirmation", + "signupConfirmationLoginHint": "Once your email is confirmed, you can sign in below.", "connectivityError": "Cannot reach the server at {serverAddress}. Check your network and try again.", "errorAccountNotVerified": "Your account hasn't been verified yet. Please check your email to complete the verification", "errorLoginUnauthorized": "Login or password is incorrect. Please try again", diff --git a/frontend/pweb/lib/l10n/ru.arb b/frontend/pweb/lib/l10n/ru.arb index a53bf96..8c0710b 100644 --- a/frontend/pweb/lib/l10n/ru.arb +++ b/frontend/pweb/lib/l10n/ru.arb @@ -41,6 +41,30 @@ "goToSignUp": "Перейти к регистрации", "signupError": "Не удалось зарегистрироваться: {error}", "signupSuccess": "Письмо с подтверждением email отправлено на {email}. Пожалуйста, откройте его и перейдите по ссылке для активации вашего аккаунта.", + "signupConfirmationTitle": "Подтвердите email", + "signupConfirmationDescription": "Мы отправили ссылку для подтверждения на {email}. Откройте письмо и перейдите по ссылке, чтобы активировать аккаунт.", + "@signupConfirmationDescription": { + "placeholders": { + "email": {} + } + }, + "signupConfirmationDescriptionNoEmail": "Мы отправили ссылку для подтверждения на ваш email. Откройте письмо и перейдите по ссылке, чтобы активировать аккаунт.", + "signupConfirmationResend": "Отправить письмо снова", + "signupConfirmationResendCooldown": "Повторная отправка через {time}", + "@signupConfirmationResendCooldown": { + "placeholders": { + "time": {} + } + }, + "signupConfirmationResent": "Письмо с подтверждением повторно отправлено на {email}.", + "@signupConfirmationResent": { + "placeholders": { + "email": {} + } + }, + "signupConfirmationResendError": "Не удалось повторно отправить письмо с подтверждением", + "signupConfirmationLoginTitle": "Войдите после подтверждения", + "signupConfirmationLoginHint": "После подтверждения email вы можете войти ниже.", "connectivityError": "Не удается связаться с сервером {serverAddress}. Проверьте ваше интернет-соединение и попробуйте снова.", "errorAccountNotVerified": "Ваш аккаунт еще не подтвержден. Пожалуйста, проверьте вашу электронную почту для завершения верификации", "errorLoginUnauthorized": "Неверный логин или пароль. Пожалуйста, попробуйте снова", diff --git a/frontend/pweb/lib/pages/login/form.dart b/frontend/pweb/lib/pages/login/form.dart index 29b1dce..28224fb 100644 --- a/frontend/pweb/lib/pages/login/form.dart +++ b/frontend/pweb/lib/pages/login/form.dart @@ -22,7 +22,9 @@ import 'package:pweb/generated/i18n/app_localizations.dart'; class LoginForm extends StatefulWidget { - const LoginForm({super.key}); + final String? initialEmail; + + const LoginForm({super.key, this.initialEmail}); @override State createState() => _LoginFormState(); @@ -37,6 +39,16 @@ class _LoginFormState extends State { final ValueNotifier _isUsernameAcceptable = ValueNotifier(false); final ValueNotifier _isPasswordAcceptable = ValueNotifier(false); + @override + void initState() { + super.initState(); + final initialEmail = widget.initialEmail?.trim(); + if (initialEmail != null && initialEmail.isNotEmpty) { + _usernameController.text = initialEmail; + _isUsernameAcceptable.value = true; + } + } + Future _login(BuildContext context, VoidCallback onLogin, void Function(Object e) onError) async { final provider = Provider.of(context, listen: false); diff --git a/frontend/pweb/lib/pages/signup/confirmation/args.dart b/frontend/pweb/lib/pages/signup/confirmation/args.dart new file mode 100644 index 0000000..712d198 --- /dev/null +++ b/frontend/pweb/lib/pages/signup/confirmation/args.dart @@ -0,0 +1,5 @@ +class SignupConfirmationArgs { + final String? email; + + const SignupConfirmationArgs({this.email}); +} diff --git a/frontend/pweb/lib/pages/signup/confirmation/card.dart b/frontend/pweb/lib/pages/signup/confirmation/card.dart new file mode 100644 index 0000000..fb9922d --- /dev/null +++ b/frontend/pweb/lib/pages/signup/confirmation/card.dart @@ -0,0 +1,163 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/provider/account.dart'; + +import 'package:pweb/utils/snackbar.dart'; +import 'package:pweb/widgets/error/snackbar.dart'; +import 'package:pweb/widgets/vspacer.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class SignupConfirmationCard extends StatefulWidget { + final String? email; + + const SignupConfirmationCard({super.key, this.email}); + + @override + State createState() => _SignupConfirmationCardState(); +} + +class _SignupConfirmationCardState extends State { + static const int _defaultCooldownSeconds = 60; + + Timer? _cooldownTimer; + int _cooldownRemainingSeconds = 0; + bool _isResending = false; + + @override + void initState() { + super.initState(); + _startCooldown(_defaultCooldownSeconds); + } + + @override + void dispose() { + _cooldownTimer?.cancel(); + super.dispose(); + } + + bool get _isCooldownActive => _cooldownRemainingSeconds > 0; + + void _startCooldown(int seconds) { + _cooldownTimer?.cancel(); + if (seconds <= 0) { + setState(() => _cooldownRemainingSeconds = 0); + return; + } + setState(() => _cooldownRemainingSeconds = seconds); + _cooldownTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (!mounted) { + timer.cancel(); + return; + } + if (_cooldownRemainingSeconds <= 1) { + timer.cancel(); + setState(() => _cooldownRemainingSeconds = 0); + return; + } + setState(() => _cooldownRemainingSeconds -= 1); + }); + } + + Future _resendVerificationEmail() async { + final email = widget.email?.trim(); + final locs = AppLocalizations.of(context)!; + if (email == null || email.isEmpty) { + notifyUser(context, locs.errorEmailMissing); + return; + } + if (_isResending || _isCooldownActive) return; + + setState(() => _isResending = true); + try { + await context.read().resendVerificationEmail(email); + if (!mounted) return; + notifyUser(context, locs.signupConfirmationResent(email)); + _startCooldown(_defaultCooldownSeconds); + } catch (e) { + if (!mounted) return; + postNotifyUserOfErrorX( + context: context, + errorSituation: locs.signupConfirmationResendError, + exception: e, + ); + } finally { + if (mounted) { + setState(() => _isResending = false); + } + } + } + + String _formatCooldown(int seconds) { + final minutes = seconds ~/ 60; + final remainingSeconds = seconds % 60; + if (minutes > 0) { + return '$minutes:${remainingSeconds.toString().padLeft(2, '0')}'; + } + return remainingSeconds.toString(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final locs = AppLocalizations.of(context)!; + final email = widget.email?.trim(); + final description = (email != null && email.isNotEmpty) + ? locs.signupConfirmationDescription(email) + : locs.signupConfirmationDescriptionNoEmail; + final canResend = !_isResending && !_isCooldownActive && email != null && email.isNotEmpty; + final resendLabel = _isCooldownActive + ? locs.signupConfirmationResendCooldown(_formatCooldown(_cooldownRemainingSeconds)) + : locs.signupConfirmationResend; + + return Card( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + locs.signupConfirmationTitle, + style: theme.textTheme.headlineSmall, + ), + const VSpacer(), + Text(description, style: theme.textTheme.bodyMedium), + if (email != null && email.isNotEmpty) ...[ + const VSpacer(), + SelectableText( + email, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ], + const VSpacer(multiplier: 1.5), + Row( + children: [ + ElevatedButton.icon( + onPressed: canResend ? _resendVerificationEmail : null, + icon: _isResending + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: theme.colorScheme.onPrimary, + ), + ) + : const Icon(Icons.mark_email_read_outlined), + label: Text(resendLabel), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/signup/confirmation/login_prompt.dart b/frontend/pweb/lib/pages/signup/confirmation/login_prompt.dart new file mode 100644 index 0000000..b1bb7cd --- /dev/null +++ b/frontend/pweb/lib/pages/signup/confirmation/login_prompt.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/widgets/vspacer.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class SignupConfirmationLoginPrompt extends StatelessWidget { + const SignupConfirmationLoginPrompt({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final locs = AppLocalizations.of(context)!; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + locs.signupConfirmationLoginTitle, + style: theme.textTheme.titleLarge, + ), + const VSpacer(), + Text( + locs.signupConfirmationLoginHint, + style: theme.textTheme.bodyMedium, + ), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/signup/confirmation/page.dart b/frontend/pweb/lib/pages/signup/confirmation/page.dart new file mode 100644 index 0000000..f5ca978 --- /dev/null +++ b/frontend/pweb/lib/pages/signup/confirmation/page.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/pages/login/app_bar.dart'; +import 'package:pweb/pages/login/form.dart'; +import 'package:pweb/pages/signup/confirmation/card.dart'; +import 'package:pweb/pages/signup/confirmation/login_prompt.dart'; +import 'package:pweb/pages/with_footer.dart'; +import 'package:pweb/widgets/vspacer.dart'; + + +class SignUpConfirmationPage extends StatefulWidget { + final String? email; + + const SignUpConfirmationPage({super.key, this.email}); + + @override + State createState() => _SignUpConfirmationPageState(); +} + +class _SignUpConfirmationPageState extends State { + @override + Widget build(BuildContext context) { + final email = widget.email?.trim(); + + return PageWithFooter( + appBar: const LoginAppBar(), + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520), + child: SignupConfirmationCard(email: email), + ), + ), + const VSpacer(multiplier: 2), + Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520), + child: const SignupConfirmationLoginPrompt(), + ), + ), + const VSpacer(multiplier: 1.5), + LoginForm(initialEmail: email), + ], + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/signup/form/state.dart b/frontend/pweb/lib/pages/signup/form/state.dart index 27d3b87..91b9324 100644 --- a/frontend/pweb/lib/pages/signup/form/state.dart +++ b/frontend/pweb/lib/pages/signup/form/state.dart @@ -15,7 +15,7 @@ import 'package:pweb/app/router/pages.dart'; import 'package:pweb/pages/signup/form/content.dart'; import 'package:pweb/pages/signup/form/controllers.dart'; import 'package:pweb/pages/signup/form/form.dart'; -import 'package:pweb/utils/snackbar.dart'; +import 'package:pweb/pages/signup/confirmation/args.dart'; import 'package:pweb/widgets/error/snackbar.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -91,12 +91,12 @@ class SignUpFormState extends State { void handleSignUp() => signUp( context, () { - final locs = AppLocalizations.of(context)!; - notifyUser( - context, - locs.signupSuccess(controllers.email.text.trim()), + context.goNamed( + Pages.signupConfirm.name, + extra: SignupConfirmationArgs( + email: controllers.email.text.trim(), + ), ); - context.goNamed(Pages.login.name); }, (e) => postNotifyUserOfErrorX( context: context,