Адрес бэкенда хранится в shared_preferences и читается до runApp; switchApiBaseUrl сбрасывает токены и кэш старого сервера. Выход стирает refresh-токен и sqlite-кэш ответов. Экран настроек переделан под разделы.
135 lines
3.9 KiB
Dart
135 lines
3.9 KiB
Dart
import 'package:dio/dio.dart';
|
|
import 'package:fintracker_api/fintracker_api.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../cache/response_cache_provider.dart';
|
|
import '../config.dart';
|
|
import 'token_store.dart';
|
|
|
|
enum AuthStatus { unknown, signedOut, signedIn }
|
|
|
|
class AuthState {
|
|
const AuthState(this.status, {this.accessToken, this.email});
|
|
final AuthStatus status;
|
|
final String? accessToken;
|
|
final String? email;
|
|
|
|
bool get signedIn => status == AuthStatus.signedIn;
|
|
}
|
|
|
|
final tokenStoreProvider = Provider<TokenStore>((_) => TokenStore());
|
|
|
|
/// A bare Dio for auth calls: no auth interceptor, so a refresh can never recurse.
|
|
final authDioProvider = Provider<Dio>(
|
|
(ref) => Dio(
|
|
BaseOptions(
|
|
baseUrl: ref.watch(apiBaseUrlProvider),
|
|
connectTimeout: const Duration(seconds: 10),
|
|
receiveTimeout: const Duration(seconds: 20),
|
|
),
|
|
),
|
|
);
|
|
|
|
final authControllerProvider = NotifierProvider<AuthController, AuthState>(
|
|
AuthController.new,
|
|
);
|
|
|
|
class AuthController extends Notifier<AuthState> {
|
|
@override
|
|
AuthState build() {
|
|
Future.microtask(_restore);
|
|
return const AuthState(AuthStatus.unknown);
|
|
}
|
|
|
|
AuthApi get _api => AuthApi(ref.read(authDioProvider));
|
|
TokenStore get _store => ref.read(tokenStoreProvider);
|
|
|
|
Future<void> _restore() async {
|
|
final refresh = await _store.readRefresh();
|
|
if (refresh == null) {
|
|
state = const AuthState(AuthStatus.signedOut);
|
|
return;
|
|
}
|
|
final ok = await refreshSession(refresh);
|
|
if (!ok) state = const AuthState(AuthStatus.signedOut);
|
|
}
|
|
|
|
Future<String?> login(String email, String password) async {
|
|
try {
|
|
final r = await _api.authLogin(
|
|
loginRequest: LoginRequest(email: email, password: password),
|
|
);
|
|
await _accept(r.data!);
|
|
return null;
|
|
} on DioException catch (e) {
|
|
return problemMessage(e);
|
|
}
|
|
}
|
|
|
|
/// Exchange a refresh token for a new pair. Returns false when it is no longer valid.
|
|
Future<bool> refreshSession([String? refresh]) async {
|
|
final token = refresh ?? await _store.readRefresh();
|
|
if (token == null) return false;
|
|
try {
|
|
final r = await _api.authRefresh(
|
|
refreshRequest: RefreshRequest(refreshToken: token),
|
|
);
|
|
await _accept(r.data!);
|
|
return true;
|
|
} on DioException catch (e) {
|
|
if (e.response?.statusCode == 401) await _wipeLocal();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<void> _accept(TokenPair pair) async {
|
|
await _store.writeRefresh(pair.refreshToken);
|
|
state = AuthState(
|
|
AuthStatus.signedIn,
|
|
accessToken: pair.accessToken,
|
|
email: state.email,
|
|
);
|
|
}
|
|
|
|
Future<void> logout() async {
|
|
final refresh = await _store.readRefresh();
|
|
if (refresh != null) {
|
|
try {
|
|
await _api.authLogout(
|
|
refreshRequest: RefreshRequest(refreshToken: refresh),
|
|
);
|
|
} on DioException {
|
|
// the server may already consider it revoked; local state wins
|
|
}
|
|
}
|
|
await _wipeLocal();
|
|
state = const AuthState(AuthStatus.signedOut);
|
|
}
|
|
|
|
/// Everything this device keeps for the account: the refresh token and the cached
|
|
/// responses (the whole financial picture, in plain sqlite / IndexedDB).
|
|
Future<void> _wipeLocal() async {
|
|
await _store.clear();
|
|
try {
|
|
await ref.read(responseCacheDbProvider).clear();
|
|
} on Object {
|
|
// the token is already gone; a cache that cannot be opened must not block signing out
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Human-readable text from an RFC 7807 body, falling back to the transport error.
|
|
String problemMessage(DioException e) {
|
|
final data = e.response?.data;
|
|
if (data is Map) {
|
|
final detail = data['detail'] ?? data['title'];
|
|
if (detail is String && detail.isNotEmpty) return detail;
|
|
}
|
|
return switch (e.type) {
|
|
DioExceptionType.connectionError ||
|
|
DioExceptionType.connectionTimeout ||
|
|
DioExceptionType.receiveTimeout => 'Сервер недоступен',
|
|
_ => e.message ?? 'Ошибка запроса',
|
|
};
|
|
}
|