From 7c5c35d4693404d5b545199350c878c513dd5970 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Sat, 7 Nov 2026 01:23:00 +0200 Subject: [PATCH 1/8] Implement pending email verification and preserve offline account data --- .../powersync_books_integration_test.dart | 14 +- .../powersync_timer_integration_test.dart | 14 +- app/lib/auth/auth_api_client.dart | 62 +++- app/lib/auth/auth_models.dart | 36 ++ app/lib/auth/auth_repository.dart | 175 ++++++--- app/lib/auth/papyrus_api_config.dart | 1 + app/lib/config/app_router.dart | 28 +- app/lib/main.dart | 22 +- app/lib/pages/auth/actions.dart | 2 + app/lib/pages/auth/oauth_callback_page.dart | 27 ++ app/lib/pages/auth/verification_page.dart | 269 ++++++++++++++ app/lib/pages/login_page.dart | 26 +- app/lib/pages/register_page.dart | 28 +- app/lib/platform/web_redirect_stub.dart | 8 + app/lib/platform/web_redirect_web.dart | 18 + app/lib/providers/auth_provider.dart | 280 +++++++++++--- app/lib/services/book_import_session.dart | 10 +- .../widgets/auth/auth_continue_button.dart | 84 +++-- app/lib/widgets/auth/auth_page_layouts.dart | 22 +- app/lib/widgets/book/private_book_cover.dart | 5 +- app/lib/widgets/buttons/google_sign_in.dart | 18 + ...auth_repository_account_deletion_test.dart | 1 + app/test/auth/pending_verification_test.dart | 347 ++++++++++++++++++ app/test/config/app_router_test.dart | 64 ++++ .../media_profile_switch_contract_test.dart | 64 +++- .../pages/auth_password_submission_test.dart | 2 +- .../platform/auth_browser_callback_test.dart | 26 ++ .../powersync/library_live_sync_test.dart | 15 +- .../verification_preservation_test.dart | 57 +++ .../services/book_import_session_test.dart | 12 +- .../support/disposable_sync_accounts.dart | 14 + app/test/web/auth_landing_test.cjs | 34 ++ .../widgets/auth/verification_page_test.dart | 278 ++++++++++++++ .../widgets/book/private_book_cover_test.dart | 27 ++ app/web/auth_landing.js | 15 + app/web/index.html | 2 + tools/prepare_web_offline.py | 2 +- tools/tests/test_web_offline.py | 3 +- 38 files changed, 1927 insertions(+), 185 deletions(-) create mode 100644 app/lib/pages/auth/verification_page.dart create mode 100644 app/test/auth/pending_verification_test.dart create mode 100644 app/test/platform/auth_browser_callback_test.dart create mode 100644 app/test/powersync/verification_preservation_test.dart create mode 100644 app/test/support/disposable_sync_accounts.dart create mode 100644 app/test/web/auth_landing_test.cjs create mode 100644 app/test/widgets/auth/verification_page_test.dart create mode 100644 app/web/auth_landing.js diff --git a/app/integration_test/powersync_books_integration_test.dart b/app/integration_test/powersync_books_integration_test.dart index 572c27a5..6abbf000 100644 --- a/app/integration_test/powersync_books_integration_test.dart +++ b/app/integration_test/powersync_books_integration_test.dart @@ -1,3 +1,4 @@ +import '../test/support/disposable_sync_accounts.dart'; import 'dart:io'; import 'dart:typed_data'; @@ -168,8 +169,9 @@ void main() { testWidgets('two clients sync books, reconnect offline writes, and isolate users', (tester) async { final config = PapyrusApiConfig.fromEnvironment(); - final email = 'powersync-${DateTime.now().microsecondsSinceEpoch}@example.com'; - const password = 'SecureP@ss123'; + requireDisposableSyncAccounts(); + const email = syncOwnerEmail; + const password = syncPassword; final root = await Directory.systemTemp.createTemp('papyrus-e2e-'); AuthRepository repository(String label) { @@ -189,10 +191,9 @@ void main() { final firstAuth = repository('first'); - final firstTokens = await firstAuth.register( + final firstTokens = await firstAuth.login( email: email, password: password, - displayName: 'PowerSync Test', clientType: 'desktop', ); @@ -200,10 +201,9 @@ void main() { await secondAuth.login(email: email, password: password, clientType: 'desktop'); final otherAuth = repository('other'); - final otherTokens = await otherAuth.register( - email: 'other-$email', + final otherTokens = await otherAuth.login( + email: syncOtherEmail, password: password, - displayName: 'Other User', clientType: 'desktop', ); diff --git a/app/integration_test/powersync_timer_integration_test.dart b/app/integration_test/powersync_timer_integration_test.dart index 51a3c9a2..b192d30a 100644 --- a/app/integration_test/powersync_timer_integration_test.dart +++ b/app/integration_test/powersync_timer_integration_test.dart @@ -1,3 +1,4 @@ +import '../test/support/disposable_sync_accounts.dart'; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; @@ -42,8 +43,9 @@ void main() { (tester) async { final config = PapyrusApiConfig.fromEnvironment(); final root = await Directory.systemTemp.createTemp('papyrus-timer-sync-'); - final email = 'timer-${DateTime.now().microsecondsSinceEpoch}@example.com'; - const password = 'SecureP@ss123'; + requireDisposableSyncAccounts(); + const email = syncOwnerEmail; + const password = syncPassword; final accounts = []; final services = []; final stores = []; @@ -82,20 +84,18 @@ void main() { try { final browserAuth = auth(); - final owner = await browserAuth.register( + final owner = await browserAuth.login( email: email, password: password, - displayName: 'Timer test', clientType: 'desktop', ); accounts.add(browserAuth); final phoneAuth = auth(); await phoneAuth.login(email: email, password: password, clientType: 'desktop'); final otherAuth = auth(); - final otherOwner = await otherAuth.register( - email: 'other-$email', + final otherOwner = await otherAuth.login( + email: syncOtherEmail, password: password, - displayName: 'Other', clientType: 'desktop', ); accounts.add(otherAuth); diff --git a/app/lib/auth/auth_api_client.dart b/app/lib/auth/auth_api_client.dart index de09691a..f2986bb1 100644 --- a/app/lib/auth/auth_api_client.dart +++ b/app/lib/auth/auth_api_client.dart @@ -14,6 +14,7 @@ class AuthApiException implements Exception { final String? code; final Duration? retryAfter; final AccountDeletionAuthorization? recoveryAuthorization; + final PendingVerification? pendingVerification; const AuthApiException({ required this.statusCode, @@ -21,6 +22,7 @@ class AuthApiException implements Exception { this.code, this.retryAfter, this.recoveryAuthorization, + this.pendingVerification, }); @override @@ -63,7 +65,7 @@ class AuthApiClient { return config.endpoint('/auth/oauth/google/start', {'redirect_uri': redirectUri}); } - Future register({ + Future register({ required String email, required String password, required String displayName, @@ -81,7 +83,30 @@ class AuthApiClient { }, ); - return AuthTokens.fromJson(json); + if (json['status'] == 'verification_required' && + json['email'] is String && + json['message'] is String && + !json.containsKey('access_token') && + !json.containsKey('refresh_token')) { + return PendingVerification.fromJson(json); + } + + // During rollout an older server may still issue an unverified session. + final accessToken = json['access_token']; + + if (accessToken is String && accessToken.isNotEmpty) { + try { + await logout(accessToken); + } catch (_) { + // Never install the rejected session, even when revocation is unavailable. + } + } + + if (json['user'] is Map && json['user']['email_verified'] == false) { + return PendingVerification(email: email.trim().toLowerCase(), resendAfter: 0); + } + + throw const FormatException('Expected pending registration'); } Future login({ @@ -199,12 +224,18 @@ class AuthApiClient { Future verifyEmail(String token) async { final json = await _postJson(config.endpoint('/auth/verify-email'), body: {'token': token}); - return json['message'] as String? ?? 'Email verified successfully'; + final message = json['message']; + + if (message is! String || message.isEmpty) { + throw const FormatException('Invalid verification response'); + } + + return message; } - Future resendVerification(String email) async { + Future resendVerification(String email) async { final json = await _postJson(config.endpoint('/auth/resend-verification'), body: {'email': email}); - return json['message'] as String? ?? 'If the email is registered, a verification link has been sent'; + return VerificationDelivery.fromJson(json); } Future powerSyncToken(String accessToken) async { @@ -314,11 +345,26 @@ class AuthApiClient { Map _decodeResponse(http.Response response) { if (response.statusCode == 429) { + String? retryHeader = response.headers['retry-after']; + + if (retryHeader == null) { + try { + final body = jsonDecode(response.body); + final seconds = body['error']['details']['retry_after']; + + if (seconds is int && seconds >= 0) { + retryHeader = '$seconds'; + } + } catch (_) { + // Proxies may return a non-JSON rate-limit response. + } + } + throw AuthApiException( statusCode: 429, code: 'RATE_LIMIT_EXCEEDED', message: rateLimitMessage, - retryAfter: _retryAfter(response.headers['retry-after']), + retryAfter: _retryAfter(retryHeader), ); } @@ -334,6 +380,10 @@ class AuthApiClient { throw AuthApiException( statusCode: response.statusCode, code: error['code'] as String?, + retryAfter: response.headers.containsKey('retry-after') ? _retryAfter(response.headers['retry-after']) : null, + pendingVerification: error['code'] == 'EMAIL_VERIFICATION_REQUIRED' + ? PendingVerification.fromJson(error['details'] as Map) + : null, recoveryAuthorization: error['code'] == 'ACCOUNT_DELETION_PENDING' ? AccountDeletionAuthorization.fromJson(error['details']['authorization'] as Map) : null, diff --git a/app/lib/auth/auth_models.dart b/app/lib/auth/auth_models.dart index 75dba036..853b72b5 100644 --- a/app/lib/auth/auth_models.dart +++ b/app/lib/auth/auth_models.dart @@ -108,3 +108,39 @@ class AccountDeletionAuthorization { ); } } + +class PendingVerification { + final String? email; + final int resendAfter; + + const PendingVerification({required this.email, required this.resendAfter}); + + factory PendingVerification.fromJson(Map json) { + final email = json['email']; + final seconds = json['resend_after']; + + if ((email != null && (email is! String || email.isEmpty)) || seconds is! int || seconds < 0) { + throw const FormatException('Invalid verification response'); + } + + return PendingVerification(email: email as String?, resendAfter: seconds); + } +} + +class VerificationDelivery { + final String message; + final int resendAfter; + + const VerificationDelivery({required this.message, required this.resendAfter}); + + factory VerificationDelivery.fromJson(Map json) { + final message = json['message']; + final seconds = json['resend_after']; + + if (message is! String || message.isEmpty || seconds is! int || seconds < 0) { + throw const FormatException('Invalid verification delivery response'); + } + + return VerificationDelivery(message: message, resendAfter: seconds); + } +} diff --git a/app/lib/auth/auth_repository.dart b/app/lib/auth/auth_repository.dart index 9533888b..0a780ab9 100644 --- a/app/lib/auth/auth_repository.dart +++ b/app/lib/auth/auth_repository.dart @@ -17,6 +17,13 @@ class AuthRepository { Future? _refreshOperation; int _tokenGeneration = 0; + Future _credentialOperation = Future.value(); + void Function(AuthApiException error)? onVerificationRequired; + + void cancelPendingAuthentication() { + _tokenGeneration += 1; + _refreshOperation = null; + } AuthRepository({required this.apiClient, required this.tokenStore}); @@ -29,7 +36,9 @@ class AuthRepository { } Future bootstrap() async { + final generation = _tokenGeneration; final refreshToken = await tokenStore.readRefreshToken(); + _requireGeneration(generation); if (refreshToken == null) { return null; @@ -38,23 +47,21 @@ class AuthRepository { return refresh(); } - Future register({ + Future register({ required String email, required String password, required String displayName, required String clientType, String? deviceLabel, - }) async { - final tokens = await apiClient.register( + }) { + cancelPendingAuthentication(); + return apiClient.register( email: email, password: password, displayName: displayName, clientType: clientType, deviceLabel: deviceLabel, ); - - await _save(tokens); - return tokens; } Future login({ @@ -63,6 +70,8 @@ class AuthRepository { required String clientType, String? deviceLabel, }) async { + final generation = ++_tokenGeneration; + final tokens = await apiClient.login( email: email, password: password, @@ -70,13 +79,25 @@ class AuthRepository { deviceLabel: deviceLabel, ); - await _save(tokens); + await _save(tokens, generation); return tokens; } Future refresh() { - _refreshOperation ??= _refresh(); - return _refreshOperation!; + final pending = _refreshOperation; + + if (pending != null) { + return pending; + } + + final operation = _refresh(); + _refreshOperation = operation; + + return operation.whenComplete(() { + if (identical(_refreshOperation, operation)) { + _refreshOperation = null; + } + }); } Future _refresh() async { @@ -95,20 +116,23 @@ class AuthRepository { throw const AuthApiException(statusCode: 401, message: 'Session has ended'); } - await _save(tokens); + await _save(tokens, generation); return tokens; - } catch (_) { + } catch (error) { if (generation == _tokenGeneration) { - await tokenStore.clear(); + await clearTokens(); + + if (error is AuthApiException && error.code == 'EMAIL_VERIFICATION_REQUIRED') { + onVerificationRequired?.call(error); + } } rethrow; - } finally { - _refreshOperation = null; } } Future signInWithGoogle({required String clientType, String? deviceLabel}) async { + final generation = ++_tokenGeneration; await apiClient.ensureServerReachable(); final redirectUri = switch (kIsWeb) { @@ -117,9 +141,11 @@ class AuthRepository { false => nativeOAuthRedirectUri, }; + _requireGeneration(generation); final startUri = apiClient.googleOAuthStartUri(redirectUri); if (kIsWeb) { + rememberOAuthServer('${apiClient.config.apiBaseUri}'); redirectTo(startUri.toString()); return null; } @@ -130,15 +156,38 @@ class AuthRepository { options: const FlutterWebAuth2Options(useWebview: false), ); + _requireGeneration(generation); final callbackUri = Uri.parse(callbackUrl); return completeGoogleSignIn(callbackUri, clientType: clientType, deviceLabel: deviceLabel); } Future completeGoogleSignIn(Uri callbackUri, {required String clientType, String? deviceLabel}) async { + final generation = ++_tokenGeneration; + + if (!consumeOAuthServer('${apiClient.config.apiBaseUri}')) { + throw const AuthApiException( + statusCode: 400, + message: 'The sign-in server changed or the callback expired. Start a new Google sign-in.', + ); + } + final error = callbackUri.queryParameters['error']; if (error != null && error.isNotEmpty) { - throw AuthApiException(statusCode: 400, message: error); + throw AuthApiException( + statusCode: 400, + code: error, + message: switch (error) { + 'email_required' => + 'Google did not provide an email address. Register with email, or contact your server operator for an existing account.', + 'verification_unavailable' => + 'Verification email is temporarily unavailable. Try signing in or requesting a link again later.', + 'account_exists' => + 'An account with this email already exists. Sign in to that account before linking Google.', + 'access_denied' => 'Google sign-in was cancelled. You can try again.', + _ => 'Google sign-in could not be completed. Please start a new sign-in.', + }, + ); } final code = callbackUri.queryParameters['code']; @@ -148,7 +197,7 @@ class AuthRepository { } final tokens = await apiClient.exchangeCode(code: code, clientType: clientType, deviceLabel: deviceLabel); - await _save(tokens); + await _save(tokens, generation); return tokens; } @@ -171,19 +220,16 @@ class AuthRepository { throw const AuthApiException(statusCode: 401, message: 'Session changed. Please sign in again.'); } - await _save(tokens); + await _save(tokens, generation); return tokens; } Future logout() async { final accessToken = tokenStore.accessToken; + await clearTokens(); - try { - if (accessToken != null) { - await apiClient.logout(accessToken); - } - } finally { - await tokenStore.clear(); + if (accessToken != null) { + await apiClient.logout(accessToken); } } @@ -219,7 +265,7 @@ class AuthRepository { return apiClient.verifyEmail(token); } - Future resendVerification(String email) { + Future resendVerification(String email) { return apiClient.resendVerification(email); } @@ -260,8 +306,8 @@ class AuthRepository { } Future clearTokens() { - _tokenGeneration += 1; - return tokenStore.clear(); + cancelPendingAuthentication(); + return _serializeCredentials(tokenStore.clear); } Future withFreshAccessToken(Future Function(String accessToken) action) { @@ -279,31 +325,78 @@ class AuthRepository { return tokens.accessToken; } - Future _save(AuthTokens tokens) { - return tokenStore.saveTokens(accessToken: tokens.accessToken, refreshToken: tokens.refreshToken); + void _requireGeneration(int generation) { + if (generation != _tokenGeneration) { + throw const AuthApiException(statusCode: 401, message: 'Session changed. Please sign in again.'); + } + } + + Future _serializeCredentials(Future Function() action) { + final operation = _credentialOperation.then((_) => action()); + _credentialOperation = operation.then((_) {}, onError: (Object error) {}); + return operation; + } + + Future _save(AuthTokens tokens, int generation) async { + if (!tokens.user.emailVerified) { + try { + await apiClient.logout(tokens.accessToken); + } catch (_) { + // The rejected credentials are never persisted. + } + + throw AuthApiException( + statusCode: 403, + code: 'EMAIL_VERIFICATION_REQUIRED', + message: 'Verify your email address before signing in.', + pendingVerification: PendingVerification(email: tokens.user.email, resendAfter: 0), + ); + } + + if (tokens.accessToken.isEmpty || + tokens.refreshToken.isEmpty || + tokens.user.userId.isEmpty || + tokens.expiresIn <= 0) { + throw const FormatException('Invalid authentication response'); + } + + await _serializeCredentials(() async { + _requireGeneration(generation); + await tokenStore.saveTokens(accessToken: tokens.accessToken, refreshToken: tokens.refreshToken); + + if (generation != _tokenGeneration) { + await tokenStore.clear(); + _requireGeneration(generation); + } + }); } Future _withFreshAccessToken(Future Function(String accessToken) action) async { + final generation = _tokenGeneration; + try { - return await action(await _requireAccessToken()); + try { + return await action(await _requireAccessToken()); + } on AuthApiException catch (error) { + if (error.statusCode != 401) { + rethrow; + } + + _requireGeneration(generation); + final tokens = await refresh(); + return await action(tokens.accessToken); + } } on AuthApiException catch (error) { - if (error.statusCode != 401) { - rethrow; + if (generation == _tokenGeneration && error.code == 'EMAIL_VERIFICATION_REQUIRED') { + await clearTokens(); + onVerificationRequired?.call(error); } - final tokens = await refresh(); - return action(tokens.accessToken); + rethrow; } } String _webOAuthRedirectUri() { - final base = Uri.base; - - return Uri( - scheme: base.scheme, - host: base.host, - port: base.hasPort ? base.port : null, - path: '/auth/callback', - ).toString(); + return browserAppBaseUri().resolve('auth/callback').toString(); } } diff --git a/app/lib/auth/papyrus_api_config.dart b/app/lib/auth/papyrus_api_config.dart index d3417293..f5b26bb5 100644 --- a/app/lib/auth/papyrus_api_config.dart +++ b/app/lib/auth/papyrus_api_config.dart @@ -22,6 +22,7 @@ class PapyrusApiConfig { return PapyrusApiConfig( serverBaseUri: Uri.parse(baseUrl), + apiPrefix: const String.fromEnvironment('PAPYRUS_API_PREFIX', defaultValue: '/v1'), powerSyncServiceUri: Uri.parse(powerSyncServiceUrl), ); } diff --git a/app/lib/config/app_router.dart b/app/lib/config/app_router.dart index e23e5167..baf80010 100644 --- a/app/lib/config/app_router.dart +++ b/app/lib/config/app_router.dart @@ -4,6 +4,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:papyrus/pages/auth/oauth_callback_page.dart'; +import 'package:papyrus/pages/auth/verification_page.dart'; +import 'package:papyrus/platform/web_redirect.dart'; import 'package:papyrus/pages/book_details_page.dart'; import 'package:papyrus/providers/auth_provider.dart'; import 'package:papyrus/pages/bookmarks_page.dart'; @@ -57,7 +59,7 @@ class AppRouter { } late final GoRouter router = GoRouter( - debugLogDiagnostics: true, + debugLogDiagnostics: false, navigatorKey: rootNavigatorKey, refreshListenable: Listenable.merge([ authProvider, @@ -66,6 +68,17 @@ class AppRouter { acquisitionAvailabilityProvider, ]), routes: [ + GoRoute( + path: '/verify-email', + pageBuilder: (context, state) => NoTransitionPage( + key: state.pageKey, + child: VerificationPage(token: takeVerificationToken(state.uri.queryParameters['token']), isLink: true), + ), + ), + GoRoute( + path: '/verification', + pageBuilder: (context, state) => NoTransitionPage(key: state.pageKey, child: const VerificationPage()), + ), GoRoute( path: '/startup', pageBuilder: (context, state) => NoTransitionPage( @@ -333,6 +346,14 @@ class AppRouter { String? redirectForPath(String destination) { var uri = Uri.parse(destination); + if (uri.path == '/verify-email' || uri.path == '/verification') { + return null; + } + + if (authProvider.pendingVerification != null) { + return '/verification'; + } + if (authProvider.isBootstrapping) { return uri.path == '/startup' ? null : Uri(path: '/startup', queryParameters: {'from': destination}).toString(); } @@ -361,7 +382,7 @@ class AppRouter { location == '/reset-password' || location == '/auth/callback'; - if (!authProvider.isSignedIn && !authProvider.isOfflineMode) { + if (!authProvider.isSignedIn && !authProvider.isOfflineMode && !authProvider.hasOfflineAccount) { if (isAuthRoute) { return restoredDestination; } @@ -369,7 +390,8 @@ class AppRouter { return '/'; } - if (location == '/' || location == '/login' || location == '/register' || location == '/reset-password') { + if (!authProvider.hasOfflineAccount && + (location == '/' || location == '/login' || location == '/register' || location == '/reset-password')) { return '/library/books'; } diff --git a/app/lib/main.dart b/app/lib/main.dart index 3f6e77e5..0e6c0434 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -240,6 +240,7 @@ class _PapyrusState extends State { bool _switchingSyncProfile = false; Future? _authStateOperation; bool _authStateUpdateQueued = false; + bool _authSyncSuspended = false; @override void initState() { @@ -449,12 +450,29 @@ class _PapyrusState extends State { final user = _authProvider.user; final profileKey = _activeProfileKey; + if (user != null && !_authProvider.isOfflineMode && !_authProvider.isSignedIn) { + await _watchedFolders.suspend(); + await _mediaUploadQueue.activateScope(MediaStorageScope(profileKey: profileKey, userId: user.userId)); + + if (_authProvider.pendingVerification != null && _powerSyncService.mode != null && !_authSyncSuspended) { + _authSyncSuspended = true; + await _powerSyncService.setOnline(false); + } + + return; + } + if (user != null && !_authProvider.isOfflineMode) { final userId = user.userId; final scope = MediaStorageScope(profileKey: profileKey, userId: userId); await _mediaUploadQueue.activateScope(scope); await _powerSyncService.activateAuthenticated(userId, profileKey: profileKey); + if (_authSyncSuspended && _authProvider.isSignedIn && _authProvider.user?.userId == userId) { + _authSyncSuspended = false; + await _powerSyncService.setOnline(true); + } + if (!_switchingSyncProfile && _activeProfileKey == profileKey && _syncSettingsProvider.activeProfileKey == profileKey && @@ -471,6 +489,7 @@ class _PapyrusState extends State { } if (_authProvider.isOfflineMode) { + _authSyncSuspended = false; await _mediaUploadQueue.activateScope(null); await _powerSyncService.activateGuest(); @@ -490,7 +509,7 @@ class _PapyrusState extends State { await _mediaUploadQueue.activateScope(null); if (!_authProvider.isBootstrapping && _powerSyncService.mode != null) { - await _powerSyncService.deactivate(clearAuthenticated: !_switchingSyncProfile); + await _powerSyncService.deactivate(clearAuthenticated: false); } } @@ -525,6 +544,7 @@ class _PapyrusState extends State { await _mediaUploadQueue.waitUntilIdle(); await _mediaUploadQueue.activateScope(null); await _powerSyncService.deactivate(clearAuthenticated: false); + _authSyncSuspended = false; _authRepository = _buildAuthRepository(nextConfig, nextProfileKey); _activeProfileKey = nextProfileKey; await _authProvider.replaceRepository(_authRepository, bootstrapNewRepository: !_authProvider.isOfflineMode); diff --git a/app/lib/pages/auth/actions.dart b/app/lib/pages/auth/actions.dart index 2e032da8..f3719d99 100644 --- a/app/lib/pages/auth/actions.dart +++ b/app/lib/pages/auth/actions.dart @@ -4,10 +4,12 @@ import 'package:papyrus/providers/auth_provider.dart'; import 'package:provider/provider.dart'; void navigateToLogin(BuildContext context) { + context.read().cancelAuthentication(); context.push('/login'); } void navigateToRegister(BuildContext context) { + context.read().cancelAuthentication(); context.push('/register'); } diff --git a/app/lib/pages/auth/oauth_callback_page.dart b/app/lib/pages/auth/oauth_callback_page.dart index 383e2111..ccfbff24 100644 --- a/app/lib/pages/auth/oauth_callback_page.dart +++ b/app/lib/pages/auth/oauth_callback_page.dart @@ -19,6 +19,17 @@ class OAuthCallbackPage extends StatefulWidget { class _OAuthCallbackPageState extends State { String? _errorMessage; bool _showingConfirmation = false; + AuthProvider? _auth; + bool _completing = false; + + @override + void dispose() { + if (_completing) { + _auth?.cancelAuthentication(notify: false); + } + + super.dispose(); + } @override void initState() { @@ -28,6 +39,8 @@ class _OAuthCallbackPageState extends State { Future _completeSignIn() async { final authProvider = context.read(); + _auth = authProvider; + _completing = true; clearBrowserQuery(); final success = await authProvider.completeGoogleSignIn(widget.callbackUri); @@ -36,11 +49,18 @@ class _OAuthCallbackPageState extends State { return; } + _completing = false; + if (success) { context.goNamed('LIBRARY'); return; } + if (authProvider.pendingVerification != null) { + context.go('/verification'); + return; + } + final recovery = authProvider.takePendingRecovery(); if (recovery != null) { @@ -94,6 +114,13 @@ class _OAuthCallbackPageState extends State { Text(_errorMessage!, style: theme.textTheme.titleMedium, textAlign: TextAlign.center), const SizedBox(height: Spacing.lg), FilledButton(onPressed: () => context.go('/login'), child: const Text('Back to sign in')), + if (_auth?.errorCode == 'verification_unavailable') + TextButton( + onPressed: () => context.go('/verification'), + child: const Text('Request verification email'), + ), + if (_auth?.errorCode == 'email_required') + TextButton(onPressed: () => context.go('/register'), child: const Text('Register with email')), ], ], ), diff --git a/app/lib/pages/auth/verification_page.dart b/app/lib/pages/auth/verification_page.dart new file mode 100644 index 00000000..b0f8a00b --- /dev/null +++ b/app/lib/pages/auth/verification_page.dart @@ -0,0 +1,269 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:papyrus/auth/papyrus_api_config.dart'; +import 'package:papyrus/pages/auth/actions.dart'; +import 'package:papyrus/platform/web_redirect.dart'; +import 'package:papyrus/providers/auth_provider.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/utils/responsive.dart'; +import 'package:papyrus/widgets/auth/auth_page_layouts.dart'; +import 'package:papyrus/widgets/input/email_input.dart'; +import 'package:papyrus/widgets/input/validated_text_form_field.dart'; +import 'package:provider/provider.dart'; + +class VerificationPage extends StatefulWidget { + final String? token; + final bool isLink; + + const VerificationPage({super.key, this.token, this.isLink = false}); + + @override + State createState() => _VerificationPageState(); +} + +class _VerificationPageState extends State { + final _formKey = GlobalKey(); + final _email = TextEditingController(); + Timer? _timer; + AuthProvider? _auth; + String? _serverKey; + String? _token; + String? _message; + bool _busy = false; + bool _verified = false; + bool _invalidLink = false; + bool _serverChanged = false; + + @override + void initState() { + super.initState(); + _token = widget.token; + _invalidLink = widget.isLink && (_token == null || _token!.isEmpty); + + if (widget.isLink) { + clearBrowserQuery(); + } + + _timer = Timer.periodic(const Duration(seconds: 1), (_) { + if (mounted) { + setState(() {}); + } + }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final auth = context.watch(); + _auth = auth; + + if (_serverKey == null) { + _serverKey = auth.serverKey; + _email.text = auth.pendingVerification?.email ?? ''; + } else if (_serverKey != auth.serverKey) { + _token = null; + _email.clear(); + _serverChanged = true; + _message = null; + } + } + + @override + void dispose() { + _token = null; + + if (_busy) { + _auth?.cancelAuthentication(notify: false); + } + + _timer?.cancel(); + _email.dispose(); + super.dispose(); + } + + int _secondsUntil(DateTime? deadline) { + if (deadline == null) { + return 0; + } + + return (deadline.difference(DateTime.now()).inMilliseconds / 1000).ceil().clamp(0, 86400); + } + + bool get _wrongServer => + _serverChanged || + (widget.isLink && kIsWeb && _auth?.serverKey != '${PapyrusApiConfig.fromEnvironment().apiBaseUri}'); + + Future _confirm() async { + if (_busy || _wrongServer || _token == null || _secondsUntil(_auth!.retryAt) > 0) { + return; + } + + setState(() => _busy = true); + final message = await _auth!.verifyEmail(_token!); + + if (!mounted || _wrongServer) { + return; + } + + setState(() { + _busy = false; + _verified = message != null; + _message = message; + _invalidLink = _auth!.errorCode == 'VERIFICATION_TOKEN_INVALID'; + + if (_verified || _invalidLink) { + _token = null; + } + }); + } + + Future _resend() async { + if (_busy || _wrongServer || _secondsUntil(_auth!.resendAt) > 0 || _secondsUntil(_auth!.retryAt) > 0) { + return; + } + + if (!validateAndFocusForm(_formKey.currentState!)) { + return; + } + + FocusScope.of(context).unfocus(); + setState(() => _busy = true); + final message = await _auth!.resendVerification(_email.text.trim()); + + if (!mounted || _wrongServer) { + return; + } + + setState(() { + _busy = false; + _message = message == null + ? null + : 'Request accepted. Check your inbox and spam folder. Delivery may take a few minutes.'; + }); + } + + void _signIn() { + _auth!.cancelAuthentication(); + context.go('/login'); + } + + @override + Widget build(BuildContext context) { + final auth = context.watch(); + final remaining = _secondsUntil(auth.resendAt); + final retry = _secondsUntil(auth.retryAt); + final canConfirm = widget.isLink && !_verified && !_invalidLink && !_wrongServer; + final String heading; + final String subtitle; + + if (_verified) { + heading = 'Email verified'; + subtitle = 'Sign in again to open your account. Verification does not sign you in.'; + } else if (canConfirm) { + heading = 'Verify your email'; + subtitle = 'Confirm below to verify your email address. This will not sign you in.'; + } else if (_invalidLink) { + heading = 'Request a new link'; + subtitle = + 'This link is missing, invalid, expired, or already used. Sign in if you already verified, or request a new link below.'; + } else { + heading = 'Check your email'; + subtitle = 'Open the verification link in your email, then return here to sign in.'; + } + + final resendLabel = switch (remaining) { + _ when _busy => 'Requesting link…', + > 0 => 'Resend in ${remaining}s', + _ => 'Resend verification email', + }; + const buttonSize = Size.fromHeight(ComponentSizes.buttonHeightMobile); + + final form = Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + if (_wrongServer) + const Text( + 'This link belongs to this web deployment, but a different server is selected. Return to the issuing server’s app and open the email link again.', + ), + if (_message != null && !_verified) ...[ + Semantics(liveRegion: true, child: Text(_message!)), + const SizedBox(height: Spacing.md), + ], + if (auth.error != null && !_verified && !_wrongServer && auth.errorCode != 'VERIFICATION_TOKEN_INVALID') ...[ + Semantics( + liveRegion: true, + child: Text(auth.error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + ), + const SizedBox(height: Spacing.md), + ], + if (retry > 0) ...[ + Text('Try again in ${retry}s.'), + const SizedBox(height: Spacing.md), + ], + if (canConfirm) ...[ + FilledButton( + style: FilledButton.styleFrom(minimumSize: buttonSize), + autofocus: true, + onPressed: _busy || retry > 0 ? null : _confirm, + child: Text(_busy ? 'Verifying…' : 'Confirm email address'), + ), + const SizedBox(height: Spacing.md), + ], + if (!_verified && !_wrongServer && !canConfirm) ...[ + const SizedBox(height: Spacing.md), + Form( + key: _formKey, + child: EmailInput( + labelText: 'Email address', + controller: _email, + textInputAction: TextInputAction.done, + onEditingComplete: _resend, + ), + ), + const SizedBox(height: Spacing.md), + OutlinedButton( + style: OutlinedButton.styleFrom(minimumSize: buttonSize), + onPressed: _busy || remaining > 0 || retry > 0 ? null : _resend, + child: Text(resendLabel), + ), + const SizedBox(height: Spacing.sm), + const Text('If an account needs verification, a link will be queued. Check your spam folder too.'), + const SizedBox(height: Spacing.lg), + ], + if (canConfirm) + OutlinedButton( + style: OutlinedButton.styleFrom(minimumSize: buttonSize), + onPressed: _signIn, + child: const Text('Back to sign in'), + ) + else + FilledButton( + style: FilledButton.styleFrom(minimumSize: buttonSize), + onPressed: _signIn, + child: const Text('Back to sign in'), + ), + const SizedBox(height: Spacing.sm), + TextButton(onPressed: () => navigateToOffline(context), child: const Text('Continue as guest')), + if (auth.hasOfflineAccount) + TextButton( + onPressed: () { + auth.cancelAuthentication(); + context.goNamed('LIBRARY'); + }, + child: const Text('Read downloaded books'), + ), + ], + ); + + return ResponsiveBuilder( + mobile: (context) => + MobileAuthLayout(heading: heading, subtitle: subtitle, announceSubtitle: true, form: form, footer: const []), + desktop: (context) => + DesktopAuthLayout(heading: heading, subtitle: subtitle, announceSubtitle: true, form: form, footer: const []), + ); + } +} diff --git a/app/lib/pages/login_page.dart b/app/lib/pages/login_page.dart index 3fb133aa..9e0b00f9 100644 --- a/app/lib/pages/login_page.dart +++ b/app/lib/pages/login_page.dart @@ -31,9 +31,20 @@ class _LoginPageState extends State { final _passwordFocusNode = FocusNode(); bool _isLoading = false; + AuthProvider? _auth; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _auth = context.read(); + } @override void dispose() { + if (_isLoading) { + _auth?.cancelAuthentication(notify: false); + } + _emailController.dispose(); _passwordController.dispose(); _emailFocusNode.dispose(); @@ -65,6 +76,13 @@ class _LoginPageState extends State { } if (!success) { + if (context.read().pendingVerification != null) { + _passwordController.clear(); + _isLoading = false; + context.go('/verification'); + return; + } + final recovery = context.read().takePendingRecovery(); if (recovery != null) { @@ -78,6 +96,7 @@ class _LoginPageState extends State { return; } + _isLoading = false; context.goNamed('LIBRARY'); } catch (error) { if (!mounted) { @@ -222,7 +241,12 @@ class _LoginForm extends StatelessWidget { ), ), const SizedBox(height: Spacing.sm), - AuthContinueButton(isLoading: isLoading, onPressed: onLogin, isDesktop: isDesktop), + AuthContinueButton( + retryAt: context.watch()?.retryAt, + isLoading: isLoading, + onPressed: onLogin, + isDesktop: isDesktop, + ), ], ), ), diff --git a/app/lib/pages/register_page.dart b/app/lib/pages/register_page.dart index 6024cd14..ae1168bd 100644 --- a/app/lib/pages/register_page.dart +++ b/app/lib/pages/register_page.dart @@ -35,9 +35,20 @@ class _RegisterPageState extends State { final _confirmPasswordFocusNode = FocusNode(); bool _isLoading = false; + AuthProvider? _auth; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _auth = context.read(); + } @override void dispose() { + if (_isLoading) { + _auth?.cancelAuthentication(notify: false); + } + _displayNameController.dispose(); _emailController.dispose(); _passwordController.dispose(); @@ -78,7 +89,10 @@ class _RegisterPageState extends State { return; } - context.goNamed('LIBRARY'); + _passwordController.clear(); + _confirmPasswordController.clear(); + _isLoading = false; + context.go('/verification'); } catch (error) { if (!mounted) { return; @@ -135,6 +149,11 @@ class _RegisterPageState extends State { actionText: 'Sign in', onPressed: () => navigateToLogin(context), ), + AuthSwitchLink( + promptText: 'Waiting for a link?', + actionText: 'Verify email', + onPressed: () => context.push('/verification'), + ), AuthSwitchLink( promptText: 'No internet?', actionText: 'Continue offline', @@ -272,7 +291,12 @@ class _RegisterForm extends StatelessWidget { validationDependency: passwordController, ), const SizedBox(height: Spacing.lg), - AuthContinueButton(isLoading: isLoading, onPressed: onRegister, isDesktop: isDesktop), + AuthContinueButton( + retryAt: context.watch()?.retryAt, + isLoading: isLoading, + onPressed: onRegister, + isDesktop: isDesktop, + ), ], ), ), diff --git a/app/lib/platform/web_redirect_stub.dart b/app/lib/platform/web_redirect_stub.dart index c5bf91d9..861d5fa1 100644 --- a/app/lib/platform/web_redirect_stub.dart +++ b/app/lib/platform/web_redirect_stub.dart @@ -3,3 +3,11 @@ void redirectTo(String url) { } void clearBrowserQuery() {} + +Uri browserAppBaseUri() => Uri.base; + +String? takeVerificationToken(String? token) => token; + +void rememberOAuthServer(String serverKey) {} + +bool consumeOAuthServer(String serverKey) => true; diff --git a/app/lib/platform/web_redirect_web.dart b/app/lib/platform/web_redirect_web.dart index c6382a02..0922d3c4 100644 --- a/app/lib/platform/web_redirect_web.dart +++ b/app/lib/platform/web_redirect_web.dart @@ -1,3 +1,4 @@ +import 'dart:js_interop'; import 'package:web/web.dart' as web; void redirectTo(String url) { @@ -7,3 +8,20 @@ void redirectTo(String url) { void clearBrowserQuery() { web.window.history.replaceState(null, '', web.window.location.pathname); } + +Uri browserAppBaseUri() => Uri.parse(web.document.baseURI); + +@JS('takePapyrusVerificationToken') +external JSString? _takeVerificationToken(); + +String? takeVerificationToken(String? token) => _takeVerificationToken()?.toDart ?? token; + +void rememberOAuthServer(String serverKey) { + web.window.sessionStorage.setItem('papyrus.oauth.server', serverKey); +} + +bool consumeOAuthServer(String serverKey) { + final expected = web.window.sessionStorage.getItem('papyrus.oauth.server'); + web.window.sessionStorage.removeItem('papyrus.oauth.server'); + return expected == serverKey; +} diff --git a/app/lib/providers/auth_provider.dart b/app/lib/providers/auth_provider.dart index 124d4897..b8c7e80f 100644 --- a/app/lib/providers/auth_provider.dart +++ b/app/lib/providers/auth_provider.dart @@ -12,6 +12,59 @@ class AuthProvider extends ChangeNotifier { final SharedPreferences _prefs; final GuestProfileStore _guestProfile; Future? _guestProfileWrite; + int _operation = 0; + PendingVerification? _pendingVerification; + PendingVerification? get pendingVerification => _pendingVerification; + DateTime? _resendAt; + DateTime? _retryAt; + DateTime? get resendAt => _resendAt; + DateTime? get retryAt => _retryAt; + String? _errorCode; + String? get errorCode => _errorCode; + String get serverKey => '${_repository.apiClient.config.apiBaseUri}'; + bool get hasOfflineAccount => _user != null && !isSignedIn; + + void _bindRepository() { + final repository = _repository; + + repository.onVerificationRequired = (error) { + if (identical(repository, _repository)) { + _acceptVerification(error); + } + }; + } + + void _acceptVerification(AuthApiException error) { + _pendingVerification = error.pendingVerification; + _resendAt = DateTime.now().add(Duration(seconds: error.pendingVerification?.resendAfter ?? 0)); + _error = error.message; + _errorCode = error.code; + _setStatus(AuthStatus.authError); + } + + void cancelAuthentication({bool notify = true}) { + _operation += 1; + _repository.cancelPendingAuthentication(); + _pendingRecovery = null; + _pendingVerification = null; + _resendAt = null; + _retryAt = null; + _error = null; + _errorCode = null; + _status = _user == null ? AuthStatus.signedOut : AuthStatus.authError; + + if (notify) { + notifyListeners(); + } + } + + @override + void dispose() { + _operation += 1; + _repository.cancelPendingAuthentication(); + _repository.onVerificationRequired = null; + super.dispose(); + } static const _keyOfflineMode = 'offline_mode'; @@ -62,6 +115,7 @@ class AuthProvider extends ChangeNotifier { GuestProfileStore? guestProfileStore, }) : _repository = repository, _guestProfile = guestProfileStore ?? GuestProfileStore(_prefs) { + _bindRepository(); _isOfflineMode = _prefs.getBool(_keyOfflineMode) ?? false; if (bootstrapOnCreate) { @@ -70,7 +124,10 @@ class AuthProvider extends ChangeNotifier { } Future replaceRepository(AuthRepository repository, {bool bootstrapNewRepository = true}) async { + cancelAuthentication(); + _repository.onVerificationRequired = null; _repository = repository; + _bindRepository(); _pendingRecovery = null; _user = null; _error = null; @@ -90,64 +147,83 @@ class AuthProvider extends ChangeNotifier { return; } + final operation = ++_operation; _setStatus(AuthStatus.bootstrapping); try { final tokens = await _repository.bootstrap(); + + if (operation != _operation) { + return; + } + _user = tokens?.user; _error = null; _setStatus(_user == null ? AuthStatus.signedOut : AuthStatus.signedIn); } catch (error) { + if (operation != _operation) { + return; + } + _user = null; - _error = null; - _setStatus(AuthStatus.signedOut); + + if (error is AuthApiException && error.code == 'EMAIL_VERIFICATION_REQUIRED') { + _acceptVerification(error); + } else { + _error = null; + _setStatus(AuthStatus.signedOut); + } } } Future register({required String email, required String password, required String displayName}) async { - return _runTokenAction(() { - return _repository.register( + if (_retryAt?.isAfter(DateTime.now()) == true) { + return false; + } + + final operation = ++_operation; + final previousStatus = _status; + _error = null; + _pendingRecovery = null; + _setStatus(AuthStatus.authenticating); + + try { + final pending = await _repository.register( email: email, password: password, displayName: displayName, clientType: _clientType, deviceLabel: _deviceLabel, ); - }); - } - - Future login({required String email, required String password}) async { - return _runTokenAction(() { - return _repository.login(email: email, password: password, clientType: _clientType, deviceLabel: _deviceLabel); - }); - } - - Future signInWithGoogle() async { - _pendingRecovery = null; - _setStatus(AuthStatus.authenticating); - _error = null; - try { - final tokens = await _repository.signInWithGoogle(clientType: _clientType, deviceLabel: _deviceLabel); - - if (tokens == null) { + if (operation != _operation) { return false; } - _user = tokens.user; - _isOfflineMode = false; - await _prefs.setBool(_keyOfflineMode, false); - _setStatus(AuthStatus.signedIn); + _pendingVerification = pending; + _resendAt = DateTime.now().add(Duration(seconds: pending.resendAfter)); + _setStatus(_user == null ? AuthStatus.signedOut : previousStatus); return true; } catch (error) { - _user = null; - _pendingRecovery = error is AuthApiException ? error.recoveryAuthorization : null; - _error = _messageFor(error); - _setStatus(AuthStatus.authError); + if (operation == _operation) { + _recordError(error); + _setStatus(_user == null ? AuthStatus.authError : previousStatus); + } + return false; } } + Future login({required String email, required String password}) async { + return _runTokenAction(() { + return _repository.login(email: email, password: password, clientType: _clientType, deviceLabel: _deviceLabel); + }); + } + + Future signInWithGoogle() { + return _runTokenAction(() => _repository.signInWithGoogle(clientType: _clientType, deviceLabel: _deviceLabel)); + } + Future completeGoogleSignIn(Uri callbackUri) async { return _runTokenAction(() { return _repository.completeGoogleSignIn(callbackUri, clientType: _clientType, deviceLabel: _deviceLabel); @@ -155,34 +231,60 @@ class AuthProvider extends ChangeNotifier { } Future refresh() async { + final operation = ++_operation; _setStatus(AuthStatus.refreshing); try { final tokens = await _repository.refresh(); + + if (operation != _operation) { + return false; + } + _user = tokens.user; + _pendingVerification = null; _error = null; _setStatus(AuthStatus.signedIn); return true; } catch (error) { - _user = null; - _error = _messageFor(error); - _setStatus(AuthStatus.signedOut); + if (operation != _operation) { + return false; + } + + if (error is AuthApiException && error.code == 'EMAIL_VERIFICATION_REQUIRED') { + _acceptVerification(error); + } else { + _user = null; + _recordError(error); + _setStatus(AuthStatus.signedOut); + } + return false; } } Future signOut() async { - _setStatus(AuthStatus.authenticating); + cancelAuthentication(); + final operation = _operation; + final repository = _repository; + _user = null; + _isOfflineMode = false; + await _prefs.setBool(_keyOfflineMode, false); - try { - await _repository.logout(); - } catch (error) { - _error = _messageFor(error); + if (operation != _operation) { + return; } - _user = null; - setOfflineMode(false); _setStatus(AuthStatus.signedOut); + + try { + await repository.logout(); + } catch (error) { + if (operation == _operation) { + _recordError(error); + notifyListeners(); + } + } } Future updateProfile({required String displayName, String? avatarUrl, Uint8List? photoBytes}) async { @@ -313,6 +415,7 @@ class AuthProvider extends ChangeNotifier { Future recoverAccount(AccountDeletionAuthorization authorization) async { final repository = _repository; + final operation = ++_operation; if (_user != null && _user!.userId != authorization.userId) { throw const AuthApiException(statusCode: 403, message: 'The active account has changed.'); @@ -324,13 +427,13 @@ class AuthProvider extends ChangeNotifier { deviceLabel: _deviceLabel, ); - if (!identical(_repository, repository)) { + if (!identical(_repository, repository) || operation != _operation) { return false; } await _prefs.setBool(_keyOfflineMode, false); - if (!identical(_repository, repository)) { + if (!identical(_repository, repository) || operation != _operation) { return false; } @@ -357,18 +460,39 @@ class AuthProvider extends ChangeNotifier { } Future resendVerification(String email) { - return _runMessageAction(() => _repository.resendVerification(email)); + if (_resendAt?.isAfter(DateTime.now()) == true) { + return Future.value(null); + } + + final operation = _operation; + + return _runMessageAction(() async { + final delivery = await _repository.resendVerification(email); + + if (operation == _operation) { + _pendingVerification = PendingVerification(email: email, resendAfter: delivery.resendAfter); + _resendAt = DateTime.now().add(Duration(seconds: delivery.resendAfter)); + } + + return delivery.message; + }); } Future downloadMedia(String assetId) { + if (hasOfflineAccount) { + return Future.error(StateError('Verify your email and sign in to download uncached media.')); + } + return _repository.downloadMedia(assetId); } Future withFreshAccessToken(Future Function(String accessToken) action) async { + final repository = _repository; + try { - return await _repository.withFreshAccessToken(action); + return await repository.withFreshAccessToken(action); } catch (error) { - if (error is AuthApiException && error.statusCode == 401) { + if (identical(repository, _repository) && error is AuthApiException && error.statusCode == 401) { _user = null; _error = _messageFor(error); _setStatus(AuthStatus.signedOut); @@ -379,6 +503,7 @@ class AuthProvider extends ChangeNotifier { } void setOfflineMode(bool value) { + cancelAuthentication(); _isOfflineMode = value; _prefs.setBool(_keyOfflineMode, value); @@ -396,42 +521,89 @@ class AuthProvider extends ChangeNotifier { notifyListeners(); } - Future _runTokenAction(Future Function() action) async { + Future _runTokenAction(Future Function() action) async { + if (_retryAt?.isAfter(DateTime.now()) == true) { + return false; + } + + final operation = ++_operation; _pendingRecovery = null; - _setStatus(AuthStatus.authenticating); + _pendingVerification = null; _error = null; + _errorCode = null; + _setStatus(AuthStatus.authenticating); try { final tokens = await action(); + + if (operation != _operation || tokens == null) { + return false; + } + + if (!tokens.user.emailVerified) { + throw const FormatException('Expected verified sign-in'); + } + + await _prefs.setBool(_keyOfflineMode, false); + + if (operation != _operation) { + return false; + } + _user = tokens.user; _isOfflineMode = false; - await _prefs.setBool(_keyOfflineMode, false); _setStatus(AuthStatus.signedIn); return true; } catch (error) { + if (operation != _operation) { + return false; + } + _pendingRecovery = error is AuthApiException ? error.recoveryAuthorization : null; - _error = _messageFor(error); - _setStatus(AuthStatus.authError); + + if (error is AuthApiException && error.code == 'EMAIL_VERIFICATION_REQUIRED') { + _acceptVerification(error); + } else { + _recordError(error); + _setStatus(AuthStatus.authError); + } + return false; } } Future _runMessageAction(Future Function() action) async { - _pendingRecovery = null; - _setStatus(AuthStatus.authenticating); + final operation = _operation; + final previousStatus = _status; _error = null; + _errorCode = null; try { final message = await action(); - _setStatus(_user == null ? AuthStatus.signedOut : AuthStatus.signedIn); + + if (operation != _operation) { + return null; + } + + _setStatus(_user == null ? AuthStatus.signedOut : previousStatus); return message; } catch (error) { - _error = _messageFor(error); - _setStatus(_user == null ? AuthStatus.authError : AuthStatus.signedIn); + if (operation == _operation) { + _recordError(error); + _setStatus(_user == null ? AuthStatus.authError : previousStatus); + } + return null; } } + void _recordError(Object error) { + _error = _messageFor(error); + _errorCode = error is AuthApiException ? error.code : null; + final retry = error is AuthApiException ? error.retryAfter : null; + _retryAt = retry == null ? null : DateTime.now().add(retry); + } + String _messageFor(Object error) { if (error is AuthApiException) { return error.message; diff --git a/app/lib/services/book_import_session.dart b/app/lib/services/book_import_session.dart index 5e27bb02..67969187 100644 --- a/app/lib/services/book_import_session.dart +++ b/app/lib/services/book_import_session.dart @@ -43,7 +43,8 @@ class BookImportSession { bool retainCommittedBook = false, }) { final repository = dataStore.requireBookRepository(); - final authenticated = authProvider.isSignedIn && powerSyncService.mode == LibraryDatabaseMode.authenticated; + final accountSelected = authProvider.user != null && !authProvider.isOfflineMode; + final authenticated = accountSelected && powerSyncService.mode == LibraryDatabaseMode.authenticated; final scope = authenticated ? queue.activeScope : null; final userId = authProvider.user?.userId; @@ -52,7 +53,7 @@ class BookImportSession { } if (authProvider.isDeletingGuestAccount || - (authProvider.isSignedIn && (!authenticated || scope?.userId != userId)) || + (accountSelected && (!authenticated || scope?.userId != userId)) || (authProvider.isOfflineMode && powerSyncService.mode != LibraryDatabaseMode.guest)) { throw StateError('Wait for the selected library to finish loading before importing.'); } @@ -61,7 +62,10 @@ class BookImportSession { !authProvider.isDeletingGuestAccount && dataStore.isBookRepositoryCurrent(repository) && authProvider.user?.userId == userId && - (authProvider.isSignedIn && powerSyncService.mode == LibraryDatabaseMode.authenticated) == authenticated && + (authProvider.user != null && + !authProvider.isOfflineMode && + powerSyncService.mode == LibraryDatabaseMode.authenticated) == + authenticated && queue.activeScope == scope; final service = BookImportCommitService( diff --git a/app/lib/widgets/auth/auth_continue_button.dart b/app/lib/widgets/auth/auth_continue_button.dart index 8afc625c..7faf1301 100644 --- a/app/lib/widgets/auth/auth_continue_button.dart +++ b/app/lib/widgets/auth/auth_continue_button.dart @@ -7,47 +7,65 @@ class AuthContinueButton extends StatelessWidget { final bool isLoading; final VoidCallback onPressed; final bool isDesktop; + final DateTime? retryAt; - const AuthContinueButton({super.key, required this.isLoading, required this.onPressed, required this.isDesktop}); + const AuthContinueButton({ + super.key, + required this.isLoading, + required this.onPressed, + required this.isDesktop, + this.retryAt, + }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final buttonHeight = isDesktop ? ComponentSizes.buttonHeightDesktop : ComponentSizes.buttonHeightMobile; - return ElevatedButton( - onPressed: isLoading ? null : onPressed, - style: ElevatedButton.styleFrom( - minimumSize: Size.fromHeight(buttonHeight), - backgroundColor: theme.colorScheme.primary, - foregroundColor: theme.colorScheme.onPrimary, - elevation: AppElevation.level2, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppRadius.button)), - padding: const EdgeInsets.symmetric( - horizontal: Spacing.buttonPaddingHorizontal, - vertical: Spacing.buttonPaddingVertical, - ), - ), - child: isLoading - ? SizedBox( - width: 24, - height: 24, - child: AppCircularProgressIndicator( - semanticsLabel: 'Please wait', - strokeWidth: 2, - color: theme.colorScheme.onPrimary, - ), - ) - : Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Flexible( - child: Text('Continue', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), - ), - const SizedBox(width: Spacing.sm), - Icon(Icons.arrow_forward, size: IconSizes.medium, color: theme.colorScheme.onPrimary), - ], + return StreamBuilder( + stream: retryAt == null ? null : Stream.periodic(const Duration(seconds: 1), (_) => DateTime.now()), + builder: (context, snapshot) { + final remaining = retryAt == null + ? 0 + : (retryAt!.difference(DateTime.now()).inMilliseconds / 1000).ceil().clamp(0, 86400); + + return ElevatedButton( + onPressed: isLoading || remaining > 0 ? null : onPressed, + style: ElevatedButton.styleFrom( + minimumSize: Size.fromHeight(buttonHeight), + backgroundColor: theme.colorScheme.primary, + foregroundColor: theme.colorScheme.onPrimary, + elevation: AppElevation.level2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppRadius.button)), + padding: const EdgeInsets.symmetric( + horizontal: Spacing.buttonPaddingHorizontal, + vertical: Spacing.buttonPaddingVertical, ), + ), + child: remaining > 0 + ? Text('Try again in ${remaining}s') + : isLoading + ? SizedBox( + width: 24, + height: 24, + child: AppCircularProgressIndicator( + semanticsLabel: 'Please wait', + strokeWidth: 2, + color: theme.colorScheme.onPrimary, + ), + ) + : Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Flexible( + child: Text('Continue', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), + ), + const SizedBox(width: Spacing.sm), + Icon(Icons.arrow_forward, size: IconSizes.medium, color: theme.colorScheme.onPrimary), + ], + ), + ); + }, ); } } diff --git a/app/lib/widgets/auth/auth_page_layouts.dart b/app/lib/widgets/auth/auth_page_layouts.dart index 2e2a19ac..16da2f88 100644 --- a/app/lib/widgets/auth/auth_page_layouts.dart +++ b/app/lib/widgets/auth/auth_page_layouts.dart @@ -18,6 +18,7 @@ class MobileAuthLayout extends StatelessWidget { /// Whether to show branding, heading, and subtitle above the form. final bool showHeader; + final bool announceSubtitle; const MobileAuthLayout({ super.key, @@ -27,6 +28,7 @@ class MobileAuthLayout extends StatelessWidget { required this.footer, this.bottomActions = const [], this.showHeader = true, + this.announceSubtitle = false, }); @override @@ -62,9 +64,12 @@ class MobileAuthLayout extends StatelessWidget { color: theme.colorScheme.onSurface, ), ), - Text( - subtitle, - style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + Semantics( + liveRegion: announceSubtitle, + child: Text( + subtitle, + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), ), const SizedBox(height: Spacing.lg), ], @@ -99,6 +104,7 @@ class DesktopAuthLayout extends StatefulWidget { /// Whether to show branding, heading, and subtitle above the form. final bool showHeader; + final bool announceSubtitle; const DesktopAuthLayout({ super.key, @@ -107,6 +113,7 @@ class DesktopAuthLayout extends StatefulWidget { required this.form, required this.footer, this.showHeader = true, + this.announceSubtitle = false, }); /// Persists swap state across page navigations within the same session. @@ -158,9 +165,12 @@ class _DesktopAuthLayoutState extends State { color: theme.colorScheme.onSurface, ), ), - Text( - widget.subtitle, - style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + Semantics( + liveRegion: widget.announceSubtitle, + child: Text( + widget.subtitle, + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), ), const SizedBox(height: Spacing.xl), ], diff --git a/app/lib/widgets/book/private_book_cover.dart b/app/lib/widgets/book/private_book_cover.dart index 354ca9f5..87d1ecc9 100644 --- a/app/lib/widgets/book/private_book_cover.dart +++ b/app/lib/widgets/book/private_book_cover.dart @@ -137,8 +137,7 @@ class _PrivateBookCoverState extends State { final authProvider = subscribe ? context.watch() : context.read(); final user = authProvider.user; - final needsAccountScope = - user != null && !authProvider.isOfflineMode && (_usableMediaId == null || authProvider.isSignedIn); + final needsAccountScope = user != null && !authProvider.isOfflineMode; final syncSettings = needsAccountScope ? (subscribe ? context.watch() : context.read()) @@ -159,7 +158,7 @@ class _PrivateBookCoverState extends State { final user = authProvider.user; if (mediaId != null) { - if (!authProvider.isSignedIn || authProvider.isOfflineMode || user == null) { + if (authProvider.isOfflineMode || user == null) { _clearProviderLoader(); return; } diff --git a/app/lib/widgets/buttons/google_sign_in.dart b/app/lib/widgets/buttons/google_sign_in.dart index 05a7cb47..403f64ad 100644 --- a/app/lib/widgets/buttons/google_sign_in.dart +++ b/app/lib/widgets/buttons/google_sign_in.dart @@ -25,6 +25,16 @@ class GoogleSignInButton extends StatefulWidget { class _GoogleSignInButtonState extends State { bool _isSigningIn = false; + AuthProvider? _auth; + + @override + void dispose() { + if (_isSigningIn) { + _auth?.cancelAuthentication(notify: false); + } + + super.dispose(); + } Future _handleSignIn() async { if (_isSigningIn) { @@ -35,16 +45,24 @@ class _GoogleSignInButtonState extends State { try { final provider = Provider.of(context, listen: false); + _auth = provider; final result = await provider.signInWithGoogle(); if (!mounted) { return; } + _isSigningIn = false; + if (result) { widget.onSuccess?.call(); context.goNamed('LIBRARY'); } else { + if (provider.pendingVerification != null) { + context.go('/verification'); + return; + } + final recovery = provider.takePendingRecovery(); if (recovery != null) { diff --git a/app/test/auth/auth_repository_account_deletion_test.dart b/app/test/auth/auth_repository_account_deletion_test.dart index 06cd86f8..13f568dc 100644 --- a/app/test/auth/auth_repository_account_deletion_test.dart +++ b/app/test/auth/auth_repository_account_deletion_test.dart @@ -70,6 +70,7 @@ void main() { 'expires_in': 3600, 'user': { 'user_id': 'user', + 'email_verified': true, 'connected_providers': ['google'], }, }), diff --git a/app/test/auth/pending_verification_test.dart b/app/test/auth/pending_verification_test.dart new file mode 100644 index 00000000..1c7c71eb --- /dev/null +++ b/app/test/auth/pending_verification_test.dart @@ -0,0 +1,347 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:papyrus/auth/auth_api_client.dart'; +import 'package:papyrus/auth/auth_repository.dart'; +import 'package:papyrus/auth/papyrus_api_config.dart'; +import 'package:papyrus/auth/token_store.dart'; +import 'package:papyrus/providers/auth_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class MemoryStorage implements RefreshTokenStorage { + String? value; + int writes = 0; + Completer? readGate; + Completer? writeGate; + Completer? writeStarted; + + @override + Future read() async { + await readGate?.future; + return value; + } + + @override + Future delete() async => value = null; + + @override + Future write(String refreshToken) async { + writes += 1; + writeStarted?.complete(); + await writeGate?.future; + value = refreshToken; + } +} + +Map tokens({bool verified = true, String user = 'alice'}) => { + 'access_token': 'access-$user', + 'refresh_token': 'refresh-$user', + 'expires_in': 3600, + 'user': { + 'user_id': user, + 'email': '$user@example.com', + 'display_name': user, + 'email_verified': verified, + 'connected_providers': [], + }, +}; + +http.Response response(Object body, [int status = 200, Map? headers]) => + http.Response(jsonEncode(body), status, headers: headers ?? {}); + +http.Response verificationRequired({int cooldown = 0}) => response({ + 'error': { + 'code': 'EMAIL_VERIFICATION_REQUIRED', + 'message': 'Verify your email address before signing in.', + 'details': {'email': 'alice@example.com', 'resend_after': cooldown}, + }, +}, 403); + +AuthRepository repository( + Future Function(http.Request) handler, { + MemoryStorage? storage, + String server = 'one', +}) => AuthRepository( + apiClient: AuthApiClient( + config: PapyrusApiConfig(serverBaseUri: Uri.parse('https://$server.example.test'), apiPrefix: '/custom/api'), + httpClient: MockClient(handler), + ), + tokenStore: TokenStore(storage ?? MemoryStorage()), +); + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + test('cancelled bootstrap cannot refresh after a delayed secure-storage read', () async { + final storage = MemoryStorage() + ..value = 'old-refresh' + ..readGate = Completer(); + final repo = repository((_) async => fail('Cancelled bootstrap must not refresh'), storage: storage); + final bootstrap = repo.bootstrap(); + final rejected = expectLater(bootstrap, throwsA(isA())); + repo.cancelPendingAuthentication(); + storage.readGate!.complete(); + await rejected; + expect(storage.value, 'old-refresh'); + expect(storage.writes, 0); + }); + + test('pending registration preserves guest state and persists no credentials across restart', () async { + final storage = MemoryStorage(); + final repo = repository((request) async { + expect(request.url.path, '/custom/api/auth/register'); + return response({ + 'status': 'verification_required', + 'email': 'alice@example.com', + 'message': 'Check your email', + 'resend_after': 60, + }, 201); + }, storage: storage); + final prefs = await SharedPreferences.getInstance(); + final auth = AuthProvider(prefs, repository: repo, bootstrapOnCreate: false); + auth.setOfflineMode(true); + expect(await auth.register(email: 'Alice@example.com', password: 'password', displayName: 'Alice'), isTrue); + expect(auth.pendingVerification?.email, 'alice@example.com'); + expect(auth.isOfflineMode, isTrue); + expect(auth.user, isNull); + expect(auth.isSignedIn, isFalse); + expect(storage.writes, 0); + expect(await repo.bootstrap(), isNull); + auth.dispose(); + final restarted = AuthProvider(prefs, repository: repo, bootstrapOnCreate: false); + await restarted.bootstrap(); + expect(restarted.pendingVerification, isNull); + expect(restarted.isOfflineMode, isTrue); + restarted.dispose(); + }); + + test('verified sign-in alone stores tokens and leaves guest mode', () async { + final storage = MemoryStorage(); + final repo = repository((_) async => response(tokens()), storage: storage); + final auth = AuthProvider(await SharedPreferences.getInstance(), repository: repo, bootstrapOnCreate: false); + auth.setOfflineMode(true); + expect(await auth.login(email: 'alice@example.com', password: 'password'), isTrue); + expect(auth.isSignedIn, isTrue); + expect(auth.isOfflineMode, isFalse); + expect(storage.value, 'refresh-alice'); + auth.dispose(); + }); + + for (final operation in ['login', 'google', 'refresh', 'sync']) { + test('$operation verification-required response never installs credentials', () async { + final storage = MemoryStorage(); + var denying = false; + final repo = repository( + (request) async => denying ? verificationRequired(cooldown: 12) : response(tokens()), + storage: storage, + ); + final auth = AuthProvider(await SharedPreferences.getInstance(), repository: repo, bootstrapOnCreate: false); + + if (operation == 'refresh' || operation == 'sync') { + await auth.login(email: 'alice@example.com', password: 'password'); + } + + denying = true; + switch (operation) { + case 'login': + expect(await auth.login(email: 'alice@example.com', password: 'password'), isFalse); + case 'google': + expect(await auth.completeGoogleSignIn(Uri.parse('papyrus://auth/callback?code=pending-once')), isFalse); + case 'refresh': + expect(await auth.refresh(), isFalse); + case 'sync': + await expectLater(repo.createPowerSyncToken(), throwsA(isA())); + } + + expect(auth.isSignedIn, isFalse); + expect(auth.pendingVerification?.resendAfter, 12); + expect(storage.value, isNull); + expect(repo.tokenStore.accessToken, isNull); + expect(auth.hasOfflineAccount, operation == 'refresh' || operation == 'sync'); + auth.dispose(); + }); + } + + test('old-server unverified tokens are revoked before any storage', () async { + final paths = []; + final storage = MemoryStorage(); + final repo = repository((request) async { + paths.add(request.url.path); + if (request.url.path.endsWith('/logout')) return http.Response('', 204); + return response(tokens(verified: false)); + }, storage: storage); + await expectLater( + repo.login(email: 'alice@example.com', password: 'password', clientType: 'web'), + throwsA(isA()), + ); + expect(paths, ['/custom/api/auth/login', '/custom/api/auth/logout']); + expect(storage.writes, 0); + final pending = await repo.register( + email: 'alice@example.com', + password: 'password', + displayName: 'Alice', + clientType: 'web', + ); + expect(pending.email, 'alice@example.com'); + expect(storage.writes, 0); + }); + + test('malformed pending and token payloads are rejected', () async { + for (final body in [ + {'status': 'verification_required', 'email': 'alice@example.com', 'message': 'Check email', 'resend_after': -1}, + {'status': 'verification_required', 'resend_after': 60}, + { + 'status': 'verification_required', + 'email': 'alice@example.com', + 'message': 'Check email', + 'resend_after': 0, + 'refresh_token': 'secret', + }, + ]) { + final storage = MemoryStorage(); + final repo = repository((_) async => response(body, 201), storage: storage); + await expectLater( + repo.register(email: 'alice@example.com', password: 'password', displayName: 'Alice', clientType: 'web'), + throwsA(isA()), + ); + expect(storage.writes, 0); + } + }); + + for (final cancel in ['guest', 'server', 'cancel', 'signout']) { + test('$cancel discards delayed sign-in and pending responses', () async { + for (final registration in [false, true]) { + final reply = Completer(); + final started = Completer(); + final storage = MemoryStorage(); + final repo = repository((_) { + started.complete(); + return reply.future; + }, storage: storage); + final auth = AuthProvider(await SharedPreferences.getInstance(), repository: repo, bootstrapOnCreate: false); + final pending = registration + ? auth.register(email: 'alice@example.com', password: 'password', displayName: 'Alice') + : auth.login(email: 'alice@example.com', password: 'password'); + await started.future; + switch (cancel) { + case 'guest': + auth.setOfflineMode(true); + case 'server': + await auth.replaceRepository( + repository((_) async => response(tokens(user: 'bob')), server: 'two'), + bootstrapNewRepository: false, + ); + case 'cancel': + auth.cancelAuthentication(); + case 'signout': + await auth.signOut(); + } + reply.complete( + registration + ? response({ + 'status': 'verification_required', + 'email': 'alice@example.com', + 'message': 'Check email', + 'resend_after': 60, + }, 201) + : response(tokens()), + ); + expect(await pending, isFalse); + expect(auth.user, isNull); + expect(auth.pendingVerification, isNull); + expect(storage.value, isNull); + auth.dispose(); + } + }); + } + + test('cancellation during secure storage write removes the stale credentials', () async { + final storage = MemoryStorage() + ..writeGate = Completer() + ..writeStarted = Completer(); + final repo = repository((_) async => response(tokens()), storage: storage); + final auth = AuthProvider(await SharedPreferences.getInstance(), repository: repo, bootstrapOnCreate: false); + final login = auth.login(email: 'alice@example.com', password: 'password'); + await storage.writeStarted!.future; + auth.cancelAuthentication(); + storage.writeGate!.complete(); + expect(await login, isFalse); + expect(storage.value, isNull); + expect(repo.tokenStore.accessToken, isNull); + auth.dispose(); + }); + + test('pending Google handoff is consumed once and verification requires a fresh sign-in', () async { + var exchanges = 0; + final storage = MemoryStorage(); + final repo = repository((request) async { + if (request.url.path.endsWith('/verify-email')) { + return response({'message': 'Email verified successfully'}); + } + + exchanges += 1; + if (exchanges == 1) { + return verificationRequired(); + } + + if (jsonDecode(request.body)['code'] == 'pending') { + return response({ + 'error': {'code': 'UNAUTHORIZED', 'message': 'Invalid code', 'details': {}}, + }, 401); + } + + return response(tokens()); + }, storage: storage); + final auth = AuthProvider(await SharedPreferences.getInstance(), repository: repo, bootstrapOnCreate: false); + expect(await auth.completeGoogleSignIn(Uri.parse('papyrus://auth/callback?code=pending')), isFalse); + expect(await auth.verifyEmail('verification-token'), isNotNull); + expect(storage.value, isNull); + expect(await auth.completeGoogleSignIn(Uri.parse('papyrus://auth/callback?code=pending')), isFalse); + expect(await auth.completeGoogleSignIn(Uri.parse('papyrus://auth/callback?code=fresh')), isTrue); + auth.dispose(); + }); + + for (final error in ['email_required', 'verification_unavailable', 'account_exists', 'access_denied']) { + test('Google $error gives actionable guidance without exchange', () async { + final repo = repository((_) async => fail('No request expected')); + final auth = AuthProvider(await SharedPreferences.getInstance(), repository: repo, bootstrapOnCreate: false); + expect(await auth.completeGoogleSignIn(Uri.parse('papyrus://auth/callback?error=$error')), isFalse); + expect(auth.error, isNot(error)); + expect(auth.isSignedIn, isFalse); + auth.dispose(); + }); + } + + test('resend parses cooldown and delivery failures preserve retry headers', () async { + var requests = 0; + final repo = repository((_) async { + requests += 1; + if (requests == 1) { + return response({ + 'message': 'If the email is registered, a verification link has been sent', + 'resend_after': 60, + }); + } + + return response( + { + 'error': {'code': 'VERIFICATION_UNAVAILABLE', 'message': 'Please try later', 'details': {}}, + }, + 503, + {'retry-after': '60'}, + ); + }); + final auth = AuthProvider(await SharedPreferences.getInstance(), repository: repo, bootstrapOnCreate: false); + expect(await auth.resendVerification('alice@example.com'), isNotNull); + expect(await auth.resendVerification('alice@example.com'), isNull); + expect(requests, 1); + auth.cancelAuthentication(); + expect(await auth.resendVerification('alice@example.com'), isNull); + expect(auth.retryAt!.isAfter(DateTime.now()), isTrue); + expect(auth.errorCode, 'VERIFICATION_UNAVAILABLE'); + auth.dispose(); + }); +} diff --git a/app/test/config/app_router_test.dart b/app/test/config/app_router_test.dart index 47ebd4ff..c6bbba51 100644 --- a/app/test/config/app_router_test.dart +++ b/app/test/config/app_router_test.dart @@ -11,6 +11,7 @@ import 'package:papyrus/themes/app_theme.dart'; import 'package:provider/provider.dart'; import '../helpers/test_helpers.dart'; +import '../auth/pending_verification_test.dart' as verification; import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus/acquisition/acquisition_models.dart'; import 'package:papyrus/auth/auth_api_client.dart'; @@ -311,6 +312,69 @@ void main() { } } + testWidgets('registration redirects to guidance without installing an account or changing guest ownership', ( + tester, + ) async { + final prefs = await SharedPreferences.getInstance(); + final storage = verification.MemoryStorage(); + final repository = verification.repository( + (_) async => verification.response({ + 'status': 'verification_required', + 'email': 'reader@example.com', + 'message': 'Check email', + 'resend_after': 60, + }, 201), + storage: storage, + ); + final auth = AuthProvider(prefs, repository: repository, bootstrapOnCreate: false); + await auth.bootstrap(); + final appRouter = await _buildRouter(authProvider: auth, prefs: prefs); + appRouter.router.go('/register'); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: auth, + child: MaterialApp.router(routerConfig: appRouter.router, theme: AppTheme.light), + ), + ); + await tester.pumpAndSettle(); + await tester.enterText(find.widgetWithText(TextFormField, 'Display name'), 'Reader'); + await tester.enterText(find.widgetWithText(TextFormField, 'Email address'), 'reader@example.com'); + await tester.enterText(find.widgetWithText(TextFormField, 'Password'), 'valid-password'); + await tester.enterText(find.widgetWithText(TextFormField, 'Confirm password'), 'valid-password'); + await tester.ensureVisible(find.widgetWithText(ElevatedButton, 'Continue')); + await tester.tap(find.widgetWithText(ElevatedButton, 'Continue')); + await tester.pumpAndSettle(); + expect(appRouter.router.routeInformationProvider.value.uri.path, '/verification'); + expect(find.text('Check your email'), findsOneWidget); + expect(auth.pendingVerification?.email, 'reader@example.com'); + expect(auth.user, isNull); + expect(storage.writes, 0); + await tester.pumpWidget(const SizedBox.shrink()); + appRouter.router.dispose(); + auth.dispose(); + }); + + test('verification links bypass bootstrap and pending refresh offers guidance with offline return', () async { + final prefs = await SharedPreferences.getInstance(); + var denied = false; + final repository = verification.repository( + (_) async => denied ? verification.verificationRequired() : verification.response(verification.tokens()), + ); + final auth = AuthProvider(prefs, repository: repository, bootstrapOnCreate: false); + final appRouter = await _buildRouter(authProvider: auth, prefs: prefs); + expect(appRouter.redirectForPath('/verify-email?token=opaque'), isNull); + await auth.login(email: 'alice@example.com', password: 'password'); + denied = true; + await auth.refresh(); + expect(appRouter.redirectForPath('/library/books'), '/verification'); + expect(appRouter.redirectForPath('/verification'), isNull); + auth.cancelAuthentication(); + expect(auth.hasOfflineAccount, isTrue); + expect(appRouter.redirectForPath('/library/books'), isNull); + expect(appRouter.redirectForPath('/login'), isNull); + auth.dispose(); + }); + test('redirects signed-out users away from protected routes', () async { final prefs = await SharedPreferences.getInstance(); final provider = AuthProvider(prefs, repository: FakeAuthRepository(), bootstrapOnCreate: false); diff --git a/app/test/media/media_profile_switch_contract_test.dart b/app/test/media/media_profile_switch_contract_test.dart index c31d2b82..f44ce048 100644 --- a/app/test/media/media_profile_switch_contract_test.dart +++ b/app/test/media/media_profile_switch_contract_test.dart @@ -5,6 +5,8 @@ import 'dart:typed_data'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus/auth/auth_models.dart'; +import 'package:papyrus/auth/auth_api_client.dart'; +import 'package:papyrus/providers/auth_provider.dart'; import 'package:papyrus/data/repositories/library_repository.dart'; import 'package:papyrus/main.dart'; import 'package:papyrus/models/book.dart'; @@ -24,6 +26,11 @@ import '../providers/auth_provider_test.dart' show FakeAuthRepository; class _Database extends Fake implements PapyrusPowerSyncService { final activation = Completer(); + final onlineChanges = []; + final clears = []; + + @override + Future setOnline(bool online) async => onlineChanges.add(online); final started = Completer(); @override @@ -50,7 +57,11 @@ class _Database extends Fake implements PapyrusPowerSyncService { } @override - Future deactivate({bool clearAuthenticated = false}) async => mode = null; + Future deactivate({bool clearAuthenticated = false}) async { + clears.add(clearAuthenticated); + mode = null; + } + @override Future close() async {} @override @@ -71,7 +82,7 @@ class _Repository extends FakeAuthRepository { 'access_token': 'access', 'refresh_token': 'refresh', 'expires_in': 3600, - 'user': {'user_id': userId, 'connected_providers': []}, + 'user': {'user_id': userId, 'email_verified': true, 'connected_providers': []}, }); } @@ -125,6 +136,55 @@ class _Importer extends BookImportService { } void main() { + testWidgets('verification denial disconnects the account without clearing storage, and fresh sign-in reconnects', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final database = _Database()..activation.complete(); + final repository = _Repository('alice'); + late AuthProvider auth; + + await tester.pumpWidget( + Papyrus.withServices( + prefs: prefs, + repositoryFactory: (_, _) => repository, + database: database, + uploads: MediaUploadQueue(prefs), + importer: _Importer(), + child: Builder( + builder: (context) { + auth = context.read(); + return const SizedBox.shrink(); + }, + ), + ), + ); + await tester.pumpAndSettle(); + expect(auth.isSignedIn, isTrue); + repository.refreshError = const AuthApiException( + statusCode: 403, + code: 'EMAIL_VERIFICATION_REQUIRED', + message: 'Verify your email', + pendingVerification: PendingVerification(email: 'alice@example.com', resendAfter: 0), + ); + await auth.refresh(); + await tester.pumpAndSettle(); + expect(auth.hasOfflineAccount, isTrue); + expect(database.onlineChanges, [false]); + expect(database.clears, isEmpty); + repository.refreshError = null; + repository.refreshResult = repository.bootstrapResult; + await auth.refresh(); + await tester.pumpAndSettle(); + expect(database.onlineChanges, [false, true]); + await auth.signOut(); + await tester.pumpAndSettle(); + expect(database.clears, isNotEmpty); + expect(database.clears, everyElement(isFalse)); + await tester.pumpWidget(const SizedBox.shrink()); + }); + for (final guest in [false, true]) { testWidgets('catalogs wait for the ${guest ? 'guest' : 'account'} database activation', (tester) async { SharedPreferences.setMockInitialValues({'offline_mode': guest}); diff --git a/app/test/pages/auth_password_submission_test.dart b/app/test/pages/auth_password_submission_test.dart index 2cdda2f9..29877b12 100644 --- a/app/test/pages/auth_password_submission_test.dart +++ b/app/test/pages/auth_password_submission_test.dart @@ -50,7 +50,7 @@ class CapturingAuthRepository extends AuthRepository { } @override - Future register({ + Future register({ required String email, required String password, required String displayName, diff --git a/app/test/platform/auth_browser_callback_test.dart b/app/test/platform/auth_browser_callback_test.dart new file mode 100644 index 00000000..ce48ec39 --- /dev/null +++ b/app/test/platform/auth_browser_callback_test.dart @@ -0,0 +1,26 @@ +@TestOn('browser') +library; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/platform/web_redirect.dart'; +import 'package:web/web.dart' as web; + +void main() { + test('web OAuth return URI preserves a self-hosted app base path', () { + final base = web.document.createElement('base')..setAttribute('href', '/reader/'); + web.document.head!.prepend(base); + addTearDown(() => base.remove()); + expect(browserAppBaseUri().resolve('auth/callback').path, '/reader/auth/callback'); + }); + + test('web handoffs require the initiating API prefix and server and are consumed once', () { + rememberOAuthServer('https://one.test/custom/api'); + expect(consumeOAuthServer('https://two.test/custom/api'), isFalse); + expect(consumeOAuthServer('https://one.test/custom/api'), isFalse); + rememberOAuthServer('https://one.test/custom/api'); + expect(consumeOAuthServer('https://one.test/v1'), isFalse); + rememberOAuthServer('https://one.test/custom/api'); + expect(consumeOAuthServer('https://one.test/custom/api'), isTrue); + expect(consumeOAuthServer('https://one.test/custom/api'), isFalse); + }); +} diff --git a/app/test/powersync/library_live_sync_test.dart b/app/test/powersync/library_live_sync_test.dart index 2d3c0af5..727321dd 100644 --- a/app/test/powersync/library_live_sync_test.dart +++ b/app/test/powersync/library_live_sync_test.dart @@ -1,3 +1,4 @@ +import '../support/disposable_sync_accounts.dart'; import 'dart:convert'; import 'dart:io'; @@ -80,23 +81,21 @@ void main() { final firstAuth = auth(); final secondAuth = auth(); final otherAuth = auth(); - final suffix = const Uuid().v4(); - final email = 'sync-check-$suffix@example.com'; - final password = 'SyncCheck-$suffix'; + requireDisposableSyncAccounts(); + const email = syncOwnerEmail; + const password = syncPassword; - final owner = await firstAuth.register( + final owner = await firstAuth.login( email: email, password: password, - displayName: 'Sync validation', clientType: 'desktop', ); await secondAuth.login(email: email, password: password, clientType: 'desktop'); - final other = await otherAuth.register( - email: 'other-$email', + final other = await otherAuth.login( + email: syncOtherEmail, password: password, - displayName: 'Isolation validation', clientType: 'desktop', ); diff --git a/app/test/powersync/verification_preservation_test.dart b/app/test/powersync/verification_preservation_test.dart new file mode 100644 index 00000000..fde820a8 --- /dev/null +++ b/app/test/powersync/verification_preservation_test.dart @@ -0,0 +1,57 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/powersync/powersync_service.dart'; +import 'package:papyrus/providers/auth_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../auth/pending_verification_test.dart' show repository, response, tokens, verificationRequired; +import 'powersync_service_test.dart' show OfflineConnector; + +void main() { + test( + 'denied refresh retains offline books and queued writes through guest, server and restart transitions', + () async { + SharedPreferences.setMockInitialValues({}); + final root = await Directory.systemTemp.createTemp('papyrus-verification-'); + addTearDown(() => root.delete(recursive: true)); + PapyrusPowerSyncService database() => PapyrusPowerSyncService( + connectorFactory: OfflineConnector.new, + connectAuthenticated: false, + pathResolver: (mode, profile, user) async => '${root.path}/${mode.name}-${profile ?? ''}-${user ?? ''}.db', + ); + var denied = false; + final repo = repository((_) async => denied ? verificationRequired() : response(tokens())); + final auth = AuthProvider(await SharedPreferences.getInstance(), repository: repo, bootstrapOnCreate: false); + addTearDown(auth.dispose); + await auth.login(email: 'alice@example.com', password: 'password'); + final db = database(); + await db.activateAuthenticated(auth.user!.userId, profileKey: 'one'); + await db.upsert(Book(id: 'offline-book', title: 'Keep this book', author: 'Reader', addedAt: DateTime.utc(2026))); + expect(db.syncState.hasPendingWrites, isTrue); + denied = true; + expect(await auth.refresh(), isFalse); + await db.setOnline(false); + expect(auth.hasOfflineAccount, isTrue); + expect((await db.getById('offline-book'))?.title, 'Keep this book'); + expect(db.syncState.hasPendingWrites, isTrue); + await db.deactivate(clearAuthenticated: false); + await db.activateGuest(); + expect(await db.getById('offline-book'), isNull); + await db.upsert(Book(id: 'guest-book', title: 'Guest only', author: 'Guest', addedAt: DateTime.utc(2026))); + await db.activateAuthenticated('bob', profileKey: 'one'); + expect(await db.getById('offline-book'), isNull); + expect(await db.getById('guest-book'), isNull); + await db.activateAuthenticated('alice', profileKey: 'two'); + expect(await db.getById('offline-book'), isNull); + await db.close(); + final restarted = database(); + await restarted.activateAuthenticated('alice', profileKey: 'one'); + expect((await restarted.getById('offline-book'))?.title, 'Keep this book'); + expect(restarted.syncState.hasPendingWrites, isTrue); + expect(await restarted.getById('guest-book'), isNull); + await restarted.close(); + }, + ); +} diff --git a/app/test/services/book_import_session_test.dart b/app/test/services/book_import_session_test.dart index e50cc40d..3e95073a 100644 --- a/app/test/services/book_import_session_test.dart +++ b/app/test/services/book_import_session_test.dart @@ -27,14 +27,15 @@ class _DataStore extends Fake implements DataStore { } class _Auth extends Fake implements AuthProvider { - _Auth({this.guest = false}); + _Auth({this.guest = false, this.verificationRequired = false}); final bool guest; + final bool verificationRequired; @override bool isDeletingGuestAccount = false; @override - bool get isSignedIn => !guest; + bool get isSignedIn => !guest && !verificationRequired; @override bool get isOfflineMode => guest; @@ -105,8 +106,9 @@ BookImportResult _result() => BookImportResult( void main() { setUp(() => SharedPreferences.setMockInitialValues({})); - for (final guest in [false, true]) { - test('captured ${guest ? 'guest' : 'account'} session persists metadata and cover to its destination', () async { + for (final mode in ['guest', 'account', 'verification-required']) { + final guest = mode == 'guest'; + test('captured $mode session persists metadata and cover to its destination', () async { final repository = InMemoryBookRepository(); final store = DataStore(bookRepository: repository); final queue = MediaUploadQueue(await SharedPreferences.getInstance()); @@ -121,7 +123,7 @@ void main() { dataStore: store, queue: queue, importService: storage, - authProvider: _Auth(guest: guest), + authProvider: _Auth(guest: guest, verificationRequired: mode == 'verification-required'), powerSyncService: _Database(guest ? LibraryDatabaseMode.guest : LibraryDatabaseMode.authenticated), ); diff --git a/app/test/support/disposable_sync_accounts.dart b/app/test/support/disposable_sync_accounts.dart new file mode 100644 index 00000000..1172a382 --- /dev/null +++ b/app/test/support/disposable_sync_accounts.dart @@ -0,0 +1,14 @@ +// Live sync checks delete these accounts. Provision verified, disposable users +// on the isolated test deployment before explicitly enabling the live tests. +const syncOwnerEmail = String.fromEnvironment('PAPYRUS_DISPOSABLE_OWNER_EMAIL'); +const syncOtherEmail = String.fromEnvironment('PAPYRUS_DISPOSABLE_OTHER_EMAIL'); +const syncPassword = String.fromEnvironment('PAPYRUS_DISPOSABLE_PASSWORD'); + +void requireDisposableSyncAccounts() { + if (syncOwnerEmail.isEmpty || syncOtherEmail.isEmpty || syncPassword.isEmpty || syncOwnerEmail == syncOtherEmail) { + throw StateError( + 'Configure two distinct verified disposable accounts using PAPYRUS_DISPOSABLE_OWNER_EMAIL, ' + 'PAPYRUS_DISPOSABLE_OTHER_EMAIL and PAPYRUS_DISPOSABLE_PASSWORD. Live sync tests delete these accounts.', + ); + } +} diff --git a/app/test/web/auth_landing_test.cjs b/app/test/web/auth_landing_test.cjs new file mode 100644 index 00000000..82c6e641 --- /dev/null +++ b/app/test/web/auth_landing_test.cjs @@ -0,0 +1,34 @@ +const assert = require('node:assert/strict'); +const {test} = require('node:test'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const path = require('node:path'); +const source = fs.readFileSync(path.join(__dirname, '../../web/auth_landing.js'), 'utf8'); + +for (const base of ['/', '/reader/']) { + test(`verification capture removes secrets before app bootstrap at ${base}`, () => { + let replaced; + const window = { + location: {href: `https://app.test${base}verify-email?token=private&other=value#fragment`}, + history: {replaceState: (_, __, value) => {replaced = value;}}, + }; + vm.runInNewContext(source, {window, URL}); + assert.equal(replaced, `${base}verify-email`); + assert.equal(window.takePapyrusVerificationToken(), 'private'); + assert.equal(window.takePapyrusVerificationToken(), null); + assert.equal(Object.values(window).includes('private'), false); + }); +} + +test('ordinary routes are not rewritten', () => { + const window = {location: {href: 'https://app.test/library?search=book'}, history: {replaceState() { assert.fail(); }}}; + vm.runInNewContext(source, {window, URL}); + assert.equal(window.takePapyrusVerificationToken(), null); +}); + +test('no-referrer and token capture precede all app resources', () => { + const html = fs.readFileSync(path.join(__dirname, '../../web/index.html'), 'utf8'); + assert.ok(html.indexOf('name="referrer" content="no-referrer"') < html.indexOf('src="auth_landing.js"')); + assert.ok(html.indexOf('src="auth_landing.js"') < html.indexOf('rel="apple-touch-icon"')); + assert.ok(html.indexOf('src="auth_landing.js"') < html.indexOf('src="flutter_bootstrap.js"')); +}); diff --git a/app/test/widgets/auth/verification_page_test.dart b/app/test/widgets/auth/verification_page_test.dart new file mode 100644 index 00000000..8a796e9f --- /dev/null +++ b/app/test/widgets/auth/verification_page_test.dart @@ -0,0 +1,278 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:http/http.dart' as http; +import 'package:papyrus/pages/auth/verification_page.dart'; +import 'package:papyrus/providers/auth_provider.dart'; +import 'package:papyrus/themes/app_theme.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../auth/pending_verification_test.dart' show repository, response; + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + Future mount( + WidgetTester tester, + Future Function(http.Request) handler, { + String? token, + bool isLink = true, + Size size = const Size(390, 844), + double scale = 1, + }) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final auth = AuthProvider( + await SharedPreferences.getInstance(), + repository: repository(handler), + bootstrapOnCreate: false, + ); + final router = GoRouter( + initialLocation: '/verify-email', + routes: [ + GoRoute( + path: '/verify-email', + builder: (_, _) => VerificationPage(token: token, isLink: isLink), + ), + GoRoute( + path: '/login', + builder: (_, _) => const Scaffold(body: Text('Fresh sign-in')), + ), + GoRoute( + name: 'LIBRARY', + path: '/library', + builder: (_, _) => const Scaffold(body: Text('Library')), + ), + ], + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: auth, + child: MaterialApp.router( + routerConfig: router, + theme: AppTheme.light, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(scale)), + child: child!, + ), + ), + ), + ); + await tester.pumpAndSettle(); + addTearDown(() async { + await tester.pumpWidget(const SizedBox.shrink()); + router.dispose(); + auth.dispose(); + }); + return auth; + } + + for (final size in [const Size(320, 700), const Size(390, 844), const Size(1440, 900)]) { + testWidgets('confirmation is explicit and keyboard accessible at $size', (tester) async { + final semantics = tester.ensureSemantics(); + var calls = 0; + final auth = await mount( + tester, + (request) async { + calls += 1; + expect(request.url.path, '/custom/api/auth/verify-email'); + expect(jsonDecode(request.body), {'token': 'opaque-token'}); + return response({'message': 'Email verified successfully'}); + }, + token: 'opaque-token', + size: size, + ); + expect(calls, 0); + expect(find.text('Confirm email address'), findsOneWidget); + await tester.ensureVisible(find.text('Confirm email address')); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect(calls, 1); + expect(find.text('Email verified'), findsOneWidget); + expect( + tester + .getSemantics(find.text('Sign in again to open your account. Verification does not sign you in.')) + .getSemanticsData() + .flagsCollection + .isLiveRegion, + isTrue, + ); + expect(auth.isSignedIn, isFalse); + expect(find.text('Confirm email address'), findsNothing); + await tester.ensureVisible(find.text('Back to sign in')); + await tester.tap(find.text('Back to sign in')); + await tester.pumpAndSettle(); + expect(find.text('Fresh sign-in'), findsOneWidget); + semantics.dispose(); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('invalid, expired and already-used links offer resend and sign-in', (tester) async { + final semantics = tester.ensureSemantics(); + final auth = await mount( + tester, + (_) async => response({ + 'error': { + 'code': 'VERIFICATION_TOKEN_INVALID', + 'message': 'This verification link is invalid or expired.', + 'details': {}, + }, + }, 400), + token: 'expired', + ); + await tester.ensureVisible(find.text('Confirm email address')); + await tester.tap(find.text('Confirm email address')); + await tester.pumpAndSettle(); + expect(find.text('Resend verification email'), findsOneWidget); + expect( + tester + .getSemantics(find.textContaining('This link is missing, invalid')) + .getSemanticsData() + .flagsCollection + .isLiveRegion, + isTrue, + ); + expect(find.text('Back to sign in'), findsOneWidget); + expect(auth.isSignedIn, isFalse); + semantics.dispose(); + expect(tester.takeException(), isNull); + }); + + testWidgets('network failure retains explicit confirmation for retry', (tester) async { + var calls = 0; + await mount(tester, (_) async { + calls += 1; + if (calls == 1) throw http.ClientException('offline'); + return response({'message': 'Email verified successfully'}); + }, token: 'retry'); + await tester.ensureVisible(find.text('Confirm email address')); + await tester.tap(find.text('Confirm email address')); + await tester.pumpAndSettle(); + expect(find.text('Authentication request failed. Please try again.'), findsOneWidget); + await tester.ensureVisible(find.text('Confirm email address')); + await tester.tap(find.text('Confirm email address')); + await tester.pumpAndSettle(); + expect(calls, 2); + expect(find.text('Email verified'), findsOneWidget); + }); + + testWidgets('resend cooldown and enlarged text preserve actionable controls', (tester) async { + var calls = 0; + await mount( + tester, + (_) async { + calls += 1; + return response({'message': 'Request accepted', 'resend_after': 60}); + }, + isLink: false, + size: const Size(320, 700), + scale: 1.6, + ); + await tester.enterText(find.byType(TextFormField), 'alice@example.com'); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.widgetWithText(OutlinedButton, 'Resend verification email')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Resend verification email')); + await tester.pumpAndSettle(); + expect(calls, 1); + expect(find.textContaining('Resend in'), findsOneWidget); + expect(tester.widget(find.byType(OutlinedButton)).onPressed, isNull); + expect(tester.takeException(), isNull); + }); + + testWidgets('rate limit responses disable retries', (tester) async { + await mount( + tester, + (_) async => response({ + 'error': { + 'code': 'RATE_LIMIT_EXCEEDED', + 'message': 'Too many requests', + 'details': {'retry_after': 35}, + }, + }, 429), + isLink: false, + ); + await tester.enterText(find.byType(TextFormField), 'alice@example.com'); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.widgetWithText(OutlinedButton, 'Resend verification email')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Resend verification email')); + await tester.pumpAndSettle(); + expect(find.textContaining('Try again in'), findsOneWidget); + expect(tester.widget(find.byType(OutlinedButton)).onPressed, isNull); + }); + + testWidgets('resend delivery failure remains visible after an invalid link', (tester) async { + await mount(tester, (request) async { + if (request.url.path.endsWith('/verify-email')) { + return response({ + 'error': {'code': 'VERIFICATION_TOKEN_INVALID', 'message': 'Invalid link', 'details': {}}, + }, 400); + } + + return response( + { + 'error': { + 'code': 'VERIFICATION_UNAVAILABLE', + 'message': 'Verification email is temporarily unavailable. Please try again later.', + 'details': {}, + }, + }, + 503, + {'retry-after': '60'}, + ); + }, token: 'expired'); + await tester.ensureVisible(find.text('Confirm email address')); + await tester.tap(find.text('Confirm email address')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextFormField), 'alice@example.com'); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.widgetWithText(OutlinedButton, 'Resend verification email')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Resend verification email')); + await tester.pumpAndSettle(); + expect(find.text('Verification email is temporarily unavailable. Please try again later.'), findsOneWidget); + expect(find.textContaining('Try again in'), findsOneWidget); + expect(tester.widget(find.byType(OutlinedButton)).onPressed, isNull); + }); + + testWidgets('server change discards the captured token and pending email', (tester) async { + var calls = 0; + final auth = await mount(tester, (_) async { + calls += 1; + return response({'message': 'Verified'}); + }, token: 'private'); + await auth.replaceRepository( + repository((_) async => fail('Must not send to new server'), server: 'two'), + bootstrapNewRepository: false, + ); + await tester.pumpAndSettle(); + expect(find.text('Confirm email address'), findsNothing); + expect(find.textContaining('different server'), findsOneWidget); + expect(calls, 0); + }); + + testWidgets('leaving during confirmation ignores a late response', (tester) async { + final completed = Completer(); + final auth = await mount(tester, (_) => completed.future, token: 'private'); + await tester.ensureVisible(find.text('Confirm email address')); + await tester.tap(find.text('Confirm email address')); + await tester.pump(); + await tester.ensureVisible(find.text('Back to sign in')); + await tester.tap(find.text('Back to sign in')); + await tester.pumpAndSettle(); + completed.complete(response({'message': 'Verified'})); + await tester.pumpAndSettle(); + expect(find.text('Fresh sign-in'), findsOneWidget); + expect(auth.isSignedIn, isFalse); + expect(auth.error, isNull); + }); +} diff --git a/app/test/widgets/book/private_book_cover_test.dart b/app/test/widgets/book/private_book_cover_test.dart index d9400fa1..ea807988 100644 --- a/app/test/widgets/book/private_book_cover_test.dart +++ b/app/test/widgets/book/private_book_cover_test.dart @@ -69,6 +69,33 @@ void main() { cache.clearLiveImages(); }); + testWidgets('verification denial preserves account-scoped cached covers without requesting media', (tester) async { + final importService = _RecordingBookImportService(Uint8List.fromList(pngBytes)); + final harness = await _buildProviderHarness(importService: importService); + harness.repository.refreshGate = Completer(); + final refreshing = harness.authProvider.refresh(); + harness.repository.refreshGate!.completeError( + const AuthApiException( + statusCode: 403, + code: 'EMAIL_VERIFICATION_REQUIRED', + message: 'Verify your email', + pendingVerification: PendingVerification(email: 'reader@example.com', resendAfter: 0), + ), + ); + await refreshing; + expect(harness.authProvider.hasOfflineAccount, isTrue); + await tester.pumpWidget( + harness.wrap(const CoverImage(bookId: 'offline-book', mediaId: 'offline-cover', placeholder: SizedBox.shrink())), + ); + await _waitForDecodedCover(tester); + expect(importService.cachedCoverReads, 1); + expect(find.byType(Image), findsOneWidget); + await expectLater(harness.authProvider.downloadMedia('uncached'), throwsStateError); + await tester.pumpWidget(const SizedBox.shrink()); + harness.authProvider.dispose(); + harness.syncSettings.dispose(); + }); + testWidgets('shows a visible loading state while local cover bytes load', (tester) async { final coverBytes = Completer(); diff --git a/app/web/auth_landing.js b/app/web/auth_landing.js new file mode 100644 index 00000000..af21fc7b --- /dev/null +++ b/app/web/auth_landing.js @@ -0,0 +1,15 @@ +(() => { + const url = new URL(window.location.href); + let token = null; + if (url.pathname.replace(/\/$/, '').endsWith('/verify-email')) { + token = url.searchParams.get('token'); + url.search = ''; + url.hash = ''; + window.history.replaceState(null, '', url.pathname); + } + window.takePapyrusVerificationToken = () => { + const value = token; + token = null; + return value; + }; +})(); diff --git a/app/web/index.html b/app/web/index.html index b8e0c312..81ebf92f 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -17,6 +17,8 @@ + + diff --git a/tools/prepare_web_offline.py b/tools/prepare_web_offline.py index e6b0451c..6ef66d4e 100644 --- a/tools/prepare_web_offline.py +++ b/tools/prepare_web_offline.py @@ -8,7 +8,7 @@ def prepare(root: Path) -> dict: - required = ("index.html", "flutter_bootstrap.js", "main.dart.js", "folder_access.js", "app_updates.js", "book_worker.js", "papyrus_service_worker.js") + required = ("index.html", "flutter_bootstrap.js", "main.dart.js", "folder_access.js", "auth_landing.js", "app_updates.js", "book_worker.js", "papyrus_service_worker.js") for name in required: if not (root / name).is_file(): diff --git a/tools/tests/test_web_offline.py b/tools/tests/test_web_offline.py index b0cf9751..0ff1fb32 100644 --- a/tools/tests/test_web_offline.py +++ b/tools/tests/test_web_offline.py @@ -13,7 +13,7 @@ class OfflinePackagingTest(unittest.TestCase): def test_revision_changes_with_assets_and_worker_but_not_repeated_preparation(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) - for name in ("index.html", "flutter_bootstrap.js", "main.dart.js", "folder_access.js", "app_updates.js", "book_worker.js"): + for name in ("index.html", "flutter_bootstrap.js", "main.dart.js", "folder_access.js", "auth_landing.js", "app_updates.js", "book_worker.js"): (root / name).write_text(name) worker = root / "papyrus_service_worker.js" worker.write_text("const applicationRevision = 'unprepared';\n") @@ -23,6 +23,7 @@ def test_revision_changes_with_assets_and_worker_but_not_repeated_preparation(se self.assertNotIn("main.dart.js.map", first["assets"]) self.assertNotIn("offline-manifest.json", first["assets"]) self.assertIn("book_worker.js", first["assets"]) + self.assertIn("auth_landing.js", first["assets"]) self.assertIn(first["revision"], worker.read_text()) (root / "main.dart.js").write_text("updated application") second = prepare(root) From 1799517fddb11d1f790d1312c74e9b3b2b339ec7 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Sat, 7 Nov 2026 01:34:40 +0200 Subject: [PATCH 2/8] Simplify email verification copy around the reader's next step --- app/lib/pages/auth/verification_page.dart | 17 ++++++----------- .../widgets/auth/verification_page_test.dart | 8 ++++---- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/app/lib/pages/auth/verification_page.dart b/app/lib/pages/auth/verification_page.dart index b0f8a00b..87f46bf0 100644 --- a/app/lib/pages/auth/verification_page.dart +++ b/app/lib/pages/auth/verification_page.dart @@ -139,9 +139,7 @@ class _VerificationPageState extends State { setState(() { _busy = false; - _message = message == null - ? null - : 'Request accepted. Check your inbox and spam folder. Delivery may take a few minutes.'; + _message = message == null ? null : 'Check your inbox and spam folder for a new link. It may take a few minutes.'; }); } @@ -161,14 +159,13 @@ class _VerificationPageState extends State { if (_verified) { heading = 'Email verified'; - subtitle = 'Sign in again to open your account. Verification does not sign you in.'; + subtitle = 'You can now sign in to Papyrus.'; } else if (canConfirm) { heading = 'Verify your email'; - subtitle = 'Confirm below to verify your email address. This will not sign you in.'; + subtitle = 'Confirm this is your email address.'; } else if (_invalidLink) { heading = 'Request a new link'; - subtitle = - 'This link is missing, invalid, expired, or already used. Sign in if you already verified, or request a new link below.'; + subtitle = 'This link cannot be used. Request a new one, or sign in if you have already verified your email.'; } else { heading = 'Check your email'; subtitle = 'Open the verification link in your email, then return here to sign in.'; @@ -187,7 +184,7 @@ class _VerificationPageState extends State { children: [ if (_wrongServer) const Text( - 'This link belongs to this web deployment, but a different server is selected. Return to the issuing server’s app and open the email link again.', + 'This link is for a different server. Open it in the Papyrus app for that server.', ), if (_message != null && !_verified) ...[ Semantics(liveRegion: true, child: Text(_message!)), @@ -230,8 +227,6 @@ class _VerificationPageState extends State { onPressed: _busy || remaining > 0 || retry > 0 ? null : _resend, child: Text(resendLabel), ), - const SizedBox(height: Spacing.sm), - const Text('If an account needs verification, a link will be queued. Check your spam folder too.'), const SizedBox(height: Spacing.lg), ], if (canConfirm) @@ -244,7 +239,7 @@ class _VerificationPageState extends State { FilledButton( style: FilledButton.styleFrom(minimumSize: buttonSize), onPressed: _signIn, - child: const Text('Back to sign in'), + child: Text(_verified ? 'Sign in' : 'Back to sign in'), ), const SizedBox(height: Spacing.sm), TextButton(onPressed: () => navigateToOffline(context), child: const Text('Continue as guest')), diff --git a/app/test/widgets/auth/verification_page_test.dart b/app/test/widgets/auth/verification_page_test.dart index 8a796e9f..c303e781 100644 --- a/app/test/widgets/auth/verification_page_test.dart +++ b/app/test/widgets/auth/verification_page_test.dart @@ -98,7 +98,7 @@ void main() { expect(find.text('Email verified'), findsOneWidget); expect( tester - .getSemantics(find.text('Sign in again to open your account. Verification does not sign you in.')) + .getSemantics(find.text('You can now sign in to Papyrus.')) .getSemanticsData() .flagsCollection .isLiveRegion, @@ -106,8 +106,8 @@ void main() { ); expect(auth.isSignedIn, isFalse); expect(find.text('Confirm email address'), findsNothing); - await tester.ensureVisible(find.text('Back to sign in')); - await tester.tap(find.text('Back to sign in')); + await tester.ensureVisible(find.text('Sign in')); + await tester.tap(find.text('Sign in')); await tester.pumpAndSettle(); expect(find.text('Fresh sign-in'), findsOneWidget); semantics.dispose(); @@ -134,7 +134,7 @@ void main() { expect(find.text('Resend verification email'), findsOneWidget); expect( tester - .getSemantics(find.textContaining('This link is missing, invalid')) + .getSemantics(find.textContaining('This link cannot be used')) .getSemanticsData() .flagsCollection .isLiveRegion, From be074bf75f2cd4bdf3311f5f5b8843200fc09952 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Sat, 7 Nov 2026 01:37:59 +0200 Subject: [PATCH 3/8] Explain verification delivery failures with actionable guidance --- app/lib/auth/auth_api_client.dart | 6 +++++- app/lib/auth/auth_repository.dart | 3 +-- app/test/auth/pending_verification_test.dart | 1 + app/test/widgets/auth/verification_page_test.dart | 5 ++++- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/app/lib/auth/auth_api_client.dart b/app/lib/auth/auth_api_client.dart index f2986bb1..78a78d27 100644 --- a/app/lib/auth/auth_api_client.dart +++ b/app/lib/auth/auth_api_client.dart @@ -31,6 +31,8 @@ class AuthApiException implements Exception { class AuthApiClient { static const serverUnavailableMessage = 'Unable to connect. Please try again.'; + static const verificationUnavailableMessage = + 'We couldn’t send the verification email. Try again, or contact your server administrator.'; static const rateLimitMessage = 'Too many requests. Please try again later.'; final PapyrusApiConfig config; @@ -387,7 +389,9 @@ class AuthApiClient { recoveryAuthorization: error['code'] == 'ACCOUNT_DELETION_PENDING' ? AccountDeletionAuthorization.fromJson(error['details']['authorization'] as Map) : null, - message: error['message'] as String? ?? 'Authentication request failed', + message: error['code'] == 'VERIFICATION_UNAVAILABLE' + ? verificationUnavailableMessage + : error['message'] as String? ?? 'Authentication request failed', ); } diff --git a/app/lib/auth/auth_repository.dart b/app/lib/auth/auth_repository.dart index 0a780ab9..390f2bba 100644 --- a/app/lib/auth/auth_repository.dart +++ b/app/lib/auth/auth_repository.dart @@ -180,8 +180,7 @@ class AuthRepository { message: switch (error) { 'email_required' => 'Google did not provide an email address. Register with email, or contact your server operator for an existing account.', - 'verification_unavailable' => - 'Verification email is temporarily unavailable. Try signing in or requesting a link again later.', + 'verification_unavailable' => AuthApiClient.verificationUnavailableMessage, 'account_exists' => 'An account with this email already exists. Sign in to that account before linking Google.', 'access_denied' => 'Google sign-in was cancelled. You can try again.', diff --git a/app/test/auth/pending_verification_test.dart b/app/test/auth/pending_verification_test.dart index 1c7c71eb..2001a14a 100644 --- a/app/test/auth/pending_verification_test.dart +++ b/app/test/auth/pending_verification_test.dart @@ -342,6 +342,7 @@ void main() { expect(await auth.resendVerification('alice@example.com'), isNull); expect(auth.retryAt!.isAfter(DateTime.now()), isTrue); expect(auth.errorCode, 'VERIFICATION_UNAVAILABLE'); + expect(auth.error, AuthApiClient.verificationUnavailableMessage); auth.dispose(); }); } diff --git a/app/test/widgets/auth/verification_page_test.dart b/app/test/widgets/auth/verification_page_test.dart index c303e781..38580de1 100644 --- a/app/test/widgets/auth/verification_page_test.dart +++ b/app/test/widgets/auth/verification_page_test.dart @@ -239,7 +239,10 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.text('Resend verification email')); await tester.pumpAndSettle(); - expect(find.text('Verification email is temporarily unavailable. Please try again later.'), findsOneWidget); + expect( + find.text('We couldn’t send the verification email. Try again, or contact your server administrator.'), + findsOneWidget, + ); expect(find.textContaining('Try again in'), findsOneWidget); expect(tester.widget(find.byType(OutlinedButton)).onPressed, isNull); }); From dac89db670e32d6fc076e9f78cb6b639c7943e6a Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Sat, 7 Nov 2026 01:40:55 +0200 Subject: [PATCH 4/8] Describe registration failures without delivery details --- app/lib/auth/auth_api_client.dart | 3 +-- app/lib/auth/auth_repository.dart | 2 +- app/lib/providers/auth_provider.dart | 5 +++++ app/test/auth/pending_verification_test.dart | 18 ++++++++++++++++++ .../widgets/auth/verification_page_test.dart | 2 +- 5 files changed, 26 insertions(+), 4 deletions(-) diff --git a/app/lib/auth/auth_api_client.dart b/app/lib/auth/auth_api_client.dart index 78a78d27..e575f7da 100644 --- a/app/lib/auth/auth_api_client.dart +++ b/app/lib/auth/auth_api_client.dart @@ -31,8 +31,7 @@ class AuthApiException implements Exception { class AuthApiClient { static const serverUnavailableMessage = 'Unable to connect. Please try again.'; - static const verificationUnavailableMessage = - 'We couldn’t send the verification email. Try again, or contact your server administrator.'; + static const verificationUnavailableMessage = 'Couldn’t send the verification email. Please try again.'; static const rateLimitMessage = 'Too many requests. Please try again later.'; final PapyrusApiConfig config; diff --git a/app/lib/auth/auth_repository.dart b/app/lib/auth/auth_repository.dart index 390f2bba..71c6cbc6 100644 --- a/app/lib/auth/auth_repository.dart +++ b/app/lib/auth/auth_repository.dart @@ -180,7 +180,7 @@ class AuthRepository { message: switch (error) { 'email_required' => 'Google did not provide an email address. Register with email, or contact your server operator for an existing account.', - 'verification_unavailable' => AuthApiClient.verificationUnavailableMessage, + 'verification_unavailable' => 'Sign-in failed. Please try again.', 'account_exists' => 'An account with this email already exists. Sign in to that account before linking Google.', 'access_denied' => 'Google sign-in was cancelled. You can try again.', diff --git a/app/lib/providers/auth_provider.dart b/app/lib/providers/auth_provider.dart index b8c7e80f..1c1f2a91 100644 --- a/app/lib/providers/auth_provider.dart +++ b/app/lib/providers/auth_provider.dart @@ -207,6 +207,11 @@ class AuthProvider extends ChangeNotifier { } catch (error) { if (operation == _operation) { _recordError(error); + + if (error is AuthApiException && error.code == 'VERIFICATION_UNAVAILABLE') { + _error = 'Registration failed. Please try again.'; + } + _setStatus(_user == null ? AuthStatus.authError : previousStatus); } diff --git a/app/test/auth/pending_verification_test.dart b/app/test/auth/pending_verification_test.dart index 2001a14a..05f66053 100644 --- a/app/test/auth/pending_verification_test.dart +++ b/app/test/auth/pending_verification_test.dart @@ -315,6 +315,24 @@ void main() { }); } + test('registration delivery failure reports the failed action and retains retry timing', () async { + final repo = repository( + (_) async => response( + { + 'error': {'code': 'VERIFICATION_UNAVAILABLE', 'message': 'Please try later', 'details': {}}, + }, + 503, + {'retry-after': '60'}, + ), + ); + final auth = AuthProvider(await SharedPreferences.getInstance(), repository: repo, bootstrapOnCreate: false); + expect(await auth.register(email: 'alice@example.com', password: 'password', displayName: 'Alice'), isFalse); + expect(auth.error, 'Registration failed. Please try again.'); + expect(auth.retryAt!.isAfter(DateTime.now()), isTrue); + expect(auth.isSignedIn, isFalse); + auth.dispose(); + }); + test('resend parses cooldown and delivery failures preserve retry headers', () async { var requests = 0; final repo = repository((_) async { diff --git a/app/test/widgets/auth/verification_page_test.dart b/app/test/widgets/auth/verification_page_test.dart index 38580de1..f5ba6051 100644 --- a/app/test/widgets/auth/verification_page_test.dart +++ b/app/test/widgets/auth/verification_page_test.dart @@ -240,7 +240,7 @@ void main() { await tester.tap(find.text('Resend verification email')); await tester.pumpAndSettle(); expect( - find.text('We couldn’t send the verification email. Try again, or contact your server administrator.'), + find.text('Couldn’t send the verification email. Please try again.'), findsOneWidget, ); expect(find.textContaining('Try again in'), findsOneWidget); From ab9f98d0e1b79ad3c071e503cea740f7bc962eb9 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Sat, 7 Nov 2026 01:48:59 +0200 Subject: [PATCH 5/8] Verify email links automatically and label offline access consistently --- app/lib/pages/auth/verification_page.dart | 39 +++++++---- .../widgets/auth/verification_page_test.dart | 70 ++++++++++++------- 2 files changed, 72 insertions(+), 37 deletions(-) diff --git a/app/lib/pages/auth/verification_page.dart b/app/lib/pages/auth/verification_page.dart index 87f46bf0..cd03dff6 100644 --- a/app/lib/pages/auth/verification_page.dart +++ b/app/lib/pages/auth/verification_page.dart @@ -34,6 +34,7 @@ class _VerificationPageState extends State { String? _message; bool _busy = false; bool _verified = false; + bool _verificationFailed = false; bool _invalidLink = false; bool _serverChanged = false; @@ -63,6 +64,14 @@ class _VerificationPageState extends State { if (_serverKey == null) { _serverKey = auth.serverKey; _email.text = auth.pendingVerification?.email ?? ''; + + if (widget.isLink && !_invalidLink) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _verify(); + } + }); + } } else if (_serverKey != auth.serverKey) { _token = null; _email.clear(); @@ -96,8 +105,13 @@ class _VerificationPageState extends State { _serverChanged || (widget.isLink && kIsWeb && _auth?.serverKey != '${PapyrusApiConfig.fromEnvironment().apiBaseUri}'); - Future _confirm() async { - if (_busy || _wrongServer || _token == null || _secondsUntil(_auth!.retryAt) > 0) { + Future _verify() async { + if (_busy || _wrongServer || _token == null) { + return; + } + + if (_secondsUntil(_auth!.retryAt) > 0) { + setState(() => _verificationFailed = true); return; } @@ -111,6 +125,7 @@ class _VerificationPageState extends State { setState(() { _busy = false; _verified = message != null; + _verificationFailed = message == null; _message = message; _invalidLink = _auth!.errorCode == 'VERIFICATION_TOKEN_INVALID'; @@ -153,16 +168,16 @@ class _VerificationPageState extends State { final auth = context.watch(); final remaining = _secondsUntil(auth.resendAt); final retry = _secondsUntil(auth.retryAt); - final canConfirm = widget.isLink && !_verified && !_invalidLink && !_wrongServer; + final hasVerificationToken = widget.isLink && !_verified && !_invalidLink && !_wrongServer; final String heading; final String subtitle; if (_verified) { heading = 'Email verified'; subtitle = 'You can now sign in to Papyrus.'; - } else if (canConfirm) { - heading = 'Verify your email'; - subtitle = 'Confirm this is your email address.'; + } else if (hasVerificationToken) { + heading = _verificationFailed && !_busy ? 'Verification failed' : 'Verifying your email'; + subtitle = _verificationFailed && !_busy ? 'Please try again.' : 'Please wait…'; } else if (_invalidLink) { heading = 'Request a new link'; subtitle = 'This link cannot be used. Request a new one, or sign in if you have already verified your email.'; @@ -201,16 +216,16 @@ class _VerificationPageState extends State { Text('Try again in ${retry}s.'), const SizedBox(height: Spacing.md), ], - if (canConfirm) ...[ + if (hasVerificationToken && _verificationFailed && !_busy) ...[ FilledButton( style: FilledButton.styleFrom(minimumSize: buttonSize), autofocus: true, - onPressed: _busy || retry > 0 ? null : _confirm, - child: Text(_busy ? 'Verifying…' : 'Confirm email address'), + onPressed: _busy || retry > 0 ? null : _verify, + child: const Text('Try again'), ), const SizedBox(height: Spacing.md), ], - if (!_verified && !_wrongServer && !canConfirm) ...[ + if (!_verified && !_wrongServer && !hasVerificationToken) ...[ const SizedBox(height: Spacing.md), Form( key: _formKey, @@ -229,7 +244,7 @@ class _VerificationPageState extends State { ), const SizedBox(height: Spacing.lg), ], - if (canConfirm) + if (hasVerificationToken) OutlinedButton( style: OutlinedButton.styleFrom(minimumSize: buttonSize), onPressed: _signIn, @@ -242,7 +257,7 @@ class _VerificationPageState extends State { child: Text(_verified ? 'Sign in' : 'Back to sign in'), ), const SizedBox(height: Spacing.sm), - TextButton(onPressed: () => navigateToOffline(context), child: const Text('Continue as guest')), + TextButton(onPressed: () => navigateToOffline(context), child: const Text('Continue offline')), if (auth.hasOfflineAccount) TextButton( onPressed: () { diff --git a/app/test/widgets/auth/verification_page_test.dart b/app/test/widgets/auth/verification_page_test.dart index f5ba6051..8a26b413 100644 --- a/app/test/widgets/auth/verification_page_test.dart +++ b/app/test/widgets/auth/verification_page_test.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import 'package:http/http.dart' as http; @@ -75,13 +74,14 @@ void main() { } for (final size in [const Size(320, 700), const Size(390, 844), const Size(1440, 900)]) { - testWidgets('confirmation is explicit and keyboard accessible at $size', (tester) async { + testWidgets('opening a link verifies once without signing in at $size', (tester) async { final semantics = tester.ensureSemantics(); var calls = 0; final auth = await mount( tester, (request) async { calls += 1; + expect(request.method, 'POST'); expect(request.url.path, '/custom/api/auth/verify-email'); expect(jsonDecode(request.body), {'token': 'opaque-token'}); return response({'message': 'Email verified successfully'}); @@ -89,10 +89,7 @@ void main() { token: 'opaque-token', size: size, ); - expect(calls, 0); - expect(find.text('Confirm email address'), findsOneWidget); - await tester.ensureVisible(find.text('Confirm email address')); - await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(const Duration(seconds: 2)); await tester.pumpAndSettle(); expect(calls, 1); expect(find.text('Email verified'), findsOneWidget); @@ -128,9 +125,6 @@ void main() { }, 400), token: 'expired', ); - await tester.ensureVisible(find.text('Confirm email address')); - await tester.tap(find.text('Confirm email address')); - await tester.pumpAndSettle(); expect(find.text('Resend verification email'), findsOneWidget); expect( tester @@ -146,24 +140,42 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('network failure retains explicit confirmation for retry', (tester) async { + testWidgets('network failure offers a manual retry without repeating automatically', (tester) async { var calls = 0; await mount(tester, (_) async { calls += 1; if (calls == 1) throw http.ClientException('offline'); return response({'message': 'Email verified successfully'}); }, token: 'retry'); - await tester.ensureVisible(find.text('Confirm email address')); - await tester.tap(find.text('Confirm email address')); - await tester.pumpAndSettle(); expect(find.text('Authentication request failed. Please try again.'), findsOneWidget); - await tester.ensureVisible(find.text('Confirm email address')); - await tester.tap(find.text('Confirm email address')); + await tester.pump(const Duration(seconds: 2)); + expect(calls, 1); + await tester.ensureVisible(find.text('Try again')); + await tester.tap(find.text('Try again')); await tester.pumpAndSettle(); expect(calls, 2); expect(find.text('Email verified'), findsOneWidget); }); + testWidgets('automatic verification respects rate limits without repeated requests', (tester) async { + var calls = 0; + await mount(tester, (_) async { + calls += 1; + return response({ + 'error': { + 'code': 'RATE_LIMIT_EXCEEDED', + 'message': 'Too many requests', + 'details': {'retry_after': 35}, + }, + }, 429); + }, token: 'limited'); + expect(calls, 1); + expect(tester.widget(find.widgetWithText(FilledButton, 'Try again')).onPressed, isNull); + await tester.pump(const Duration(seconds: 36)); + expect(calls, 1); + expect(find.text('Email verified'), findsNothing); + }); + testWidgets('resend cooldown and enlarged text preserve actionable controls', (tester) async { var calls = 0; await mount( @@ -230,9 +242,6 @@ void main() { {'retry-after': '60'}, ); }, token: 'expired'); - await tester.ensureVisible(find.text('Confirm email address')); - await tester.tap(find.text('Confirm email address')); - await tester.pumpAndSettle(); await tester.enterText(find.byType(TextFormField), 'alice@example.com'); await tester.pumpAndSettle(); await tester.ensureVisible(find.widgetWithText(OutlinedButton, 'Resend verification email')); @@ -249,9 +258,10 @@ void main() { testWidgets('server change discards the captured token and pending email', (tester) async { var calls = 0; - final auth = await mount(tester, (_) async { + final completed = Completer(); + final auth = await mount(tester, (_) { calls += 1; - return response({'message': 'Verified'}); + return completed.future; }, token: 'private'); await auth.replaceRepository( repository((_) async => fail('Must not send to new server'), server: 'two'), @@ -260,15 +270,25 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Confirm email address'), findsNothing); expect(find.textContaining('different server'), findsOneWidget); - expect(calls, 0); + expect(calls, 1); + completed.complete(response({'message': 'Verified'})); + await tester.pumpAndSettle(); + expect(find.text('Email verified'), findsNothing); + }); + + testWidgets('missing link offers offline reading without a verification request', (tester) async { + final auth = await mount(tester, (_) async => fail('No verification token')); + expect(find.text('Request a new link'), findsOneWidget); + await tester.ensureVisible(find.text('Continue offline')); + await tester.tap(find.text('Continue offline')); + await tester.pumpAndSettle(); + expect(find.text('Library'), findsOneWidget); + expect(auth.isOfflineMode, isTrue); }); - testWidgets('leaving during confirmation ignores a late response', (tester) async { + testWidgets('leaving during automatic verification ignores a late response', (tester) async { final completed = Completer(); final auth = await mount(tester, (_) => completed.future, token: 'private'); - await tester.ensureVisible(find.text('Confirm email address')); - await tester.tap(find.text('Confirm email address')); - await tester.pump(); await tester.ensureVisible(find.text('Back to sign in')); await tester.tap(find.text('Back to sign in')); await tester.pumpAndSettle(); From 809b9b6f9d092c8d92219cc59f684273550af258 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Sat, 7 Nov 2026 01:55:49 +0200 Subject: [PATCH 6/8] Redesign pending verification as a compact inbox state --- app/lib/pages/auth/verification_page.dart | 291 +++++++++++++----- .../widgets/auth/verification_page_test.dart | 149 ++++++++- 2 files changed, 351 insertions(+), 89 deletions(-) diff --git a/app/lib/pages/auth/verification_page.dart b/app/lib/pages/auth/verification_page.dart index cd03dff6..1c73299a 100644 --- a/app/lib/pages/auth/verification_page.dart +++ b/app/lib/pages/auth/verification_page.dart @@ -27,11 +27,14 @@ class VerificationPage extends StatefulWidget { class _VerificationPageState extends State { final _formKey = GlobalKey(); final _email = TextEditingController(); + final _emailFocus = FocusNode(); Timer? _timer; AuthProvider? _auth; String? _serverKey; String? _token; String? _message; + String? _recipient; + bool _editingEmail = false; bool _busy = false; bool _verified = false; bool _verificationFailed = false; @@ -63,7 +66,8 @@ class _VerificationPageState extends State { if (_serverKey == null) { _serverKey = auth.serverKey; - _email.text = auth.pendingVerification?.email ?? ''; + _recipient = auth.pendingVerification?.email; + _email.text = _recipient ?? ''; if (widget.isLink && !_invalidLink) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -75,6 +79,8 @@ class _VerificationPageState extends State { } else if (_serverKey != auth.serverKey) { _token = null; _email.clear(); + _recipient = null; + _editingEmail = false; _serverChanged = true; _message = null; } @@ -89,6 +95,7 @@ class _VerificationPageState extends State { } _timer?.cancel(); + _emailFocus.dispose(); _email.dispose(); super.dispose(); } @@ -135,18 +142,21 @@ class _VerificationPageState extends State { }); } + bool get _needsEmail => _recipient == null || _editingEmail || _invalidLink; + Future _resend() async { if (_busy || _wrongServer || _secondsUntil(_auth!.resendAt) > 0 || _secondsUntil(_auth!.retryAt) > 0) { return; } - if (!validateAndFocusForm(_formKey.currentState!)) { + if (_needsEmail && !validateAndFocusForm(_formKey.currentState!)) { return; } FocusScope.of(context).unfocus(); setState(() => _busy = true); - final message = await _auth!.resendVerification(_email.text.trim()); + final recipient = _email.text.trim(); + final message = await _auth!.resendVerification(recipient); if (!mounted || _wrongServer) { return; @@ -154,7 +164,14 @@ class _VerificationPageState extends State { setState(() { _busy = false; - _message = message == null ? null : 'Check your inbox and spam folder for a new link. It may take a few minutes.'; + _message = message == null ? null : 'New link requested. Check your inbox.'; + + if (message != null) { + _recipient = recipient; + _email.text = recipient; + _editingEmail = false; + _invalidLink = false; + } }); } @@ -163,99 +180,122 @@ class _VerificationPageState extends State { context.go('/login'); } - @override - Widget build(BuildContext context) { - final auth = context.watch(); - final remaining = _secondsUntil(auth.resendAt); - final retry = _secondsUntil(auth.retryAt); - final hasVerificationToken = widget.isLink && !_verified && !_invalidLink && !_wrongServer; - final String heading; - final String subtitle; - - if (_verified) { - heading = 'Email verified'; - subtitle = 'You can now sign in to Papyrus.'; - } else if (hasVerificationToken) { - heading = _verificationFailed && !_busy ? 'Verification failed' : 'Verifying your email'; - subtitle = _verificationFailed && !_busy ? 'Please try again.' : 'Please wait…'; - } else if (_invalidLink) { - heading = 'Request a new link'; - subtitle = 'This link cannot be used. Request a new one, or sign in if you have already verified your email.'; - } else { - heading = 'Check your email'; - subtitle = 'Open the verification link in your email, then return here to sign in.'; - } - - final resendLabel = switch (remaining) { + Widget _buildDeliveryForm(AuthProvider auth, int remaining, int retry) { + final theme = Theme.of(context); + final needsEmail = _needsEmail; + final blocked = _busy || remaining > 0 || retry > 0; + final seconds = remaining > retry ? remaining : retry; + final actionLabel = switch (seconds) { _ when _busy => 'Requesting link…', - > 0 => 'Resend in ${remaining}s', - _ => 'Resend verification email', + > 0 => 'Resend in ${seconds}s', + _ => needsEmail ? 'Send verification link' : 'Resend email', }; - const buttonSize = Size.fromHeight(ComponentSizes.buttonHeightMobile); + final heading = needsEmail ? 'Request a new link' : 'Check your email'; + final guidance = _invalidLink + ? 'This link cannot be used. Request a new one, or sign in if you have already verified your email.' + : 'Enter your email to get a verification link.'; - final form = Column( + return Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ - if (_wrongServer) - const Text( - 'This link is for a different server. Open it in the Papyrus app for that server.', - ), - if (_message != null && !_verified) ...[ - Semantics(liveRegion: true, child: Text(_message!)), - const SizedBox(height: Spacing.md), - ], - if (auth.error != null && !_verified && !_wrongServer && auth.errorCode != 'VERIFICATION_TOKEN_INVALID') ...[ + Semantics( + header: true, + child: Text(heading, style: theme.textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.w600)), + ), + const SizedBox(height: Spacing.sm), + if (needsEmail) ...[ Semantics( liveRegion: true, - child: Text(auth.error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), - ), - const SizedBox(height: Spacing.md), - ], - if (retry > 0) ...[ - Text('Try again in ${retry}s.'), - const SizedBox(height: Spacing.md), - ], - if (hasVerificationToken && _verificationFailed && !_busy) ...[ - FilledButton( - style: FilledButton.styleFrom(minimumSize: buttonSize), - autofocus: true, - onPressed: _busy || retry > 0 ? null : _verify, - child: const Text('Try again'), + child: Text( + guidance, + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), ), - const SizedBox(height: Spacing.md), - ], - if (!_verified && !_wrongServer && !hasVerificationToken) ...[ - const SizedBox(height: Spacing.md), + const SizedBox(height: Spacing.lg), Form( key: _formKey, child: EmailInput( labelText: 'Email address', controller: _email, + focusNode: _emailFocus, textInputAction: TextInputAction.done, onEditingComplete: _resend, ), ), const SizedBox(height: Spacing.md), - OutlinedButton( - style: OutlinedButton.styleFrom(minimumSize: buttonSize), - onPressed: _busy || remaining > 0 || retry > 0 ? null : _resend, - child: Text(resendLabel), + ] else ...[ + Text( + 'Open the link sent to', + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + const SizedBox(height: Spacing.xs), + Text(_recipient!, style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600)), + Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton( + onPressed: _busy + ? null + : () => setState(() { + _editingEmail = true; + _message = null; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _emailFocus.requestFocus(); + } + }); + }), + child: const Text('Use another email'), + ), ), const SizedBox(height: Spacing.lg), + Semantics( + liveRegion: _message != null, + child: Text( + _message ?? 'Check your spam folder if it hasn’t arrived.', + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ), ], - if (hasVerificationToken) - OutlinedButton( - style: OutlinedButton.styleFrom(minimumSize: buttonSize), - onPressed: _signIn, - child: const Text('Back to sign in'), + if (auth.error != null && + auth.errorCode != 'VERIFICATION_TOKEN_INVALID' && + auth.errorCode != 'EMAIL_VERIFICATION_REQUIRED') ...[ + const SizedBox(height: Spacing.sm), + Semantics( + liveRegion: true, + child: Text(auth.error!, style: TextStyle(color: theme.colorScheme.error)), + ), + ], + if (needsEmail) + FilledButton( + style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(ComponentSizes.buttonHeightMobile)), + onPressed: blocked ? null : _resend, + child: Text(actionLabel), ) else + Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton(onPressed: blocked ? null : _resend, child: Text(actionLabel)), + ), + const SizedBox(height: Spacing.lg), + if (!needsEmail) FilledButton( - style: FilledButton.styleFrom(minimumSize: buttonSize), + style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(ComponentSizes.buttonHeightMobile)), onPressed: _signIn, - child: Text(_verified ? 'Sign in' : 'Back to sign in'), - ), + child: const Text('Sign in'), + ) + else if (_editingEmail && _recipient != null) + TextButton( + onPressed: _busy + ? null + : () => setState(() { + _email.text = _recipient!; + _editingEmail = false; + }), + child: const Text('Cancel'), + ) + else + TextButton(onPressed: _signIn, child: const Text('Back to sign in')), const SizedBox(height: Spacing.sm), TextButton(onPressed: () => navigateToOffline(context), child: const Text('Continue offline')), if (auth.hasOfflineAccount) @@ -268,12 +308,113 @@ class _VerificationPageState extends State { ), ], ); + } + + @override + Widget build(BuildContext context) { + final auth = context.watch(); + final remaining = _secondsUntil(auth.resendAt); + final retry = _secondsUntil(auth.retryAt); + final hasVerificationToken = widget.isLink && !_verified && !_invalidLink && !_wrongServer; + final String heading; + final String subtitle; + + if (_verified) { + heading = 'Email verified'; + subtitle = 'You can now sign in to Papyrus.'; + } else if (hasVerificationToken) { + heading = _verificationFailed && !_busy ? 'Verification failed' : 'Verifying your email'; + subtitle = _verificationFailed && !_busy ? 'Please try again.' : 'Please wait…'; + } else if (_invalidLink) { + heading = 'Request a new link'; + subtitle = 'This link cannot be used. Request a new one, or sign in if you have already verified your email.'; + } else { + heading = 'Check your email'; + subtitle = 'Open the verification link in your email, then return here to sign in.'; + } + + const buttonSize = Size.fromHeight(ComponentSizes.buttonHeightMobile); + + final deliveryView = !_verified && !_wrongServer && !hasVerificationToken; + final form = deliveryView + ? _buildDeliveryForm(auth, remaining, retry) + : Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + if (_wrongServer) + const Text( + 'This link is for a different server. Open it in the Papyrus app for that server.', + ), + if (_message != null && !_verified) ...[ + Semantics(liveRegion: true, child: Text(_message!)), + const SizedBox(height: Spacing.md), + ], + if (auth.error != null && + !_verified && + !_wrongServer && + auth.errorCode != 'VERIFICATION_TOKEN_INVALID') ...[ + Semantics( + liveRegion: true, + child: Text(auth.error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + ), + const SizedBox(height: Spacing.md), + ], + if (retry > 0) ...[ + Text('Try again in ${retry}s.'), + const SizedBox(height: Spacing.md), + ], + if (hasVerificationToken && _verificationFailed && !_busy) ...[ + FilledButton( + style: FilledButton.styleFrom(minimumSize: buttonSize), + autofocus: true, + onPressed: _busy || retry > 0 ? null : _verify, + child: const Text('Try again'), + ), + const SizedBox(height: Spacing.md), + ], + if (hasVerificationToken) + OutlinedButton( + style: OutlinedButton.styleFrom(minimumSize: buttonSize), + onPressed: _signIn, + child: const Text('Back to sign in'), + ) + else + FilledButton( + style: FilledButton.styleFrom(minimumSize: buttonSize), + onPressed: _signIn, + child: Text(_verified ? 'Sign in' : 'Back to sign in'), + ), + const SizedBox(height: Spacing.sm), + TextButton(onPressed: () => navigateToOffline(context), child: const Text('Continue offline')), + if (auth.hasOfflineAccount) + TextButton( + onPressed: () { + auth.cancelAuthentication(); + context.goNamed('LIBRARY'); + }, + child: const Text('Read downloaded books'), + ), + ], + ); return ResponsiveBuilder( - mobile: (context) => - MobileAuthLayout(heading: heading, subtitle: subtitle, announceSubtitle: true, form: form, footer: const []), - desktop: (context) => - DesktopAuthLayout(heading: heading, subtitle: subtitle, announceSubtitle: true, form: form, footer: const []), + mobile: (context) => MobileAuthLayout( + heading: heading, + subtitle: subtitle, + showHeader: !deliveryView, + announceSubtitle: true, + form: form, + footer: const [], + ), + desktop: (context) => DesktopAuthLayout( + heading: heading, + subtitle: subtitle, + showHeader: !deliveryView, + announceSubtitle: true, + form: form, + footer: const [], + ), ); } } diff --git a/app/test/widgets/auth/verification_page_test.dart b/app/test/widgets/auth/verification_page_test.dart index 8a26b413..559ba323 100644 --- a/app/test/widgets/auth/verification_page_test.dart +++ b/app/test/widgets/auth/verification_page_test.dart @@ -23,6 +23,8 @@ void main() { bool isLink = true, Size size = const Size(390, 844), double scale = 1, + String? pendingEmail, + ThemeData? theme, }) async { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; @@ -30,9 +32,26 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); final auth = AuthProvider( await SharedPreferences.getInstance(), - repository: repository(handler), + repository: repository((request) { + if (pendingEmail != null && request.url.path.endsWith('/register')) { + return Future.value( + response({ + 'status': 'verification_required', + 'email': pendingEmail, + 'message': 'Check your email', + 'resend_after': 0, + }, 201), + ); + } + + return handler(request); + }), bootstrapOnCreate: false, ); + if (pendingEmail != null) { + await auth.register(email: pendingEmail, password: 'password', displayName: 'Reader'); + } + final router = GoRouter( initialLocation: '/verify-email', routes: [ @@ -56,7 +75,7 @@ void main() { value: auth, child: MaterialApp.router( routerConfig: router, - theme: AppTheme.light, + theme: theme ?? AppTheme.light, builder: (context, child) => MediaQuery( data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(scale)), child: child!, @@ -125,7 +144,7 @@ void main() { }, 400), token: 'expired', ); - expect(find.text('Resend verification email'), findsOneWidget); + expect(find.text('Send verification link'), findsOneWidget); expect( tester .getSemantics(find.textContaining('This link cannot be used')) @@ -190,13 +209,25 @@ void main() { ); await tester.enterText(find.byType(TextFormField), 'alice@example.com'); await tester.pumpAndSettle(); - await tester.ensureVisible(find.widgetWithText(OutlinedButton, 'Resend verification email')); + await tester.ensureVisible(find.widgetWithText(FilledButton, 'Send verification link')); await tester.pumpAndSettle(); - await tester.tap(find.text('Resend verification email')); + await tester.tap(find.text('Send verification link')); await tester.pumpAndSettle(); expect(calls, 1); expect(find.textContaining('Resend in'), findsOneWidget); - expect(tester.widget(find.byType(OutlinedButton)).onPressed, isNull); + expect( + tester + .widget( + find + .ancestor( + of: find.textContaining('Resend in'), + matching: find.byWidgetPredicate((widget) => widget is ButtonStyleButton), + ) + .first, + ) + .onPressed, + isNull, + ); expect(tester.takeException(), isNull); }); @@ -214,12 +245,24 @@ void main() { ); await tester.enterText(find.byType(TextFormField), 'alice@example.com'); await tester.pumpAndSettle(); - await tester.ensureVisible(find.widgetWithText(OutlinedButton, 'Resend verification email')); + await tester.ensureVisible(find.widgetWithText(FilledButton, 'Send verification link')); await tester.pumpAndSettle(); - await tester.tap(find.text('Resend verification email')); + await tester.tap(find.text('Send verification link')); await tester.pumpAndSettle(); - expect(find.textContaining('Try again in'), findsOneWidget); - expect(tester.widget(find.byType(OutlinedButton)).onPressed, isNull); + expect(find.textContaining('Resend in'), findsOneWidget); + expect( + tester + .widget( + find + .ancestor( + of: find.textContaining('Resend in'), + matching: find.byWidgetPredicate((widget) => widget is ButtonStyleButton), + ) + .first, + ) + .onPressed, + isNull, + ); }); testWidgets('resend delivery failure remains visible after an invalid link', (tester) async { @@ -244,16 +287,94 @@ void main() { }, token: 'expired'); await tester.enterText(find.byType(TextFormField), 'alice@example.com'); await tester.pumpAndSettle(); - await tester.ensureVisible(find.widgetWithText(OutlinedButton, 'Resend verification email')); + await tester.ensureVisible(find.widgetWithText(FilledButton, 'Send verification link')); await tester.pumpAndSettle(); - await tester.tap(find.text('Resend verification email')); + await tester.tap(find.text('Send verification link')); await tester.pumpAndSettle(); expect( find.text('Couldn’t send the verification email. Please try again.'), findsOneWidget, ); - expect(find.textContaining('Try again in'), findsOneWidget); - expect(tester.widget(find.byType(OutlinedButton)).onPressed, isNull); + expect(find.textContaining('Resend in'), findsOneWidget); + expect( + tester + .widget( + find + .ancestor( + of: find.textContaining('Resend in'), + matching: find.byWidgetPredicate((widget) => widget is ButtonStyleButton), + ) + .first, + ) + .onPressed, + isNull, + ); + }); + + for (final theme in [AppTheme.light, AppTheme.dark, AppTheme.eink]) { + testWidgets( + 'waiting state keeps the address readable and secondary actions compact in ${theme.brightness} / ${theme.colorScheme.primary}', + (tester) async { + final semantics = tester.ensureSemantics(); + var calls = 0; + const address = 'reader.with.a.long.address@example.com'; + await mount( + tester, + (request) async { + calls += 1; + expect(jsonDecode(request.body), {'email': address}); + return response({'message': 'Request accepted', 'resend_after': 60}); + }, + isLink: false, + pendingEmail: address, + size: const Size(320, 700), + scale: 2, + theme: theme, + ); + expect(find.text('Check your email'), findsOneWidget); + expect(find.text(address), findsOneWidget); + expect(find.byType(TextFormField), findsNothing); + expect(find.byType(OutlinedButton), findsNothing); + expect(find.widgetWithText(FilledButton, 'Sign in'), findsOneWidget); + await tester.ensureVisible(find.widgetWithText(TextButton, 'Resend email')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Resend email')); + await tester.pumpAndSettle(); + expect(calls, 1); + expect(find.byType(TextFormField), findsNothing); + expect(find.text('New link requested. Check your inbox.'), findsOneWidget); + expect( + tester + .getSemantics(find.text('New link requested. Check your inbox.')) + .getSemanticsData() + .flagsCollection + .isLiveRegion, + isTrue, + ); + expect(tester.takeException(), isNull); + semantics.dispose(); + }, + ); + } + + testWidgets('requesting another address is explicit and cancelling restores the recipient', (tester) async { + await mount( + tester, + (_) async => fail('Editing must not send a request'), + isLink: false, + pendingEmail: 'reader@example.com', + ); + await tester.tap(find.text('Use another email')); + await tester.pumpAndSettle(); + expect(find.text('Request a new link'), findsOneWidget); + expect(find.byType(TextFormField), findsOneWidget); + await tester.enterText(find.byType(TextFormField), 'other@example.com'); + await tester.ensureVisible(find.text('Cancel')); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(find.text('reader@example.com'), findsOneWidget); + expect(find.text('other@example.com'), findsNothing); + expect(find.byType(TextFormField), findsNothing); }); testWidgets('server change discards the captured token and pending email', (tester) async { From b56c40a242d5e1bca9cd834b4a9dbd5454c36b81 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Sat, 7 Nov 2026 02:04:56 +0200 Subject: [PATCH 7/8] Simplify email confirmation flow and bound secondary actions --- app/lib/pages/auth/verification_page.dart | 77 +++++++------------ .../widgets/auth/verification_page_test.dart | 29 ++----- 2 files changed, 32 insertions(+), 74 deletions(-) diff --git a/app/lib/pages/auth/verification_page.dart b/app/lib/pages/auth/verification_page.dart index 1c73299a..e5f16c94 100644 --- a/app/lib/pages/auth/verification_page.dart +++ b/app/lib/pages/auth/verification_page.dart @@ -27,14 +27,12 @@ class VerificationPage extends StatefulWidget { class _VerificationPageState extends State { final _formKey = GlobalKey(); final _email = TextEditingController(); - final _emailFocus = FocusNode(); Timer? _timer; AuthProvider? _auth; String? _serverKey; String? _token; String? _message; String? _recipient; - bool _editingEmail = false; bool _busy = false; bool _verified = false; bool _verificationFailed = false; @@ -80,7 +78,6 @@ class _VerificationPageState extends State { _token = null; _email.clear(); _recipient = null; - _editingEmail = false; _serverChanged = true; _message = null; } @@ -95,7 +92,6 @@ class _VerificationPageState extends State { } _timer?.cancel(); - _emailFocus.dispose(); _email.dispose(); super.dispose(); } @@ -142,7 +138,7 @@ class _VerificationPageState extends State { }); } - bool get _needsEmail => _recipient == null || _editingEmail || _invalidLink; + bool get _needsEmail => _recipient == null || _invalidLink; Future _resend() async { if (_busy || _wrongServer || _secondsUntil(_auth!.resendAt) > 0 || _secondsUntil(_auth!.retryAt) > 0) { @@ -169,7 +165,6 @@ class _VerificationPageState extends State { if (message != null) { _recipient = recipient; _email.text = recipient; - _editingEmail = false; _invalidLink = false; } }); @@ -218,7 +213,6 @@ class _VerificationPageState extends State { child: EmailInput( labelText: 'Email address', controller: _email, - focusNode: _emailFocus, textInputAction: TextInputAction.done, onEditingComplete: _resend, ), @@ -231,23 +225,6 @@ class _VerificationPageState extends State { ), const SizedBox(height: Spacing.xs), Text(_recipient!, style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600)), - Align( - alignment: AlignmentDirectional.centerStart, - child: TextButton( - onPressed: _busy - ? null - : () => setState(() { - _editingEmail = true; - _message = null; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _emailFocus.requestFocus(); - } - }); - }), - child: const Text('Use another email'), - ), - ), const SizedBox(height: Spacing.lg), Semantics( liveRegion: _message != null, @@ -266,6 +243,7 @@ class _VerificationPageState extends State { child: Text(auth.error!, style: TextStyle(color: theme.colorScheme.error)), ), ], + const SizedBox(height: Spacing.md), if (needsEmail) FilledButton( style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(ComponentSizes.buttonHeightMobile)), @@ -273,39 +251,34 @@ class _VerificationPageState extends State { child: Text(actionLabel), ) else - Align( - alignment: AlignmentDirectional.centerStart, - child: TextButton(onPressed: blocked ? null : _resend, child: Text(actionLabel)), + OutlinedButton( + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(ComponentSizes.buttonHeightMobile), + disabledForegroundColor: theme.colorScheme.onSurfaceVariant, + side: + theme.outlinedButtonTheme.style?.side?.resolve(const {}) ?? + BorderSide(color: theme.colorScheme.outline), + ), + onPressed: blocked ? null : _resend, + child: Text(actionLabel), ), const SizedBox(height: Spacing.lg), - if (!needsEmail) - FilledButton( - style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(ComponentSizes.buttonHeightMobile)), - onPressed: _signIn, - child: const Text('Sign in'), - ) - else if (_editingEmail && _recipient != null) - TextButton( - onPressed: _busy - ? null - : () => setState(() { - _email.text = _recipient!; - _editingEmail = false; - }), - child: const Text('Cancel'), - ) - else - TextButton(onPressed: _signIn, child: const Text('Back to sign in')), + OutlinedButton( + onPressed: _signIn, + child: const Text('Back to sign in'), + ), const SizedBox(height: Spacing.sm), - TextButton(onPressed: () => navigateToOffline(context), child: const Text('Continue offline')), - if (auth.hasOfflineAccount) - TextButton( + OutlinedButton(onPressed: () => navigateToOffline(context), child: const Text('Continue offline')), + if (auth.hasOfflineAccount) ...[ + const SizedBox(height: Spacing.sm), + OutlinedButton( onPressed: () { auth.cancelAuthentication(); context.goNamed('LIBRARY'); }, child: const Text('Read downloaded books'), ), + ], ], ); } @@ -386,15 +359,17 @@ class _VerificationPageState extends State { child: Text(_verified ? 'Sign in' : 'Back to sign in'), ), const SizedBox(height: Spacing.sm), - TextButton(onPressed: () => navigateToOffline(context), child: const Text('Continue offline')), - if (auth.hasOfflineAccount) - TextButton( + OutlinedButton(onPressed: () => navigateToOffline(context), child: const Text('Continue offline')), + if (auth.hasOfflineAccount) ...[ + const SizedBox(height: Spacing.sm), + OutlinedButton( onPressed: () { auth.cancelAuthentication(); context.goNamed('LIBRARY'); }, child: const Text('Read downloaded books'), ), + ], ], ); diff --git a/app/test/widgets/auth/verification_page_test.dart b/app/test/widgets/auth/verification_page_test.dart index 559ba323..fcd77361 100644 --- a/app/test/widgets/auth/verification_page_test.dart +++ b/app/test/widgets/auth/verification_page_test.dart @@ -334,9 +334,12 @@ void main() { expect(find.text('Check your email'), findsOneWidget); expect(find.text(address), findsOneWidget); expect(find.byType(TextFormField), findsNothing); - expect(find.byType(OutlinedButton), findsNothing); - expect(find.widgetWithText(FilledButton, 'Sign in'), findsOneWidget); - await tester.ensureVisible(find.widgetWithText(TextButton, 'Resend email')); + expect(find.byType(TextButton), findsNothing); + expect(find.byType(FilledButton), findsNothing); + expect(find.text('Use another email'), findsNothing); + expect(find.widgetWithText(OutlinedButton, 'Back to sign in'), findsOneWidget); + expect(find.widgetWithText(OutlinedButton, 'Continue offline'), findsOneWidget); + await tester.ensureVisible(find.widgetWithText(OutlinedButton, 'Resend email')); await tester.pumpAndSettle(); await tester.tap(find.text('Resend email')); await tester.pumpAndSettle(); @@ -357,26 +360,6 @@ void main() { ); } - testWidgets('requesting another address is explicit and cancelling restores the recipient', (tester) async { - await mount( - tester, - (_) async => fail('Editing must not send a request'), - isLink: false, - pendingEmail: 'reader@example.com', - ); - await tester.tap(find.text('Use another email')); - await tester.pumpAndSettle(); - expect(find.text('Request a new link'), findsOneWidget); - expect(find.byType(TextFormField), findsOneWidget); - await tester.enterText(find.byType(TextFormField), 'other@example.com'); - await tester.ensureVisible(find.text('Cancel')); - await tester.tap(find.text('Cancel')); - await tester.pumpAndSettle(); - expect(find.text('reader@example.com'), findsOneWidget); - expect(find.text('other@example.com'), findsNothing); - expect(find.byType(TextFormField), findsNothing); - }); - testWidgets('server change discards the captured token and pending email', (tester) async { var calls = 0; final completed = Completer(); From 7cd627d48b67155aa42cec30b7d89c196db2e8a1 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Sat, 7 Nov 2026 21:44:02 +0200 Subject: [PATCH 8/8] Polish verification states and guard cancelled credential cleanup --- app/lib/auth/auth_api_client.dart | 6 +- app/lib/auth/auth_repository.dart | 19 ++- app/lib/pages/auth/verification_page.dart | 123 ++++++++++-------- app/lib/pages/register_page.dart | 5 - app/lib/widgets/auth/auth_page_layouts.dart | 63 ++++++++- app/test/auth/pending_verification_test.dart | 62 +++++++-- .../widgets/auth/verification_page_test.dart | 77 ++++++----- 7 files changed, 231 insertions(+), 124 deletions(-) diff --git a/app/lib/auth/auth_api_client.dart b/app/lib/auth/auth_api_client.dart index e575f7da..9cb38219 100644 --- a/app/lib/auth/auth_api_client.dart +++ b/app/lib/auth/auth_api_client.dart @@ -92,7 +92,7 @@ class AuthApiClient { return PendingVerification.fromJson(json); } - // During rollout an older server may still issue an unverified session. + // Revoke unexpected credentials without installing an authenticated session. final accessToken = json['access_token']; if (accessToken is String && accessToken.isNotEmpty) { @@ -103,10 +103,6 @@ class AuthApiClient { } } - if (json['user'] is Map && json['user']['email_verified'] == false) { - return PendingVerification(email: email.trim().toLowerCase(), resendAfter: 0); - } - throw const FormatException('Expected pending registration'); } diff --git a/app/lib/auth/auth_repository.dart b/app/lib/auth/auth_repository.dart index 71c6cbc6..695ce732 100644 --- a/app/lib/auth/auth_repository.dart +++ b/app/lib/auth/auth_repository.dart @@ -120,11 +120,7 @@ class AuthRepository { return tokens; } catch (error) { if (generation == _tokenGeneration) { - await clearTokens(); - - if (error is AuthApiException && error.code == 'EMAIL_VERIFICATION_REQUIRED') { - onVerificationRequired?.call(error); - } + await _clearRejectedCredentials(error); } rethrow; @@ -309,6 +305,16 @@ class AuthRepository { return _serializeCredentials(tokenStore.clear); } + Future _clearRejectedCredentials(Object error) async { + final clearing = clearTokens(); + final generation = _tokenGeneration; + await clearing; + + if (generation == _tokenGeneration && error is AuthApiException && error.code == 'EMAIL_VERIFICATION_REQUIRED') { + onVerificationRequired?.call(error); + } + } + Future withFreshAccessToken(Future Function(String accessToken) action) { return _withFreshAccessToken(action); } @@ -387,8 +393,7 @@ class AuthRepository { } } on AuthApiException catch (error) { if (generation == _tokenGeneration && error.code == 'EMAIL_VERIFICATION_REQUIRED') { - await clearTokens(); - onVerificationRequired?.call(error); + await _clearRejectedCredentials(error); } rethrow; diff --git a/app/lib/pages/auth/verification_page.dart b/app/lib/pages/auth/verification_page.dart index e5f16c94..dcdb03da 100644 --- a/app/lib/pages/auth/verification_page.dart +++ b/app/lib/pages/auth/verification_page.dart @@ -138,7 +138,7 @@ class _VerificationPageState extends State { }); } - bool get _needsEmail => _recipient == null || _invalidLink; + bool get _needsEmail => _recipient == null; Future _resend() async { if (_busy || _wrongServer || _secondsUntil(_auth!.resendAt) > 0 || _secondsUntil(_auth!.retryAt) > 0) { @@ -186,9 +186,7 @@ class _VerificationPageState extends State { _ => needsEmail ? 'Send verification link' : 'Resend email', }; final heading = needsEmail ? 'Request a new link' : 'Check your email'; - final guidance = _invalidLink - ? 'This link cannot be used. Request a new one, or sign in if you have already verified your email.' - : 'Enter your email to get a verification link.'; + const guidance = 'Enter your email to get a verification link.'; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -219,20 +217,29 @@ class _VerificationPageState extends State { ), const SizedBox(height: Spacing.md), ] else ...[ - Text( - 'Open the link sent to', + Text.rich( + TextSpan( + children: [ + const TextSpan(text: 'Open the link sent to '), + TextSpan( + text: _recipient!, + style: TextStyle(fontWeight: FontWeight.w600, color: theme.colorScheme.onSurface), + ), + const TextSpan(text: '. Check your spam folder if it hasn’t arrived.'), + ], + ), style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), ), - const SizedBox(height: Spacing.xs), - Text(_recipient!, style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600)), - const SizedBox(height: Spacing.lg), - Semantics( - liveRegion: _message != null, - child: Text( - _message ?? 'Check your spam folder if it hasn’t arrived.', - style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + if (_message != null) ...[ + const SizedBox(height: Spacing.sm), + Semantics( + liveRegion: true, + child: Text( + _message!, + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), ), - ), + ], ], if (auth.error != null && auth.errorCode != 'VERIFICATION_TOKEN_INVALID' && @@ -244,31 +251,19 @@ class _VerificationPageState extends State { ), ], const SizedBox(height: Spacing.md), - if (needsEmail) - FilledButton( - style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(ComponentSizes.buttonHeightMobile)), - onPressed: blocked ? null : _resend, - child: Text(actionLabel), - ) - else - OutlinedButton( - style: OutlinedButton.styleFrom( - minimumSize: const Size.fromHeight(ComponentSizes.buttonHeightMobile), - disabledForegroundColor: theme.colorScheme.onSurfaceVariant, - side: - theme.outlinedButtonTheme.style?.side?.resolve(const {}) ?? - BorderSide(color: theme.colorScheme.outline), - ), - onPressed: blocked ? null : _resend, - child: Text(actionLabel), + FilledButton( + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(ComponentSizes.buttonHeightMobile), + disabledForegroundColor: theme.colorScheme.onSurfaceVariant, ), - const SizedBox(height: Spacing.lg), + onPressed: blocked ? null : _resend, + child: Text(actionLabel), + ), + const SizedBox(height: Spacing.sm), OutlinedButton( onPressed: _signIn, child: const Text('Back to sign in'), ), - const SizedBox(height: Spacing.sm), - OutlinedButton(onPressed: () => navigateToOffline(context), child: const Text('Continue offline')), if (auth.hasOfflineAccount) ...[ const SizedBox(height: Spacing.sm), OutlinedButton( @@ -299,8 +294,8 @@ class _VerificationPageState extends State { heading = _verificationFailed && !_busy ? 'Verification failed' : 'Verifying your email'; subtitle = _verificationFailed && !_busy ? 'Please try again.' : 'Please wait…'; } else if (_invalidLink) { - heading = 'Request a new link'; - subtitle = 'This link cannot be used. Request a new one, or sign in if you have already verified your email.'; + heading = 'Link unavailable'; + subtitle = 'This link has expired or is no longer valid.'; } else { heading = 'Check your email'; subtitle = 'Open the verification link in your email, then return here to sign in.'; @@ -308,7 +303,7 @@ class _VerificationPageState extends State { const buttonSize = Size.fromHeight(ComponentSizes.buttonHeightMobile); - final deliveryView = !_verified && !_wrongServer && !hasVerificationToken; + final deliveryView = !_verified && !_invalidLink && !_wrongServer && !hasVerificationToken; final form = deliveryView ? _buildDeliveryForm(auth, remaining, retry) : Column( @@ -333,7 +328,7 @@ class _VerificationPageState extends State { ), const SizedBox(height: Spacing.md), ], - if (retry > 0) ...[ + if (retry > 0 && !_invalidLink) ...[ Text('Try again in ${retry}s.'), const SizedBox(height: Spacing.md), ], @@ -358,9 +353,11 @@ class _VerificationPageState extends State { onPressed: _signIn, child: Text(_verified ? 'Sign in' : 'Back to sign in'), ), - const SizedBox(height: Spacing.sm), - OutlinedButton(onPressed: () => navigateToOffline(context), child: const Text('Continue offline')), - if (auth.hasOfflineAccount) ...[ + if (!_invalidLink) ...[ + const SizedBox(height: Spacing.sm), + OutlinedButton(onPressed: () => navigateToOffline(context), child: const Text('Continue offline')), + ], + if (auth.hasOfflineAccount && !_invalidLink) ...[ const SizedBox(height: Spacing.sm), OutlinedButton( onPressed: () { @@ -373,22 +370,34 @@ class _VerificationPageState extends State { ], ); - return ResponsiveBuilder( - mobile: (context) => MobileAuthLayout( - heading: heading, - subtitle: subtitle, - showHeader: !deliveryView, - announceSubtitle: true, - form: form, - footer: const [], + final theme = Theme.of(context); + final buttonShape = theme.filledButtonTheme.style?.shape ?? const WidgetStatePropertyAll(StadiumBorder()); + + return OutlinedButtonTheme( + data: OutlinedButtonThemeData( + style: (theme.outlinedButtonTheme.style ?? const ButtonStyle()).copyWith(shape: buttonShape), ), - desktop: (context) => DesktopAuthLayout( - heading: heading, - subtitle: subtitle, - showHeader: !deliveryView, - announceSubtitle: true, - form: form, - footer: const [], + child: ResponsiveBuilder( + mobile: (context) => MobileAuthLayout( + heading: heading, + headingIcon: _verified ? Icons.check_circle_outline : null, + subtitle: subtitle, + showHeader: !deliveryView, + announceSubtitle: true, + headerBottomSpacing: Spacing.md, + form: form, + footer: const [], + ), + desktop: (context) => DesktopAuthLayout( + heading: heading, + headingIcon: _verified ? Icons.check_circle_outline : null, + subtitle: subtitle, + showHeader: !deliveryView, + announceSubtitle: true, + headerBottomSpacing: Spacing.md, + form: form, + footer: const [], + ), ), ); } diff --git a/app/lib/pages/register_page.dart b/app/lib/pages/register_page.dart index ae1168bd..d1beb37c 100644 --- a/app/lib/pages/register_page.dart +++ b/app/lib/pages/register_page.dart @@ -149,11 +149,6 @@ class _RegisterPageState extends State { actionText: 'Sign in', onPressed: () => navigateToLogin(context), ), - AuthSwitchLink( - promptText: 'Waiting for a link?', - actionText: 'Verify email', - onPressed: () => context.push('/verification'), - ), AuthSwitchLink( promptText: 'No internet?', actionText: 'Continue offline', diff --git a/app/lib/widgets/auth/auth_page_layouts.dart b/app/lib/widgets/auth/auth_page_layouts.dart index 16da2f88..fe5ffacb 100644 --- a/app/lib/widgets/auth/auth_page_layouts.dart +++ b/app/lib/widgets/auth/auth_page_layouts.dart @@ -5,6 +5,7 @@ import 'package:papyrus/widgets/auth/auth_hero_panel.dart'; /// Mobile auth layout with a scrolling hero and a form that fills available space. class MobileAuthLayout extends StatelessWidget { final String heading; + final IconData? headingIcon; final String subtitle; /// The form widget (login form, register form, etc.) @@ -19,16 +20,19 @@ class MobileAuthLayout extends StatelessWidget { /// Whether to show branding, heading, and subtitle above the form. final bool showHeader; final bool announceSubtitle; + final double headerBottomSpacing; const MobileAuthLayout({ super.key, required this.heading, + this.headingIcon, required this.subtitle, required this.form, required this.footer, this.bottomActions = const [], this.showHeader = true, this.announceSubtitle = false, + this.headerBottomSpacing = Spacing.lg, }); @override @@ -57,8 +61,9 @@ class MobileAuthLayout extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (showHeader) ...[ - Text( - heading, + _AuthHeading( + text: heading, + icon: headingIcon, style: theme.textTheme.headlineMedium?.copyWith( fontWeight: FontWeight.w600, color: theme.colorScheme.onSurface, @@ -71,7 +76,7 @@ class MobileAuthLayout extends StatelessWidget { style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), ), ), - const SizedBox(height: Spacing.lg), + SizedBox(height: headerBottomSpacing), ], form, ...footer, @@ -94,6 +99,7 @@ class MobileAuthLayout extends StatelessWidget { /// Includes a swap button to flip hero/form positions. class DesktopAuthLayout extends StatefulWidget { final String heading; + final IconData? headingIcon; final String subtitle; /// The form widget (login form, register form, etc.) @@ -105,15 +111,18 @@ class DesktopAuthLayout extends StatefulWidget { /// Whether to show branding, heading, and subtitle above the form. final bool showHeader; final bool announceSubtitle; + final double headerBottomSpacing; const DesktopAuthLayout({ super.key, required this.heading, + this.headingIcon, required this.subtitle, required this.form, required this.footer, this.showHeader = true, this.announceSubtitle = false, + this.headerBottomSpacing = Spacing.xl, }); /// Persists swap state across page navigations within the same session. @@ -158,8 +167,9 @@ class _DesktopAuthLayoutState extends State { if (widget.showHeader) ...[ // const AuthBranding(), const SizedBox(height: Spacing.md), - Text( - widget.heading, + _AuthHeading( + text: widget.heading, + icon: widget.headingIcon, style: theme.textTheme.headlineLarge?.copyWith( fontWeight: FontWeight.w600, color: theme.colorScheme.onSurface, @@ -172,7 +182,7 @@ class _DesktopAuthLayoutState extends State { style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), ), ), - const SizedBox(height: Spacing.xl), + SizedBox(height: widget.headerBottomSpacing), ], widget.form, ...widget.footer, @@ -261,3 +271,44 @@ class _SwapPanelsButton extends StatelessWidget { ); } } + +class _AuthHeading extends StatelessWidget { + final String text; + final IconData? icon; + final TextStyle? style; + + const _AuthHeading({required this.text, required this.style, this.icon}); + + @override + Widget build(BuildContext context) { + final heading = Text(text, style: style); + final textScaler = MediaQuery.textScalerOf(context); + final iconSize = textScaler.scale((style?.fontSize ?? 24) + Spacing.xs); + + return Semantics( + header: true, + child: icon == null + ? heading + : Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + ExcludeSemantics( + child: Transform.translate( + // Account for the circle inset and the heading font’s optical baseline. + offset: Offset(0, iconSize * 2 / 24 + textScaler.scale(2)), + child: Icon( + icon, + size: iconSize, + applyTextScaling: false, + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + const SizedBox(width: Spacing.sm), + Expanded(child: heading), + ], + ), + ); + } +} diff --git a/app/test/auth/pending_verification_test.dart b/app/test/auth/pending_verification_test.dart index 05f66053..fae6b19b 100644 --- a/app/test/auth/pending_verification_test.dart +++ b/app/test/auth/pending_verification_test.dart @@ -17,6 +17,8 @@ class MemoryStorage implements RefreshTokenStorage { Completer? readGate; Completer? writeGate; Completer? writeStarted; + Completer? deleteGate; + Completer? deleteStarted; @override Future read() async { @@ -25,7 +27,14 @@ class MemoryStorage implements RefreshTokenStorage { } @override - Future delete() async => value = null; + Future delete() async { + if (deleteStarted?.isCompleted == false) { + deleteStarted!.complete(); + } + + await deleteGate?.future; + value = null; + } @override Future write(String refreshToken) async { @@ -165,7 +174,46 @@ void main() { }); } - test('old-server unverified tokens are revoked before any storage', () async { + for (final request in ['refresh', 'sync']) { + for (final cancellation in ['signout', 'login']) { + test('$request denial during storage deletion cannot override $cancellation', () async { + final storage = MemoryStorage(); + final repo = repository((request) async { + if (request.url.path.endsWith('/login')) { + final email = jsonDecode(request.body)['email'] as String; + return response(tokens(user: email.split('@').first)); + } + + if (request.url.path.endsWith('/logout')) { + return http.Response('', 204); + } + + return verificationRequired(); + }, storage: storage); + final auth = AuthProvider(await SharedPreferences.getInstance(), repository: repo, bootstrapOnCreate: false); + expect(await auth.login(email: 'alice@example.com', password: 'password'), isTrue); + storage.deleteGate = Completer(); + storage.deleteStarted = Completer(); + final denied = request == 'refresh' + ? expectLater(auth.refresh(), completion(isFalse)) + : expectLater(repo.createPowerSyncToken(), throwsA(isA())); + await storage.deleteStarted!.future; + final replacement = cancellation == 'signout' + ? auth.signOut() + : auth.login(email: 'bob@example.com', password: 'password'); + storage.deleteGate!.complete(); + await denied; + await replacement; + expect(auth.pendingVerification, isNull); + expect(auth.isSignedIn, cancellation == 'login'); + expect(auth.user?.userId, cancellation == 'login' ? 'bob' : null); + expect(storage.value, cancellation == 'login' ? 'refresh-bob' : null); + auth.dispose(); + }); + } + } + + test('unexpected unverified tokens are revoked before any storage', () async { final paths = []; final storage = MemoryStorage(); final repo = repository((request) async { @@ -179,13 +227,11 @@ void main() { ); expect(paths, ['/custom/api/auth/login', '/custom/api/auth/logout']); expect(storage.writes, 0); - final pending = await repo.register( - email: 'alice@example.com', - password: 'password', - displayName: 'Alice', - clientType: 'web', + await expectLater( + repo.register(email: 'alice@example.com', password: 'password', displayName: 'Alice', clientType: 'web'), + throwsA(isA()), ); - expect(pending.email, 'alice@example.com'); + expect(paths.last, '/custom/api/auth/logout'); expect(storage.writes, 0); }); diff --git a/app/test/widgets/auth/verification_page_test.dart b/app/test/widgets/auth/verification_page_test.dart index fcd77361..98bd6e20 100644 --- a/app/test/widgets/auth/verification_page_test.dart +++ b/app/test/widgets/auth/verification_page_test.dart @@ -131,7 +131,7 @@ void main() { }); } - testWidgets('invalid, expired and already-used links offer resend and sign-in', (tester) async { + testWidgets('invalid, expired and already-used links show an error without a form', (tester) async { final semantics = tester.ensureSemantics(); final auth = await mount( tester, @@ -144,10 +144,13 @@ void main() { }, 400), token: 'expired', ); - expect(find.text('Send verification link'), findsOneWidget); + expect(find.text('Link unavailable'), findsOneWidget); + expect(find.byType(TextFormField), findsNothing); + expect(find.text('Send verification link'), findsNothing); + expect(find.text('Continue offline'), findsNothing); expect( tester - .getSemantics(find.textContaining('This link cannot be used')) + .getSemantics(find.text('This link has expired or is no longer valid.')) .getSemanticsData() .flagsCollection .isLiveRegion, @@ -265,31 +268,28 @@ void main() { ); }); - testWidgets('resend delivery failure remains visible after an invalid link', (tester) async { - await mount(tester, (request) async { - if (request.url.path.endsWith('/verify-email')) { - return response({ - 'error': {'code': 'VERIFICATION_TOKEN_INVALID', 'message': 'Invalid link', 'details': {}}, - }, 400); - } - - return response( - { - 'error': { - 'code': 'VERIFICATION_UNAVAILABLE', - 'message': 'Verification email is temporarily unavailable. Please try again later.', - 'details': {}, + testWidgets('resend delivery failure remains visible while waiting for verification', (tester) async { + await mount( + tester, + (request) async { + return response( + { + 'error': { + 'code': 'VERIFICATION_UNAVAILABLE', + 'message': 'Verification email is temporarily unavailable. Please try again later.', + 'details': {}, + }, }, - }, - 503, - {'retry-after': '60'}, - ); - }, token: 'expired'); - await tester.enterText(find.byType(TextFormField), 'alice@example.com'); - await tester.pumpAndSettle(); - await tester.ensureVisible(find.widgetWithText(FilledButton, 'Send verification link')); + 503, + {'retry-after': '60'}, + ); + }, + isLink: false, + pendingEmail: 'alice@example.com', + ); + await tester.ensureVisible(find.widgetWithText(FilledButton, 'Resend email')); await tester.pumpAndSettle(); - await tester.tap(find.text('Send verification link')); + await tester.tap(find.text('Resend email')); await tester.pumpAndSettle(); expect( find.text('Couldn’t send the verification email. Please try again.'), @@ -332,14 +332,17 @@ void main() { theme: theme, ); expect(find.text('Check your email'), findsOneWidget); - expect(find.text(address), findsOneWidget); + expect( + find.text('Open the link sent to $address. Check your spam folder if it hasn’t arrived.', findRichText: true), + findsOneWidget, + ); expect(find.byType(TextFormField), findsNothing); expect(find.byType(TextButton), findsNothing); - expect(find.byType(FilledButton), findsNothing); + expect(find.byType(FilledButton), findsOneWidget); expect(find.text('Use another email'), findsNothing); expect(find.widgetWithText(OutlinedButton, 'Back to sign in'), findsOneWidget); - expect(find.widgetWithText(OutlinedButton, 'Continue offline'), findsOneWidget); - await tester.ensureVisible(find.widgetWithText(OutlinedButton, 'Resend email')); + expect(find.widgetWithText(OutlinedButton, 'Continue offline'), findsNothing); + await tester.ensureVisible(find.widgetWithText(FilledButton, 'Resend email')); await tester.pumpAndSettle(); await tester.tap(find.text('Resend email')); await tester.pumpAndSettle(); @@ -380,14 +383,16 @@ void main() { expect(find.text('Email verified'), findsNothing); }); - testWidgets('missing link offers offline reading without a verification request', (tester) async { + testWidgets('missing link offers a return to sign-in without a verification request', (tester) async { final auth = await mount(tester, (_) async => fail('No verification token')); - expect(find.text('Request a new link'), findsOneWidget); - await tester.ensureVisible(find.text('Continue offline')); - await tester.tap(find.text('Continue offline')); + expect(find.text('Link unavailable'), findsOneWidget); + expect(find.byType(TextFormField), findsNothing); + expect(find.text('Continue offline'), findsNothing); + await tester.ensureVisible(find.text('Back to sign in')); + await tester.tap(find.text('Back to sign in')); await tester.pumpAndSettle(); - expect(find.text('Library'), findsOneWidget); - expect(auth.isOfflineMode, isTrue); + expect(find.text('Fresh sign-in'), findsOneWidget); + expect(auth.isSignedIn, isFalse); }); testWidgets('leaving during automatic verification ignores a late response', (tester) async {