sneaky email verification fix
This commit is contained in:
@@ -1,10 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/api/responses/error/server.dart';
|
||||||
import 'package:pshared/provider/resource.dart';
|
import 'package:pshared/provider/resource.dart';
|
||||||
import 'package:pshared/service/account.dart';
|
import 'package:pshared/service/account.dart';
|
||||||
import 'package:pshared/utils/exception.dart';
|
import 'package:pshared/utils/exception.dart';
|
||||||
|
|
||||||
|
|
||||||
class EmailVerificationProvider extends ChangeNotifier {
|
class EmailVerificationProvider extends ChangeNotifier {
|
||||||
Resource<bool> _resource = Resource(data: null, isLoading: false);
|
Resource<bool> _resource = Resource(data: null, isLoading: false);
|
||||||
String? _token;
|
String? _token;
|
||||||
@@ -13,6 +13,11 @@ class EmailVerificationProvider extends ChangeNotifier {
|
|||||||
bool get isLoading => _resource.isLoading;
|
bool get isLoading => _resource.isLoading;
|
||||||
bool get isSuccess => _resource.data == true;
|
bool get isSuccess => _resource.data == true;
|
||||||
Exception? get error => _resource.error;
|
Exception? get error => _resource.error;
|
||||||
|
int? get errorCode => _resource.error is ErrorResponse
|
||||||
|
? (_resource.error as ErrorResponse).code
|
||||||
|
: null;
|
||||||
|
bool get canResendVerification =>
|
||||||
|
errorCode == 400 || errorCode == 410 || errorCode == 500;
|
||||||
|
|
||||||
Future<void> verify(String token) async {
|
Future<void> verify(String token) async {
|
||||||
final trimmed = token.trim();
|
final trimmed = token.trim();
|
||||||
@@ -33,12 +38,12 @@ class EmailVerificationProvider extends ChangeNotifier {
|
|||||||
await AccountService.verifyEmail(trimmed);
|
await AccountService.verifyEmail(trimmed);
|
||||||
_setResource(Resource(data: true, isLoading: false));
|
_setResource(Resource(data: true, isLoading: false));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e is ErrorResponse && e.code == 404) {
|
||||||
|
_setResource(Resource(data: true, isLoading: false));
|
||||||
|
return;
|
||||||
|
}
|
||||||
_setResource(
|
_setResource(
|
||||||
Resource(
|
Resource(data: null, isLoading: false, error: toException(e)),
|
||||||
data: null,
|
|
||||||
isLoading: false,
|
|
||||||
error: toException(e),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,12 +11,14 @@ class ErrorPage extends StatelessWidget {
|
|||||||
final String title;
|
final String title;
|
||||||
final String errorMessage;
|
final String errorMessage;
|
||||||
final String errorHint;
|
final String errorHint;
|
||||||
|
final Widget? action;
|
||||||
|
|
||||||
const ErrorPage({
|
const ErrorPage({
|
||||||
super.key,
|
super.key,
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.errorMessage,
|
required this.errorMessage,
|
||||||
required this.errorHint,
|
required this.errorHint,
|
||||||
|
this.action,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -26,19 +28,34 @@ class ErrorPage extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.error_outline, size: 72, color: Theme.of(context).colorScheme.error),
|
Icon(
|
||||||
|
Icons.error_outline,
|
||||||
|
size: 72,
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
const VSpacer(),
|
const VSpacer(),
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(color: Theme.of(context).colorScheme.error),
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const VSpacer(multiplier: 0.5),
|
const VSpacer(multiplier: 0.5),
|
||||||
ListTile(
|
ListTile(
|
||||||
title: Text(errorMessage, textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleLarge),
|
title: Text(
|
||||||
subtitle: Text(errorHint, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall),
|
errorMessage,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
errorHint,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const VSpacer(multiplier: 1.5),
|
const VSpacer(multiplier: 1.5),
|
||||||
|
if (action != null) ...[action!, const VSpacer(multiplier: 0.5)],
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => navigate(context, Pages.root),
|
onPressed: () => navigate(context, Pages.root),
|
||||||
child: Text(AppLocalizations.of(context)!.goToMainPage),
|
child: Text(AppLocalizations.of(context)!.goToMainPage),
|
||||||
@@ -54,8 +71,10 @@ Widget exceptionToErrorPage({
|
|||||||
required String title,
|
required String title,
|
||||||
required String errorMessage,
|
required String errorMessage,
|
||||||
required Object exception,
|
required Object exception,
|
||||||
|
Widget? action,
|
||||||
}) => ErrorPage(
|
}) => ErrorPage(
|
||||||
title: title,
|
title: title,
|
||||||
errorMessage: errorMessage,
|
errorMessage: errorMessage,
|
||||||
errorHint: ErrorHandler.handleError(context, exception),
|
errorHint: ErrorHandler.handleError(context, exception),
|
||||||
|
action: action,
|
||||||
);
|
);
|
||||||
|
|||||||
111
frontend/pweb/lib/pages/verification/content.dart
Normal file
111
frontend/pweb/lib/pages/verification/content.dart
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/widgets/locale.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/app/router/pages.dart';
|
||||||
|
import 'package:pweb/pages/errors/error.dart';
|
||||||
|
import 'package:pweb/pages/status/success.dart';
|
||||||
|
import 'package:pweb/pages/with_footer.dart';
|
||||||
|
import 'package:pweb/pages/verification/controller.dart';
|
||||||
|
import 'package:pweb/pages/verification/resend_dialog.dart';
|
||||||
|
import 'package:pweb/utils/snackbar.dart';
|
||||||
|
import 'package:pweb/widgets/error/snackbar.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class AccountVerificationContent extends StatefulWidget {
|
||||||
|
const AccountVerificationContent();
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AccountVerificationContent> createState() =>
|
||||||
|
AccountVerificationContentState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class AccountVerificationContentState
|
||||||
|
extends State<AccountVerificationContent> {
|
||||||
|
Future<void> _resendVerificationEmail() async {
|
||||||
|
final controller = context.read<AccountVerificationController>();
|
||||||
|
if (controller.isResending) return;
|
||||||
|
final locs = AppLocalizations.of(context)!;
|
||||||
|
final email = await requestVerificationEmail(context, locs);
|
||||||
|
if (!mounted || email == null) return;
|
||||||
|
if (email.isEmpty) {
|
||||||
|
notifyUser(context, locs.errorEmailMissing);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await controller.resendVerificationEmail(email);
|
||||||
|
if (!mounted) return;
|
||||||
|
await notifyUser(context, locs.signupConfirmationResent(email));
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
await postNotifyUserOfErrorX(
|
||||||
|
context: context,
|
||||||
|
errorSituation: locs.signupConfirmationResendError,
|
||||||
|
exception: e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final locs = AppLocalizations.of(context)!;
|
||||||
|
final controller = context.watch<AccountVerificationController>();
|
||||||
|
final action = OutlinedButton.icon(
|
||||||
|
onPressed: () => navigateAndReplace(context, Pages.login),
|
||||||
|
icon: const Icon(Icons.login),
|
||||||
|
label: Text(locs.login),
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget content;
|
||||||
|
if (controller.isLoading) {
|
||||||
|
content = const Center(child: CircularProgressIndicator());
|
||||||
|
} else if (controller.isSuccess) {
|
||||||
|
content = StatusPageSuccess(
|
||||||
|
successMessage: locs.accountVerified,
|
||||||
|
successDescription: locs.accountVerifiedDescription,
|
||||||
|
action: action,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
content = exceptionToErrorPage(
|
||||||
|
context: context,
|
||||||
|
title: locs.verificationFailed,
|
||||||
|
errorMessage: locs.accountVerificationFailed,
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return PageWithFooter(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(locs.verifyAccount),
|
||||||
|
centerTitle: true,
|
||||||
|
actions: [
|
||||||
|
const LocaleChangerDropdown(
|
||||||
|
availableLocales: AppLocalizations.supportedLocales,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: content,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
64
frontend/pweb/lib/pages/verification/controller.dart
Normal file
64
frontend/pweb/lib/pages/verification/controller.dart
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/provider/account.dart';
|
||||||
|
import 'package:pshared/provider/email_verification.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/models/flow_status.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class AccountVerificationController extends ChangeNotifier {
|
||||||
|
AccountVerificationController({
|
||||||
|
required AccountProvider accountProvider,
|
||||||
|
required EmailVerificationProvider verificationProvider,
|
||||||
|
}) : _accountProvider = accountProvider,
|
||||||
|
_verificationProvider = verificationProvider {
|
||||||
|
_verificationProvider.addListener(_onVerificationChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
final AccountProvider _accountProvider;
|
||||||
|
final EmailVerificationProvider _verificationProvider;
|
||||||
|
|
||||||
|
FlowStatus _resendStatus = FlowStatus.idle;
|
||||||
|
String? _verificationToken;
|
||||||
|
|
||||||
|
bool get isLoading => _verificationProvider.isLoading;
|
||||||
|
bool get isSuccess => _verificationProvider.isSuccess;
|
||||||
|
Exception? get error => _verificationProvider.error;
|
||||||
|
bool get canResend => _verificationProvider.canResendVerification;
|
||||||
|
bool get isResending => _resendStatus == FlowStatus.resending;
|
||||||
|
|
||||||
|
void startVerification(String token) {
|
||||||
|
final trimmed = token.trim();
|
||||||
|
if (trimmed.isEmpty || trimmed == _verificationToken) return;
|
||||||
|
_verificationToken = trimmed;
|
||||||
|
_verificationProvider.verify(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> resendVerificationEmail(String email) async {
|
||||||
|
final trimmed = email.trim();
|
||||||
|
if (trimmed.isEmpty || isResending) return;
|
||||||
|
_setResendStatus(FlowStatus.resending);
|
||||||
|
try {
|
||||||
|
await _accountProvider.resendVerificationEmail(trimmed);
|
||||||
|
_setResendStatus(FlowStatus.idle);
|
||||||
|
} catch (_) {
|
||||||
|
_setResendStatus(FlowStatus.error);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onVerificationChanged() {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setResendStatus(FlowStatus status) {
|
||||||
|
_resendStatus = status;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_verificationProvider.removeListener(_onVerificationChanged);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,81 +2,26 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/provider/account.dart';
|
||||||
import 'package:pshared/provider/email_verification.dart';
|
import 'package:pshared/provider/email_verification.dart';
|
||||||
import 'package:pshared/widgets/locale.dart';
|
|
||||||
|
|
||||||
import 'package:pweb/app/router/pages.dart';
|
import 'package:pweb/pages/verification/content.dart';
|
||||||
import 'package:pweb/pages/errors/error.dart';
|
import 'package:pweb/pages/verification/controller.dart';
|
||||||
import 'package:pweb/pages/status/success.dart';
|
|
||||||
import 'package:pweb/pages/with_footer.dart';
|
|
||||||
|
|
||||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
|
||||||
|
|
||||||
|
|
||||||
class AccountVerificationPage extends StatefulWidget {
|
class AccountVerificationPage extends StatelessWidget {
|
||||||
final String token;
|
final String token;
|
||||||
|
|
||||||
const AccountVerificationPage({super.key, required this.token});
|
const AccountVerificationPage({super.key, required this.token});
|
||||||
|
|
||||||
@override
|
|
||||||
State<AccountVerificationPage> createState() => _AccountVerificationPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AccountVerificationPageState extends State<AccountVerificationPage> {
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
context.read<EmailVerificationProvider>().verify(widget.token);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return const _AccountVerificationContent();
|
return ChangeNotifierProvider(
|
||||||
}
|
create: (context) => AccountVerificationController(
|
||||||
}
|
accountProvider: context.read<AccountProvider>(),
|
||||||
|
verificationProvider: context.read<EmailVerificationProvider>(),
|
||||||
class _AccountVerificationContent extends StatelessWidget {
|
)..startVerification(token),
|
||||||
const _AccountVerificationContent();
|
child: AccountVerificationContent(),
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final locs = AppLocalizations.of(context)!;
|
|
||||||
final provider = context.watch<EmailVerificationProvider>();
|
|
||||||
final action = OutlinedButton.icon(
|
|
||||||
onPressed: () => navigateAndReplace(context, Pages.login),
|
|
||||||
icon: const Icon(Icons.login),
|
|
||||||
label: Text(locs.login),
|
|
||||||
);
|
|
||||||
|
|
||||||
Widget content;
|
|
||||||
if (provider.isLoading) {
|
|
||||||
content = const Center(child: CircularProgressIndicator());
|
|
||||||
} else if (provider.isSuccess) {
|
|
||||||
content = StatusPageSuccess(
|
|
||||||
successMessage: locs.accountVerified,
|
|
||||||
successDescription: locs.accountVerifiedDescription,
|
|
||||||
action: action,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
content = exceptionToErrorPage(
|
|
||||||
context: context,
|
|
||||||
title: locs.verificationFailed,
|
|
||||||
errorMessage: locs.accountVerificationFailed,
|
|
||||||
exception: provider.error ?? Exception(locs.accountVerificationFailed),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return PageWithFooter(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: Text(locs.verifyAccount),
|
|
||||||
centerTitle: true,
|
|
||||||
actions: [
|
|
||||||
const LocaleChangerDropdown(
|
|
||||||
availableLocales: AppLocalizations.supportedLocales,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: content,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
41
frontend/pweb/lib/pages/verification/resend_dialog.dart
Normal file
41
frontend/pweb/lib/pages/verification/resend_dialog.dart
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
|
||||||
|
Future<String?> requestVerificationEmail(
|
||||||
|
BuildContext context,
|
||||||
|
AppLocalizations locs,
|
||||||
|
) async {
|
||||||
|
final controller = TextEditingController();
|
||||||
|
final email = await showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: Text(locs.signupConfirmationResend),
|
||||||
|
content: TextField(
|
||||||
|
controller: controller,
|
||||||
|
autofocus: true,
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: locs.username,
|
||||||
|
hintText: locs.usernameHint,
|
||||||
|
),
|
||||||
|
onSubmitted: (_) =>
|
||||||
|
Navigator.of(dialogContext).pop(controller.text.trim()),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||||
|
child: Text(locs.cancel),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () =>
|
||||||
|
Navigator.of(dialogContext).pop(controller.text.trim()),
|
||||||
|
child: Text(locs.signupConfirmationResend),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
controller.dispose();
|
||||||
|
return email?.trim();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user