Add Supa account and search changes
This commit is contained in:
@@ -0,0 +1,408 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:supabase/supabase.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:weblibre/core/logger.dart';
|
||||
import 'package:weblibre/core/providers/device_info.dart';
|
||||
import 'package:weblibre/features/about/domain/providers.dart';
|
||||
import 'package:weblibre/features/account/data/account_secure_store.dart';
|
||||
import 'package:weblibre/features/account/data/models/account_auth_state.dart';
|
||||
import 'package:weblibre/features/account/data/models/account_persisted_data.dart';
|
||||
import 'package:weblibre/features/account/data/models/persisted_session.dart';
|
||||
import 'package:weblibre/features/account/data/supabase_config.dart';
|
||||
import 'package:weblibre/features/account/domain/services/handoff_redeem_client.dart';
|
||||
import 'package:weblibre/features/account/domain/utils/pkce.dart';
|
||||
|
||||
// Re-export so call sites that already imported AccountAuthFlowException from
|
||||
// this repository keep compiling after the redeem client split.
|
||||
export 'package:weblibre/features/account/domain/services/handoff_redeem_client.dart'
|
||||
show AccountAuthFlowException;
|
||||
|
||||
part 'account_auth.g.dart';
|
||||
|
||||
/// Convert any thrown error into a message safe to show in the UI.
|
||||
/// Untrusted exception strings (e.g. `e.toString()` for arbitrary HTTP /
|
||||
/// platform errors) can include response bodies, headers, or auth tokens —
|
||||
/// log them in full but never put them in user-visible state.
|
||||
String _sanitizeAuthError(Object error, String fallback) {
|
||||
if (error is AccountAuthFlowException) {
|
||||
return error.userMessage;
|
||||
}
|
||||
if (error is AuthRetryableFetchException) {
|
||||
return 'Network error. Please check your connection and try again.';
|
||||
}
|
||||
if (error is AuthException) {
|
||||
return error.message;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class AccountAuthRepository extends _$AccountAuthRepository {
|
||||
StreamSubscription<AuthState>? _authSubscription;
|
||||
Timer? _signingInTimeout;
|
||||
Timer? _restoreRetryTimer;
|
||||
|
||||
AccountSecureStore get _store => ref.read(accountSecureStoreProvider);
|
||||
HandoffRedeemClient get _redeemClient =>
|
||||
ref.read(handoffRedeemClientProvider);
|
||||
|
||||
AccountAuthState get _currentOrEmpty => state.value ?? AccountAuthState();
|
||||
|
||||
@override
|
||||
Future<AccountAuthState> build() async {
|
||||
ref.onDispose(() async {
|
||||
_signingInTimeout?.cancel();
|
||||
_restoreRetryTimer?.cancel();
|
||||
await _authSubscription?.cancel();
|
||||
final client = state.value?.client;
|
||||
await client?.dispose();
|
||||
});
|
||||
|
||||
_restoreRetryTimer?.cancel();
|
||||
_restoreRetryTimer = null;
|
||||
|
||||
final data = await _store.read();
|
||||
|
||||
if (data.session == null) {
|
||||
return AccountAuthState();
|
||||
}
|
||||
|
||||
try {
|
||||
final client = _createClient();
|
||||
final response = await client.auth.setSession(data.session!.refreshToken);
|
||||
|
||||
if (response.session != null) {
|
||||
_listenToAuthState(client);
|
||||
final user = response.session!.user;
|
||||
|
||||
await _persistSession(response.session!, data);
|
||||
|
||||
return AccountAuthState(
|
||||
status: AccountAuthStatus.signedIn,
|
||||
email: user.email,
|
||||
displayName:
|
||||
user.userMetadata?['display_name'] as String? ??
|
||||
user.userMetadata?['full_name'] as String? ??
|
||||
user.email,
|
||||
userId: user.id,
|
||||
syncKey: data.syncKey,
|
||||
client: client,
|
||||
);
|
||||
} else {
|
||||
await client.dispose();
|
||||
return AccountAuthState();
|
||||
}
|
||||
} on AuthRetryableFetchException catch (e) {
|
||||
// Transient network error — preserve session and retry shortly.
|
||||
return _transientRestoreFailure(data, e);
|
||||
} on AuthException {
|
||||
// Definitive auth failure (expired/revoked token) — clear credentials.
|
||||
await _store.clear();
|
||||
return AccountAuthState();
|
||||
} catch (e) {
|
||||
// Non-auth error (e.g. SocketException) — also transient, preserve.
|
||||
return _transientRestoreFailure(data, e);
|
||||
}
|
||||
}
|
||||
|
||||
AccountAuthState _transientRestoreFailure(
|
||||
AccountPersistedData data,
|
||||
Object error,
|
||||
) {
|
||||
_scheduleRestoreRetry();
|
||||
return AccountAuthState(
|
||||
status: AccountAuthStatus.error,
|
||||
email: data.email,
|
||||
displayName: data.displayName ?? data.email,
|
||||
userId: data.userId,
|
||||
syncKey: data.syncKey,
|
||||
lastError: _sanitizeAuthError(
|
||||
error,
|
||||
'Could not restore your account session. Retrying shortly.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _scheduleRestoreRetry() {
|
||||
_restoreRetryTimer?.cancel();
|
||||
_restoreRetryTimer = Timer(const Duration(seconds: 30), () {
|
||||
if (ref.mounted) {
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -- Auth state listener ---------------------------------------------------
|
||||
|
||||
SupabaseClient _createClient() {
|
||||
return SupabaseClient(
|
||||
SupabaseConfig.supabaseUrl,
|
||||
SupabaseConfig.supabaseAnonKey,
|
||||
);
|
||||
}
|
||||
|
||||
void _listenToAuthState(SupabaseClient client) {
|
||||
unawaited(_authSubscription?.cancel());
|
||||
_authSubscription = client.auth.onAuthStateChange.listen((data) {
|
||||
if (data.event == AuthChangeEvent.signedOut ||
|
||||
// ignore: deprecated_member_use
|
||||
data.event == AuthChangeEvent.userDeleted) {
|
||||
unawaited(_handleSignedOut());
|
||||
} else if (data.event == AuthChangeEvent.tokenRefreshed &&
|
||||
data.session != null) {
|
||||
unawaited(_persistSessionRefresh(data.session!));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _handleSignedOut() async {
|
||||
await _store.clear();
|
||||
// Stashed Privacy Pass tokens survive sign-out: they are anonymous
|
||||
// blobs already redeemed against the user's credit balance, and the
|
||||
// backend cannot link them back to the issuing account. Clearing them
|
||||
// here would destroy prepaid value with no refund path.
|
||||
await _authSubscription?.cancel();
|
||||
final client = state.value?.client;
|
||||
await client?.dispose();
|
||||
state = AsyncData(AccountAuthState());
|
||||
}
|
||||
|
||||
// -- Sign-in flow ----------------------------------------------------------
|
||||
|
||||
Future<void> startSignIn() async {
|
||||
_signingInTimeout?.cancel();
|
||||
_signingInTimeout = null;
|
||||
state = AsyncData(
|
||||
_currentOrEmpty.copyWith(status: AccountAuthStatus.signingIn),
|
||||
);
|
||||
|
||||
try {
|
||||
final codes = PkceCodes.generate();
|
||||
|
||||
final data = await _store.read();
|
||||
await _store.write(data.copyWith(pendingCodeVerifier: codes.verifier));
|
||||
|
||||
final queryParams = <String, String>{
|
||||
'mode': 'handoff',
|
||||
'code_challenge': codes.challenge,
|
||||
};
|
||||
|
||||
final packageInfoData = ref.read(packageInfoProvider).value;
|
||||
if (packageInfoData != null) {
|
||||
queryParams['app_version'] =
|
||||
'${packageInfoData.version}+${packageInfoData.buildNumber}';
|
||||
}
|
||||
|
||||
final deviceInfoData = ref.read(androidDeviceInfoProvider).value;
|
||||
if (deviceInfoData != null) {
|
||||
queryParams['device_name'] = deviceInfoData.deviceName;
|
||||
}
|
||||
|
||||
final baseUri = Uri.parse(SupabaseConfig.accountWebUrl);
|
||||
final uri = baseUri.replace(queryParameters: queryParams);
|
||||
|
||||
final launched = await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
|
||||
if (!launched) {
|
||||
state = AsyncData(
|
||||
_currentOrEmpty.copyWith(
|
||||
status: AccountAuthStatus.error,
|
||||
lastError: 'Could not open sign-in page',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the timer last so any earlier error path doesn't have to think
|
||||
// about cancelling a timer it never started. Guard the body so that a
|
||||
// timer fired after handleHandoffCode has reset _signingInTimeout to
|
||||
// null does nothing.
|
||||
late final Timer timer;
|
||||
timer = Timer(const Duration(minutes: 5), () {
|
||||
if (!identical(_signingInTimeout, timer)) return;
|
||||
_signingInTimeout = null;
|
||||
if (state.value?.status == AccountAuthStatus.signingIn) {
|
||||
state = AsyncData(
|
||||
AccountAuthState(
|
||||
status: AccountAuthStatus.error,
|
||||
lastError: 'Sign-in timed out. Please try again.',
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
_signingInTimeout = timer;
|
||||
} catch (e, s) {
|
||||
logger.e('startSignIn failed', error: e, stackTrace: s);
|
||||
state = AsyncData(
|
||||
_currentOrEmpty.copyWith(
|
||||
status: AccountAuthStatus.error,
|
||||
lastError: _sanitizeAuthError(
|
||||
e,
|
||||
'Could not open the sign-in page. Please try again.',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cancelSignIn() async {
|
||||
_signingInTimeout?.cancel();
|
||||
_signingInTimeout = null;
|
||||
|
||||
// Clear the pending code verifier so a late browser callback is rejected.
|
||||
final data = await _store.read();
|
||||
// ignore: avoid_redundant_argument_values
|
||||
await _store.write(data.copyWith(pendingCodeVerifier: null));
|
||||
|
||||
state = AsyncData(AccountAuthState());
|
||||
}
|
||||
|
||||
Future<void> handleHandoffCode(String code) async {
|
||||
_signingInTimeout?.cancel();
|
||||
_signingInTimeout = null;
|
||||
state = AsyncData(
|
||||
_currentOrEmpty.copyWith(status: AccountAuthStatus.signingIn),
|
||||
);
|
||||
|
||||
try {
|
||||
final data = await _store.read();
|
||||
final codeVerifier = data.pendingCodeVerifier;
|
||||
|
||||
if (codeVerifier == null) {
|
||||
throw AccountAuthFlowException(
|
||||
'No pending sign-in found. Please start sign-in again.',
|
||||
);
|
||||
}
|
||||
|
||||
final result = await _redeemClient.redeem(
|
||||
handoffCode: code,
|
||||
codeVerifier: codeVerifier,
|
||||
);
|
||||
|
||||
final refreshToken = result.session['refresh_token'] as String;
|
||||
|
||||
final previousClient = _currentOrEmpty.client;
|
||||
final newClient = _createClient();
|
||||
|
||||
try {
|
||||
await newClient.auth.setSession(refreshToken);
|
||||
} catch (e) {
|
||||
await newClient.dispose();
|
||||
rethrow;
|
||||
}
|
||||
|
||||
await previousClient?.dispose();
|
||||
_listenToAuthState(newClient);
|
||||
|
||||
// Persist session and clear the verifier in one write. Reuse the
|
||||
// existing persisted record via copyWith so any unrelated fields
|
||||
// (notably syncKey) survive a re-sign-in without being clobbered.
|
||||
final persistedSession = PersistedSession.fromJson(result.session);
|
||||
final user = result.session['user'] as Map<String, dynamic>?;
|
||||
await _store.write(
|
||||
data.copyWith(
|
||||
session: persistedSession,
|
||||
userId: user?['id'] as String?,
|
||||
email: user?['email'] as String?,
|
||||
displayName:
|
||||
(user?['user_metadata'] as Map<String, dynamic>?)?['display_name']
|
||||
as String?,
|
||||
// ignore: avoid_redundant_argument_values
|
||||
pendingCodeVerifier: null,
|
||||
),
|
||||
);
|
||||
|
||||
state = AsyncData(
|
||||
AccountAuthState(
|
||||
status: AccountAuthStatus.signedIn,
|
||||
email: result.account['email'] as String?,
|
||||
displayName: result.account['display_name'] as String?,
|
||||
userId: result.account['user_id'] as String?,
|
||||
syncKey: _currentOrEmpty.syncKey,
|
||||
client: newClient,
|
||||
),
|
||||
);
|
||||
} catch (e, s) {
|
||||
logger.e('handleHandoffCode failed', error: e, stackTrace: s);
|
||||
state = AsyncData(
|
||||
_currentOrEmpty.copyWith(
|
||||
status: AccountAuthStatus.error,
|
||||
lastError: _sanitizeAuthError(e, 'Sign-in failed. Please try again.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> signOut() async {
|
||||
try {
|
||||
await state.value?.client?.auth.signOut();
|
||||
} catch (_) {
|
||||
// Sign out may fail if the session is already invalid
|
||||
}
|
||||
await _handleSignedOut();
|
||||
}
|
||||
|
||||
// -- Sync key management ---------------------------------------------------
|
||||
|
||||
Future<void> setSyncKey(String key) async {
|
||||
final data = await _store.read();
|
||||
await _store.write(data.copyWith(syncKey: key));
|
||||
state = AsyncData(_currentOrEmpty.copyWith(syncKey: key));
|
||||
}
|
||||
|
||||
Future<void> clearSyncKey() async {
|
||||
final data = await _store.read();
|
||||
// ignore: avoid_redundant_argument_values
|
||||
await _store.write(data.copyWith(syncKey: null));
|
||||
// ignore: avoid_redundant_argument_values
|
||||
state = AsyncData(_currentOrEmpty.copyWith(syncKey: null));
|
||||
}
|
||||
|
||||
// -- Session persistence helpers ------------------------------------------
|
||||
|
||||
Future<void> _persistSession(
|
||||
Session session,
|
||||
AccountPersistedData current,
|
||||
) async {
|
||||
await _store.write(
|
||||
current.copyWith(
|
||||
session: PersistedSession(
|
||||
accessToken: session.accessToken,
|
||||
refreshToken: session.refreshToken!,
|
||||
tokenType: session.tokenType,
|
||||
expiresIn: session.expiresIn ?? 3600,
|
||||
),
|
||||
userId: session.user.id,
|
||||
email: session.user.email,
|
||||
displayName:
|
||||
session.user.userMetadata?['display_name'] as String? ??
|
||||
session.user.userMetadata?['full_name'] as String?,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _persistSessionRefresh(Session session) async {
|
||||
final data = await _store.read();
|
||||
await _persistSession(session, data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'account_auth.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(AccountAuthRepository)
|
||||
final accountAuthRepositoryProvider = AccountAuthRepositoryProvider._();
|
||||
|
||||
final class AccountAuthRepositoryProvider
|
||||
extends $AsyncNotifierProvider<AccountAuthRepository, AccountAuthState> {
|
||||
AccountAuthRepositoryProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'accountAuthRepositoryProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$accountAuthRepositoryHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
AccountAuthRepository create() => AccountAuthRepository();
|
||||
}
|
||||
|
||||
String _$accountAuthRepositoryHash() =>
|
||||
r'604bd3954347d0ed6cd4f894bff8388642787104';
|
||||
|
||||
abstract class _$AccountAuthRepository
|
||||
extends $AsyncNotifier<AccountAuthState> {
|
||||
FutureOr<AccountAuthState> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref =
|
||||
this.ref as $Ref<AsyncValue<AccountAuthState>, AccountAuthState>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<AsyncValue<AccountAuthState>, AccountAuthState>,
|
||||
AsyncValue<AccountAuthState>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/features/account/data/models/subscription_status.dart';
|
||||
import 'package:weblibre/features/account/domain/repositories/account_auth.dart';
|
||||
|
||||
part 'subscription_repository.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class SubscriptionRepository extends _$SubscriptionRepository {
|
||||
@override
|
||||
Future<SubscriptionStatus> build() async {
|
||||
final authState = ref.watch(accountAuthRepositoryProvider).value;
|
||||
if (authState == null || !authState.isSignedIn) {
|
||||
// Signed-out is not an error; it's a known "no subscription" state.
|
||||
return SubscriptionStatus.inactive;
|
||||
}
|
||||
|
||||
// Don't swallow RPC failures as `SubscriptionStatus.inactive` — that
|
||||
// renders identically to "user has no subscription" and would prompt
|
||||
// them to subscribe when the actual fix is to retry. Let errors
|
||||
// propagate so the AsyncValue carries them.
|
||||
final client = authState.client!;
|
||||
final response = await client.rpc('get_my_subscription_status').single();
|
||||
return SubscriptionStatus.fromJson(response);
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
ref.invalidateSelf();
|
||||
// Fire-and-forget; consumers re-render from the new AsyncValue.
|
||||
try {
|
||||
await future;
|
||||
} catch (_) {
|
||||
// intentionally ignored — see comment above
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'subscription_repository.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(SubscriptionRepository)
|
||||
final subscriptionRepositoryProvider = SubscriptionRepositoryProvider._();
|
||||
|
||||
final class SubscriptionRepositoryProvider
|
||||
extends $AsyncNotifierProvider<SubscriptionRepository, SubscriptionStatus> {
|
||||
SubscriptionRepositoryProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'subscriptionRepositoryProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$subscriptionRepositoryHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
SubscriptionRepository create() => SubscriptionRepository();
|
||||
}
|
||||
|
||||
String _$subscriptionRepositoryHash() =>
|
||||
r'c618435ad9e22316809a0a7f573dcea28301ba46';
|
||||
|
||||
abstract class _$SubscriptionRepository
|
||||
extends $AsyncNotifier<SubscriptionStatus> {
|
||||
FutureOr<SubscriptionStatus> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref =
|
||||
this.ref as $Ref<AsyncValue<SubscriptionStatus>, SubscriptionStatus>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<AsyncValue<SubscriptionStatus>, SubscriptionStatus>,
|
||||
AsyncValue<SubscriptionStatus>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user