diff --git a/lib/providers/auth_provider.dart b/lib/providers/auth_provider.dart index 69e95842..03daa383 100644 --- a/lib/providers/auth_provider.dart +++ b/lib/providers/auth_provider.dart @@ -25,7 +25,9 @@ class AuthProvider with StreamSubscriber { })); } - Future login( + /// Returns a [TwoFactorChallenge] when the server requires a second factor, + /// or `null` when the credentials alone completed the login. + Future login( {required String host, required String email, required String password}) async { @@ -37,8 +39,25 @@ class AuthProvider with StreamSubscriber { }; final response = await post('me', data: loginData); - preferences.apiToken = response['token']; - preferences.audioToken = response['audio-token']; + + if (response['two_factor'] == true) { + return TwoFactorChallenge(loginToken: response['login_token']); + } + + _storeCompositeToken(response); + return null; + } + + Future completeTwoFactorChallenge({ + required String loginToken, + required String code, + }) async { + final response = await post('me/two-factor-challenge', data: { + 'login_token': loginToken, + 'code': code, + }); + + _storeCompositeToken(response); } Future loginWithOneTimeToken({ @@ -51,7 +70,10 @@ class AuthProvider with StreamSubscriber { 'token': token, }; - final response = await post('me/otp', data: loginData); + _storeCompositeToken(await post('me/otp', data: loginData)); + } + + void _storeCompositeToken(dynamic response) { preferences.apiToken = response['token']; preferences.audioToken = response['audio-token']; } @@ -79,3 +101,9 @@ class AuthProvider with StreamSubscriber { _userLoggedOut.add(null); } } + +class TwoFactorChallenge { + final String loginToken; + + const TwoFactorChallenge({required this.loginToken}); +} diff --git a/lib/ui/screens/login.dart b/lib/ui/screens/login.dart index e7ece1a5..2357bf71 100644 --- a/lib/ui/screens/login.dart +++ b/lib/ui/screens/login.dart @@ -95,10 +95,19 @@ class _LoginScreenState extends State with StreamSubscriber { try { _host = standardizeHost(_host); - await _auth.login(host: _host, email: _email, password: _password); + final challenge = + await _auth.login(host: _host, email: _email, password: _password); + if (!mounted) return; + + if (challenge != null) { + _gotoTwoFactorChallenge(challenge); + return; + } + await _auth.tryGetAuthUser(); successful = true; } on HttpResponseException catch (error) { + if (!mounted) return; await showErrorDialog( context, message: error.response.statusCode == 401 @@ -106,18 +115,29 @@ class _LoginScreenState extends State with StreamSubscriber { : null, ); } catch (error) { + if (!mounted) return; await showErrorDialog(context); } finally { - setState(() => _authenticating = false); + if (mounted) setState(() => _authenticating = false); } - if (successful) { + if (successful && mounted) { preferences.host = _host; preferences.userEmail = _email; redirectToDataLoadingScreen(); } } + void _gotoTwoFactorChallenge(TwoFactorChallenge challenge) { + Navigator.of(context).push(CupertinoPageRoute( + builder: (_) => TwoFactorChallengeScreen( + host: _host, + email: _email, + loginToken: challenge.loginToken, + ), + )); + } + Future attemptLoginWithOtp({ required String host, required String token, @@ -131,18 +151,20 @@ class _LoginScreenState extends State with StreamSubscriber { await _auth.tryGetAuthUser(); successful = true; } on HttpResponseException catch (error) { + if (!mounted) return; await showErrorDialog( context, message: error.response.statusCode == 401 ? 'Invalid login token.' : null, ); } catch (error) { + if (!mounted) return; await showErrorDialog(context); } finally { - setState(() => _authenticating = false); + if (mounted) setState(() => _authenticating = false); } - if (successful) { + if (successful && mounted) { preferences.host = host; redirectToDataLoadingScreen(); } diff --git a/lib/ui/screens/screens.dart b/lib/ui/screens/screens.dart index 97f889a8..8a437479 100644 --- a/lib/ui/screens/screens.dart +++ b/lib/ui/screens/screens.dart @@ -22,6 +22,7 @@ export 'info_sheet/info_sheet.dart'; export 'initial.dart'; export 'library.dart'; export 'login.dart'; +export 'two_factor_challenge.dart'; export 'main.dart'; export 'no_connection.dart'; export 'now_playing.dart'; diff --git a/lib/ui/screens/two_factor_challenge.dart b/lib/ui/screens/two_factor_challenge.dart new file mode 100644 index 00000000..56f9e755 --- /dev/null +++ b/lib/ui/screens/two_factor_challenge.dart @@ -0,0 +1,252 @@ +import 'package:app/constants/constants.dart'; +import 'package:app/exceptions/exceptions.dart'; +import 'package:app/providers/providers.dart'; +import 'package:app/ui/screens/screens.dart'; +import 'package:app/ui/widgets/widgets.dart'; +import 'package:app/utils/preferences.dart' as preferences; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_spinkit/flutter_spinkit.dart'; +import 'package:provider/provider.dart'; + +enum _CodeMode { totp, recovery } + +class TwoFactorChallengeScreen extends StatefulWidget { + static const routeName = '/two-factor-challenge'; + + final String host; + final String email; + final String loginToken; + + const TwoFactorChallengeScreen({ + Key? key, + required this.host, + required this.email, + required this.loginToken, + }) : super(key: key); + + @override + _TwoFactorChallengeScreenState createState() => + _TwoFactorChallengeScreenState(); +} + +class _TwoFactorChallengeScreenState extends State { + late final AuthProvider _auth; + var _mode = _CodeMode.totp; + var _code = ''; + var _verifying = false; + var _totpResetToken = 0; + + @override + void initState() { + super.initState(); + _auth = context.read(); + } + + void _switchMode(_CodeMode mode) { + setState(() { + _mode = mode; + _code = ''; + }); + } + + Future showErrorDialog(BuildContext context, {String? message}) async { + await showDialog( + context: context, + builder: (BuildContext context) => AlertDialog( + title: const Text('Error'), + content: Text( + message ?? 'There was a problem verifying the code. Please try again.', + ), + actions: [ + TextButton( + child: const Text('OK'), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ); + } + + Future verify() async { + final code = _code.trim(); + if (code.isEmpty || _verifying) return; + + var successful = false; + setState(() => _verifying = true); + + try { + await _auth.completeTwoFactorChallenge( + loginToken: widget.loginToken, + code: code, + ); + await _auth.tryGetAuthUser(); + successful = true; + } on HttpResponseException catch (error) { + if (!mounted) return; + _resetTotpInput(); + await showErrorDialog( + context, + message: error.response.statusCode == 401 + ? 'Invalid authentication code.' + : null, + ); + } catch (error) { + if (!mounted) return; + _resetTotpInput(); + await showErrorDialog(context); + } finally { + if (mounted) setState(() => _verifying = false); + } + + if (successful && mounted) { + preferences.host = widget.host; + preferences.userEmail = widget.email; + Navigator.of(context, rootNavigator: true) + .pushReplacementNamed(DataLoadingScreen.routeName); + } + } + + void _resetTotpInput() { + setState(() { + _code = ''; + _totpResetToken++; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: GradientDecoratedContainer( + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric( + horizontal: AppDimensions.hPadding, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ...[ + Text( + 'Two-Factor Authentication', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineSmall, + ), + if (_mode == _CodeMode.totp) + ..._buildTotpInput() + else + ..._buildRecoveryInput(), + SizedBox( + width: double.infinity, + child: ElevatedButton( + child: _verifying + ? const SpinKitThreeBounce( + color: Colors.white, + size: 16, + ) + : const Text('Verify'), + onPressed: _verifying ? null : verify, + ), + ), + _FooterButton( + label: _mode == _CodeMode.totp + ? 'Use a recovery code' + : 'Use authenticator code instead', + onPressed: _verifying + ? null + : () => _switchMode( + _mode == _CodeMode.totp + ? _CodeMode.recovery + : _CodeMode.totp, + ), + ), + _FooterButton( + label: 'Back to login', + onPressed: _verifying + ? null + : () => Navigator.of(context).pop(), + ), + ].expand((child) => [child, const SizedBox(height: 16)]), + ], + ), + ), + ), + ), + ), + ); + } + + List _buildTotpInput() { + return [ + const Text( + 'Enter the code from your authenticator app.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.white70), + ), + OneTimeCodeInput( + key: ValueKey(_totpResetToken), + onChanged: (value) => _code = value, + onCompleted: (value) { + _code = value; + verify(); + }, + ), + ]; + } + + List _buildRecoveryInput() { + return [ + const Text( + 'Enter one of your recovery codes.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.white70), + ), + TextField( + autofocus: true, + autocorrect: false, + enableSuggestions: false, + textAlign: TextAlign.center, + textInputAction: TextInputAction.go, + style: const TextStyle(fontFamily: 'monospace', letterSpacing: 2), + inputFormatters: [_UpperCaseFormatter()], + onChanged: (value) => _code = value, + onSubmitted: (_) => verify(), + decoration: const InputDecoration( + hintText: 'Recovery code', + ), + ), + ]; + } +} + +class _FooterButton extends StatelessWidget { + final String label; + final VoidCallback? onPressed; + + const _FooterButton({Key? key, required this.label, this.onPressed}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: double.infinity, + child: TextButton( + onPressed: onPressed, + style: TextButton.styleFrom(foregroundColor: Colors.white), + child: Text(label, textAlign: TextAlign.center), + ), + ); + } +} + +class _UpperCaseFormatter extends TextInputFormatter { + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + return newValue.copyWith(text: newValue.text.toUpperCase()); + } +} diff --git a/lib/ui/widgets/one_time_code_input.dart b/lib/ui/widgets/one_time_code_input.dart new file mode 100644 index 00000000..825b2f1d --- /dev/null +++ b/lib/ui/widgets/one_time_code_input.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// A segmented one-time-code field: [length] single-digit boxes with +/// auto-advance, backspace-to-previous, and paste/autofill distribution. +class OneTimeCodeInput extends StatefulWidget { + final int length; + final ValueChanged? onChanged; + final ValueChanged? onCompleted; + + const OneTimeCodeInput({ + Key? key, + this.length = 6, + this.onChanged, + this.onCompleted, + }) : super(key: key); + + @override + State createState() => _OneTimeCodeInputState(); +} + +class _OneTimeCodeInputState extends State { + late final List _controllers; + late final List _focusNodes; + + @override + void initState() { + super.initState(); + _controllers = List.generate(widget.length, (_) => TextEditingController()); + _focusNodes = List.generate(widget.length, (index) { + return FocusNode()..onKeyEvent = (_, event) => _handleKey(index, event); + }); + } + + @override + void dispose() { + for (final controller in _controllers) controller.dispose(); + for (final node in _focusNodes) node.dispose(); + super.dispose(); + } + + String get _code => _controllers.map((controller) => controller.text).join(); + + void _setBox(int index, String digit) { + _controllers[index].value = TextEditingValue( + text: digit, + selection: TextSelection.collapsed(offset: digit.length), + ); + } + + void _emit() { + final code = _code; + widget.onChanged?.call(code); + if (code.length == widget.length) widget.onCompleted?.call(code); + } + + void _handleChanged(int index, String value) { + final digits = value.replaceAll(RegExp(r'\D'), ''); + + if (digits.length > 1) { + for (var offset = 0; index + offset < widget.length; offset++) { + _setBox(index + offset, + offset < digits.length ? digits[offset] : ''); + } + final focusIndex = + (index + digits.length).clamp(0, widget.length - 1); + _focusNodes[focusIndex].requestFocus(); + } else { + _setBox(index, digits); + if (digits.isNotEmpty && index < widget.length - 1) { + _focusNodes[index + 1].requestFocus(); + } + } + + _emit(); + } + + KeyEventResult _handleKey(int index, KeyEvent event) { + final isBackspace = event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.backspace; + + if (isBackspace && _controllers[index].text.isEmpty && index > 0) { + _setBox(index - 1, ''); + _focusNodes[index - 1].requestFocus(); + _emit(); + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + + @override + Widget build(BuildContext context) { + final highlight = Theme.of(context).colorScheme.primary; + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(widget.length, (index) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: SizedBox( + width: 44, + height: 54, + child: TextField( + controller: _controllers[index], + focusNode: _focusNodes[index], + autofocus: index == 0, + textAlign: TextAlign.center, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + style: const TextStyle( + fontSize: 20, + fontFamily: 'monospace', + fontWeight: FontWeight.w600, + ), + decoration: InputDecoration( + counterText: '', + filled: true, + fillColor: Colors.white10, + contentPadding: EdgeInsets.zero, + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Colors.white24), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: highlight, width: 2), + ), + ), + onChanged: (value) => _handleChanged(index, value), + ), + ), + ); + }), + ); + } +} diff --git a/lib/ui/widgets/widgets.dart b/lib/ui/widgets/widgets.dart index bde4abef..9a26fd88 100644 --- a/lib/ui/widgets/widgets.dart +++ b/lib/ui/widgets/widgets.dart @@ -19,6 +19,7 @@ export 'now_playing/playable_info.dart'; export 'now_playing/progress_bar.dart'; export 'now_playing/repeat_mode_button.dart'; export 'now_playing/volume_slider.dart'; +export 'one_time_code_input.dart'; export 'oops_box.dart'; export 'playable_cache_icon.dart'; export 'playable_quick_action.dart'; diff --git a/test/providers/auth_provider_test.dart b/test/providers/auth_provider_test.dart new file mode 100644 index 00000000..3642c8ae --- /dev/null +++ b/test/providers/auth_provider_test.dart @@ -0,0 +1,93 @@ +import 'package:app/exceptions/exceptions.dart'; +import 'package:app/providers/auth_provider.dart'; +import 'package:app/utils/preferences.dart' as preferences; +import 'package:flutter_test/flutter_test.dart'; + +import '../helpers/api_test_setup.dart'; + +void main() { + late AuthProvider auth; + late CapturingClient client; + + setUpAll(() async => await initApiTestEnvironment()); + + setUp(() { + auth = AuthProvider(); + client = CapturingClient(); + client.install(); + setUpApiTest(); + preferences.apiToken = null; + preferences.audioToken = null; + }); + + tearDown(tearDownApiTest); + + group('login', () { + test('stores the composite token and returns null on a normal login', + () async { + client.willReturn(json: {'token': 'api-tok', 'audio-token': 'aud-tok'}); + + final challenge = await auth.login( + host: 'https://koel.test', + email: 'user@koel.test', + password: 'secret', + ); + + expect(challenge, isNull); + expect(preferences.apiToken, 'api-tok'); + expect(preferences.audioToken, 'aud-tok'); + + final request = client.requests.single; + expect(request.url, 'https://koel.test/api/me'); + expect(request.method, 'POST'); + expect(request.jsonBody, {'email': 'user@koel.test', 'password': 'secret'}); + }); + + test('returns a challenge and stores no token when 2FA is required', + () async { + client.willReturn(json: {'two_factor': true, 'login_token': 'lt-123'}); + + final challenge = await auth.login( + host: 'https://koel.test', + email: 'user@koel.test', + password: 'secret', + ); + + expect(challenge, isNotNull); + expect(challenge!.loginToken, 'lt-123'); + expect(preferences.apiToken, isNull); + expect(preferences.audioToken, isNull); + }); + }); + + group('completeTwoFactorChallenge', () { + test('posts the login token and code, then stores the composite token', + () async { + client.willReturn(json: {'token': 'api-2fa', 'audio-token': 'aud-2fa'}); + + await auth.completeTwoFactorChallenge( + loginToken: 'lt-123', + code: '123456', + ); + + expect(preferences.apiToken, 'api-2fa'); + expect(preferences.audioToken, 'aud-2fa'); + + final request = client.requests.single; + expect(request.url, 'https://koel.test/api/me/two-factor-challenge'); + expect(request.method, 'POST'); + expect(request.jsonBody, {'login_token': 'lt-123', 'code': '123456'}); + }); + + test('propagates a 401 for an invalid code', () async { + client.willReturnRaw(status: 401, body: 'Invalid credentials'); + + await expectLater( + auth.completeTwoFactorChallenge(loginToken: 'lt-123', code: 'wrong'), + throwsA(isA()), + ); + + expect(preferences.apiToken, isNull); + }); + }); +} diff --git a/test/ui/screens/two_factor_challenge_test.dart b/test/ui/screens/two_factor_challenge_test.dart new file mode 100644 index 00000000..e63a416d --- /dev/null +++ b/test/ui/screens/two_factor_challenge_test.dart @@ -0,0 +1,136 @@ +import 'package:app/exceptions/exceptions.dart'; +import 'package:app/providers/auth_provider.dart'; +import 'package:app/ui/screens/data_loading.dart'; +import 'package:app/ui/screens/two_factor_challenge.dart'; +import 'package:app/ui/widgets/widgets.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:provider/provider.dart'; + +import '../../extensions/widget_tester_extension.dart'; +import '../../helpers/api_test_setup.dart'; +import 'two_factor_challenge_test.mocks.dart'; + +@GenerateMocks([AuthProvider]) +void main() { + late MockAuthProvider authMock; + + setUpAll(() async => await initApiTestEnvironment()); + + setUp(() { + authMock = MockAuthProvider(); + setUpApiTest(); + }); + + tearDown(tearDownApiTest); + + Future _mount(WidgetTester tester) async { + await tester.pumpAppWidget( + Provider.value( + value: authMock, + child: const TwoFactorChallengeScreen( + host: 'https://koel.test', + email: 'user@koel.test', + loginToken: 'lt-123', + ), + ), + routes: { + DataLoadingScreen.routeName: (_) => const Text('LOADING'), + }, + ); + await tester.pump(); + } + + Future _pumpSettle(WidgetTester tester) async { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + } + + testWidgets('shows six code boxes by default', (tester) async { + await _mount(tester); + + expect(find.byType(OneTimeCodeInput), findsOneWidget); + expect(find.descendant( + of: find.byType(OneTimeCodeInput), + matching: find.byType(TextField), + ), findsNWidgets(6)); + expect(find.text('Use a recovery code'), findsOneWidget); + }); + + testWidgets('auto-submits once all six digits are entered', (tester) async { + when(authMock.completeTwoFactorChallenge( + loginToken: 'lt-123', + code: '123456', + )).thenAnswer((_) async {}); + when(authMock.tryGetAuthUser()).thenAnswer((_) async => null); + + await _mount(tester); + await tester.enterText(find.byType(TextField).first, '123456'); + await _pumpSettle(tester); + + verify(authMock.completeTwoFactorChallenge( + loginToken: 'lt-123', + code: '123456', + )).called(1); + expect(find.text('LOADING'), findsOneWidget); + }); + + testWidgets('toggles to recovery mode and back', (tester) async { + await _mount(tester); + + await tester.tap(find.text('Use a recovery code')); + await tester.pump(); + + expect(find.byType(OneTimeCodeInput), findsNothing); + expect(find.text('Enter one of your recovery codes.'), findsOneWidget); + expect(find.text('Use authenticator code instead'), findsOneWidget); + + await tester.tap(find.text('Use authenticator code instead')); + await tester.pump(); + + expect(find.byType(OneTimeCodeInput), findsOneWidget); + expect(find.text('Use a recovery code'), findsOneWidget); + }); + + testWidgets('submits an upper-cased recovery code', (tester) async { + when(authMock.completeTwoFactorChallenge( + loginToken: 'lt-123', + code: 'ABCDE-FGHIJ', + )).thenAnswer((_) async {}); + when(authMock.tryGetAuthUser()).thenAnswer((_) async => null); + + await _mount(tester); + await tester.tap(find.text('Use a recovery code')); + await tester.pump(); + + await tester.enterText(find.byType(TextField), 'abcde-fghij'); + await tester.tap(find.text('Verify')); + await _pumpSettle(tester); + + verify(authMock.completeTwoFactorChallenge( + loginToken: 'lt-123', + code: 'ABCDE-FGHIJ', + )).called(1); + }); + + testWidgets('shows an error for an invalid code and stays on the screen', + (tester) async { + when(authMock.completeTwoFactorChallenge( + loginToken: anyNamed('loginToken'), + code: anyNamed('code'), + )).thenThrow(HttpResponseException( + response: http.Response('nope', 401), + )); + + await _mount(tester); + await tester.enterText(find.byType(TextField).first, '123456'); + await _pumpSettle(tester); + + expect(find.text('Invalid authentication code.'), findsOneWidget); + expect(find.text('LOADING'), findsNothing); + verifyNever(authMock.tryGetAuthUser()); + }); +} diff --git a/test/ui/screens/two_factor_challenge_test.mocks.dart b/test/ui/screens/two_factor_challenge_test.mocks.dart new file mode 100644 index 00000000..b12027f6 --- /dev/null +++ b/test/ui/screens/two_factor_challenge_test.mocks.dart @@ -0,0 +1,153 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in app/test/ui/screens/two_factor_challenge_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i4; + +import 'package:app/models/models.dart' as _i2; +import 'package:app/providers/auth_provider.dart' as _i3; +import 'package:mockito/mockito.dart' as _i1; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeUser_0 extends _i1.SmartFake implements _i2.User { + _FakeUser_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +/// A class which mocks [AuthProvider]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockAuthProvider extends _i1.Mock implements _i3.AuthProvider { + MockAuthProvider() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.User get authUser => (super.noSuchMethod( + Invocation.getter(#authUser), + returnValue: _FakeUser_0( + this, + Invocation.getter(#authUser), + ), + ) as _i2.User); + + @override + _i4.Future<_i3.TwoFactorChallenge?> login({ + required String? host, + required String? email, + required String? password, + }) => + (super.noSuchMethod( + Invocation.method( + #login, + [], + { + #host: host, + #email: email, + #password: password, + }, + ), + returnValue: _i4.Future<_i3.TwoFactorChallenge?>.value(), + ) as _i4.Future<_i3.TwoFactorChallenge?>); + + @override + _i4.Future completeTwoFactorChallenge({ + required String? loginToken, + required String? code, + }) => + (super.noSuchMethod( + Invocation.method( + #completeTwoFactorChallenge, + [], + { + #loginToken: loginToken, + #code: code, + }, + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + + @override + _i4.Future loginWithOneTimeToken({ + required String? host, + required String? token, + }) => + (super.noSuchMethod( + Invocation.method( + #loginWithOneTimeToken, + [], + { + #host: host, + #token: token, + }, + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + + @override + void setAuthUser(_i2.User? user) => super.noSuchMethod( + Invocation.method( + #setAuthUser, + [user], + ), + returnValueForMissingStub: null, + ); + + @override + _i4.Future<_i2.User?> tryGetAuthUser() => (super.noSuchMethod( + Invocation.method( + #tryGetAuthUser, + [], + ), + returnValue: _i4.Future<_i2.User?>.value(), + ) as _i4.Future<_i2.User?>); + + @override + _i4.Future logout() => (super.noSuchMethod( + Invocation.method( + #logout, + [], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + + @override + void unsubscribeAll() => super.noSuchMethod( + Invocation.method( + #unsubscribeAll, + [], + ), + returnValueForMissingStub: null, + ); + + @override + void subscribe(_i4.StreamSubscription? sub) => super.noSuchMethod( + Invocation.method( + #subscribe, + [sub], + ), + returnValueForMissingStub: null, + ); +} diff --git a/test/ui/widgets/oops_box_test.mocks.dart b/test/ui/widgets/oops_box_test.mocks.dart index 7fdf5c94..b18733bc 100644 --- a/test/ui/widgets/oops_box_test.mocks.dart +++ b/test/ui/widgets/oops_box_test.mocks.dart @@ -51,7 +51,7 @@ class MockAuthProvider extends _i1.Mock implements _i3.AuthProvider { ) as _i2.User); @override - _i4.Future login({ + _i4.Future<_i3.TwoFactorChallenge?> login({ required String? host, required String? email, required String? password, @@ -66,6 +66,23 @@ class MockAuthProvider extends _i1.Mock implements _i3.AuthProvider { #password: password, }, ), + returnValue: _i4.Future<_i3.TwoFactorChallenge?>.value(), + ) as _i4.Future<_i3.TwoFactorChallenge?>); + + @override + _i4.Future completeTwoFactorChallenge({ + required String? loginToken, + required String? code, + }) => + (super.noSuchMethod( + Invocation.method( + #completeTwoFactorChallenge, + [], + { + #loginToken: loginToken, + #code: code, + }, + ), returnValue: _i4.Future.value(), returnValueForMissingStub: _i4.Future.value(), ) as _i4.Future);