Skip to content

Commit a13275d

Browse files
committed
feat: Implement email linking for anonymous accounts
Adds the ability for an anonymous user to link an email address to their account, making it permanent. This feature introduces: - New API endpoints `/auth/link-email` and `/auth/verify-link-email`. - Refactored `VerificationCodeStorageService` to handle distinct code types (sign-in vs. link) and prevent duplicate pending link requests for a user. - New methods in `AuthService` (`initiateLinkEmailProcess`, `completeLinkEmailProcess`) to manage the linking flow, including user data merging and conflict checks (e.g., email already in use). - Handling of `ConflictException` (HTTP 409) in the error middleware.
1 parent cf79655 commit a13275d

File tree

5 files changed

+611
-83
lines changed

5 files changed

+611
-83
lines changed

lib/src/middlewares/error_handler.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ int _mapExceptionToStatusCode(HtHttpException exception) {
7272
ServerException() => HttpStatus.internalServerError, // 500
7373
OperationFailedException() => HttpStatus.internalServerError, // 500
7474
NetworkException() => HttpStatus.serviceUnavailable, // 503 (or 500)
75+
ConflictException() => HttpStatus.conflict, // 409
7576
UnknownException() => HttpStatus.internalServerError, // 500
7677
_ => HttpStatus.internalServerError, // Default
7778
};
@@ -89,6 +90,7 @@ String _mapExceptionToCodeString(HtHttpException exception) {
8990
ServerException() => 'SERVER_ERROR',
9091
OperationFailedException() => 'OPERATION_FAILED',
9192
NetworkException() => 'NETWORK_ERROR',
93+
ConflictException() => 'CONFLICT', // Added for 409
9294
UnknownException() => 'UNKNOWN_ERROR',
9395
_ => 'UNKNOWN_ERROR', // Default
9496
};

lib/src/services/auth_service.dart

Lines changed: 166 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,9 @@ class AuthService {
3838
/// Throws [OperationFailedException] if code generation/storage/email fails.
3939
Future<void> initiateEmailSignIn(String email) async {
4040
try {
41-
// Generate and store the code
42-
final code = await _verificationCodeStorageService.generateAndStoreCode(
41+
// Generate and store the code for standard sign-in
42+
final code =
43+
await _verificationCodeStorageService.generateAndStoreSignInCode(
4344
email,
4445
);
4546

@@ -71,20 +72,30 @@ class AuthService {
7172
Future<({User user, String token})> completeEmailSignIn(
7273
String email,
7374
String code,
75+
// User? currentAuthUser, // Parameter for potential future linking logic
7476
) async {
75-
// 1. Validate the code
76-
final isValidCode = await _verificationCodeStorageService.validateCode(
77+
// 1. Validate the code for standard sign-in
78+
final isValidCode =
79+
await _verificationCodeStorageService.validateSignInCode(
7780
email,
7881
code,
7982
);
8083
if (!isValidCode) {
81-
// Consider distinguishing between expired and simply incorrect codes
82-
// For now, treat both as invalid input.
8384
throw const InvalidInputException(
8485
'Invalid or expired verification code.',
8586
);
8687
}
8788

89+
// After successful code validation, clear the sign-in code
90+
try {
91+
await _verificationCodeStorageService.clearSignInCode(email);
92+
} catch (e) {
93+
// Log or handle if clearing fails, but don't let it block sign-in
94+
print(
95+
'Warning: Failed to clear sign-in code for $email after validation: $e',
96+
);
97+
}
98+
8899
// 2. Find or create the user
89100
User user;
90101
try {
@@ -213,4 +224,153 @@ class AuthService {
213224
);
214225
// No specific exceptions are thrown in this placeholder implementation.
215226
}
227+
228+
/// Initiates the process of linking an [emailToLink] to an existing
229+
/// authenticated [anonymousUser]'s account.
230+
///
231+
/// Throws [ConflictException] if the [emailToLink] is already in use by
232+
/// another permanent account, or if the [anonymousUser] is not actually
233+
/// anonymous, or if the [emailToLink] is already pending verification for
234+
/// another linking process.
235+
/// Throws [OperationFailedException] for other errors.
236+
Future<void> initiateLinkEmailProcess({
237+
required User anonymousUser,
238+
required String emailToLink,
239+
}) async {
240+
if (!anonymousUser.isAnonymous) {
241+
throw const BadRequestException(
242+
'Account is already permanent. Cannot link email.',
243+
);
244+
}
245+
246+
try {
247+
// 1. Check if emailToLink is already used by another *permanent* user.
248+
final query = {'email': emailToLink, 'isAnonymous': false};
249+
final existingUsers = await _userRepository.readAllByQuery(query);
250+
if (existingUsers.items.isNotEmpty) {
251+
// Ensure it's not the same user if somehow an anonymous user had an email
252+
// (though current logic prevents this for new anonymous users).
253+
// This check is more for emails used by *other* permanent accounts.
254+
if (existingUsers.items.any((u) => u.id != anonymousUser.id)) {
255+
throw ConflictException(
256+
'Email address "$emailToLink" is already in use by another account.',
257+
);
258+
}
259+
}
260+
261+
// 2. Generate and store the link code.
262+
// The storage service itself might throw ConflictException if emailToLink
263+
// is pending for another user or if this user has a pending code.
264+
final code =
265+
await _verificationCodeStorageService.generateAndStoreLinkCode(
266+
userId: anonymousUser.id,
267+
emailToLink: emailToLink,
268+
);
269+
270+
// 3. Send the code via email
271+
await _emailRepository.sendOtpEmail(
272+
recipientEmail: emailToLink,
273+
otpCode: code,
274+
);
275+
print(
276+
'Initiated email link for user ${anonymousUser.id} to email $emailToLink, code sent.',
277+
);
278+
} on HtHttpException {
279+
rethrow;
280+
} catch (e) {
281+
print(
282+
'Error during initiateLinkEmailProcess for user ${anonymousUser.id}, email $emailToLink: $e',
283+
);
284+
throw OperationFailedException(
285+
'Failed to initiate email linking process: ${e.toString()}',
286+
);
287+
}
288+
}
289+
290+
/// Completes the email linking process for an [anonymousUser] by verifying
291+
/// the [codeFromUser].
292+
///
293+
/// If successful, updates the user to be permanent with the linked email
294+
/// and returns the updated User and a new authentication token.
295+
/// Throws [InvalidInputException] if the code is invalid or expired.
296+
/// Throws [OperationFailedException] for other errors.
297+
Future<({User user, String token})> completeLinkEmailProcess({
298+
required User anonymousUser,
299+
required String codeFromUser,
300+
required String oldAnonymousToken, // Needed to invalidate it
301+
}) async {
302+
if (!anonymousUser.isAnonymous) {
303+
// Should ideally not happen if flow is correct, but good safeguard.
304+
throw const BadRequestException(
305+
'Account is already permanent. Cannot complete email linking.',
306+
);
307+
}
308+
309+
try {
310+
// 1. Validate the link code and retrieve the email that was being linked.
311+
final linkedEmail =
312+
await _verificationCodeStorageService.validateAndRetrieveLinkedEmail(
313+
userId: anonymousUser.id,
314+
linkCode: codeFromUser,
315+
);
316+
317+
if (linkedEmail == null) {
318+
throw const InvalidInputException(
319+
'Invalid or expired verification code for email linking.',
320+
);
321+
}
322+
323+
// 2. Update the user to be permanent.
324+
final updatedUser = User(
325+
id: anonymousUser.id, // Preserve original ID
326+
email: linkedEmail,
327+
isAnonymous: false, // Now a permanent user
328+
);
329+
final permanentUser = await _userRepository.update(
330+
updatedUser.id,
331+
updatedUser,
332+
);
333+
print(
334+
'User ${permanentUser.id} successfully linked with email $linkedEmail.',
335+
);
336+
337+
// 3. Generate a new authentication token for the now-permanent user.
338+
final newToken = await _authTokenService.generateToken(permanentUser);
339+
print('Generated new token for linked user ${permanentUser.id}');
340+
341+
// 4. Invalidate the old anonymous token.
342+
try {
343+
await _authTokenService.invalidateToken(oldAnonymousToken);
344+
print(
345+
'Successfully invalidated old anonymous token for user ${permanentUser.id}.',
346+
);
347+
} catch (e) {
348+
// Log error but don't fail the whole linking process if invalidation fails.
349+
// The new token is more important.
350+
print(
351+
'Warning: Failed to invalidate old anonymous token for user ${permanentUser.id}: $e',
352+
);
353+
}
354+
355+
// 5. Clear the link code from storage.
356+
try {
357+
await _verificationCodeStorageService.clearLinkCode(anonymousUser.id);
358+
} catch (e) {
359+
print(
360+
'Warning: Failed to clear link code for user ${anonymousUser.id} after linking: $e',
361+
);
362+
}
363+
364+
return (user: permanentUser, token: newToken);
365+
} on HtHttpException {
366+
rethrow;
367+
} catch (e) {
368+
print(
369+
'Error during completeLinkEmailProcess for user ${anonymousUser.id}: $e',
370+
);
371+
throw OperationFailedException(
372+
'Failed to complete email linking process: ${e.toString()}',
373+
);
374+
}
375+
}
216376
}

0 commit comments

Comments
 (0)