@@ -129,33 +129,60 @@ class AuthService {
129
129
/// Returns the authenticated [User] and a new authentication token.
130
130
///
131
131
/// 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.
132
149
Future <({User user, String token})> completeEmailSignIn (
133
150
String email,
134
151
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,
138
154
}) 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);
142
158
if (! isValidCode) {
143
- throw const InvalidInputException (
144
- 'Invalid or expired verification code.' ,
145
- );
159
+ throw const InvalidInputException ('Invalid or expired verification code.' );
146
160
}
147
161
148
- // After successful code validation, clear the sign-in code
162
+ // After successful validation, clear the code from storage.
149
163
try {
150
164
await _verificationCodeStorageService.clearSignInCode (email);
151
165
} catch (e) {
152
- // Log or handle if clearing fails, but don't let it block sign-in
153
166
_log.warning (
154
167
'Warning: Failed to clear sign-in code for $email after validation: $e ' ,
155
168
);
156
169
}
157
170
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.
159
186
User user;
160
187
try {
161
188
// Attempt to find user by email
@@ -166,18 +193,6 @@ class AuthService {
166
193
// This closes the loophole where a non-admin user could request a code
167
194
// via the app flow and then use it to log into the dashboard.
168
195
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
- }
181
196
if (! _permissionService.hasPermission (
182
197
user,
183
198
Permissions .dashboardLogin,
@@ -358,155 +373,6 @@ class AuthService {
358
373
/// Initiates the process of linking an [emailToLink] to an existing
359
374
/// authenticated [anonymousUser] 's account.
360
375
///
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
- }
510
376
511
377
/// Deletes a user account and associated authentication data.
512
378
///
@@ -538,32 +404,18 @@ class AuthService {
538
404
await _userRepository.delete (id: userId);
539
405
_log.info ('User ${userToDelete .id } deleted from repository.' );
540
406
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 .
542
408
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 }.' );
545
413
} catch (e) {
546
- // Log but don't fail deletion if clearing codes fails
547
414
_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 ' ,
549
416
);
550
417
}
551
418
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
-
567
419
_log.info ('Account deletion process completed for user $userId .' );
568
420
} on NotFoundException {
569
421
// Propagate NotFoundException if user doesn't exist
@@ -633,7 +485,10 @@ class AuthService {
633
485
634
486
// Check for UserContentPreferences
635
487
try {
636
- await _userContentPreferencesRepository.read (id: user.id, userId: user.id);
488
+ await _userContentPreferencesRepository.read (
489
+ id: user.id,
490
+ userId: user.id,
491
+ );
637
492
} on NotFoundException {
638
493
_log.info (
639
494
'UserContentPreferences not found for user ${user .id }. Creating with defaults.' ,
@@ -651,4 +506,52 @@ class AuthService {
651
506
);
652
507
}
653
508
}
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
+ }
654
557
}
0 commit comments