From 01da8abd5b936e369a46c316b444f12006ad4dd4 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 20 Jul 2025 09:37:47 +0100 Subject: [PATCH 1/3] refactor(auth_service): improve email sign-in flow for guest users - Expand import to include dart:async for unawaited operation - Enhance documentation of completeEmailSignIn method - Refactor guest user sign-in logic to handle existing account check - Simplify _convertGuestUserToPermanent method by removing email conflict check --- lib/src/services/auth_service.dart | 106 +++++++++++++++++------------ 1 file changed, 63 insertions(+), 43 deletions(-) diff --git a/lib/src/services/auth_service.dart b/lib/src/services/auth_service.dart index 4f51ba4..9cc001c 100644 --- a/lib/src/services/auth_service.dart +++ b/lib/src/services/auth_service.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:ht_api/src/rbac/permission_service.dart'; import 'package:ht_api/src/rbac/permissions.dart'; import 'package:ht_api/src/services/auth_token_service.dart'; @@ -116,29 +118,21 @@ class AuthService { } } - /// Completes the email sign-in process by verifying the code. - /// - /// This method is context-aware based on the [isDashboardLogin] flag. - /// - /// - For the dashboard (`isDashboardLogin: true`), it validates the code and - /// logs in the existing user. It will not create a new user in this flow. - /// - For the user-facing app (`isDashboardLogin: false`), it validates the - /// code and either logs in the existing user or creates a new one with a - /// 'standardUser' role if they don't exist. - /// - /// 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. + /// - **Guest Sign-In:** If an authenticated `guestUser` (from + /// [authenticatedUser]) performs this action, the service checks if a + /// permanent account with the verified [email] already exists. + /// - If it exists, the user is signed into that account, and the temporary + /// guest account is deleted. + /// - If it does not exist, the guest account is converted into a new + /// permanent `standardUser` with the verified [email]. + /// /// - **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`. @@ -168,21 +162,56 @@ class AuthService { ); } - // 2. Check for Guest-to-Permanent user conversion flow. + // 2. Check if the sign-in is initiated from an authenticated guest session. 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, + 'Guest user ${authenticatedUser.id} is attempting to sign in with email $email.', ); - } - // 3. If not a conversion, proceed with standard or dashboard login. + // Check if an account with the target email already exists. + final existingUser = await _findUserByEmail(email); - // Find or create the user based on the context. + if (existingUser != null) { + // --- Scenario A: Sign-in to an existing account --- + // The user wants to log into their existing account, abandoning the + // guest session. + _log.info( + 'Existing account found for email $email (ID: ${existingUser.id}). ' + 'Signing in and abandoning guest session ${authenticatedUser.id}.', + ); + + // Delete the now-orphaned anonymous user account and its data. + // This is a fire-and-forget operation; we don't want to block the + // login if cleanup fails, but we should log any errors. + unawaited( + deleteAccount(userId: authenticatedUser.id).catchError((e, s) { + _log.severe( + 'Failed to clean up orphaned anonymous user ${authenticatedUser.id} after sign-in.', + e, + s is StackTrace ? s : null, + ); + }), + ); + + // Generate a new token for the existing permanent user. + final token = await _authTokenService.generateToken(existingUser); + _log.info('Generated new token for existing user ${existingUser.id}.'); + return (user: existingUser, token: token); + } else { + // --- Scenario B: Convert guest to a new permanent account --- + // No account exists with this email, so proceed with conversion. + _log.info( + 'No existing account for $email. Converting guest user ${authenticatedUser.id} to a new permanent account.', + ); + return _convertGuestUserToPermanent( + guestUser: authenticatedUser, + verifiedEmail: email, + ); + } + } + + // 3. If not a guest flow, proceed with standard or dashboard login. User user; try { // Attempt to find user by email @@ -507,29 +536,20 @@ class AuthService { } } - /// Converts a guest user to a permanent standard user. + /// Converts a guest user to a new 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. + /// record with a verified email and upgrading their role. It assumes that + /// the target email is not already in use by another 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.', - ); - } + // The check for an existing user with the verifiedEmail is now handled + // by the calling method, `completeEmailSignIn`. This method now only + // handles the conversion itself. - // 2. Update the guest user's details to make them permanent. + // 1. Update the guest user's details to make them permanent. final updatedUser = guestUser.copyWith( email: verifiedEmail, appRole: AppUserRole.standardUser, @@ -543,7 +563,7 @@ class AuthService { 'User ${permanentUser.id} successfully converted to permanent account with email $verifiedEmail.', ); - // 3. Generate a new token for the now-permanent user. + // 2. 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}'); From 28b118539728036b85d5c02a072ae78be50e6f7d Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 20 Jul 2025 09:50:59 +0100 Subject: [PATCH 2/3] feat(auth): improve guest to standard user conversion - Invalidate old guest token during account conversion - Update comments and step numbers in the login process - Add error logging for token invalidation --- lib/src/services/auth_service.dart | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/lib/src/services/auth_service.dart b/lib/src/services/auth_service.dart index 9cc001c..d46aa74 100644 --- a/lib/src/services/auth_service.dart +++ b/lib/src/services/auth_service.dart @@ -125,10 +125,10 @@ class AuthService { /// - **Guest Sign-In:** If an authenticated `guestUser` (from /// [authenticatedUser]) performs this action, the service checks if a /// permanent account with the verified [email] already exists. - /// - If it exists, the user is signed into that account, and the temporary - /// guest account is deleted. + /// - If it exists, the user is signed into that account, the old guest + /// token is invalidated, and the temporary guest account is deleted. /// - If it does not exist, the guest account is converted into a new - /// permanent `standardUser` with the verified [email]. + /// permanent `standardUser`, and the old guest token is invalidated. /// /// - **Dashboard Login:** If [isDashboardLogin] is true, it performs a /// strict login for an existing user with dashboard permissions. @@ -145,6 +145,7 @@ class AuthService { String code, { required bool isDashboardLogin, User? authenticatedUser, + String? currentToken, }) async { // 1. Validate the verification code. final isValidCode = @@ -162,7 +163,24 @@ class AuthService { ); } - // 2. Check if the sign-in is initiated from an authenticated guest session. + // 2. If this is a guest flow, invalidate the old anonymous token. + // This is a fire-and-forget operation; we don't want to block the + // login if invalidation fails, but we should log any errors. + if (authenticatedUser != null && + authenticatedUser.appRole == AppUserRole.guestUser && + currentToken != null) { + unawaited( + _authTokenService.invalidateToken(currentToken).catchError((e, s) { + _log.warning( + 'Failed to invalidate old anonymous token for user ${authenticatedUser.id}.', + e, + s is StackTrace ? s : null, + ); + }), + ); + } + + // 3. Check if the sign-in is initiated from an authenticated guest session. if (authenticatedUser != null && authenticatedUser.appRole == AppUserRole.guestUser) { _log.info( @@ -211,7 +229,7 @@ class AuthService { } } - // 3. If not a guest flow, proceed with standard or dashboard login. + // 4. If not a guest flow, proceed with standard or dashboard login. User user; try { // Attempt to find user by email @@ -287,7 +305,7 @@ class AuthService { throw const OperationFailedException('Failed to process user account.'); } - // 3. Generate authentication token + // 4. Generate authentication token try { final token = await _authTokenService.generateToken(user); _log.info('Generated token for user ${user.id}'); From af9d55fc39ffed4cb46ee6bdd3c9a2df893f94c5 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 20 Jul 2025 09:52:53 +0100 Subject: [PATCH 3/3] feat(auth): enhance email sign-in process - Add current token extraction from Authorization header - Enhance anonymous-to-permanent account conversion - Support token invalidation in guest-to-permanent flow --- routes/api/v1/auth/verify-code.dart | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/routes/api/v1/auth/verify-code.dart b/routes/api/v1/auth/verify-code.dart index ae5703a..1d464b6 100644 --- a/routes/api/v1/auth/verify-code.dart +++ b/routes/api/v1/auth/verify-code.dart @@ -69,15 +69,24 @@ Future onRequest(RequestContext context) async { // Check for the optional dashboard login flag. Default to false. final isDashboardLogin = (body['isDashboardLogin'] as bool?) ?? false; + // Extract the current token from the Authorization header, if it exists. + // This is needed for the guest-to-permanent flow to invalidate the old token. + final authHeader = context.request.headers[HttpHeaders.authorizationHeader]; + String? currentToken; + if (authHeader != null && authHeader.startsWith('Bearer ')) { + currentToken = authHeader.substring(7); + } + try { // Call the AuthService to handle the verification and sign-in logic. // Pass the authenticatedUser to allow for anonymous-to-permanent account - // conversion. + // conversion, and the currentToken for invalidation. final result = await authService.completeEmailSignIn( email, code, isDashboardLogin: isDashboardLogin, authenticatedUser: authenticatedUser, + currentToken: currentToken, ); // Create the specific payload containing user and token