Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion app/lib/auth/auth_api_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -129,6 +136,28 @@ class AuthApiClient {
return PapyrusUser.fromJson(json);
}

Future<DateTime> 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<AuthTokens> 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<String> 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';
Expand Down Expand Up @@ -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<String, dynamic>)
: null,
message: error['message'] as String? ?? 'Authentication request failed',
);
}
Expand Down
32 changes: 32 additions & 0 deletions app/lib/auth/auth_models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ class PapyrusUser {
final bool emailVerified;
final DateTime? createdAt;
final DateTime? lastLoginAt;
final List<String> connectedProviders;

const PapyrusUser({
required this.userId,
Expand All @@ -17,6 +18,7 @@ class PapyrusUser {
required this.emailVerified,
required this.createdAt,
required this.lastLoginAt,
this.connectedProviders = const [],
});

factory PapyrusUser.fromJson(Map<String, dynamic> json) {
Expand All @@ -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<String>.unmodifiable(json['connected_providers'] as List),
);
}
}
Expand Down Expand Up @@ -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<String, dynamic> 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),
);
}
}
37 changes: 36 additions & 1 deletion app/lib/auth/auth_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class AuthRepository {
final TokenStore tokenStore;

Future<AuthTokens>? _refreshOperation;
int _tokenGeneration = 0;

AuthRepository({required this.apiClient, required this.tokenStore});

Expand Down Expand Up @@ -79,6 +80,8 @@ class AuthRepository {
}

Future<AuthTokens> _refresh() async {
final generation = _tokenGeneration;

try {
final refreshToken = await tokenStore.readRefreshToken();

Expand All @@ -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;
Expand Down Expand Up @@ -141,6 +152,29 @@ class AuthRepository {
return tokens;
}

Future<DateTime> deleteAccount() => _withFreshAccessToken(apiClient.deleteAccount);

Future<AuthTokens> 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<void> logout() async {
final accessToken = tokenStore.accessToken;

Expand Down Expand Up @@ -216,6 +250,7 @@ class AuthRepository {
}

Future<void> clearTokens() {
_tokenGeneration += 1;
return tokenStore.clear();
}

Expand Down
30 changes: 30 additions & 0 deletions app/lib/pages/auth/oauth_callback_page.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -16,6 +18,7 @@ class OAuthCallbackPage extends StatefulWidget {

class _OAuthCallbackPageState extends State<OAuthCallbackPage> {
String? _errorMessage;
bool _showingConfirmation = false;

@override
void initState() {
Expand All @@ -25,6 +28,8 @@ class _OAuthCallbackPageState extends State<OAuthCallbackPage> {

Future<void> _completeSignIn() async {
final authProvider = context.read<AuthProvider>();
clearBrowserQuery();

final success = await authProvider.completeGoogleSignIn(widget.callbackUri);

if (!mounted) {
Expand All @@ -36,6 +41,19 @@ class _OAuthCallbackPageState extends State<OAuthCallbackPage> {
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.';
});
Expand All @@ -45,6 +63,18 @@ class _OAuthCallbackPageState extends State<OAuthCallbackPage> {
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(
Expand Down
10 changes: 10 additions & 0 deletions app/lib/pages/login_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -64,6 +65,15 @@ class _LoginPageState extends State<LoginPage> {
}

if (!success) {
final recovery = context.read<AuthProvider>().takePendingRecovery();

if (recovery != null) {
_passwordController.clear();
setState(() => _isLoading = false);
await showAccountDeletionDialog(context, authorization: recovery);
return;
}

_showErrorSnackBar(context.read<AuthProvider>().error ?? 'Incorrect email or password.');
return;
}
Expand Down
2 changes: 2 additions & 0 deletions app/lib/platform/web_redirect_stub.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
void redirectTo(String url) {
throw UnsupportedError('Browser redirects are only available on web');
}

void clearBrowserQuery() {}
4 changes: 4 additions & 0 deletions app/lib/platform/web_redirect_web.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Loading
Loading