Add reusable two-factor verification for login and money operations (#32)

This commit is contained in:
Stephan D committed 2026-09-12 12:44:34 +02:00
1 parent b7a58b1a87
commit fb40f860d2
41 files changed
+2439 -43

No files matched your search

@@ -0,0 +1,25 @@
class FactorSettingsDTO {
const FactorSettingsDTO(this.enabled, this.target, this.method);
final bool enabled;
final String method;
final String target;
}
class FactorChallengeDTO {
const FactorChallengeDTO(
this.id,
this.purpose,
this.operation,
this.target,
this.expiresAt,
this.resendAt,
this.method,
);
final String id, purpose, operation, target, method;
final DateTime expiresAt, resendAt;
}
class FactorGrantDTO {
const FactorGrantDTO(this.token);
final String token;
}
@@ -0,0 +1,20 @@
import '../dto/two_factor.dart';
import '../models/two_factor.dart';
abstract final class FactorMapper {
static FactorSettings settings(FactorSettingsDTO dto) => FactorSettings(
enabled: dto.enabled,
target: dto.target,
method: FactorMethod.values.byName(dto.method),
);
static FactorChallenge challenge(FactorChallengeDTO dto) => FactorChallenge(
id: dto.id,
purpose: FactorPurpose.values.byName(dto.purpose),
operation: dto.operation,
target: dto.target,
expiresAt: dto.expiresAt,
resendAt: dto.resendAt,
method: FactorMethod.values.byName(dto.method),
);
static FactorGrant grant(FactorGrantDTO dto) => FactorGrant(dto.token);
}
@@ -1,3 +1,5 @@
import 'two_factor.dart';
/// Ephemeral input: never persist, log, or include passwords in provider state.
class LoginRequest {
const LoginRequest({
@@ -25,12 +27,25 @@ class SignupRequest {
/// An operation result, not proof of a session or an authorization decision.
class AuthResult {
const AuthResult({required this.userId, required this.isSimulated});
const AuthResult({
required this.userId,
required this.isSimulated,
this.challenge,
});
final String userId;
final FactorChallenge? challenge;
final bool isSimulated;
}
enum AuthError { invalidCredentials, emailInUse, unavailable, unexpected }
enum AuthError {
invalidCredentials,
emailInUse,
unavailable,
unexpected,
factorLocked,
factorCooldown,
factorRequired,
}
class AuthException implements Exception {
const AuthException(this.code);
@@ -39,7 +54,7 @@ class AuthException implements Exception {
String toString() => 'AuthException(${code.name})';
}
enum AuthStatus { idle, loading, success, error }
enum AuthStatus { idle, loading, challenge, success, error }
class AuthState {
const AuthState._(this.status, {this.result, this.error});
@@ -47,6 +62,8 @@ class AuthState {
const AuthState.loading() : this._(AuthStatus.loading);
const AuthState.success(AuthResult result)
: this._(AuthStatus.success, result: result);
const AuthState.challenge(AuthResult result)
: this._(AuthStatus.challenge, result: result);
const AuthState.failure(AuthError error)
: this._(AuthStatus.error, error: error);
final AuthStatus status;
@@ -0,0 +1,42 @@
enum FactorMethod { email, authenticator }
enum FactorPurpose { login, topUp, exchange, payout, enable, disable }
enum FactorError { invalid, expired, locked, cooldown, unavailable, required }
class FactorException implements Exception {
const FactorException(this.code);
final FactorError code;
}
class FactorSettings {
const FactorSettings({
required this.enabled,
required this.target,
this.method = FactorMethod.email,
});
final bool enabled;
final FactorMethod method;
final String target;
}
class FactorChallenge {
const FactorChallenge({
required this.id,
required this.purpose,
required this.operation,
required this.target,
required this.expiresAt,
required this.resendAt,
this.method = FactorMethod.email,
});
final String id, operation, target;
final FactorPurpose purpose;
final FactorMethod method;
final DateTime expiresAt, resendAt;
}
class FactorGrant {
const FactorGrant(this.token);
final String token;
}
@@ -1,6 +1,7 @@
import 'package:flutter/foundation.dart';
import '../models/auth.dart';
import '../models/two_factor.dart';
/// Shared operation lifecycle adapted from Sendico's ChangeNotifier providers.
/// Reset invalidates pending results; it does not cancel service-side work.
@@ -19,13 +20,21 @@ abstract class AuthProvider extends ChangeNotifier {
try {
final result = await operation();
if (_disposed || generation != _generation) return;
_state = AuthState.success(result);
_state = result.challenge == null
? AuthState.success(result)
: AuthState.challenge(result);
} catch (error) {
if (_disposed || generation != _generation) return;
// Never retain or expose arbitrary exceptions that may contain credentials.
_state = AuthState.failure(
error is AuthException ? error.code : AuthError.unexpected,
);
_state = AuthState.failure(switch (error) {
AuthException(code: final code) => code,
FactorException(code: FactorError.locked) => AuthError.factorLocked,
FactorException(code: FactorError.cooldown) => AuthError.factorCooldown,
FactorException(code: FactorError.required || FactorError.expired) =>
AuthError.factorRequired,
FactorException() => AuthError.unavailable,
_ => AuthError.unexpected,
});
}
if (!_disposed && generation == _generation) notifyListeners();
}
@@ -1,11 +1,21 @@
import '../models/auth.dart';
import '../models/two_factor.dart';
import '../services/contracts.dart';
import '../services/guarded_login.dart';
import 'auth.dart';
class LoginProvider extends AuthProvider {
LoginProvider(this.service);
final LoginService service;
Future<void> complete(FactorChallenge challenge, FactorGrant grant) =>
run(() {
final adapter = service;
if (adapter is! PendingLoginService)
throw const AuthException(AuthError.unavailable);
return adapter.completeLogin(challenge, grant);
});
Future<void> login({
required String email,
required String password,
@@ -0,0 +1,86 @@
import 'package:flutter/foundation.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import '../models/two_factor.dart';
import '../services/two_factor.dart';
class SecurityProvider extends ChangeNotifier {
SecurityProvider(this.service, AnalyticsService analytics)
: recorder = JourneyRecorder(analytics);
final TwoFactorService service;
final JourneyRecorder recorder;
FactorSettings? data;
FactorError? error;
bool busy = false, _disposed = false;
Future<void> refresh() async {
if (busy || _disposed) return;
await _run(() async {
final value = await service.fetch();
if (!_disposed) data = value;
});
}
Future<FactorChallenge?> prepare(bool enabled) async {
if (busy || _disposed || data == null) return null;
FactorChallenge? challenge;
final op = recorder.begin(
JourneyKind.security,
JourneyStep.factorChallenge,
simulated: service.isSimulated,
);
await _run(() async {
final purpose = enabled ? FactorPurpose.enable : FactorPurpose.disable;
challenge = await service.begin(purpose, purpose.name);
});
if (_disposed) return null;
recorder.finish(
op,
simulated: service.isSimulated,
failure: error == null ? null : JourneyFailure.unavailable,
);
return challenge;
}
Future<void> apply(bool enabled, FactorGrant grant) async {
if (busy || _disposed) return;
final op = recorder.begin(
JourneyKind.security,
JourneyStep.factorSettings,
simulated: service.isSimulated,
);
await _run(() async {
final result = await service.setEnabled(enabled, grant.token);
if (!_disposed) data = result;
});
if (_disposed) return;
recorder.finish(
op,
simulated: service.isSimulated,
terminal: error == null,
failure: error == null ? null : JourneyFailure.unavailable,
);
}
Future<void> _run(Future<void> Function() action) async {
busy = true;
error = null;
notifyListeners();
try {
await action();
} catch (e) {
if (!_disposed)
error = e is FactorException ? e.code : FactorError.unavailable;
}
if (_disposed) return;
busy = false;
notifyListeners();
}
@override
void dispose() {
_disposed = true;
recorder.dispose();
super.dispose();
}
}
@@ -0,0 +1,100 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import '../models/two_factor.dart';
import '../services/two_factor.dart';
class TwoFactorProvider extends ChangeNotifier {
TwoFactorProvider(
this.service,
this.challenge, {
required this.recorder,
required this.journey,
DateTime Function()? now,
}) : now = now ?? DateTime.now {
_timer = Timer.periodic(
const Duration(seconds: 1),
(_) => notifyListeners(),
);
}
final TwoFactorService service;
final JourneyRecorder recorder;
final JourneyKind journey;
final DateTime Function() now;
FactorChallenge challenge;
FactorGrant? result;
FactorError? error;
bool busy = false, _disposed = false;
Timer? _timer;
bool get expired => !now().isBefore(challenge.expiresAt);
int get cooldown {
final remaining =
(challenge.resendAt.difference(now()).inMilliseconds / 1000).ceil();
return remaining < 0 ? 0 : remaining;
}
bool get locked => error == FactorError.locked;
Future<void> submit(String code) async {
if (_disposed || busy || result != null || locked) return;
if (expired) {
error = FactorError.expired;
notifyListeners();
return;
}
if (!RegExp(r'^\d{6}$').hasMatch(code)) {
error = FactorError.invalid;
notifyListeners();
return;
}
await _run(JourneyStep.factorVerification, () async {
final grant = await service.verify(challenge.id, code);
if (!_disposed) result = grant;
});
}
Future<void> resend() async {
if (_disposed || busy || cooldown > 0 || locked || result != null) return;
await _run(JourneyStep.factorDelivery, () async {
final next = await service.resend(challenge.id);
if (!_disposed) challenge = next;
});
}
Future<void> _run(JourneyStep step, Future<void> Function() action) async {
busy = true;
error = null;
notifyListeners();
final op = recorder.begin(journey, step, simulated: service.isSimulated);
try {
await action();
if (_disposed) return;
recorder.finish(op, simulated: service.isSimulated);
} catch (e) {
if (_disposed) return;
error = e is FactorException ? e.code : FactorError.unavailable;
recorder.finish(
op,
simulated: service.isSimulated,
failure: switch (error!) {
FactorError.invalid => JourneyFailure.invalid,
FactorError.expired => JourneyFailure.expired,
_ => JourneyFailure.unavailable,
},
);
}
if (_disposed) return;
busy = false;
notifyListeners();
}
@override
void dispose() {
if (_disposed) return;
_disposed = true;
_timer?.cancel();
super.dispose();
}
}
@@ -0,0 +1,186 @@
import 'dart:math';
import '../dto/two_factor.dart';
import '../mappers/two_factor.dart';
import '../models/two_factor.dart';
import 'two_factor.dart';
/// Single-account backend stand-in. Codes and approvals never leave memory.
class DummyTwoFactorService implements TwoFactorService {
DummyTwoFactorService({
bool enabled = false,
this.delay = const Duration(milliseconds: 250),
DateTime Function()? now,
this.method = FactorMethod.email,
this.code = '123456',
this.target = 'a•••@example.com',
}) : _enabled = enabled,
now = now ?? DateTime.now;
bool _enabled;
final Duration delay;
final DateTime Function() now;
final String code, target;
final FactorMethod method;
FactorError? nextError;
final _challenges = <String, _Attempt>{};
final _grants = <String, FactorChallengeDTO>{};
final _failures = <FactorPurpose, int>{};
final _deliveryAfter = <FactorPurpose, DateTime>{};
final _blockedUntil = <FactorPurpose, DateTime>{};
@override
bool get isSimulated => true;
String _id() => List.generate(
24,
(_) => Random.secure().nextInt(256).toRadixString(16).padLeft(2, '0'),
).join();
Future<void> _wait() async {
await Future<void>.delayed(delay);
final failure = nextError;
nextError = null;
if (failure != null) throw FactorException(failure);
}
FactorSettings _settings() =>
FactorMapper.settings(FactorSettingsDTO(_enabled, target, method.name));
@override
Future<FactorSettings> fetch() async {
await _wait();
return _settings();
}
void _checkLock(FactorPurpose purpose) {
if (now().isBefore(_blockedUntil[purpose] ?? DateTime(2000))) {
throw const FactorException(FactorError.locked);
}
if (_blockedUntil.remove(purpose) != null) _failures.remove(purpose);
}
FactorChallenge _issue(FactorPurpose purpose, String operation) {
_checkLock(purpose);
if (method == FactorMethod.email &&
now().isBefore(_deliveryAfter[purpose] ?? DateTime(2000))) {
throw const FactorException(FactorError.cooldown);
}
_deliveryAfter[purpose] = now().add(const Duration(seconds: 30));
final dto = FactorChallengeDTO(
_id(),
purpose.name,
operation,
target,
now().add(const Duration(minutes: 5)),
now().add(const Duration(seconds: 30)),
method.name,
);
_challenges[dto.id] = _Attempt(dto);
return FactorMapper.challenge(dto);
}
@override
Future<FactorChallenge?> begin(
FactorPurpose purpose,
String operation,
) async {
if (!_enabled &&
nextError == null &&
purpose != FactorPurpose.enable &&
purpose != FactorPurpose.disable)
return null;
await _wait();
return _issue(purpose, operation);
}
@override
Future<FactorChallenge> resend(String challengeId) async {
await _wait();
final attempt = _challenges[challengeId];
if (method != FactorMethod.email)
throw const FactorException(FactorError.required);
if (attempt == null || attempt.verified)
throw const FactorException(FactorError.expired);
_checkLock(FactorPurpose.values.byName(attempt.dto.purpose));
if (now().isBefore(attempt.dto.resendAt))
throw const FactorException(FactorError.cooldown);
_challenges.remove(challengeId);
return _issue(
FactorPurpose.values.byName(attempt.dto.purpose),
attempt.dto.operation,
);
}
@override
Future<FactorGrant> verify(String challengeId, String value) async {
await _wait();
final attempt = _challenges[challengeId];
if (attempt == null || !now().isBefore(attempt.dto.expiresAt)) {
throw const FactorException(FactorError.expired);
}
final purpose = FactorPurpose.values.byName(attempt.dto.purpose);
_checkLock(purpose);
if (attempt.verified) {
if (value == code && _grants.containsKey(attempt.grant)) {
return FactorMapper.grant(FactorGrantDTO(attempt.grant!));
}
throw const FactorException(FactorError.expired);
}
if (value != code) {
_failures[purpose] = (_failures[purpose] ?? 0) + 1;
if (_failures[purpose]! >= 5) {
_blockedUntil[purpose] = now().add(const Duration(minutes: 5));
throw const FactorException(FactorError.locked);
}
throw const FactorException(FactorError.invalid);
}
_failures.remove(purpose);
attempt.verified = true;
final token = _id();
_grants[token] = attempt.dto;
attempt.grant = token;
return FactorMapper.grant(FactorGrantDTO(token));
}
void _consume(FactorPurpose purpose, String operation, String? grant) {
final dto = _grants[grant];
if (dto == null ||
dto.purpose != purpose.name ||
dto.operation != operation) {
throw const FactorException(FactorError.required);
}
if (!now().isBefore(dto.expiresAt))
throw const FactorException(FactorError.expired);
_grants.remove(grant);
}
@override
Future<void> authorize(
FactorPurpose purpose,
String operation,
String? grant,
) async {
await _wait();
if (grant != null ||
_enabled ||
purpose == FactorPurpose.enable ||
purpose == FactorPurpose.disable) {
_consume(purpose, operation, grant);
}
}
@override
Future<FactorSettings> setEnabled(bool enabled, String grant) async {
await _wait();
final purpose = enabled ? FactorPurpose.enable : FactorPurpose.disable;
_consume(purpose, purpose.name, grant);
_enabled = enabled;
// Changing protection invalidates all prior challenges and approvals.
_challenges.clear();
_grants.clear();
return _settings();
}
}
class _Attempt {
_Attempt(this.dto);
final FactorChallengeDTO dto;
String? grant;
bool verified = false;
}
@@ -0,0 +1,71 @@
import '../models/auth.dart';
import '../models/two_factor.dart';
import 'contracts.dart';
import 'two_factor.dart';
import 'verification.dart';
abstract interface class PendingLoginService implements LoginService {
Future<AuthResult> completeLogin(
FactorChallenge challenge,
FactorGrant grant,
);
}
/// Holds a successful credential result until the second factor is authorized.
/// A production adapter should receive the pending result from the backend.
class GuardedLoginService implements PendingLoginService, SimulationInfo {
GuardedLoginService(this.credentials, this.factors);
final LoginService credentials;
final TwoFactorService factors;
final _pending = <String, AuthResult>{};
int _serial = 0;
@override
bool get isSimulated =>
factors.isSimulated ||
(credentials is SimulationInfo &&
(credentials as SimulationInfo).isSimulated);
@override
Future<AuthResult> login(LoginRequest request) async {
final accepted = await credentials.login(request);
if (accepted.challenge != null) return accepted;
final result = AuthResult(
userId: accepted.userId,
isSimulated: accepted.isSimulated || factors.isSimulated,
);
final challenge = await factors.begin(
FactorPurpose.login,
'login-${++_serial}',
);
if (challenge == null) return result;
_pending[challenge.operation] = result;
return AuthResult(
userId: '',
isSimulated: result.isSimulated,
challenge: challenge,
);
}
@override
Future<AuthResult> completeLogin(
FactorChallenge challenge,
FactorGrant grant,
) async {
final pending = _pending[challenge.operation];
if (pending == null) {
if (credentials is PendingLoginService) {
return (credentials as PendingLoginService).completeLogin(
challenge,
grant,
);
}
throw const AuthException(AuthError.unavailable);
}
await factors.authorize(
FactorPurpose.login,
challenge.operation,
grant.token,
);
return _pending.remove(challenge.operation) ??
(throw const AuthException(AuthError.unavailable));
}
}
@@ -1,14 +1,16 @@
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import '../models/auth.dart';
import '../models/two_factor.dart';
import '../models/verification.dart';
import 'contracts.dart';
import 'guarded_login.dart';
import 'verification.dart';
/// Decorates domain services, so widget rebuilds never produce analytics events.
class InstrumentedAuthServices
implements
LoginService,
PendingLoginService,
SignupService,
VerificationService,
SimulationInfo {
@@ -33,6 +35,7 @@ class InstrumentedAuthServices
Future<T> Function() action,
bool Function(T) simulated, {
bool terminal = false,
bool Function(T)? terminalWhen,
bool onboarding = false,
bool simulation = false,
}) async {
@@ -47,7 +50,7 @@ class InstrumentedAuthServices
recorder.finish(
operation,
simulated: simulated(result),
terminal: terminal,
terminal: terminalWhen?.call(result) ?? terminal,
);
return result;
} catch (error) {
@@ -57,12 +60,17 @@ class InstrumentedAuthServices
AuthError.emailInUse => JourneyFailure.emailInUse,
AuthError.unavailable => JourneyFailure.unavailable,
AuthError.unexpected => JourneyFailure.unexpected,
AuthError.factorLocked ||
AuthError.factorCooldown => JourneyFailure.unavailable,
AuthError.factorRequired => JourneyFailure.invalid,
},
VerificationException(code: final code) => switch (code) {
VerificationError.expired => JourneyFailure.expired,
VerificationError.invalid => JourneyFailure.invalid,
VerificationError.internal => JourneyFailure.internal,
},
FactorException(code: FactorError.expired) => JourneyFailure.expired,
FactorException() => JourneyFailure.unavailable,
_ => JourneyFailure.unexpected,
};
recorder.finish(operation, failure: failure);
@@ -79,6 +87,23 @@ class InstrumentedAuthServices
JourneyStep.login,
() => loginService.login(request),
(r) => r.isSimulated,
terminalWhen: (r) => r.challenge == null,
simulation: _simulation(loginService),
);
@override
Future<AuthResult> completeLogin(
FactorChallenge challenge,
FactorGrant grant,
) => _run(
JourneyKind.login,
JourneyStep.login,
() {
final service = loginService;
if (service is! PendingLoginService)
throw const AuthException(AuthError.unavailable);
return service.completeLogin(challenge, grant);
},
(r) => r.isSimulated,
terminal: true,
simulation: _simulation(loginService),
);
@@ -0,0 +1,19 @@
import '../models/two_factor.dart';
abstract interface class TwoFactorService {
bool get isSimulated;
Future<FactorSettings> fetch();
/// The service decides whether this purpose requires an additional factor.
Future<FactorChallenge?> begin(FactorPurpose purpose, String operation);
Future<FactorChallenge> resend(String challengeId);
Future<FactorGrant> verify(String challengeId, String code);
/// Operation adapters consume a one-use, scope-bound grant at execution.
Future<void> authorize(
FactorPurpose purpose,
String operation,
String? grant,
);
Future<FactorSettings> setEnabled(bool enabled, String grant);
}
@@ -0,0 +1,200 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/two_factor.dart';
import '../providers/two_factor.dart';
/// Labels are supplied by each client's localization layer.
class TwoFactorLabels {
const TwoFactorLabels({
required this.title,
required this.description,
required this.code,
required this.confirm,
required this.cancel,
required this.resend,
required this.expired,
required this.help,
required this.errorText,
required this.cooldownText,
});
final String title, description, code, confirm, cancel, resend, expired, help;
final String Function(FactorError) errorText;
final String Function(int) cooldownText;
}
/// Reusable full-page code entry, owned by the caller's route/provider lifecycle.
class TwoFactorPage extends StatefulWidget {
const TwoFactorPage({
super.key,
required this.provider,
required this.labels,
required this.onVerified,
required this.onCancel,
this.summary,
});
final TwoFactorProvider provider;
final TwoFactorLabels labels;
final ValueChanged<FactorGrant> onVerified;
final VoidCallback onCancel;
final Widget? summary;
@override
State<TwoFactorPage> createState() => _TwoFactorPageState();
}
class _TwoFactorPageState extends State<TwoFactorPage> {
final _code = TextEditingController();
final _focus = FocusNode();
bool _delivered = false;
@override
void initState() {
super.initState();
widget.provider.addListener(_changed);
}
void _changed() {
final p = widget.provider;
if (p.result != null && !_delivered) {
_delivered = true;
_code.clear();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) widget.onVerified(p.result!);
});
}
}
void _submit() {
widget.provider.submit(_code.text.trim());
}
@override
void dispose() {
widget.provider.removeListener(_changed);
_code.dispose();
_focus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: widget.provider,
builder: (context, _) {
final p = widget.provider, l = widget.labels;
final t = Theme.of(context), c = Theme.of(context).colorScheme;
return PopScope(
canPop: !p.busy,
child: Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Align(
alignment: Alignment.centerLeft,
child: CircleAvatar(
radius: 32,
backgroundColor: c.primaryContainer,
child: Icon(
Icons.verified_user_outlined,
size: 30,
color: c.onPrimaryContainer,
),
),
),
const SizedBox(height: 24),
Semantics(
header: true,
child: Text(l.title, style: t.textTheme.displaySmall),
),
const SizedBox(height: 16),
Text(l.description, style: t.textTheme.bodyLarge),
if (p.challenge.method == FactorMethod.email) ...[
const SizedBox(height: 12),
Text(p.challenge.target),
],
if (widget.summary != null) ...[
const SizedBox(height: 20),
widget.summary!,
],
const SizedBox(height: 28),
if (p.busy) const LinearProgressIndicator(),
TextField(
key: const ValueKey('two-factor-code'),
controller: _code,
focusNode: _focus,
autofocus: true,
enabled: !p.busy && !p.locked && !p.expired,
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
style: t.textTheme.headlineMedium?.copyWith(
letterSpacing: 8,
),
autofillHints: const [AutofillHints.oneTimeCode],
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(6),
],
textInputAction: TextInputAction.done,
onSubmitted: (_) => _submit(),
decoration: InputDecoration(labelText: l.code),
),
if (p.error != null || p.expired) ...[
const SizedBox(height: 12),
Semantics(
liveRegion: true,
child: Text(
p.error == null ? l.expired : l.errorText(p.error!),
key: const ValueKey('two-factor-error'),
style: TextStyle(color: c.error),
),
),
],
const SizedBox(height: 24),
FilledButton(
key: const ValueKey('two-factor-confirm'),
onPressed: p.busy || p.expired || p.locked
? null
: _submit,
child: Text(l.confirm),
),
const SizedBox(height: 12),
if (p.challenge.method == FactorMethod.email)
TextButton(
key: const ValueKey('two-factor-resend'),
onPressed: p.busy || p.cooldown > 0 || p.locked
? null
: () async {
await p.resend();
if (mounted && p.error == null) {
_code.clear();
_focus.requestFocus();
}
},
child: Text(
p.cooldown > 0
? l.cooldownText(p.cooldown)
: l.resend,
),
),
Text(l.help, style: t.textTheme.bodySmall),
const SizedBox(height: 16),
TextButton(
key: const ValueKey('two-factor-cancel'),
onPressed: p.busy ? null : widget.onCancel,
child: Text(l.cancel),
),
],
),
),
),
),
),
),
);
},
);
}
@@ -12,3 +12,11 @@ export 'src/providers/verification.dart';
export 'src/services/verification.dart';
export 'src/services/instrumented.dart';
export 'src/models/two_factor.dart';
export 'src/providers/two_factor.dart';
export 'src/providers/security.dart';
export 'src/services/two_factor.dart';
export 'src/services/dummy_factor.dart';
export 'src/widgets/two_factor.dart';
export 'src/services/guarded_login.dart';