diff --git a/app/lib/auth/auth_api_client.dart b/app/lib/auth/auth_api_client.dart index 45b210ca..308b4d31 100644 --- a/app/lib/auth/auth_api_client.dart +++ b/app/lib/auth/auth_api_client.dart @@ -13,8 +13,15 @@ class AuthApiException implements Exception { final String message; final String? code; final Duration? retryAfter; + final AccountDeletionAuthorization? recoveryAuthorization; - const AuthApiException({required this.statusCode, required this.message, this.code, this.retryAfter}); + const AuthApiException({ + required this.statusCode, + required this.message, + this.code, + this.retryAfter, + this.recoveryAuthorization, + }); @override String toString() => message; @@ -129,6 +136,28 @@ class AuthApiClient { return PapyrusUser.fromJson(json); } + Future deleteAccount(String accessToken) async { + final json = await _postJson(config.endpoint('/account-deletion/current'), accessToken: accessToken); + return DateTime.parse(json['deletion_due_at'] as String); + } + + Future recoverAccount( + AccountDeletionAuthorization authorization, { + required String clientType, + String? deviceLabel, + }) async { + final json = await _postJson( + config.endpoint('/account-deletion/cancel'), + body: { + 'authorization_token': authorization.token, + 'client_type': clientType, + 'device_label': deviceLabel, + }, + ); + + return AuthTokens.fromJson(json); + } + Future forgotPassword(String email) async { final json = await _postJson(config.endpoint('/auth/forgot-password'), body: {'email': email}); return json['message'] as String? ?? 'If the email is registered, a reset link has been sent'; @@ -276,6 +305,9 @@ class AuthApiClient { throw AuthApiException( statusCode: response.statusCode, code: error['code'] as String?, + recoveryAuthorization: error['code'] == 'ACCOUNT_DELETION_PENDING' + ? AccountDeletionAuthorization.fromJson(error['details']['authorization'] as Map) + : null, message: error['message'] as String? ?? 'Authentication request failed', ); } diff --git a/app/lib/auth/auth_models.dart b/app/lib/auth/auth_models.dart index 63e851ee..75dba036 100644 --- a/app/lib/auth/auth_models.dart +++ b/app/lib/auth/auth_models.dart @@ -8,6 +8,7 @@ class PapyrusUser { final bool emailVerified; final DateTime? createdAt; final DateTime? lastLoginAt; + final List connectedProviders; const PapyrusUser({ required this.userId, @@ -17,6 +18,7 @@ class PapyrusUser { required this.emailVerified, required this.createdAt, required this.lastLoginAt, + this.connectedProviders = const [], }); factory PapyrusUser.fromJson(Map json) { @@ -28,6 +30,7 @@ class PapyrusUser { emailVerified: json['email_verified'] as bool? ?? false, createdAt: _parseDateTime(json['created_at']), lastLoginAt: _parseDateTime(json['last_login_at']), + connectedProviders: List.unmodifiable(json['connected_providers'] as List), ); } } @@ -76,3 +79,32 @@ DateTime? _parseDateTime(Object? value) { return DateTime.tryParse(value); } + +class AccountDeletionAuthorization { + final String token; + final String userId; + final String? email; + final bool deletionPending; + final DateTime deletionDueAt; + final DateTime expiresAt; + + const AccountDeletionAuthorization({ + required this.token, + required this.userId, + required this.email, + required this.deletionPending, + required this.deletionDueAt, + required this.expiresAt, + }); + + factory AccountDeletionAuthorization.fromJson(Map json) { + return AccountDeletionAuthorization( + token: json['authorization_token'] as String, + userId: json['user_id'] as String, + email: json['email'] as String?, + deletionPending: json['deletion_pending'] as bool, + deletionDueAt: DateTime.parse(json['deletion_due_at'] as String), + expiresAt: DateTime.parse(json['expires_at'] as String), + ); + } +} diff --git a/app/lib/auth/auth_repository.dart b/app/lib/auth/auth_repository.dart index e6644da6..679dff23 100644 --- a/app/lib/auth/auth_repository.dart +++ b/app/lib/auth/auth_repository.dart @@ -16,6 +16,7 @@ class AuthRepository { final TokenStore tokenStore; Future? _refreshOperation; + int _tokenGeneration = 0; AuthRepository({required this.apiClient, required this.tokenStore}); @@ -79,6 +80,8 @@ class AuthRepository { } Future _refresh() async { + final generation = _tokenGeneration; + try { final refreshToken = await tokenStore.readRefreshToken(); @@ -87,10 +90,18 @@ class AuthRepository { } final tokens = await apiClient.refresh(refreshToken); + + if (generation != _tokenGeneration) { + throw const AuthApiException(statusCode: 401, message: 'Session has ended'); + } + await _save(tokens); return tokens; } catch (_) { - await tokenStore.clear(); + if (generation == _tokenGeneration) { + await tokenStore.clear(); + } + rethrow; } finally { _refreshOperation = null; @@ -141,6 +152,29 @@ class AuthRepository { return tokens; } + Future deleteAccount() => _withFreshAccessToken(apiClient.deleteAccount); + + Future recoverAccount( + AccountDeletionAuthorization authorization, { + required String clientType, + String? deviceLabel, + }) async { + final generation = _tokenGeneration; + + final tokens = await apiClient.recoverAccount( + authorization, + clientType: clientType, + deviceLabel: deviceLabel, + ); + + if (generation != _tokenGeneration) { + throw const AuthApiException(statusCode: 401, message: 'Session changed. Please sign in again.'); + } + + await _save(tokens); + return tokens; + } + Future logout() async { final accessToken = tokenStore.accessToken; @@ -216,6 +250,7 @@ class AuthRepository { } Future clearTokens() { + _tokenGeneration += 1; return tokenStore.clear(); } diff --git a/app/lib/pages/auth/oauth_callback_page.dart b/app/lib/pages/auth/oauth_callback_page.dart index 076f5ed9..383e2111 100644 --- a/app/lib/pages/auth/oauth_callback_page.dart +++ b/app/lib/pages/auth/oauth_callback_page.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:papyrus/providers/auth_provider.dart'; +import 'package:papyrus/platform/web_redirect.dart'; +import 'package:papyrus/widgets/profile/account_deletion_dialog.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:provider/provider.dart'; import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; @@ -16,6 +18,7 @@ class OAuthCallbackPage extends StatefulWidget { class _OAuthCallbackPageState extends State { String? _errorMessage; + bool _showingConfirmation = false; @override void initState() { @@ -25,6 +28,8 @@ class _OAuthCallbackPageState extends State { Future _completeSignIn() async { final authProvider = context.read(); + clearBrowserQuery(); + final success = await authProvider.completeGoogleSignIn(widget.callbackUri); if (!mounted) { @@ -36,6 +41,19 @@ class _OAuthCallbackPageState extends State { return; } + final recovery = authProvider.takePendingRecovery(); + + if (recovery != null) { + setState(() => _showingConfirmation = true); + await showAccountDeletionDialog(context, authorization: recovery); + + if (mounted && !authProvider.isSignedIn) { + context.go('/login'); + } + + return; + } + setState(() { _errorMessage = authProvider.error ?? 'Google sign-in failed.'; }); @@ -45,6 +63,18 @@ class _OAuthCallbackPageState extends State { Widget build(BuildContext context) { final theme = Theme.of(context); + if (_showingConfirmation) { + return Scaffold( + body: SizedBox.expand( + child: Image.asset( + 'assets/images/welcome-illustration.png', + fit: BoxFit.cover, + excludeFromSemantics: true, + ), + ), + ); + } + return Scaffold( body: Center( child: Padding( diff --git a/app/lib/pages/login_page.dart b/app/lib/pages/login_page.dart index 276dec21..5afbb43f 100644 --- a/app/lib/pages/login_page.dart +++ b/app/lib/pages/login_page.dart @@ -5,6 +5,7 @@ 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_continue_button.dart'; +import 'package:papyrus/widgets/profile/account_deletion_dialog.dart'; import 'package:papyrus/widgets/auth/auth_page_layouts.dart'; import 'package:papyrus/widgets/auth/auth_switch_link.dart'; import 'package:papyrus/widgets/buttons/google_sign_in.dart'; @@ -64,6 +65,15 @@ class _LoginPageState extends State { } if (!success) { + final recovery = context.read().takePendingRecovery(); + + if (recovery != null) { + _passwordController.clear(); + setState(() => _isLoading = false); + await showAccountDeletionDialog(context, authorization: recovery); + return; + } + _showErrorSnackBar(context.read().error ?? 'Incorrect email or password.'); return; } diff --git a/app/lib/platform/web_redirect_stub.dart b/app/lib/platform/web_redirect_stub.dart index 650a1fd5..c5bf91d9 100644 --- a/app/lib/platform/web_redirect_stub.dart +++ b/app/lib/platform/web_redirect_stub.dart @@ -1,3 +1,5 @@ void redirectTo(String url) { throw UnsupportedError('Browser redirects are only available on web'); } + +void clearBrowserQuery() {} diff --git a/app/lib/platform/web_redirect_web.dart b/app/lib/platform/web_redirect_web.dart index b7acfc66..c6382a02 100644 --- a/app/lib/platform/web_redirect_web.dart +++ b/app/lib/platform/web_redirect_web.dart @@ -3,3 +3,7 @@ import 'package:web/web.dart' as web; void redirectTo(String url) { web.window.location.assign(url); } + +void clearBrowserQuery() { + web.window.history.replaceState(null, '', web.window.location.pathname); +} diff --git a/app/lib/providers/auth_provider.dart b/app/lib/providers/auth_provider.dart index 879f6546..7d0d3103 100644 --- a/app/lib/providers/auth_provider.dart +++ b/app/lib/providers/auth_provider.dart @@ -31,6 +31,14 @@ class AuthProvider extends ChangeNotifier { _status == AuthStatus.refreshing; } + AccountDeletionAuthorization? _pendingRecovery; + + AccountDeletionAuthorization? takePendingRecovery() { + final authorization = _pendingRecovery; + _pendingRecovery = null; + return authorization; + } + String? _error; String? get error => _error; @@ -45,6 +53,7 @@ class AuthProvider extends ChangeNotifier { Future replaceRepository(AuthRepository repository, {bool bootstrapNewRepository = true}) async { _repository = repository; + _pendingRecovery = null; _user = null; _error = null; @@ -90,6 +99,7 @@ class AuthProvider extends ChangeNotifier { } Future signInWithGoogle() async { + _pendingRecovery = null; _setStatus(AuthStatus.authenticating); _error = null; @@ -107,6 +117,7 @@ class AuthProvider extends ChangeNotifier { return true; } catch (error) { _user = null; + _pendingRecovery = error is AuthApiException ? error.recoveryAuthorization : null; _error = _messageFor(error); _setStatus(AuthStatus.authError); return false; @@ -163,6 +174,56 @@ class AuthProvider extends ChangeNotifier { } } + Future deleteAccount() async { + final repository = _repository; + final userId = _user?.userId; + final deadline = await repository.deleteAccount(); + + try { + await repository.clearTokens(); + } finally { + if (identical(_repository, repository) && _user?.userId == userId) { + _user = null; + _error = null; + _pendingRecovery = null; + _setStatus(AuthStatus.signedOut); + } + } + + return identical(_repository, repository) ? deadline : null; + } + + Future recoverAccount(AccountDeletionAuthorization authorization) async { + final repository = _repository; + + if (_user != null && _user!.userId != authorization.userId) { + throw const AuthApiException(statusCode: 403, message: 'The active account has changed.'); + } + + final tokens = await repository.recoverAccount( + authorization, + clientType: _clientType, + deviceLabel: _deviceLabel, + ); + + if (!identical(_repository, repository)) { + return false; + } + + await _prefs.setBool(_keyOfflineMode, false); + + if (!identical(_repository, repository)) { + return false; + } + + _user = tokens.user; + _isOfflineMode = false; + _error = null; + _pendingRecovery = null; + _setStatus(AuthStatus.signedIn); + return true; + } + Future forgotPassword(String email) { return _runMessageAction(() => _repository.forgotPassword(email)); } @@ -218,6 +279,7 @@ class AuthProvider extends ChangeNotifier { } Future _runTokenAction(Future Function() action) async { + _pendingRecovery = null; _setStatus(AuthStatus.authenticating); _error = null; @@ -229,6 +291,7 @@ class AuthProvider extends ChangeNotifier { _setStatus(AuthStatus.signedIn); return true; } catch (error) { + _pendingRecovery = error is AuthApiException ? error.recoveryAuthorization : null; _error = _messageFor(error); _setStatus(AuthStatus.authError); return false; @@ -236,6 +299,7 @@ class AuthProvider extends ChangeNotifier { } Future _runMessageAction(Future Function() action) async { + _pendingRecovery = null; _setStatus(AuthStatus.authenticating); _error = null; diff --git a/app/lib/widgets/buttons/google_auth_button.dart b/app/lib/widgets/buttons/google_auth_button.dart new file mode 100644 index 00000000..9b6dfa65 --- /dev/null +++ b/app/lib/widgets/buttons/google_auth_button.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; + +class GoogleAuthButton extends StatelessWidget { + final String title; + final bool isLoading; + final VoidCallback? onPressed; + + const GoogleAuthButton({super.key, required this.title, required this.onPressed, this.isLoading = false}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return OutlinedButton( + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(ComponentSizes.buttonHeightMobile), + side: BorderSide(width: BorderWidths.thin, color: theme.colorScheme.outline), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppRadius.googleButton)), + padding: const EdgeInsets.symmetric(horizontal: Spacing.lg, vertical: Spacing.buttonPaddingVertical), + ), + onPressed: onPressed, + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox.square( + dimension: 24, + child: isLoading + ? AppCircularProgressIndicator( + strokeWidth: 2, + color: theme.colorScheme.primary, + semanticsLabel: 'Signing in with Google', + ) + : const Image(image: AssetImage('assets/images/google_logo.png'), fit: BoxFit.contain), + ), + const SizedBox(width: Spacing.sm), + Flexible( + child: Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500), + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/widgets/buttons/google_sign_in.dart b/app/lib/widgets/buttons/google_sign_in.dart index 26fbeacb..05a7cb47 100644 --- a/app/lib/widgets/buttons/google_sign_in.dart +++ b/app/lib/widgets/buttons/google_sign_in.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:papyrus/providers/auth_provider.dart'; -import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/buttons/google_auth_button.dart'; +import 'package:papyrus/widgets/profile/account_deletion_dialog.dart'; import 'package:provider/provider.dart'; import 'package:papyrus/themes/app_motion.dart'; -import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; /// A Google Sign-in button with loading state and error handling. class GoogleSignInButton extends StatefulWidget { @@ -44,7 +44,19 @@ class _GoogleSignInButtonState extends State { if (result) { widget.onSuccess?.call(); context.goNamed('LIBRARY'); - } else if (provider.error != null) { + } else { + final recovery = provider.takePendingRecovery(); + + if (recovery != null) { + setState(() => _isSigningIn = false); + await showAccountDeletionDialog(context, authorization: recovery); + return; + } + + if (provider.error == null) { + return; + } + widget.onError?.call(); ScaffoldMessenger.of(context).showSnackBar( @@ -80,41 +92,10 @@ class _GoogleSignInButtonState extends State { @override Widget build(BuildContext context) { - final theme = Theme.of(context); - - return OutlinedButton( - style: OutlinedButton.styleFrom( - minimumSize: const Size.fromHeight(ComponentSizes.buttonHeightMobile), - side: BorderSide(width: BorderWidths.thin, color: theme.colorScheme.outline), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppRadius.googleButton)), - padding: const EdgeInsets.symmetric(horizontal: Spacing.lg, vertical: Spacing.buttonPaddingVertical), - ), + return GoogleAuthButton( + title: widget.title, + isLoading: _isSigningIn, onPressed: _isSigningIn ? null : _handleSignIn, - child: Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SizedBox.square( - dimension: 24, - child: _isSigningIn - ? AppCircularProgressIndicator( - strokeWidth: 2, - color: theme.colorScheme.primary, - semanticsLabel: 'Signing in with Google', - ) - : const Image(image: AssetImage('assets/images/google_logo.png'), fit: BoxFit.contain), - ), - const SizedBox(width: Spacing.sm), - Flexible( - child: Text( - widget.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500), - ), - ), - ], - ), ); } } diff --git a/app/lib/widgets/profile/account_deletion_dialog.dart b/app/lib/widgets/profile/account_deletion_dialog.dart new file mode 100644 index 00000000..62fe871e --- /dev/null +++ b/app/lib/widgets/profile/account_deletion_dialog.dart @@ -0,0 +1,184 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; +import 'package:papyrus/auth/auth_api_client.dart'; +import 'package:papyrus/auth/auth_models.dart'; +import 'package:papyrus/providers/auth_provider.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; +import 'package:provider/provider.dart'; + +Future showAccountDeletionDialog( + BuildContext context, { + AccountDeletionAuthorization? authorization, +}) async { + final auth = context.read(); + final router = GoRouter.of(context); + final messenger = ScaffoldMessenger.of(context); + final animationStyle = AppMotion.animationStyle(context); + + final message = await showDialog( + context: context, + barrierDismissible: false, + animationStyle: animationStyle, + builder: (_) => AccountDeletionDialog( + auth: auth, + authorization: authorization, + ), + ); + + if (message == null) { + return; + } + + if (authorization != null) { + router.goNamed('LIBRARY'); + return; + } + + router.go('/login'); + + messenger.showSnackBar( + snackBarAnimationStyle: animationStyle, + SnackBar(content: Text(message), duration: const Duration(seconds: 8)), + ); +} + +class AccountDeletionDialog extends StatefulWidget { + final AuthProvider auth; + final AccountDeletionAuthorization? authorization; + + const AccountDeletionDialog({ + super.key, + required this.auth, + this.authorization, + }); + + @override + State createState() => _AccountDeletionDialogState(); +} + +class _AccountDeletionDialogState extends State { + String? _error; + bool _busy = false; + + Future _submit() async { + final authorization = widget.authorization; + String? message; + + if (authorization != null) { + if (await widget.auth.recoverAccount(authorization)) { + message = 'Account kept.'; + } + } else { + final deadline = await widget.auth.deleteAccount(); + + if (deadline != null) { + message = + 'Account deletion scheduled for ${DateFormat.yMMMd().format(deadline.toLocal())}. ' + 'Sign in before then to keep your account.'; + } + } + + if (mounted) { + Navigator.pop(context, message); + } + } + + Future _run(Future Function() action) async { + if (_busy) { + return; + } + + setState(() { + _busy = true; + _error = null; + }); + + try { + await action(); + } catch (error) { + if (mounted) { + _error = error is AuthApiException ? error.message : 'Unable to connect. Please try again.'; + } + } finally { + if (mounted) { + setState(() => _busy = false); + } + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final authorization = widget.authorization; + final recovering = authorization?.deletionPending ?? false; + final deadline = authorization == null + ? null + : '${DateFormat.yMMMd().format(authorization.deletionDueAt.toLocal())} at ' + '${DateFormat.jm().format(authorization.deletionDueAt.toLocal())}'; + + return PopScope( + canPop: !_busy, + child: AlertDialog( + scrollable: true, + insetPadding: const EdgeInsets.all(Spacing.lg), + title: Text(recovering ? 'Keep your account?' : 'Delete account?'), + content: SizedBox( + width: 400, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (recovering) + Text.rich( + TextSpan( + children: [ + const TextSpan(text: 'Your account is scheduled for deletion on '), + TextSpan( + text: deadline, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + const TextSpan(text: '. Keep your account to cancel deletion.'), + ], + ), + ) + else + const Text( + 'Your account, synced library and reading data will be permanently deleted after 30 days. ' + 'You’ll be signed out immediately. You can cancel deletion by signing in before then.', + ), + + if (_error != null) ...[ + const SizedBox(height: Spacing.md), + Semantics( + liveRegion: true, + child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)), + ), + ], + ], + ), + ), + actions: [ + TextButton( + onPressed: _busy ? null : () => Navigator.pop(context), + child: Text(recovering ? 'Not now' : 'Cancel'), + ), + FilledButton( + onPressed: _busy ? null : () => _run(_submit), + style: !recovering + ? FilledButton.styleFrom( + backgroundColor: theme.colorScheme.error, + foregroundColor: theme.colorScheme.onError, + ) + : null, + child: _busy + ? const SizedBox.square(dimension: 20, child: AppCircularProgressIndicator(strokeWidth: 2)) + : Text(recovering ? 'Keep account' : 'Delete account'), + ), + ], + ), + ); + } +} diff --git a/app/lib/widgets/profile/account_settings_section.dart b/app/lib/widgets/profile/account_settings_section.dart index f56443cc..cfd8b01f 100644 --- a/app/lib/widgets/profile/account_settings_section.dart +++ b/app/lib/widgets/profile/account_settings_section.dart @@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart'; import 'package:papyrus/providers/auth_provider.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/settings/settings_row.dart'; +import 'package:papyrus/widgets/profile/account_deletion_dialog.dart'; import 'package:papyrus/widgets/settings/settings_section.dart'; import 'package:provider/provider.dart'; @@ -26,11 +27,15 @@ class AccountSettingsSection extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ const SettingsSectionHeader(title: 'Account'), - SettingsRow(label: 'Change password', onTap: () {}), - SettingsRow(label: 'Linked accounts', onTap: () {}), - SettingsRow(label: 'Two-factor authentication', onTap: () {}), - SettingsRow(label: 'Active sessions', onTap: () {}), - SettingsRow(label: 'Delete account', onTap: () {}), + const SettingsRow(label: 'Change password', enabled: false, showChevron: false), + _buildGoogleConnection(context), + const SettingsRow(label: 'Two-factor authentication', enabled: false, showChevron: false), + const SettingsRow(label: 'Active sessions', enabled: false, showChevron: false), + SettingsRow( + label: 'Delete account', + enabled: context.watch().isSignedIn, + onTap: () => showAccountDeletionDialog(context), + ), ], ); } @@ -79,25 +84,41 @@ class AccountSettingsSection extends StatelessWidget { SettingsCard( title: 'Security', children: [ - SettingsRow(label: 'Change password', onTap: () {}), - SettingsRow(label: 'Two-factor authentication', onTap: () {}), - SettingsRow(label: 'Active sessions', onTap: () {}), + const SettingsRow(label: 'Change password', enabled: false, showChevron: false), + const SettingsRow(label: 'Two-factor authentication', enabled: false, showChevron: false), + const SettingsRow(label: 'Active sessions', enabled: false, showChevron: false), ], ), const SizedBox(height: Spacing.lg), SettingsCard( title: 'Connected accounts', - children: [SettingsRow(label: 'Google', value: 'Not connected', onTap: () {})], + children: [_buildGoogleConnection(context)], ), const SizedBox(height: Spacing.lg), SettingsCard( title: 'Danger zone', - children: [SettingsRow(label: 'Delete account', onTap: () {})], + children: [ + SettingsRow( + label: 'Delete account', + enabled: context.watch().isSignedIn, + onTap: () => showAccountDeletionDialog(context), + ), + ], ), ], ); } + Widget _buildGoogleConnection(BuildContext context) { + final providers = context.watch().user?.connectedProviders ?? const []; + + return SettingsRow( + label: 'Google', + value: providers.contains('google') ? 'Connected' : 'Not connected', + showChevron: false, + ); + } + Widget _buildAvatar( BuildContext context, { required String displayName, diff --git a/app/lib/widgets/settings/settings_row.dart b/app/lib/widgets/settings/settings_row.dart index 5a2c9f02..63fb99db 100644 --- a/app/lib/widgets/settings/settings_row.dart +++ b/app/lib/widgets/settings/settings_row.dart @@ -51,6 +51,7 @@ class SettingsRow extends StatelessWidget { /// Whether to show the trailing chevron. final bool showChevron; + final bool enabled; /// Creates a settings row widget. const SettingsRow({ @@ -61,6 +62,7 @@ class SettingsRow extends StatelessWidget { this.trailing, this.onTap, this.showChevron = true, + this.enabled = true, }); @override @@ -69,45 +71,52 @@ class SettingsRow extends StatelessWidget { final textTheme = Theme.of(context).textTheme; final showsValueAsTrailing = value != null && !showChevron && trailing == null; - return Material( - color: Colors.transparent, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(AppRadius.sm), - child: ConstrainedBox( - constraints: const BoxConstraints(minHeight: TouchTargets.mobileRecommended), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: Spacing.sm, vertical: Spacing.sm), - child: Row( - children: [ - if (leading != null) ...[leading!, const SizedBox(width: Spacing.md)], - Expanded( - child: switch (showsValueAsTrailing) { - true => Text(label, style: textTheme.bodyLarge), - false when value != null => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: textTheme.bodyLarge), - const SizedBox(height: 2), - Text(value!, style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), - ], + return Semantics( + enabled: enabled, + child: Opacity( + opacity: enabled ? 1 : 0.38, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: enabled ? onTap : null, + canRequestFocus: enabled, + borderRadius: BorderRadius.circular(AppRadius.sm), + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: TouchTargets.mobileRecommended), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: Spacing.sm, vertical: Spacing.sm), + child: Row( + children: [ + if (leading != null) ...[leading!, const SizedBox(width: Spacing.md)], + Expanded( + child: switch (showsValueAsTrailing) { + true => Text(label, style: textTheme.bodyLarge), + false when value != null => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: textTheme.bodyLarge), + const SizedBox(height: 2), + Text(value!, style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), + ], + ), + false => Text(label, style: textTheme.bodyLarge), + }, ), - false => Text(label, style: textTheme.bodyLarge), - }, + if (trailing != null) + trailing! + else if (showChevron && onTap != null) + Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant, size: IconSizes.medium) + else if (showsValueAsTrailing) + Expanded( + child: Text( + value!, + textAlign: TextAlign.end, + style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ), + ], ), - if (trailing != null) - trailing! - else if (showChevron && onTap != null) - Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant, size: IconSizes.medium) - else if (showsValueAsTrailing) - Expanded( - child: Text( - value!, - textAlign: TextAlign.end, - style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), - ), - ), - ], + ), ), ), ), diff --git a/app/test/auth/auth_api_client_test.dart b/app/test/auth/auth_api_client_test.dart index 4d0dc8c5..d7999b3d 100644 --- a/app/test/auth/auth_api_client_test.dart +++ b/app/test/auth/auth_api_client_test.dart @@ -18,6 +18,7 @@ const _authResponse = { 'email': 'reader@example.com', 'display_name': 'Reader', 'avatar_url': null, + 'connected_providers': ['google'], 'email_verified': false, 'created_at': '2026-05-09T12:00:00Z', 'last_login_at': null, @@ -52,6 +53,7 @@ void main() { expect(tokens.accessToken, 'access-token'); expect(tokens.refreshToken, 'refresh-token'); expect(tokens.user.displayName, 'Reader'); + expect(tokens.user.connectedProviders, ['google']); }); test('throws AuthApiException for server errors', () async { diff --git a/app/test/auth/auth_repository_account_deletion_test.dart b/app/test/auth/auth_repository_account_deletion_test.dart new file mode 100644 index 00000000..06cd86f8 --- /dev/null +++ b/app/test/auth/auth_repository_account_deletion_test.dart @@ -0,0 +1,98 @@ +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_models.dart'; +import 'package:papyrus/auth/auth_repository.dart'; +import 'package:papyrus/auth/papyrus_api_config.dart'; +import 'package:papyrus/auth/token_store.dart'; + +class _Storage implements RefreshTokenStorage { + String? value; + + @override + Future read() async => value; + + @override + Future write(String token) async => value = token; + + @override + Future delete() async => value = null; +} + +void main() { + for (final sessionCleared in [false, true]) { + test('recovery persists a usable session unless it was cleared, cleared=$sessionCleared', () async { + final storage = _Storage(); + final response = Completer(); + final started = Completer(); + final requests = []; + + final repository = AuthRepository( + apiClient: AuthApiClient( + config: PapyrusApiConfig(serverBaseUri: Uri.parse('https://server.test')), + httpClient: MockClient((request) { + requests.add(request); + started.complete(); + return response.future; + }), + ), + tokenStore: TokenStore(storage), + ); + + final recovery = repository.recoverAccount( + AccountDeletionAuthorization( + token: 'restricted-token', + userId: 'user', + email: 'reader@example.com', + deletionPending: true, + deletionDueAt: DateTime.now().add(const Duration(days: 30)), + expiresAt: DateTime.now().add(const Duration(minutes: 5)), + ), + clientType: 'web', + deviceLabel: 'flutter-web', + ); + + await started.future; + + if (sessionCleared) { + await repository.clearTokens(); + } + + response.complete( + http.Response( + jsonEncode({ + 'access_token': 'restored-access', + 'refresh_token': 'restored-refresh', + 'expires_in': 3600, + 'user': { + 'user_id': 'user', + 'connected_providers': ['google'], + }, + }), + 200, + ), + ); + + if (sessionCleared) { + await expectLater(recovery, throwsA(isA())); + expect(repository.tokenStore.accessToken, isNull); + expect(storage.value, isNull); + } else { + expect((await recovery).user.userId, 'user'); + expect(repository.tokenStore.accessToken, 'restored-access'); + expect(await repository.tokenStore.readRefreshToken(), 'restored-refresh'); + } + + expect(requests.single.url.path, '/v1/account-deletion/cancel'); + expect(jsonDecode(requests.single.body), { + 'authorization_token': 'restricted-token', + 'client_type': 'web', + 'device_label': 'flutter-web', + }); + }); + } +} diff --git a/app/test/auth/auth_repository_powersync_test.dart b/app/test/auth/auth_repository_powersync_test.dart index 216ccf67..fbb5f1bb 100644 --- a/app/test/auth/auth_repository_powersync_test.dart +++ b/app/test/auth/auth_repository_powersync_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; @@ -26,6 +27,33 @@ class MemoryRefreshTokenStorage implements RefreshTokenStorage { } void main() { + test('clearing credentials prevents an in-flight refresh from restoring the deleted session', () async { + final response = Completer(); + final requested = Completer(); + final store = TokenStore(MemoryRefreshTokenStorage()); + await store.saveTokens(accessToken: 'old-access', refreshToken: 'old-refresh'); + + final repository = AuthRepository( + apiClient: AuthApiClient( + config: PapyrusApiConfig(serverBaseUri: Uri.parse('http://server.test')), + httpClient: MockClient((request) { + requested.complete(); + return response.future; + }), + ), + tokenStore: store, + ); + + final refresh = repository.refresh(); + final rejected = expectLater(refresh, throwsA(isA())); + await requested.future; + await repository.clearTokens(); + response.complete(http.Response(jsonEncode(_authResponse), 200)); + await rejected; + expect(store.accessToken, isNull); + expect(await store.readRefreshToken(), isNull); + }); + test('createPowerSyncToken refreshes Papyrus auth once after 401', () async { var powerSyncTokenCalls = 0; final store = TokenStore(MemoryRefreshTokenStorage()); @@ -79,6 +107,7 @@ const _authResponse = { 'email': 'reader@example.com', 'display_name': 'Reader', 'avatar_url': null, + 'connected_providers': [], 'email_verified': true, 'created_at': null, 'last_login_at': null, diff --git a/app/test/pages/account_deletion_dialog_test.dart b/app/test/pages/account_deletion_dialog_test.dart new file mode 100644 index 00000000..4514f186 --- /dev/null +++ b/app/test/pages/account_deletion_dialog_test.dart @@ -0,0 +1,374 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.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_models.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/pages/login_page.dart'; +import 'package:papyrus/pages/auth/oauth_callback_page.dart'; +import 'package:papyrus/themes/app_theme.dart'; +import 'package:papyrus/providers/auth_provider.dart'; +import 'package:papyrus/widgets/profile/account_settings_section.dart'; +import 'package:papyrus/widgets/settings/settings_row.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _Storage implements RefreshTokenStorage { + String? value = 'refresh'; + + @override + Future read() async => value; + + @override + Future write(String token) async => value = token; + + @override + Future delete() async => value = null; +} + +class _Repository extends AuthRepository { + _Repository({required super.apiClient, required super.tokenStore, required this.connected}); + + final bool connected; + + @override + Future bootstrap() async => AuthTokens( + accessToken: 'access', + refreshToken: 'refresh', + tokenType: 'Bearer', + expiresIn: 3600, + user: PapyrusUser( + userId: 'user', + email: 'reader@example.com', + displayName: 'Reader', + avatarUrl: null, + emailVerified: true, + createdAt: null, + lastLoginAt: null, + connectedProviders: connected ? ['google'] : [], + ), + ); +} + +void main() { + late AuthProvider auth; + late _Storage storage; + late List requests; + var pending = false; + var failSchedule = false; + var clockAhead = false; + var rejectRecovery = false; + + Future setup({bool signedIn = true, bool connected = false}) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + storage = _Storage(); + requests = []; + + final api = AuthApiClient( + config: PapyrusApiConfig(serverBaseUri: Uri.parse('https://server.test')), + httpClient: MockClient((request) async { + requests.add(request); + + final authorization = { + 'authorization_token': 'restricted-token', + 'user_id': 'user', + 'email': 'reader@example.com', + 'deletion_pending': pending, + 'deletion_due_at': '2026-11-22T12:00:00Z', + 'expires_at': DateTime.now().add(Duration(minutes: clockAhead ? -10 : 5)).toIso8601String(), + }; + + if ((request.url.path.endsWith('/login') || request.url.path.endsWith('/exchange-code')) && pending) { + return http.Response( + jsonEncode({ + 'error': { + 'code': 'ACCOUNT_DELETION_PENDING', + 'message': 'This account is scheduled for deletion.', + 'details': {'authorization': authorization}, + }, + }), + 403, + ); + } + + if (request.url.path.endsWith('/cancel') && rejectRecovery) { + return http.Response( + jsonEncode({ + 'error': {'message': 'Authorization is invalid or expired. Authenticate again.'}, + }), + 401, + ); + } + + if (failSchedule) { + return http.Response( + jsonEncode({ + 'error': {'message': 'Try again later'}, + }), + 503, + ); + } + + return http.Response( + jsonEncode( + pending + ? { + 'access_token': 'restored-access', + 'refresh_token': 'restored-refresh', + 'expires_in': 3600, + 'user': { + 'user_id': 'user', + 'email': 'reader@example.com', + 'display_name': 'Reader', + 'email_verified': true, + 'connected_providers': connected ? ['google'] : [], + }, + } + : {'deletion_due_at': '2026-11-22T12:00:00Z', 'message': 'Deletion scheduled.'}, + ), + 200, + ); + }), + ); + + auth = AuthProvider( + prefs, + repository: _Repository( + apiClient: api, + tokenStore: TokenStore(storage)..setAccessToken('access'), + connected: connected, + ), + bootstrapOnCreate: false, + ); + + if (signedIn) { + await auth.bootstrap(); + } + + addTearDown(auth.dispose); + } + + Future showPage(WidgetTester tester, {bool desktop = false, bool login = false, String? callback}) async { + final router = GoRouter( + initialLocation: callback ?? (login ? '/login' : '/profile'), + refreshListenable: auth, + redirect: (_, state) => state.uri.path == '/profile' && !auth.isSignedIn ? '/login' : null, + routes: [ + GoRoute( + path: '/auth/callback', + builder: (_, state) => OAuthCallbackPage(callbackUri: state.uri), + ), + GoRoute( + path: '/profile', + builder: (_, _) => Scaffold( + body: SingleChildScrollView(child: AccountSettingsSection(isDesktop: desktop)), + ), + ), + GoRoute( + path: '/library', + name: 'LIBRARY', + builder: (_, _) => const Scaffold(body: Text('Your library')), + ), + GoRoute(path: '/login', builder: (_, _) => const LoginPage()), + ], + ); + + addTearDown(router.dispose); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: auth, + child: MaterialApp.router(theme: AppTheme.light, routerConfig: router), + ), + ); + + await tester.pumpAndSettle(); + } + + Future openDialog(WidgetTester tester) async { + await tester.ensureVisible(find.text('Delete account')); + await tester.tap(find.text('Delete account')); + await tester.pumpAndSettle(); + } + + Future confirm(WidgetTester tester) async { + await tester.tap(find.widgetWithText(FilledButton, 'Delete account')); + await tester.pumpAndSettle(); + } + + setUp(() { + pending = false; + failSchedule = false; + clockAhead = false; + rejectRecovery = false; + }); + + for (final desktop in [false, true]) { + testWidgets('Google deletion completes in the existing session, desktop=$desktop', (tester) async { + await setup(connected: true); + final size = Size(desktop ? 1200 : 360, 800); + await tester.binding.setSurfaceSize(size); + tester.view.physicalSize = size * tester.view.devicePixelRatio; + addTearDown(() => tester.binding.setSurfaceSize(null)); + addTearDown(tester.view.resetPhysicalSize); + await showPage(tester, desktop: desktop); + expect(find.text('Connected'), findsOneWidget); + + for (final label in ['Change password', 'Two-factor authentication', 'Active sessions']) { + final row = find.widgetWithText(SettingsRow, label); + final ink = tester.widget(find.descendant(of: row, matching: find.byType(InkWell))); + expect(ink.onTap, isNull); + expect(ink.canRequestFocus, isFalse); + } + + await openDialog(tester); + expect(find.text('Delete account?'), findsOneWidget); + expect(find.text('Confirm it’s you'), findsNothing); + expect(find.text('Continue with Google'), findsNothing); + expect(find.byType(TextFormField), findsNothing); + expect(requests, isEmpty); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(auth.isSignedIn, isTrue); + expect(requests, isEmpty); + await openDialog(tester); + await confirm(tester); + expect(auth.isSignedIn, isFalse); + expect(find.byType(AlertDialog), findsNothing); + expect(requests.map((request) => request.url.path), [ + '/v1/account-deletion/current', + ]); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('password account deletion uses the current session without another password prompt', (tester) async { + await setup(); + await showPage(tester); + await openDialog(tester); + await confirm(tester); + expect(find.textContaining('Account deletion scheduled for Nov 22, 2026'), findsOneWidget); + expect(auth.isSignedIn, isFalse); + expect(storage.value, isNull); + expect(find.byType(AlertDialog), findsNothing); + expect(requests.single.url.path, '/v1/account-deletion/current'); + expect(requests.single.headers['Authorization'], 'Bearer access'); + }); + + testWidgets('failed deletion keeps the account signed in and permits retry', (tester) async { + await setup(); + failSchedule = true; + await showPage(tester); + await openDialog(tester); + await confirm(tester); + expect(find.text('Try again later'), findsOneWidget); + expect(auth.isSignedIn, isTrue); + expect(storage.value, 'refresh'); + failSchedule = false; + await tester.tap(find.widgetWithText(FilledButton, 'Delete account')); + await tester.pumpAndSettle(); + expect(find.textContaining('Account deletion scheduled for'), findsOneWidget); + expect(requests.map((request) => request.url.path), [ + '/v1/account-deletion/current', + '/v1/account-deletion/current', + ]); + }); + + testWidgets('normal login offers recovery without another password prompt', (tester) async { + pending = true; + await setup(signedIn: false); + await showPage(tester, login: true); + await tester.enterText(find.byType(TextFormField).first, 'reader@example.com'); + await tester.enterText(find.byType(TextFormField).last, 'current-password'); + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); + expect(find.text('Keep your account?'), findsOneWidget); + expect(auth.isSignedIn, isFalse); + expect(requests.where((request) => request.url.path.endsWith('/cancel')), isEmpty); + await tester.tap(find.text('Not now')); + await tester.pumpAndSettle(); + expect(requests.where((request) => request.url.path.endsWith('/cancel')), isEmpty); + await tester.enterText(find.byType(TextFormField).last, 'current-password'); + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Keep account')); + await tester.pumpAndSettle(); + expect(find.text('Your library'), findsOneWidget); + expect(find.byType(SnackBar), findsNothing); + expect(auth.isSignedIn, isTrue); + expect(storage.value, 'restored-refresh'); + expect(find.byType(LoginPage), findsNothing); + expect(requests.where((request) => request.url.path.endsWith('/cancel')), hasLength(1)); + }); + + testWidgets('Google sign-in callback offers recovery and leaves it pending on dismissal', (tester) async { + pending = true; + await setup(signedIn: false); + await showPage(tester, callback: '/auth/callback?code=login-code'); + expect(find.text('Keep your account?'), findsOneWidget); + expect(auth.isSignedIn, isFalse); + await tester.tap(find.text('Not now')); + await tester.pumpAndSettle(); + expect(find.byType(LoginPage), findsOneWidget); + expect(requests.where((request) => request.url.path.endsWith('/cancel')), isEmpty); + }); + + testWidgets('Google sign-in recovery opens the library without signing in twice', (tester) async { + pending = true; + await setup(signedIn: false, connected: true); + await showPage(tester, callback: '/auth/callback?code=login-code'); + expect(find.text('Keep your account?'), findsOneWidget); + expect(auth.isSignedIn, isFalse); + failSchedule = true; + await tester.tap(find.text('Keep account')); + await tester.pumpAndSettle(); + expect(find.text('Try again later'), findsOneWidget); + expect(auth.isSignedIn, isFalse); + failSchedule = false; + await tester.tap(find.text('Keep account')); + await tester.pumpAndSettle(); + expect(find.text('Your library'), findsOneWidget); + expect(find.byType(SnackBar), findsNothing); + expect(auth.isSignedIn, isTrue); + expect(storage.value, 'restored-refresh'); + expect(requests.where((request) => request.url.path.endsWith('/exchange-code')), hasLength(1)); + expect(find.byType(LoginPage), findsNothing); + expect(find.byType(AlertDialog), findsNothing); + }); + + testWidgets('recovery succeeds when the proof appears expired on the device clock', (tester) async { + clockAhead = true; + pending = true; + await setup(signedIn: false); + await showPage(tester, callback: '/auth/callback?code=login-code'); + await tester.tap(find.text('Keep account')); + await tester.pumpAndSettle(); + expect(requests.where((request) => request.url.path.endsWith('/cancel')), hasLength(1)); + expect(auth.isSignedIn, isTrue); + expect(storage.value, 'restored-refresh'); + expect(find.text('Your library'), findsOneWidget); + expect(find.byType(AlertDialog), findsNothing); + }); + + testWidgets('server rejection of an expired proof leaves recovery pending', (tester) async { + rejectRecovery = true; + pending = true; + await setup(signedIn: false); + await showPage(tester, callback: '/auth/callback?code=login-code'); + await tester.tap(find.text('Keep account')); + await tester.pumpAndSettle(); + expect(find.text('Authorization is invalid or expired. Authenticate again.'), findsOneWidget); + expect(requests.where((request) => request.url.path.endsWith('/cancel')), hasLength(1)); + expect(auth.isSignedIn, isFalse); + expect(storage.value, 'refresh'); + expect(find.byType(AlertDialog), findsOneWidget); + expect(find.text('Your library'), findsNothing); + }); +} diff --git a/app/test/providers/auth_provider_test.dart b/app/test/providers/auth_provider_test.dart index c028953f..e875a7f2 100644 --- a/app/test/providers/auth_provider_test.dart +++ b/app/test/providers/auth_provider_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus/auth/auth_api_client.dart'; import 'package:papyrus/auth/auth_models.dart'; @@ -25,9 +27,9 @@ class MemoryRefreshTokenStorage implements RefreshTokenStorage { } class FakeAuthRepository extends AuthRepository { - FakeAuthRepository() + FakeAuthRepository({AuthApiClient? apiClient}) : super( - apiClient: AuthApiClient(config: PapyrusApiConfig(serverBaseUri: Uri.parse('http://server.test'))), + apiClient: apiClient ?? AuthApiClient(config: PapyrusApiConfig(serverBaseUri: Uri.parse('http://server.test'))), tokenStore: TokenStore(MemoryRefreshTokenStorage()), ); @@ -36,6 +38,18 @@ class FakeAuthRepository extends AuthRepository { AuthTokens? refreshResult; Object? refreshError; bool clearCalled = false; + Completer? clearStarted; + Completer? clearPause; + Completer? recoveryResult; + + @override + Future recoverAccount( + AccountDeletionAuthorization authorization, { + required String clientType, + String? deviceLabel, + }) { + return recoveryResult!.future; + } @override Future bootstrap() async { @@ -58,14 +72,72 @@ class FakeAuthRepository extends AuthRepository { @override Future clearTokens() async { clearCalled = true; + clearStarted?.complete(); + await clearPause?.future; } } +class _DeletionApi extends AuthApiClient { + _DeletionApi() : super(config: PapyrusApiConfig(serverBaseUri: Uri.parse('http://server.test'))); + + @override + Future deleteAccount(String accessToken) async => DateTime.now().add(const Duration(days: 30)); +} + void main() { setUp(() { SharedPreferences.setMockInitialValues({}); }); + test('deletion finishing after a server switch preserves the newly selected account', () async { + final prefs = await SharedPreferences.getInstance(); + + final oldRepository = FakeAuthRepository(apiClient: _DeletionApi()) + ..bootstrapResult = _tokens('Old server') + ..clearStarted = Completer() + ..clearPause = Completer(); + + final newRepository = FakeAuthRepository()..bootstrapResult = _tokens('New server'); + final provider = AuthProvider(prefs, repository: oldRepository, bootstrapOnCreate: false); + await provider.bootstrap(); + + final deletion = provider.deleteAccount(); + + await oldRepository.clearStarted!.future; + await provider.replaceRepository(newRepository); + oldRepository.clearPause!.complete(); + await deletion; + expect(provider.isSignedIn, isTrue); + expect(provider.user?.displayName, 'New server'); + expect(newRepository.clearCalled, isFalse); + provider.dispose(); + }); + + test('recovery finishing after a server switch preserves the newly selected account', () async { + final prefs = await SharedPreferences.getInstance(); + final oldRepository = FakeAuthRepository()..recoveryResult = Completer(); + final newRepository = FakeAuthRepository()..bootstrapResult = _tokens('New server'); + final provider = AuthProvider(prefs, repository: oldRepository, bootstrapOnCreate: false); + + final recovery = provider.recoverAccount( + AccountDeletionAuthorization( + token: 'restricted', + userId: _tokens('Old server').user.userId, + email: 'reader@example.com', + deletionPending: true, + deletionDueAt: DateTime.now().add(const Duration(days: 30)), + expiresAt: DateTime.now().add(const Duration(minutes: 5)), + ), + ); + + await provider.replaceRepository(newRepository); + oldRepository.recoveryResult!.complete(_tokens('Old server')); + expect(await recovery, isFalse); + expect(provider.isSignedIn, isTrue); + expect(provider.user?.displayName, 'New server'); + provider.dispose(); + }); + test('bootstraps signed in state from stored refresh token', () async { final prefs = await SharedPreferences.getInstance(); final repository = FakeAuthRepository()..bootstrapResult = _tokens('Bootstrap User'); diff --git a/app/test/services/book_import_session_test.dart b/app/test/services/book_import_session_test.dart index 5a969f36..d3f09176 100644 --- a/app/test/services/book_import_session_test.dart +++ b/app/test/services/book_import_session_test.dart @@ -30,7 +30,7 @@ class _Auth extends Fake implements AuthProvider { bool get isOfflineMode => false; @override - PapyrusUser get user => PapyrusUser.fromJson({'user_id': 'bob'}); + PapyrusUser get user => PapyrusUser.fromJson({'user_id': 'bob', 'connected_providers': []}); } class _Queue extends Fake implements MediaUploadQueue {