diff --git a/README.md b/README.md index c51f7be..e4d405f 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,10 @@ management dashboard](https://github.com/headlines-toolkit/ht-dashboard). ## ✨ Key Capabilities * 🔒 **Flexible & Secure Authentication:** Provide seamless user access with - a unified system supporting passwordless email sign-in, anonymous guest - accounts, and a secure, role-aware login flow for privileged dashboard - users. + a unified system supporting passwordless email sign-in and anonymous guest + accounts. The API intelligently handles the conversion from a guest to a + permanent user, preserving all settings and preferences. It also includes + a secure, role-aware login flow for privileged dashboard users. * ⚡️ **Granular Role-Based Access Control (RBAC):** Implement precise permissions with a dual-role system (`appRole` for application features, diff --git a/lib/src/services/auth_service.dart b/lib/src/services/auth_service.dart index acd4e9d..4f51ba4 100644 --- a/lib/src/services/auth_service.dart +++ b/lib/src/services/auth_service.dart @@ -129,33 +129,60 @@ class AuthService { /// Returns the authenticated [User] and a new authentication token. /// /// Throws [InvalidInputException] if the code is invalid or expired. + /// Completes the email sign-in process by verifying the code. + /// + /// This method is context-aware and handles multiple scenarios: + /// + /// - **Guest to Permanent Conversion:** If an authenticated `guestUser` + /// (from [authenticatedUser]) performs this action, their account is + /// upgraded to a permanent `standardUser` with the verified [email]. + /// Their existing data is preserved. + /// - **Dashboard Login:** If [isDashboardLogin] is true, it performs a + /// strict login for an existing user with dashboard permissions. + /// - **Standard Sign-In/Sign-Up:** If no authenticated user is present, it + /// either logs in an existing user with the given [email] or creates a + /// new `standardUser`. + /// + /// Returns the authenticated [User] and a new authentication token. + /// + /// Throws [InvalidInputException] if the code is invalid or expired. Future<({User user, String token})> completeEmailSignIn( String email, String code, { - // Flag to indicate if this is a login attempt from the dashboard, - // which enforces stricter checks. - bool isDashboardLogin = false, + required bool isDashboardLogin, + User? authenticatedUser, }) async { - // 1. Validate the code for standard sign-in - final isValidCode = await _verificationCodeStorageService - .validateSignInCode(email, code); + // 1. Validate the verification code. + final isValidCode = + await _verificationCodeStorageService.validateSignInCode(email, code); if (!isValidCode) { - throw const InvalidInputException( - 'Invalid or expired verification code.', - ); + throw const InvalidInputException('Invalid or expired verification code.'); } - // After successful code validation, clear the sign-in code + // After successful validation, clear the code from storage. try { await _verificationCodeStorageService.clearSignInCode(email); } catch (e) { - // Log or handle if clearing fails, but don't let it block sign-in _log.warning( 'Warning: Failed to clear sign-in code for $email after validation: $e', ); } - // 2. Find or create the user based on the context + // 2. Check for Guest-to-Permanent user conversion flow. + if (authenticatedUser != null && + authenticatedUser.appRole == AppUserRole.guestUser) { + _log.info( + 'Starting account conversion for guest user ${authenticatedUser.id} to email $email.', + ); + return _convertGuestUserToPermanent( + guestUser: authenticatedUser, + verifiedEmail: email, + ); + } + + // 3. If not a conversion, proceed with standard or dashboard login. + + // Find or create the user based on the context. User user; try { // Attempt to find user by email @@ -166,18 +193,6 @@ class AuthService { // This closes the loophole where a non-admin user could request a code // via the app flow and then use it to log into the dashboard. if (isDashboardLogin) { - if (user.email != email) { - // This is a critical security check. If the user found by email - // somehow has a different email than the one provided, it's a - // sign of a serious issue (like the data layer bug we fixed). - // We throw a generic error to avoid revealing information. - _log.severe( - 'CRITICAL: Mismatch between requested email ($email) and found ' - 'user email (${user.email}) during dashboard login for user ' - 'ID ${user.id}.', - ); - throw const UnauthorizedException('User account does not exist.'); - } if (!_permissionService.hasPermission( user, Permissions.dashboardLogin, @@ -358,155 +373,6 @@ class AuthService { /// Initiates the process of linking an [emailToLink] to an existing /// authenticated [anonymousUser]'s account. /// - /// Throws [ConflictException] if the [emailToLink] is already in use by - /// another permanent account, or if the [anonymousUser] is not actually - /// anonymous, or if the [emailToLink] is already pending verification for - /// another linking process. - /// Throws [OperationFailedException] for other errors. - Future initiateLinkEmailProcess({ - required User anonymousUser, - required String emailToLink, - }) async { - if (anonymousUser.appRole != AppUserRole.guestUser) { - throw const BadRequestException( - 'Account is already permanent. Cannot link email.', - ); - } - - try { - // 1. Check if emailToLink is already used by another permanent user. - final existingUsersResponse = await _userRepository.readAll( - filter: {'email': emailToLink}, - ); - - // Filter for permanent users (not guests) that are not the current user. - final conflictingPermanentUsers = existingUsersResponse.items.where( - (u) => u.appRole != AppUserRole.guestUser && u.id != anonymousUser.id, - ); - - if (conflictingPermanentUsers.isNotEmpty) { - throw ConflictException( - 'Email address "$emailToLink" is already in use by another account.', - ); - } - - // 2. Generate and store the link code. - // The storage service itself might throw ConflictException if emailToLink - // is pending for another user or if this user has a pending code. - final code = await _verificationCodeStorageService - .generateAndStoreLinkCode( - userId: anonymousUser.id, - emailToLink: emailToLink, - ); - - // 3. Send the code via email - await _emailRepository.sendOtpEmail( - recipientEmail: emailToLink, - otpCode: code, - ); - _log.info( - 'Initiated email link for user ${anonymousUser.id} to email $emailToLink, code sent: $code .', - ); - } on HtHttpException { - rethrow; - } catch (e) { - _log.severe( - 'Error during initiateLinkEmailProcess for user ${anonymousUser.id}, email $emailToLink: $e', - ); - throw OperationFailedException( - 'Failed to initiate email linking process: $e', - ); - } - } - - /// Completes the email linking process for an [anonymousUser] by verifying - /// the [codeFromUser]. - /// - /// If successful, updates the user to be permanent with the linked email - /// and returns the updated User and a new authentication token. - /// Throws [InvalidInputException] if the code is invalid or expired. - /// Throws [OperationFailedException] for other errors. - Future<({User user, String token})> completeLinkEmailProcess({ - required User anonymousUser, - required String codeFromUser, - required String oldAnonymousToken, // Needed to invalidate it - }) async { - if (anonymousUser.appRole != AppUserRole.guestUser) { - // Should ideally not happen if flow is correct, but good safeguard. - throw const BadRequestException( - 'Account is already permanent. Cannot complete email linking.', - ); - } - - try { - // 1. Validate the link code and retrieve the email that was being linked. - final linkedEmail = await _verificationCodeStorageService - .validateAndRetrieveLinkedEmail( - userId: anonymousUser.id, - linkCode: codeFromUser, - ); - - if (linkedEmail == null) { - throw const InvalidInputException( - 'Invalid or expired verification code for email linking.', - ); - } - - // 2. Update the user to be permanent. - final updatedUser = anonymousUser.copyWith( - email: linkedEmail, - appRole: AppUserRole.standardUser, - ); - final permanentUser = await _userRepository.update( - id: updatedUser.id, - item: updatedUser, - ); - _log.info( - 'User ${permanentUser.id} successfully linked with email $linkedEmail.', - ); - - // Ensure user data exists after linking. - await _ensureUserDataExists(permanentUser); - - // 3. Generate a new authentication token for the now-permanent user. - final newToken = await _authTokenService.generateToken(permanentUser); - _log.info('Generated new token for linked user ${permanentUser.id}'); - - // 4. Invalidate the old anonymous token. - try { - await _authTokenService.invalidateToken(oldAnonymousToken); - _log.info( - 'Successfully invalidated old anonymous token for user ${permanentUser.id}.', - ); - } catch (e) { - // Log error but don't fail the whole linking process if invalidation fails. - // The new token is more important. - _log.warning( - 'Warning: Failed to invalidate old anonymous token for user ${permanentUser.id}: $e', - ); - } - - // 5. Clear the link code from storage. - try { - await _verificationCodeStorageService.clearLinkCode(anonymousUser.id); - } catch (e) { - _log.warning( - 'Warning: Failed to clear link code for user ${anonymousUser.id} after linking: $e', - ); - } - - return (user: permanentUser, token: newToken); - } on HtHttpException { - rethrow; - } catch (e) { - _log.severe( - 'Error during completeLinkEmailProcess for user ${anonymousUser.id}: $e', - ); - throw OperationFailedException( - 'Failed to complete email linking process: $e', - ); - } - } /// Deletes a user account and associated authentication data. /// @@ -538,32 +404,18 @@ class AuthService { await _userRepository.delete(id: userId); _log.info('User ${userToDelete.id} deleted from repository.'); - // 3. Clear any pending verification codes for this user ID (linking). + // 3. Clear any pending sign-in codes for the user's email. try { - await _verificationCodeStorageService.clearLinkCode(userId); - _log.info('Cleared link code for user ${userToDelete.id}.'); + await _verificationCodeStorageService.clearSignInCode( + userToDelete.email, + ); + _log.info('Cleared sign-in code for email ${userToDelete.email}.'); } catch (e) { - // Log but don't fail deletion if clearing codes fails _log.warning( - 'Warning: Failed to clear link code for user ${userToDelete.id}: $e', + 'Warning: Failed to clear sign-in code for email ${userToDelete.email}: $e', ); } - // 4. Clear any pending sign-in codes for the user's email (if they had one). - // The email for anonymous users is a placeholder and not used for sign-in. - if (userToDelete.appRole != AppUserRole.guestUser) { - try { - await _verificationCodeStorageService.clearSignInCode( - userToDelete.email, - ); - _log.info('Cleared sign-in code for email ${userToDelete.email}.'); - } catch (e) { - _log.warning( - 'Warning: Failed to clear sign-in code for email ${userToDelete.email}: $e', - ); - } - } - _log.info('Account deletion process completed for user $userId.'); } on NotFoundException { // Propagate NotFoundException if user doesn't exist @@ -633,7 +485,10 @@ class AuthService { // Check for UserContentPreferences try { - await _userContentPreferencesRepository.read(id: user.id, userId: user.id); + await _userContentPreferencesRepository.read( + id: user.id, + userId: user.id, + ); } on NotFoundException { _log.info( 'UserContentPreferences not found for user ${user.id}. Creating with defaults.', @@ -651,4 +506,52 @@ class AuthService { ); } } + + /// Converts a guest user to a permanent standard user. + /// + /// This helper method encapsulates the logic for updating the user's + /// record with a verified email, upgrading their role, and generating a new + /// authentication token. It ensures that all associated user data is + /// preserved during the conversion. + /// + /// Throws [ConflictException] if the target email is already in use by + /// another permanent account. + Future<({User user, String token})> _convertGuestUserToPermanent({ + required User guestUser, + required String verifiedEmail, + }) async { + // 1. Check if the target email is already in use by another permanent user. + final existingUser = await _findUserByEmail(verifiedEmail); + if (existingUser != null && existingUser.id != guestUser.id) { + // If a different user already exists with this email, throw an error. + throw ConflictException( + 'This email address is already associated with another account.', + ); + } + + // 2. Update the guest user's details to make them permanent. + final updatedUser = guestUser.copyWith( + email: verifiedEmail, + appRole: AppUserRole.standardUser, + ); + + final permanentUser = await _userRepository.update( + id: updatedUser.id, + item: updatedUser, + ); + _log.info( + 'User ${permanentUser.id} successfully converted to permanent account with email $verifiedEmail.', + ); + + // 3. Generate a new token for the now-permanent user. + final newToken = await _authTokenService.generateToken(permanentUser); + _log.info('Generated new token for converted user ${permanentUser.id}'); + + // Note: Invalidation of the old anonymous token is handled implicitly. + // The client will receive the new token and stop using the old one. + // The old token will eventually expire. For immediate invalidation, + // the old token would need to be passed into this flow and blacklisted. + + return (user: permanentUser, token: newToken); + } } diff --git a/lib/src/services/verification_code_storage_service.dart b/lib/src/services/verification_code_storage_service.dart index c47aefe..ef21f89 100644 --- a/lib/src/services/verification_code_storage_service.dart +++ b/lib/src/services/verification_code_storage_service.dart @@ -32,17 +32,6 @@ class _SignInCodeEntry extends _CodeEntryBase { _SignInCodeEntry(super.code, super.expiresAt); } -/// {@template link_code_entry} -/// Stores a verification code for linking an email to an existing user. -/// {@endtemplate} -class _LinkCodeEntry extends _CodeEntryBase { - /// {@macro link_code_entry} - _LinkCodeEntry(super.code, super.expiresAt, this.emailToLink); - - /// The email address this link code is intended to verify. - final String emailToLink; -} - /// {@template verification_code_storage_service} /// Defines the interface for a service that manages verification codes /// for different authentication flows (sign-in and account linking). @@ -69,36 +58,6 @@ abstract class VerificationCodeStorageService { /// Throws [OperationFailedException] if clearing fails. Future clearSignInCode(String email); - // --- For Linking an Email to an Existing Authenticated (Anonymous) User --- - - /// Generates, stores, and returns a verification code for linking - /// [emailToLink] to the account of [userId]. - /// The [userId] is that of the currently authenticated anonymous user. - /// Codes are typically 6 digits. - /// Throws [OperationFailedException] on storage failure. - /// Throws [ConflictException] if [emailToLink] is already actively pending - /// for linking by another user, or if this [userId] already has an active - /// link code pending. - Future generateAndStoreLinkCode({ - required String userId, - required String emailToLink, - }); - - /// Validates the [linkCode] provided by the user with [userId] who is - /// attempting to link an email. - /// Returns the "emailToLink" if the code is valid and matches the one - /// stored for this [userId]. Returns `null` if invalid or expired. - /// Throws [OperationFailedException] on validation failure if an unexpected - /// error occurs during the check. - Future validateAndRetrieveLinkedEmail({ - required String userId, - required String linkCode, - }); - - /// Clears any pending link-code data associated with [userId]. - /// Throws [OperationFailedException] if clearing fails. - Future clearLinkCode(String userId); - // --- General --- /// Periodically cleans up expired codes of all types. @@ -144,10 +103,6 @@ class InMemoryVerificationCodeStorageService @visibleForTesting final Map signInCodesStore = {}; - /// Store for account linking codes: Key is userId. - @visibleForTesting - final Map linkCodesStore = {}; - Timer? _cleanupTimer; bool _isDisposed = false; final Random _random = Random(); @@ -196,71 +151,6 @@ class InMemoryVerificationCodeStorageService ); } - @override - Future generateAndStoreLinkCode({ - required String userId, - required String emailToLink, - }) async { - if (_isDisposed) { - throw const OperationFailedException('Service is disposed.'); - } - await Future.delayed(Duration.zero); // Simulate async - - // Check if this userId already has a pending link code - if (linkCodesStore.containsKey(userId) && - !linkCodesStore[userId]!.isExpired) { - throw const ConflictException( - 'User already has an active email linking process pending.', - ); - } - // Check if emailToLink is already pending for another user - final isEmailPendingForOther = linkCodesStore.values.any( - (entry) => - entry.emailToLink == emailToLink && - !entry.isExpired && - linkCodesStore.keys.firstWhere((id) => linkCodesStore[id] == entry) != - userId, - ); - if (isEmailPendingForOther) { - throw const ConflictException( - 'Email is already pending verification for another account linking process.', - ); - } - - final code = _generateNumericCode(); - final expiresAt = DateTime.now().add(codeExpiryDuration); - linkCodesStore[userId] = _LinkCodeEntry(code, expiresAt, emailToLink); - print( - '[InMemoryVerificationCodeStorageService] Stored link code for user $userId, email $emailToLink (expires: $expiresAt)', - ); - return code; - } - - @override - Future validateAndRetrieveLinkedEmail({ - required String userId, - required String linkCode, - }) async { - if (_isDisposed) return null; - await Future.delayed(Duration.zero); // Simulate async - final entry = linkCodesStore[userId]; - if (entry == null || entry.isExpired || entry.code != linkCode) { - return null; - } - return entry - .emailToLink; // Return the email associated with this valid code - } - - @override - Future clearLinkCode(String userId) async { - if (_isDisposed) return; - await Future.delayed(Duration.zero); // Simulate async - linkCodesStore.remove(userId); - print( - '[InMemoryVerificationCodeStorageService] Cleared link code for user $userId', - ); - } - @override Future cleanupExpiredCodes() async { if (_isDisposed) return; @@ -275,14 +165,6 @@ class InMemoryVerificationCodeStorageService return false; }); - linkCodesStore.removeWhere((key, entry) { - if (entry.isExpired) { - cleanedCount++; - return true; - } - return false; - }); - if (cleanedCount > 0) { print( '[InMemoryVerificationCodeStorageService] Cleaned up $cleanedCount expired codes.', @@ -296,7 +178,6 @@ class InMemoryVerificationCodeStorageService _isDisposed = true; _cleanupTimer?.cancel(); signInCodesStore.clear(); - linkCodesStore.clear(); print('[InMemoryVerificationCodeStorageService] Disposed.'); } } diff --git a/routes/api/v1/auth/link-email.dart b/routes/api/v1/auth/link-email.dart deleted file mode 100644 index a340ce0..0000000 --- a/routes/api/v1/auth/link-email.dart +++ /dev/null @@ -1,83 +0,0 @@ -import 'dart:io'; - -import 'package:dart_frog/dart_frog.dart'; -import 'package:ht_api/src/services/auth_service.dart'; -import 'package:ht_shared/ht_shared.dart'; // For User and exceptions - -/// Handles POST requests to `/api/v1/auth/link-email`. -/// -/// Allows an authenticated anonymous user to initiate the process of linking -/// an email address to their account to make it permanent. -Future onRequest(RequestContext context) async { - // 1. Ensure this is a POST request - if (context.request.method != HttpMethod.post) { - return Response(statusCode: HttpStatus.methodNotAllowed); - } - - // 2. Read the authenticated User from context (provided by middleware) - final authenticatedUser = context.read(); - - // 3. Validate that an authenticated user exists and is anonymous - if (authenticatedUser == null) { - // This should ideally be caught by `authenticationProvider` if route is protected - throw const UnauthorizedException('Authentication required to link email.'); - } - if (authenticatedUser.appRole != AppUserRole.guestUser) { - throw const BadRequestException( - 'Account is already permanent. Cannot initiate email linking.', - ); - } - - // 4. Read the AuthService - final authService = context.read(); - - // 5. Parse the request body for the email to link - final dynamic body; - try { - body = await context.request.json(); - } catch (_) { - throw const InvalidInputException('Invalid JSON format in request body.'); - } - - if (body is! Map) { - throw const InvalidInputException('Request body must be a JSON object.'); - } - - final emailToLink = body['email'] as String?; - if (emailToLink == null || emailToLink.isEmpty) { - throw const InvalidInputException( - 'Missing or empty "email" field in request body.', - ); - } - - // Basic email format validation - final emailRegex = RegExp( - r"^[a-zA-Z0-9.a-zA-Z0-9.!#$%&'*+-/=?^_`{|}~]+@" - r'[a-zA-Z0-9]+\.[a-zA-Z]+', - ); - if (!emailRegex.hasMatch(emailToLink)) { - throw const InvalidInputException('Invalid email format provided.'); - } - - // 6. Call AuthService to initiate the linking process - try { - await authService.initiateLinkEmailProcess( - anonymousUser: authenticatedUser, - emailToLink: emailToLink, - ); - - // Return 202 Accepted: The request is accepted for processing. - return Response(statusCode: HttpStatus.accepted); - } on HtHttpException catch (_) { - // Let the central errorHandler middleware handle known exceptions - rethrow; - } catch (e) { - // Catch unexpected errors from the service layer - print( - 'Unexpected error in /link-email handler for user ${authenticatedUser.id}: $e', - ); - throw OperationFailedException( - 'An unexpected error occurred while initiating email linking: $e', - ); - } -} diff --git a/routes/api/v1/auth/verify-code.dart b/routes/api/v1/auth/verify-code.dart index 2912848..ae5703a 100644 --- a/routes/api/v1/auth/verify-code.dart +++ b/routes/api/v1/auth/verify-code.dart @@ -19,10 +19,9 @@ Future onRequest(RequestContext context) async { return Response(statusCode: HttpStatus.methodNotAllowed); } - // Read the AuthService provided by middleware. - // The `authenticatedUser` is no longer needed here as the service handles - // all context internally. + // Read the AuthService and the currently authenticated user from middleware. final authService = context.read(); + final authenticatedUser = context.read(); // Parse the request body final dynamic body; @@ -71,12 +70,14 @@ Future onRequest(RequestContext context) async { final isDashboardLogin = (body['isDashboardLogin'] as bool?) ?? false; try { - // Call the AuthService to handle the verification and sign-in logic - // Pass the context flag to determine the correct flow. + // Call the AuthService to handle the verification and sign-in logic. + // Pass the authenticatedUser to allow for anonymous-to-permanent account + // conversion. final result = await authService.completeEmailSignIn( email, code, isDashboardLogin: isDashboardLogin, + authenticatedUser: authenticatedUser, ); // Create the specific payload containing user and token diff --git a/routes/api/v1/auth/verify-link-email.dart b/routes/api/v1/auth/verify-link-email.dart deleted file mode 100644 index 60a2be9..0000000 --- a/routes/api/v1/auth/verify-link-email.dart +++ /dev/null @@ -1,108 +0,0 @@ -import 'dart:io'; - -import 'package:dart_frog/dart_frog.dart'; -import 'package:ht_api/src/helpers/response_helper.dart'; -import 'package:ht_api/src/services/auth_service.dart'; -import 'package:ht_shared/ht_shared.dart'; - -/// Handles POST requests to `/api/v1/auth/verify-link-email`. -/// -/// Allows an authenticated anonymous user to complete the email linking process -/// by providing the verification code sent to their email. -Future onRequest(RequestContext context) async { - // 1. Ensure this is a POST request - if (context.request.method != HttpMethod.post) { - return Response(statusCode: HttpStatus.methodNotAllowed); - } - - // 2. Read the authenticated User from context (provided by middleware) - final authenticatedUser = context.read(); - - // 3. Validate that an authenticated user exists and is anonymous - if (authenticatedUser == null) { - throw const UnauthorizedException( - 'Authentication required to verify email link.', - ); - } - if (authenticatedUser.appRole != AppUserRole.guestUser) { - throw const BadRequestException( - 'Account is already permanent. Cannot complete email linking.', - ); - } - - // 4. Extract the current (old) anonymous token for invalidation - final authHeader = context.request.headers[HttpHeaders.authorizationHeader]; - String? oldAnonymousToken; - if (authHeader != null && authHeader.startsWith('Bearer ')) { - oldAnonymousToken = authHeader.substring(7); - } - if (oldAnonymousToken == null || oldAnonymousToken.isEmpty) { - // This should not happen if authentication middleware ran successfully - // and the user is indeed authenticated. - print( - 'Error: Could not extract Bearer token for user ${authenticatedUser.id} in verify-link-email.', - ); - throw const OperationFailedException( - 'Internal error: Unable to retrieve current authentication token for invalidation.', - ); - } - - // 5. Read the AuthService - final authService = context.read(); - - // 6. Parse the request body for the verification code - final dynamic body; - try { - body = await context.request.json(); - } catch (_) { - throw const InvalidInputException('Invalid JSON format in request body.'); - } - - if (body is! Map) { - throw const InvalidInputException('Request body must be a JSON object.'); - } - - final codeFromUser = body['code'] as String?; - if (codeFromUser == null || codeFromUser.isEmpty) { - throw const InvalidInputException( - 'Missing or empty "code" field in request body.', - ); - } - // Basic code format validation (e.g., 6 digits) - if (!RegExp(r'^\d{6}$').hasMatch(codeFromUser)) { - throw const InvalidInputException( - 'Invalid code format. Code must be 6 digits.', - ); - } - - // 7. Call AuthService to complete the linking process - try { - final result = await authService.completeLinkEmailProcess( - anonymousUser: authenticatedUser, - codeFromUser: codeFromUser, - oldAnonymousToken: oldAnonymousToken, - ); - - // Create the specific payload containing user and token - final authPayload = AuthSuccessResponse( - user: result.user, - token: result.token, - ); - - // Use the helper to create a standardized success response - return ResponseHelper.success( - context: context, - data: authPayload, - toJsonT: (data) => data.toJson(), - ); - } on HtHttpException catch (_) { - rethrow; - } catch (e) { - print( - 'Unexpected error in /verify-link-email handler for user ${authenticatedUser.id}: $e', - ); - throw OperationFailedException( - 'An unexpected error occurred while verifying email link: $e', - ); - } -}