diff --git a/frontend/pshared/lib/models/auth/probe_result.dart b/frontend/pshared/lib/models/auth/probe_result.dart new file mode 100644 index 00000000..bfa88ca5 --- /dev/null +++ b/frontend/pshared/lib/models/auth/probe_result.dart @@ -0,0 +1,5 @@ +enum AuthProbeResult { + authorized, + notVerified, + error, +} diff --git a/frontend/pshared/lib/provider/account.dart b/frontend/pshared/lib/provider/account.dart index e2c95853..6c3bfcb5 100644 --- a/frontend/pshared/lib/provider/account.dart +++ b/frontend/pshared/lib/provider/account.dart @@ -7,10 +7,12 @@ import 'package:share_plus/share_plus.dart'; import 'package:pshared/api/errors/unauthorized.dart'; import 'package:pshared/api/requests/signup.dart'; import 'package:pshared/api/requests/login_data.dart'; +import 'package:pshared/api/responses/error/server.dart'; import 'package:pshared/api/responses/verification/response.dart'; import 'package:pshared/config/constants.dart'; import 'package:pshared/models/account/account.dart'; import 'package:pshared/models/auth/login_outcome.dart'; +import 'package:pshared/models/auth/probe_result.dart'; import 'package:pshared/models/auth/pending_login.dart'; import 'package:pshared/models/auth/state.dart'; import 'package:pshared/models/describable.dart'; @@ -309,4 +311,31 @@ class AccountProvider extends ChangeNotifier { } await restore(); } + + Future probeAuthorization({ + required String email, + required String password, + required String locale, + }) async { + try { + final outcome = await AccountService.login(LoginData.build( + login: email, + password: password, + locale: locale, + )); + if (outcome.isCompleted) { + await AuthorizationService.logout(); + return AuthProbeResult.authorized; + } + if (outcome.isPending) { + return AuthProbeResult.authorized; + } + return AuthProbeResult.error; + } catch (e) { + if (e is ErrorResponse && e.error == 'account_not_verified') { + return AuthProbeResult.notVerified; + } + return AuthProbeResult.error; + } + } } diff --git a/frontend/pshared/lib/provider/email_verification.dart b/frontend/pshared/lib/provider/email_verification.dart index 9317c458..dc3cc943 100644 --- a/frontend/pshared/lib/provider/email_verification.dart +++ b/frontend/pshared/lib/provider/email_verification.dart @@ -5,6 +5,7 @@ import 'package:pshared/provider/resource.dart'; import 'package:pshared/service/account.dart'; import 'package:pshared/utils/exception.dart'; + class EmailVerificationProvider extends ChangeNotifier { Resource _resource = Resource(data: null, isLoading: false); String? _token; @@ -16,6 +17,14 @@ class EmailVerificationProvider extends ChangeNotifier { ErrorResponse? get errorResponse => _resource.error is ErrorResponse ? _resource.error as ErrorResponse : null; int? get errorCode => errorResponse?.code; + bool get isTokenAlreadyUsed { + final response = errorResponse; + if (response == null) return false; + if (response.code != 409 || response.error != 'data_conflict') { + return false; + } + return response.details.contains('verification token has already been used'); + } bool get canResendVerification => errorCode == 400 || errorCode == 410 || errorCode == 500; diff --git a/frontend/pweb/lib/app/router/router.dart b/frontend/pweb/lib/app/router/router.dart index cc961aab..78eb3165 100644 --- a/frontend/pweb/lib/app/router/router.dart +++ b/frontend/pweb/lib/app/router/router.dart @@ -45,6 +45,9 @@ GoRouter createRouter() => GoRouter( email: state.extra is SignupConfirmationArgs ? (state.extra as SignupConfirmationArgs).email : null, + password: state.extra is SignupConfirmationArgs + ? (state.extra as SignupConfirmationArgs).password + : null, ), ), GoRoute( @@ -57,4 +60,4 @@ GoRouter createRouter() => GoRouter( payoutShellRoute(), ], errorBuilder: (_, _) => const NotFoundPage(), -); \ No newline at end of file +); diff --git a/frontend/pweb/lib/controllers/signup/confirmation.dart b/frontend/pweb/lib/controllers/signup/confirmation.dart new file mode 100644 index 00000000..6c25c82a --- /dev/null +++ b/frontend/pweb/lib/controllers/signup/confirmation.dart @@ -0,0 +1,91 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import 'package:pshared/models/auth/probe_result.dart'; +import 'package:pshared/provider/account.dart'; + + +class SignupConfirmationController extends ChangeNotifier { + SignupConfirmationController({ + required AccountProvider accountProvider, + Duration pollInterval = const Duration(seconds: 10), + }) : _accountProvider = accountProvider, + _pollInterval = pollInterval; + + final AccountProvider _accountProvider; + final Duration _pollInterval; + + Timer? _pollTimer; + bool _isChecking = false; + bool _isAuthorized = false; + + String? _email; + String? _password; + String? _locale; + + bool get isAuthorized => _isAuthorized; + bool get isChecking => _isChecking; + + void startPolling({ + required String email, + required String password, + required String locale, + }) { + final trimmedEmail = email.trim(); + final trimmedPassword = password.trim(); + final trimmedLocale = locale.trim(); + if (trimmedEmail.isEmpty || trimmedPassword.isEmpty || trimmedLocale.isEmpty) { + return; + } + + _email = trimmedEmail; + _password = trimmedPassword; + _locale = trimmedLocale; + + _pollTimer?.cancel(); + _pollTimer = Timer.periodic(_pollInterval, (_) => _probeAuthorization()); + _probeAuthorization(); + } + + void stopPolling() { + _pollTimer?.cancel(); + _pollTimer = null; + } + + @override + void dispose() { + _pollTimer?.cancel(); + super.dispose(); + } + + Future _probeAuthorization() async { + if (_isChecking || _isAuthorized) return; + final email = _email; + final password = _password; + final locale = _locale; + if (email == null || password == null || locale == null) return; + + _setChecking(true); + try { + final result = await _accountProvider.probeAuthorization( + email: email, + password: password, + locale: locale, + ); + if (result == AuthProbeResult.authorized) { + _isAuthorized = true; + stopPolling(); + notifyListeners(); + } + } finally { + _setChecking(false); + } + } + + void _setChecking(bool value) { + if (_isChecking == value) return; + _isChecking = value; + notifyListeners(); + } +} diff --git a/frontend/pweb/lib/controllers/signup/confirmation_card.dart b/frontend/pweb/lib/controllers/signup/confirmation_card.dart new file mode 100644 index 00000000..1b74648b --- /dev/null +++ b/frontend/pweb/lib/controllers/signup/confirmation_card.dart @@ -0,0 +1,132 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import 'package:pshared/provider/account.dart'; + +import 'package:pweb/models/flow_status.dart'; +import 'package:pweb/models/resend/action_result.dart'; +import 'package:pweb/models/resend/avaliability.dart'; + + +class SignupConfirmationCardController extends ChangeNotifier { + SignupConfirmationCardController({ + required AccountProvider accountProvider, + Duration defaultCooldown = const Duration(seconds: 60), + }) : _accountProvider = accountProvider, + _defaultCooldown = defaultCooldown; + + final AccountProvider _accountProvider; + final Duration _defaultCooldown; + + Timer? _cooldownTimer; + DateTime? _cooldownUntil; + int _cooldownRemainingSeconds = 0; + FlowStatus _resendState = FlowStatus.idle; + String? _email; + + int get cooldownRemainingSeconds => _cooldownRemainingSeconds; + ResendAvailability get resendAvailability { + final email = _email; + if (email == null || email.isEmpty) { + return ResendAvailability.missingEmail; + } + if (_resendState == FlowStatus.submitting) { + return ResendAvailability.resending; + } + if (_cooldownRemainingSeconds > 0) { + return ResendAvailability.cooldown; + } + return ResendAvailability.available; + } + + void updateEmail(String? email) { + final trimmed = email?.trim(); + if (_email == trimmed) return; + _email = trimmed; + notifyListeners(); + } + + void initialize({String? email}) { + updateEmail(email); + startDefaultCooldown(); + } + + void startDefaultCooldown() { + _startCooldown(_defaultCooldown); + } + + Future resendVerificationEmail() async { + switch (resendAvailability) { + case ResendAvailability.missingEmail: + return ResendActionResult.missingEmail; + case ResendAvailability.cooldown: + return ResendActionResult.cooldown; + case ResendAvailability.resending: + return ResendActionResult.inProgress; + case ResendAvailability.available: + break; + } + + _setResendState(FlowStatus.submitting); + try { + final email = _email; + if (email == null || email.isEmpty) { + _setResendState(FlowStatus.idle); + return ResendActionResult.missingEmail; + } + await _accountProvider.resendVerificationEmail(email); + _startCooldown(_defaultCooldown); + return ResendActionResult.sent; + } finally { + _setResendState(FlowStatus.idle); + } + } + + @override + void dispose() { + _cooldownTimer?.cancel(); + super.dispose(); + } + + void _startCooldown(Duration duration) { + _cooldownTimer?.cancel(); + _cooldownUntil = DateTime.now().add(duration); + _syncRemaining(); + + if (_cooldownRemainingSeconds <= 0) { + _cooldownUntil = null; + notifyListeners(); + return; + } + + _cooldownTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + _syncRemaining(); + if (_cooldownRemainingSeconds <= 0) { + timer.cancel(); + _cooldownUntil = null; + notifyListeners(); + } + }); + } + + void _syncRemaining() { + final remaining = _cooldownRemaining(); + if (remaining == _cooldownRemainingSeconds) return; + _cooldownRemainingSeconds = remaining; + notifyListeners(); + } + + int _cooldownRemaining() { + final until = _cooldownUntil; + if (until == null) return 0; + final remaining = until.difference(DateTime.now()).inSeconds; + return remaining < 0 ? 0 : remaining; + } + + void _setResendState(FlowStatus state) { + if (_resendState == state) return; + _resendState = state; + notifyListeners(); + } +} diff --git a/frontend/pweb/lib/l10n/en.arb b/frontend/pweb/lib/l10n/en.arb index 60811736..d8715c48 100644 --- a/frontend/pweb/lib/l10n/en.arb +++ b/frontend/pweb/lib/l10n/en.arb @@ -660,6 +660,8 @@ "verificationFailed": "Verification Failed", "verificationStatusUnknown": "We couldn't determine the status of your verification. Please try again later.", "verificationStatusErrorUnknown": "Unexpected error occurred while verification. Try once again or contact support", + "accountAlreadyVerified": "Your account has already been verified", + "accountAlreadyVerifiedDescription": "You can now log in to access your account.", "accountVerified": "Account Verified!", "accountVerifiedDescription": "Your account has been successfully verified. You can now log in to access your account.", "retryVerification": "Retry Verification", diff --git a/frontend/pweb/lib/l10n/ru.arb b/frontend/pweb/lib/l10n/ru.arb index 492a9693..72c2ae01 100644 --- a/frontend/pweb/lib/l10n/ru.arb +++ b/frontend/pweb/lib/l10n/ru.arb @@ -662,6 +662,8 @@ "verificationFailed": "Ошибка подтверждения", "verificationStatusUnknown": "Не удалось определить статус подтверждения. Попробуйте позже", "verificationStatusErrorUnknown": "Произошла непредвиденная ошибка при подтверждении. Попробуйте еще раз или обратитесь в службу поддержки", + "accountAlreadyVerified": "Ваш аккаунт уже подтвержден", + "accountAlreadyVerifiedDescription": "Теперь вы можете войти, чтобы получить доступ к аккаунту.", "accountVerified": "Аккаунт подтвержден!", "accountVerifiedDescription": "Ваш аккаунт успешно подтвержден. Теперь вы можете войти, чтобы получить доступ к своему аккаунту", "retryVerification": "Повторить подтверждение", diff --git a/frontend/pweb/lib/models/resend/action_result.dart b/frontend/pweb/lib/models/resend/action_result.dart new file mode 100644 index 00000000..47916a97 --- /dev/null +++ b/frontend/pweb/lib/models/resend/action_result.dart @@ -0,0 +1,6 @@ +enum ResendActionResult { + sent, + missingEmail, + cooldown, + inProgress, +} \ No newline at end of file diff --git a/frontend/pweb/lib/models/resend/avaliability.dart b/frontend/pweb/lib/models/resend/avaliability.dart new file mode 100644 index 00000000..465a2f50 --- /dev/null +++ b/frontend/pweb/lib/models/resend/avaliability.dart @@ -0,0 +1,6 @@ +enum ResendAvailability { + available, + cooldown, + resending, + missingEmail, +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/2fa/resend.dart b/frontend/pweb/lib/pages/2fa/resend.dart index 8cb8de89..11047e89 100644 --- a/frontend/pweb/lib/pages/2fa/resend.dart +++ b/frontend/pweb/lib/pages/2fa/resend.dart @@ -3,6 +3,8 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:pweb/providers/two_factor.dart'; +import 'package:pweb/utils/cooldown_format.dart'; +import 'package:pweb/widgets/resend_link.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -12,46 +14,20 @@ class ResendCodeButton extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = Theme.of(context); final localizations = AppLocalizations.of(context)!; final provider = context.watch(); final isDisabled = provider.isCooldownActive || provider.isResending; final label = provider.isCooldownActive - ? '${localizations.twoFactorResend} (${_formatCooldown(provider.cooldownRemainingSeconds)})' + ? '${localizations.twoFactorResend} (${formatCooldownSeconds(provider.cooldownRemainingSeconds)})' : localizations.twoFactorResend; - return TextButton( - onPressed: isDisabled ? null : () => provider.resendCode(), - style: TextButton.styleFrom( - padding: EdgeInsets.zero, - minimumSize: const Size(0, 0), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - alignment: Alignment.centerLeft, - foregroundColor: theme.colorScheme.primary, - textStyle: theme.textTheme.bodyMedium?.copyWith( - decoration: TextDecoration.underline, - ), - ), - child: provider.isResending - ? SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: theme.colorScheme.primary, - ), - ) - : Text(label), + return ResendLink( + label: label, + onPressed: provider.resendCode, + isDisabled: isDisabled, + isLoading: provider.isResending, ); } - 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(); - } } diff --git a/frontend/pweb/lib/pages/signup/confirmation/args.dart b/frontend/pweb/lib/pages/signup/confirmation/args.dart index 712d1987..9c577d46 100644 --- a/frontend/pweb/lib/pages/signup/confirmation/args.dart +++ b/frontend/pweb/lib/pages/signup/confirmation/args.dart @@ -1,5 +1,6 @@ class SignupConfirmationArgs { final String? email; + final String? password; - const SignupConfirmationArgs({this.email}); + const SignupConfirmationArgs({this.email, this.password}); } diff --git a/frontend/pweb/lib/pages/signup/confirmation/card/card.dart b/frontend/pweb/lib/pages/signup/confirmation/card/card.dart index 83d2bf9f..8a784cdf 100644 --- a/frontend/pweb/lib/pages/signup/confirmation/card/card.dart +++ b/frontend/pweb/lib/pages/signup/confirmation/card/card.dart @@ -1,13 +1,16 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:pshared/provider/account.dart'; +import 'package:pweb/models/resend/action_result.dart'; +import 'package:pweb/models/resend/avaliability.dart'; import 'package:pweb/utils/snackbar.dart'; import 'package:pweb/utils/error/snackbar.dart'; +import 'package:pweb/utils/cooldown_format.dart'; +import 'package:pweb/controllers/signup/confirmation_card.dart'; +import 'package:pweb/widgets/resend_link.dart'; import 'package:pweb/widgets/vspacer.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -15,7 +18,6 @@ import 'package:pweb/generated/i18n/app_localizations.dart'; part 'state.dart'; part 'content.dart'; part 'badge.dart'; -part 'resend_button.dart'; class SignupConfirmationCard extends StatefulWidget { diff --git a/frontend/pweb/lib/pages/signup/confirmation/card/content.dart b/frontend/pweb/lib/pages/signup/confirmation/card/content.dart index 552ca61c..5d78ccb3 100644 --- a/frontend/pweb/lib/pages/signup/confirmation/card/content.dart +++ b/frontend/pweb/lib/pages/signup/confirmation/card/content.dart @@ -63,11 +63,11 @@ class _SignupConfirmationContent extends StatelessWidget { runSpacing: 12, alignment: WrapAlignment.center, children: [ - _SignupConfirmationResendButton( - canResend: canResend, - isResending: isResending, - resendLabel: resendLabel, - onResend: onResend, + ResendLink( + label: resendLabel, + onPressed: onResend, + isDisabled: !canResend, + isLoading: isResending, ), ], ), diff --git a/frontend/pweb/lib/pages/signup/confirmation/card/resend_button.dart b/frontend/pweb/lib/pages/signup/confirmation/card/resend_button.dart deleted file mode 100644 index dbd6b050..00000000 --- a/frontend/pweb/lib/pages/signup/confirmation/card/resend_button.dart +++ /dev/null @@ -1,36 +0,0 @@ -part of 'card.dart'; - - -class _SignupConfirmationResendButton extends StatelessWidget { - final bool canResend; - final bool isResending; - final String resendLabel; - final VoidCallback onResend; - - const _SignupConfirmationResendButton({ - required this.canResend, - required this.isResending, - required this.resendLabel, - required this.onResend, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return ElevatedButton.icon( - onPressed: canResend ? onResend : 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/card/state.dart b/frontend/pweb/lib/pages/signup/confirmation/card/state.dart index 4c8d28ee..12a21739 100644 --- a/frontend/pweb/lib/pages/signup/confirmation/card/state.dart +++ b/frontend/pweb/lib/pages/signup/confirmation/card/state.dart @@ -3,61 +3,45 @@ part of 'card.dart'; class _SignupConfirmationCardState extends State { static const int _defaultCooldownSeconds = 60; - - Timer? _cooldownTimer; - int _cooldownRemainingSeconds = 0; - bool _isResending = false; + late final SignupConfirmationCardController _controller; @override void initState() { super.initState(); - _startCooldown(_defaultCooldownSeconds); + _controller = SignupConfirmationCardController( + accountProvider: context.read(), + defaultCooldown: const Duration(seconds: _defaultCooldownSeconds), + ); + _controller.initialize(email: widget.email); } @override void dispose() { - _cooldownTimer?.cancel(); + _controller.dispose(); super.dispose(); } - bool get _isCooldownActive => _cooldownRemainingSeconds > 0; - - void _startCooldown(int seconds) { - _cooldownTimer?.cancel(); - if (seconds <= 0) { - setState(() => _cooldownRemainingSeconds = 0); - return; + @override + void didUpdateWidget(covariant SignupConfirmationCard oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.email != widget.email) { + _controller.updateEmail(widget.email); } - 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); + final result = await _controller.resendVerificationEmail(); if (!mounted) return; - notifyUser(context, locs.signupConfirmationResent(email)); - _startCooldown(_defaultCooldownSeconds); + if (result == ResendActionResult.missingEmail) { + notifyUser(context, locs.errorEmailMissing); + return; + } + if (result == ResendActionResult.sent) { + final email = widget.email?.trim() ?? ''; + notifyUser(context, locs.signupConfirmationResent(email)); + } } catch (e) { if (!mounted) return; postNotifyUserOfErrorX( @@ -65,22 +49,9 @@ class _SignupConfirmationCardState extends State { 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); @@ -89,34 +60,44 @@ class _SignupConfirmationCardState extends State { 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; - final content = _SignupConfirmationContent( - email: email, - description: description, - canResend: canResend, - resendLabel: resendLabel, - isResending: _isResending, - onResend: _resendVerificationEmail, - ); + return AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final availability = _controller.resendAvailability; + final canResend = availability == ResendAvailability.available; + final isResending = availability == ResendAvailability.resending; + final resendLabel = availability == ResendAvailability.cooldown + ? locs.signupConfirmationResendCooldown( + formatCooldownSeconds(_controller.cooldownRemainingSeconds), + ) + : locs.signupConfirmationResend; - if (widget.isEmbedded) return content; + final content = _SignupConfirmationContent( + email: email, + description: description, + canResend: canResend, + resendLabel: resendLabel, + isResending: isResending, + onResend: _resendVerificationEmail, + ); - return Card( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - side: BorderSide( - color: theme.dividerColor.withValues(alpha: 0.6), - ), - ), - child: Padding( - padding: const EdgeInsets.all(28), - child: content, - ), + if (widget.isEmbedded) return content; + + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + side: BorderSide( + color: theme.dividerColor.withValues(alpha: 0.6), + ), + ), + child: Padding( + padding: const EdgeInsets.all(28), + child: content, + ), + ); + }, ); } } diff --git a/frontend/pweb/lib/pages/signup/confirmation/page.dart b/frontend/pweb/lib/pages/signup/confirmation/page.dart index 2d24331c..523cbed4 100644 --- a/frontend/pweb/lib/pages/signup/confirmation/page.dart +++ b/frontend/pweb/lib/pages/signup/confirmation/page.dart @@ -1,76 +1,106 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'package:pshared/provider/account.dart'; + +import 'package:pweb/app/router/pages.dart'; import 'package:pweb/pages/login/app_bar.dart'; import 'package:pweb/pages/signup/confirmation/card/card.dart'; -import 'package:pweb/pages/signup/confirmation/login_prompt.dart'; +import 'package:pweb/controllers/signup/confirmation.dart'; import 'package:pweb/pages/with_footer.dart'; -import 'package:pweb/widgets/vspacer.dart'; class SignUpConfirmationPage extends StatefulWidget { final String? email; + final String? password; - const SignUpConfirmationPage({super.key, this.email}); + const SignUpConfirmationPage({ + super.key, + this.email, + this.password, + }); @override State createState() => _SignUpConfirmationPageState(); } class _SignUpConfirmationPageState extends State { + late final SignupConfirmationController _controller; + + @override + void initState() { + super.initState(); + _controller = SignupConfirmationController( + accountProvider: context.read(), + )..addListener(_handleAuthorizationStatus); + WidgetsBinding.instance.addPostFrameCallback((_) => _startPolling()); + } + + @override + void dispose() { + _controller.removeListener(_handleAuthorizationStatus); + _controller.dispose(); + super.dispose(); + } + + void _startPolling() { + if (!mounted) return; + final email = widget.email?.trim(); + final password = widget.password; + if (email == null || email.isEmpty || password == null || password.isEmpty) { + return; + } + _controller.startPolling( + email: email, + password: password, + locale: Localizations.localeOf(context).toLanguageTag(), + ); + } + + void _handleAuthorizationStatus() { + if (!_controller.isAuthorized || !mounted) return; + navigateAndReplace(context, Pages.login); + } + @override Widget build(BuildContext context) { final email = widget.email?.trim(); + final width = MediaQuery.of(context).size.width; + final isWide = width >= 980; return PageWithFooter( appBar: const LoginAppBar(), - child: LayoutBuilder( - builder: (context, constraints) { - final isWide = constraints.maxWidth >= 980; - - return ListView( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 32), - children: [ - Center( - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: isWide ? 980 : 720), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Card( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(22), - side: BorderSide( - color: Theme.of(context).dividerColor.withValues(alpha: 0.6), - ), - ), - child: Padding( - padding: const EdgeInsets.all(28), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - SignupConfirmationCard( - email: email, - isEmbedded: true, - ), - const VSpacer(multiplier: 2), - Divider( - height: 1, - color: Theme.of(context).dividerColor.withValues(alpha: 0.6), - ), - const VSpacer(multiplier: 2), - const SignupConfirmationLoginPrompt(isEmbedded: true), - ], - ), - ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 32), + child: Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: isWide ? 980 : 720), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(22), + side: BorderSide( + color: Theme.of(context).dividerColor.withValues(alpha: 0.6), + ), + ), + child: Padding( + padding: const EdgeInsets.all(28), + child: + SignupConfirmationCard( + email: email, + isEmbedded: true, ), - ], ), ), - ), - ], - ); - }, + ], + ), + ), + ), ), ); } diff --git a/frontend/pweb/lib/pages/signup/form/state.dart b/frontend/pweb/lib/pages/signup/form/state.dart index d1f7686a..a8d1fb3f 100644 --- a/frontend/pweb/lib/pages/signup/form/state.dart +++ b/frontend/pweb/lib/pages/signup/form/state.dart @@ -95,6 +95,7 @@ class SignUpFormState extends State { Pages.signupConfirm.name, extra: SignupConfirmationArgs( email: controllers.email.text.trim(), + password: controllers.password.text, ), ); }, diff --git a/frontend/pweb/lib/pages/verification/content.dart b/frontend/pweb/lib/pages/verification/content.dart index c3c90221..9a4db490 100644 --- a/frontend/pweb/lib/pages/verification/content.dart +++ b/frontend/pweb/lib/pages/verification/content.dart @@ -12,6 +12,7 @@ import 'package:pweb/pages/verification/controller.dart'; import 'package:pweb/pages/verification/resend_dialog.dart'; import 'package:pweb/utils/snackbar.dart'; import 'package:pweb/utils/error/snackbar.dart'; +import 'package:pweb/widgets/resend_link.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -64,6 +65,12 @@ class AccountVerificationContentState Widget content; if (controller.isLoading) { content = const Center(child: CircularProgressIndicator()); + } else if (controller.isAlreadyVerified) { + content = StatusPageSuccess( + successMessage: locs.accountAlreadyVerified, + successDescription: locs.accountAlreadyVerifiedDescription, + action: action, + ); } else if (controller.isSuccess) { content = StatusPageSuccess( successMessage: locs.accountVerified, @@ -78,18 +85,11 @@ class AccountVerificationContentState exception: controller.error ?? Exception(locs.accountVerificationFailed), action: controller.canResend - ? OutlinedButton.icon( - onPressed: controller.isResending - ? null - : _resendVerificationEmail, - icon: controller.isResending - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.mark_email_unread_outlined), - label: Text(locs.signupConfirmationResend), + ? ResendLink( + label: locs.signupConfirmationResend, + onPressed: _resendVerificationEmail, + isDisabled: !controller.canResend || controller.isResending, + isLoading: controller.isResending, ) : null, ); @@ -108,4 +108,4 @@ class AccountVerificationContentState child: content, ); } -} \ No newline at end of file +} diff --git a/frontend/pweb/lib/pages/verification/controller.dart b/frontend/pweb/lib/pages/verification/controller.dart index c6dc3b3d..5d009e06 100644 --- a/frontend/pweb/lib/pages/verification/controller.dart +++ b/frontend/pweb/lib/pages/verification/controller.dart @@ -26,6 +26,7 @@ class AccountVerificationController extends ChangeNotifier { Exception? get error => _verificationProvider.error; bool get canResend => _verificationProvider.canResendVerification; bool get isResending => _resendStatus == FlowStatus.resending; + bool get isAlreadyVerified => _verificationProvider.isTokenAlreadyUsed; void startVerification(String token) { final trimmed = token.trim(); diff --git a/frontend/pweb/lib/providers/two_factor.dart b/frontend/pweb/lib/providers/two_factor.dart index 85cefca2..309fd4c7 100644 --- a/frontend/pweb/lib/providers/two_factor.dart +++ b/frontend/pweb/lib/providers/two_factor.dart @@ -22,6 +22,7 @@ class TwoFactorProvider extends ChangeNotifier { String? _currentPendingToken; Timer? _cooldownTimer; int _cooldownRemainingSeconds = 0; + DateTime? _cooldownUntil; FlowStatus get status => _status; bool get isSubmitting => _status == FlowStatus.submitting; @@ -108,48 +109,69 @@ class TwoFactorProvider extends ChangeNotifier { return; } - final remaining = pending.cooldownRemainingSeconds; - if (remaining <= 0) { + final until = pending.cooldownUntil; + if (until == null) { _stopCooldown(notify: _cooldownRemainingSeconds != 0); return; } - if (_cooldownRemainingSeconds != remaining) { - _startCooldown(remaining); + if (!_isCooldownActive(until) && _cooldownRemainingSeconds != 0) { + _stopCooldown(notify: true); + return; + } + + if (_cooldownUntil == null || _cooldownUntil != until) { + _startCooldownUntil(until); } } void _startCooldown(int seconds) { + final until = DateTime.now().add(Duration(seconds: seconds)); + _startCooldownUntil(until); + } + + void _startCooldownUntil(DateTime until) { _cooldownTimer?.cancel(); - _cooldownRemainingSeconds = seconds; + _cooldownUntil = until; + _cooldownRemainingSeconds = _cooldownRemaining(); if (_cooldownRemainingSeconds <= 0) { _cooldownTimer = null; + _cooldownUntil = null; notifyListeners(); return; } _cooldownTimer = Timer.periodic(const Duration(seconds: 1), (timer) { - if (_cooldownRemainingSeconds <= 1) { - _cooldownRemainingSeconds = 0; - _cooldownTimer?.cancel(); - _cooldownTimer = null; - notifyListeners(); + final remaining = _cooldownRemaining(); + if (remaining <= 0) { + _stopCooldown(notify: true); return; } - - _cooldownRemainingSeconds -= 1; - notifyListeners(); + if (remaining != _cooldownRemainingSeconds) { + _cooldownRemainingSeconds = remaining; + notifyListeners(); + } }); notifyListeners(); } + bool _isCooldownActive(DateTime until) => until.isAfter(DateTime.now()); + + int _cooldownRemaining() { + final until = _cooldownUntil; + if (until == null) return 0; + final remaining = until.difference(DateTime.now()).inSeconds; + return remaining < 0 ? 0 : remaining; + } + void _stopCooldown({bool notify = false}) { _cooldownTimer?.cancel(); _cooldownTimer = null; final hadCooldown = _cooldownRemainingSeconds != 0; _cooldownRemainingSeconds = 0; + _cooldownUntil = null; if (notify && hadCooldown) { notifyListeners(); diff --git a/frontend/pweb/lib/utils/cooldown_format.dart b/frontend/pweb/lib/utils/cooldown_format.dart new file mode 100644 index 00000000..10a5ee1d --- /dev/null +++ b/frontend/pweb/lib/utils/cooldown_format.dart @@ -0,0 +1,8 @@ +String formatCooldownSeconds(int seconds) { + final minutes = seconds ~/ 60; + final remainingSeconds = seconds % 60; + if (minutes > 0) { + return '$minutes:${remainingSeconds.toString().padLeft(2, '0')}'; + } + return remainingSeconds.toString(); +} diff --git a/frontend/pweb/lib/utils/error/handler.dart b/frontend/pweb/lib/utils/error/handler.dart index 333543e9..97e99072 100644 --- a/frontend/pweb/lib/utils/error/handler.dart +++ b/frontend/pweb/lib/utils/error/handler.dart @@ -15,6 +15,7 @@ class ErrorHandler { 'account_not_verified': locs.errorAccountNotVerified, 'unauthorized': locs.errorLoginUnauthorized, 'verification_token_not_found': locs.errorVerificationTokenNotFound, + 'user_already_registered': locs.errorDuplicateEmail, 'internal_error': locs.errorInternalError, 'invalid_target': locs.errorInvalidTarget, 'pending_token_required': locs.errorPendingTokenRequired, @@ -62,6 +63,9 @@ class ErrorHandler { } static String _handleErrorResponseLocs(AppLocalizations locs, ErrorResponse e) { + if (e.source == 'user_already_registered') { + return locs.errorDuplicateEmail; + } final errorMessages = getErrorMessagesLocs(locs); // Return the localized message if we recognize the error key, else use the raw details return errorMessages[e.error] ?? e.details; diff --git a/frontend/pweb/lib/widgets/resend_link.dart b/frontend/pweb/lib/widgets/resend_link.dart new file mode 100644 index 00000000..7b76a738 --- /dev/null +++ b/frontend/pweb/lib/widgets/resend_link.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; + + +class ResendLink extends StatelessWidget { + final String label; + final VoidCallback onPressed; + final bool isDisabled; + final bool isLoading; + + const ResendLink({ + super.key, + required this.label, + required this.onPressed, + this.isDisabled = false, + this.isLoading = false, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final color = theme.colorScheme.primary; + final isButtonDisabled = isDisabled || isLoading; + + return TextButton( + onPressed: isButtonDisabled ? null : onPressed, + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: const Size(0, 0), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + alignment: Alignment.centerLeft, + foregroundColor: color, + textStyle: theme.textTheme.bodyMedium?.copyWith( + decoration: TextDecoration.underline, + ), + ), + child: isLoading + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: color, + ), + ) + : Text(label), + ); + } +}