Show accepted signup delivery without a second send step (#62)
This commit is contained in:
1 parent
e84d2d1ad3
commit
3af60f38b1
19 files changed
+421
-69
No files matched your search
@@ -12,9 +12,13 @@ abstract final class BackendMapper {
|
||||
return VerificationResult(isSimulated: false, verifiedEmail: dto.email);
|
||||
}
|
||||
|
||||
static AuthResult signup(BackendDTO dto) {
|
||||
static AuthResult signup(BackendDTO dto, {required String email}) {
|
||||
if (dto.userId.isEmpty) throw const FormatException('Missing user');
|
||||
return AuthResult(userId: dto.userId, isSimulated: false);
|
||||
return AuthResult(
|
||||
userId: dto.userId,
|
||||
isSimulated: false,
|
||||
delivery: VerificationDelivery(email: email, isSimulated: false),
|
||||
);
|
||||
}
|
||||
|
||||
static FactorChallenge challenge(BackendDTO dto, String id) {
|
||||
|
||||
@@ -5,6 +5,12 @@ import '../models/verification.dart';
|
||||
abstract final class AuthMapper {
|
||||
static AuthResult fromDTO(AuthDTO dto) =>
|
||||
AuthResult(userId: dto.userId, isSimulated: dto.simulated);
|
||||
|
||||
static AuthResult signup(AuthDTO dto, {required String email}) => AuthResult(
|
||||
userId: dto.userId,
|
||||
isSimulated: dto.simulated,
|
||||
delivery: VerificationDelivery(email: email, isSimulated: dto.simulated),
|
||||
);
|
||||
}
|
||||
|
||||
abstract final class VerificationMapper {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'two_factor.dart';
|
||||
import 'verification.dart';
|
||||
|
||||
/// Ephemeral input: never persist, log, or include passwords in provider state.
|
||||
class LoginRequest {
|
||||
@@ -31,9 +32,11 @@ class AuthResult {
|
||||
required this.userId,
|
||||
required this.isSimulated,
|
||||
this.challenge,
|
||||
this.delivery,
|
||||
});
|
||||
final String userId;
|
||||
final FactorChallenge? challenge;
|
||||
final VerificationDelivery? delivery;
|
||||
final bool isSimulated;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,13 @@ enum VerificationStatus {
|
||||
|
||||
enum VerificationError { expired, invalid, internal, needsAssistance }
|
||||
|
||||
/// An accepted email-queue operation, not proof of inbox delivery or activation.
|
||||
class VerificationDelivery {
|
||||
const VerificationDelivery({required this.email, required this.isSimulated});
|
||||
final String email;
|
||||
final bool isSimulated;
|
||||
}
|
||||
|
||||
class VerificationException implements Exception {
|
||||
const VerificationException(this.code);
|
||||
final VerificationError code;
|
||||
|
||||
@@ -4,13 +4,22 @@ import '../models/verification.dart';
|
||||
import '../services/verification.dart';
|
||||
|
||||
class VerificationProvider extends ChangeNotifier {
|
||||
VerificationProvider(this.service);
|
||||
VerificationProvider(this.service, {VerificationDelivery? delivery})
|
||||
: _state = _initialState(delivery);
|
||||
final VerificationService service;
|
||||
VerificationState _state = const VerificationState(VerificationStatus.idle);
|
||||
VerificationState _state;
|
||||
VerificationState get state => _state;
|
||||
bool _disposed = false;
|
||||
int _generation = 0;
|
||||
|
||||
static VerificationState _initialState(VerificationDelivery? delivery) =>
|
||||
delivery == null
|
||||
? const VerificationState(VerificationStatus.idle)
|
||||
: VerificationState(
|
||||
VerificationStatus.sent,
|
||||
isSimulated: delivery.isSimulated,
|
||||
);
|
||||
|
||||
Future<void> send({required String email, required String locale}) => _run(
|
||||
() => service.send(email: email.trim(), locale: locale),
|
||||
VerificationStatus.sending,
|
||||
@@ -61,10 +70,10 @@ class VerificationProvider extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void reset() {
|
||||
void reset({VerificationDelivery? delivery}) {
|
||||
if (_disposed) return;
|
||||
++_generation;
|
||||
_state = const VerificationState(VerificationStatus.idle);
|
||||
_state = _initialState(delivery);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ class BackendAuthServices
|
||||
final _pending = <String, ({String token, String challengeId})>{};
|
||||
final _grants = <String, AuthSession>{};
|
||||
final _retryKeys = <String, String>{};
|
||||
String? _queuedSignup;
|
||||
AuthSession? _session;
|
||||
AuthSession? get session => _session;
|
||||
@override
|
||||
@@ -113,9 +112,8 @@ class BackendAuthServices
|
||||
'locale': r.locale,
|
||||
'idempotencyKey': key,
|
||||
});
|
||||
final result = BackendMapper.signup(dto);
|
||||
final result = BackendMapper.signup(dto, email: email);
|
||||
_retryKeys.remove('signup:$email');
|
||||
_queuedSignup = email;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -141,14 +139,6 @@ class BackendAuthServices
|
||||
required String locale,
|
||||
}) async {
|
||||
final normalized = email.trim().toLowerCase();
|
||||
// Signup already queued this email. The existing confirmation page requests
|
||||
// its initial delivery on mount; consume that receipt once.
|
||||
if (_queuedSignup == normalized) {
|
||||
_queuedSignup = null;
|
||||
return VerificationMapper.fromDTO(
|
||||
const VerificationDTO(simulated: false),
|
||||
);
|
||||
}
|
||||
final key = _retryKeys.putIfAbsent('activation:$normalized', _key);
|
||||
try {
|
||||
await _post('verification/send', {
|
||||
|
||||
@@ -40,8 +40,9 @@ class DummySignupService implements SignupService, SimulationInfo {
|
||||
Future<AuthResult> signup(SignupRequest request) async {
|
||||
await Future<void>.delayed(delay);
|
||||
if (failure case final error?) throw AuthException(error);
|
||||
return AuthMapper.fromDTO(
|
||||
return AuthMapper.signup(
|
||||
const AuthDTO(userId: 'demo-user', simulated: true),
|
||||
email: request.email.trim(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -111,13 +111,27 @@ class InstrumentedAuthServices
|
||||
simulation: _simulation(loginService),
|
||||
);
|
||||
@override
|
||||
Future<AuthResult> signup(SignupRequest request) => _run(
|
||||
JourneyKind.signup,
|
||||
JourneyStep.signup,
|
||||
() => signupService.signup(request),
|
||||
(r) => r.isSimulated,
|
||||
simulation: _simulation(signupService),
|
||||
);
|
||||
Future<AuthResult> signup(SignupRequest request) async {
|
||||
final result = await _run(
|
||||
JourneyKind.signup,
|
||||
JourneyStep.signup,
|
||||
() => signupService.signup(request),
|
||||
(r) => r.isSimulated,
|
||||
simulation: _simulation(signupService),
|
||||
);
|
||||
final delivery = result.delivery;
|
||||
if (delivery != null) {
|
||||
final operation = recorder.begin(
|
||||
JourneyKind.verification,
|
||||
JourneyStep.emailDelivery,
|
||||
onboarding: true,
|
||||
simulated: delivery.isSimulated,
|
||||
);
|
||||
recorder.finish(operation, simulated: delivery.isSimulated);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<VerificationResult> send({
|
||||
required String email,
|
||||
|
||||
Reference in new issue
Block a user