@@ -129,33 +129,60 @@ class AuthService {
129129 /// Returns the authenticated [User] and a new authentication token.
130130 ///
131131 /// Throws [InvalidInputException] if the code is invalid or expired.
132+ /// Completes the email sign-in process by verifying the code.
133+ ///
134+ /// This method is context-aware and handles multiple scenarios:
135+ ///
136+ /// - **Guest to Permanent Conversion:** If an authenticated `guestUser`
137+ /// (from [authenticatedUser] ) performs this action, their account is
138+ /// upgraded to a permanent `standardUser` with the verified [email] .
139+ /// Their existing data is preserved.
140+ /// - **Dashboard Login:** If [isDashboardLogin] is true, it performs a
141+ /// strict login for an existing user with dashboard permissions.
142+ /// - **Standard Sign-In/Sign-Up:** If no authenticated user is present, it
143+ /// either logs in an existing user with the given [email] or creates a
144+ /// new `standardUser` .
145+ ///
146+ /// Returns the authenticated [User] and a new authentication token.
147+ ///
148+ /// Throws [InvalidInputException] if the code is invalid or expired.
132149 Future <({User user, String token})> completeEmailSignIn (
133150 String email,
134151 String code, {
135- // Flag to indicate if this is a login attempt from the dashboard,
136- // which enforces stricter checks.
137- bool isDashboardLogin = false ,
152+ required bool isDashboardLogin,
153+ User ? authenticatedUser,
138154 }) async {
139- // 1. Validate the code for standard sign-in
140- final isValidCode = await _verificationCodeStorageService
141- .validateSignInCode (email, code);
155+ // 1. Validate the verification code.
156+ final isValidCode =
157+ await _verificationCodeStorageService .validateSignInCode (email, code);
142158 if (! isValidCode) {
143- throw const InvalidInputException (
144- 'Invalid or expired verification code.' ,
145- );
159+ throw const InvalidInputException ('Invalid or expired verification code.' );
146160 }
147161
148- // After successful code validation, clear the sign-in code
162+ // After successful validation, clear the code from storage.
149163 try {
150164 await _verificationCodeStorageService.clearSignInCode (email);
151165 } catch (e) {
152- // Log or handle if clearing fails, but don't let it block sign-in
153166 _log.warning (
154167 'Warning: Failed to clear sign-in code for $email after validation: $e ' ,
155168 );
156169 }
157170
158- // 2. Find or create the user based on the context
171+ // 2. Check for Guest-to-Permanent user conversion flow.
172+ if (authenticatedUser != null &&
173+ authenticatedUser.appRole == AppUserRole .guestUser) {
174+ _log.info (
175+ 'Starting account conversion for guest user ${authenticatedUser .id } to email $email .' ,
176+ );
177+ return _convertGuestUserToPermanent (
178+ guestUser: authenticatedUser,
179+ verifiedEmail: email,
180+ );
181+ }
182+
183+ // 3. If not a conversion, proceed with standard or dashboard login.
184+
185+ // Find or create the user based on the context.
159186 User user;
160187 try {
161188 // Attempt to find user by email
@@ -166,18 +193,6 @@ class AuthService {
166193 // This closes the loophole where a non-admin user could request a code
167194 // via the app flow and then use it to log into the dashboard.
168195 if (isDashboardLogin) {
169- if (user.email != email) {
170- // This is a critical security check. If the user found by email
171- // somehow has a different email than the one provided, it's a
172- // sign of a serious issue (like the data layer bug we fixed).
173- // We throw a generic error to avoid revealing information.
174- _log.severe (
175- 'CRITICAL: Mismatch between requested email ($email ) and found '
176- 'user email (${user .email }) during dashboard login for user '
177- 'ID ${user .id }.' ,
178- );
179- throw const UnauthorizedException ('User account does not exist.' );
180- }
181196 if (! _permissionService.hasPermission (
182197 user,
183198 Permissions .dashboardLogin,
@@ -358,155 +373,6 @@ class AuthService {
358373 /// Initiates the process of linking an [emailToLink] to an existing
359374 /// authenticated [anonymousUser] 's account.
360375 ///
361- /// Throws [ConflictException] if the [emailToLink] is already in use by
362- /// another permanent account, or if the [anonymousUser] is not actually
363- /// anonymous, or if the [emailToLink] is already pending verification for
364- /// another linking process.
365- /// Throws [OperationFailedException] for other errors.
366- Future <void > initiateLinkEmailProcess ({
367- required User anonymousUser,
368- required String emailToLink,
369- }) async {
370- if (anonymousUser.appRole != AppUserRole .guestUser) {
371- throw const BadRequestException (
372- 'Account is already permanent. Cannot link email.' ,
373- );
374- }
375-
376- try {
377- // 1. Check if emailToLink is already used by another permanent user.
378- final existingUsersResponse = await _userRepository.readAll (
379- filter: {'email' : emailToLink},
380- );
381-
382- // Filter for permanent users (not guests) that are not the current user.
383- final conflictingPermanentUsers = existingUsersResponse.items.where (
384- (u) => u.appRole != AppUserRole .guestUser && u.id != anonymousUser.id,
385- );
386-
387- if (conflictingPermanentUsers.isNotEmpty) {
388- throw ConflictException (
389- 'Email address "$emailToLink " is already in use by another account.' ,
390- );
391- }
392-
393- // 2. Generate and store the link code.
394- // The storage service itself might throw ConflictException if emailToLink
395- // is pending for another user or if this user has a pending code.
396- final code = await _verificationCodeStorageService
397- .generateAndStoreLinkCode (
398- userId: anonymousUser.id,
399- emailToLink: emailToLink,
400- );
401-
402- // 3. Send the code via email
403- await _emailRepository.sendOtpEmail (
404- recipientEmail: emailToLink,
405- otpCode: code,
406- );
407- _log.info (
408- 'Initiated email link for user ${anonymousUser .id } to email $emailToLink , code sent: $code .' ,
409- );
410- } on HtHttpException {
411- rethrow ;
412- } catch (e) {
413- _log.severe (
414- 'Error during initiateLinkEmailProcess for user ${anonymousUser .id }, email $emailToLink : $e ' ,
415- );
416- throw OperationFailedException (
417- 'Failed to initiate email linking process: $e ' ,
418- );
419- }
420- }
421-
422- /// Completes the email linking process for an [anonymousUser] by verifying
423- /// the [codeFromUser] .
424- ///
425- /// If successful, updates the user to be permanent with the linked email
426- /// and returns the updated User and a new authentication token.
427- /// Throws [InvalidInputException] if the code is invalid or expired.
428- /// Throws [OperationFailedException] for other errors.
429- Future <({User user, String token})> completeLinkEmailProcess ({
430- required User anonymousUser,
431- required String codeFromUser,
432- required String oldAnonymousToken, // Needed to invalidate it
433- }) async {
434- if (anonymousUser.appRole != AppUserRole .guestUser) {
435- // Should ideally not happen if flow is correct, but good safeguard.
436- throw const BadRequestException (
437- 'Account is already permanent. Cannot complete email linking.' ,
438- );
439- }
440-
441- try {
442- // 1. Validate the link code and retrieve the email that was being linked.
443- final linkedEmail = await _verificationCodeStorageService
444- .validateAndRetrieveLinkedEmail (
445- userId: anonymousUser.id,
446- linkCode: codeFromUser,
447- );
448-
449- if (linkedEmail == null ) {
450- throw const InvalidInputException (
451- 'Invalid or expired verification code for email linking.' ,
452- );
453- }
454-
455- // 2. Update the user to be permanent.
456- final updatedUser = anonymousUser.copyWith (
457- email: linkedEmail,
458- appRole: AppUserRole .standardUser,
459- );
460- final permanentUser = await _userRepository.update (
461- id: updatedUser.id,
462- item: updatedUser,
463- );
464- _log.info (
465- 'User ${permanentUser .id } successfully linked with email $linkedEmail .' ,
466- );
467-
468- // Ensure user data exists after linking.
469- await _ensureUserDataExists (permanentUser);
470-
471- // 3. Generate a new authentication token for the now-permanent user.
472- final newToken = await _authTokenService.generateToken (permanentUser);
473- _log.info ('Generated new token for linked user ${permanentUser .id }' );
474-
475- // 4. Invalidate the old anonymous token.
476- try {
477- await _authTokenService.invalidateToken (oldAnonymousToken);
478- _log.info (
479- 'Successfully invalidated old anonymous token for user ${permanentUser .id }.' ,
480- );
481- } catch (e) {
482- // Log error but don't fail the whole linking process if invalidation fails.
483- // The new token is more important.
484- _log.warning (
485- 'Warning: Failed to invalidate old anonymous token for user ${permanentUser .id }: $e ' ,
486- );
487- }
488-
489- // 5. Clear the link code from storage.
490- try {
491- await _verificationCodeStorageService.clearLinkCode (anonymousUser.id);
492- } catch (e) {
493- _log.warning (
494- 'Warning: Failed to clear link code for user ${anonymousUser .id } after linking: $e ' ,
495- );
496- }
497-
498- return (user: permanentUser, token: newToken);
499- } on HtHttpException {
500- rethrow ;
501- } catch (e) {
502- _log.severe (
503- 'Error during completeLinkEmailProcess for user ${anonymousUser .id }: $e ' ,
504- );
505- throw OperationFailedException (
506- 'Failed to complete email linking process: $e ' ,
507- );
508- }
509- }
510376
511377 /// Deletes a user account and associated authentication data.
512378 ///
@@ -538,32 +404,18 @@ class AuthService {
538404 await _userRepository.delete (id: userId);
539405 _log.info ('User ${userToDelete .id } deleted from repository.' );
540406
541- // 3. Clear any pending verification codes for this user ID (linking) .
407+ // 3. Clear any pending sign-in codes for the user's email .
542408 try {
543- await _verificationCodeStorageService.clearLinkCode (userId);
544- _log.info ('Cleared link code for user ${userToDelete .id }.' );
409+ await _verificationCodeStorageService.clearSignInCode (
410+ userToDelete.email,
411+ );
412+ _log.info ('Cleared sign-in code for email ${userToDelete .email }.' );
545413 } catch (e) {
546- // Log but don't fail deletion if clearing codes fails
547414 _log.warning (
548- 'Warning: Failed to clear link code for user ${userToDelete .id }: $e ' ,
415+ 'Warning: Failed to clear sign-in code for email ${userToDelete .email }: $e ' ,
549416 );
550417 }
551418
552- // 4. Clear any pending sign-in codes for the user's email (if they had one).
553- // The email for anonymous users is a placeholder and not used for sign-in.
554- if (userToDelete.appRole != AppUserRole .guestUser) {
555- try {
556- await _verificationCodeStorageService.clearSignInCode (
557- userToDelete.email,
558- );
559- _log.info ('Cleared sign-in code for email ${userToDelete .email }.' );
560- } catch (e) {
561- _log.warning (
562- 'Warning: Failed to clear sign-in code for email ${userToDelete .email }: $e ' ,
563- );
564- }
565- }
566-
567419 _log.info ('Account deletion process completed for user $userId .' );
568420 } on NotFoundException {
569421 // Propagate NotFoundException if user doesn't exist
@@ -633,7 +485,10 @@ class AuthService {
633485
634486 // Check for UserContentPreferences
635487 try {
636- await _userContentPreferencesRepository.read (id: user.id, userId: user.id);
488+ await _userContentPreferencesRepository.read (
489+ id: user.id,
490+ userId: user.id,
491+ );
637492 } on NotFoundException {
638493 _log.info (
639494 'UserContentPreferences not found for user ${user .id }. Creating with defaults.' ,
@@ -651,4 +506,52 @@ class AuthService {
651506 );
652507 }
653508 }
509+
510+ /// Converts a guest user to a permanent standard user.
511+ ///
512+ /// This helper method encapsulates the logic for updating the user's
513+ /// record with a verified email, upgrading their role, and generating a new
514+ /// authentication token. It ensures that all associated user data is
515+ /// preserved during the conversion.
516+ ///
517+ /// Throws [ConflictException] if the target email is already in use by
518+ /// another permanent account.
519+ Future <({User user, String token})> _convertGuestUserToPermanent ({
520+ required User guestUser,
521+ required String verifiedEmail,
522+ }) async {
523+ // 1. Check if the target email is already in use by another permanent user.
524+ final existingUser = await _findUserByEmail (verifiedEmail);
525+ if (existingUser != null && existingUser.id != guestUser.id) {
526+ // If a different user already exists with this email, throw an error.
527+ throw ConflictException (
528+ 'This email address is already associated with another account.' ,
529+ );
530+ }
531+
532+ // 2. Update the guest user's details to make them permanent.
533+ final updatedUser = guestUser.copyWith (
534+ email: verifiedEmail,
535+ appRole: AppUserRole .standardUser,
536+ );
537+
538+ final permanentUser = await _userRepository.update (
539+ id: updatedUser.id,
540+ item: updatedUser,
541+ );
542+ _log.info (
543+ 'User ${permanentUser .id } successfully converted to permanent account with email $verifiedEmail .' ,
544+ );
545+
546+ // 3. Generate a new token for the now-permanent user.
547+ final newToken = await _authTokenService.generateToken (permanentUser);
548+ _log.info ('Generated new token for converted user ${permanentUser .id }' );
549+
550+ // Note: Invalidation of the old anonymous token is handled implicitly.
551+ // The client will receive the new token and stop using the old one.
552+ // The old token will eventually expire. For immediate invalidation,
553+ // the old token would need to be passed into this flow and blacklisted.
554+
555+ return (user: permanentUser, token: newToken);
556+ }
654557}
0 commit comments