Files
tajniak81andClaude Opus 4.8 afc6952eda Initial commit: PilotVault multi-service project
Add API Server (Go/PocketBase), Web App (Go BFF + Vue), Fly App
(Flutter/DJI MSDK), Adobe Plugin, and Docker/Docker AIO deployment
configs. Design assets and build artifacts are gitignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 11:43:33 +02:00

148 lines
5.6 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:shared_preferences/shared_preferences.dart';
enum AuthStatus { signedOut, signingIn, signedIn, error }
/// Authentication through the **API Server** (login only, for now).
///
/// The app talks only to the API Server; PocketBase is hidden behind it. The
/// API Server address is configurable on the login screen ("Server settings").
/// A single global [auth] instance is shared across the app.
class PbAuth {
static const String defaultServer = 'http://10.2.1.101:8080';
SharedPreferences? _prefs;
String? _token;
String _email = '';
final StreamController<AuthStatus> _statusController =
StreamController<AuthStatus>.broadcast();
Stream<AuthStatus> get status => _statusController.stream;
AuthStatus _status = AuthStatus.signedOut;
AuthStatus get currentStatus => _status;
bool get isAuthed => _token != null && _token!.isNotEmpty;
String get userEmail => _email;
String get token => _token ?? '';
String get serverUrl => _prefs?.getString('api_url') ?? defaultServer;
/// A previously successful password login can be replayed via biometrics.
/// Credentials are stored on-device (see [signIn]); presence of a saved
/// password means "remembered account" and unlocks the biometric buttons.
bool get hasRememberedAccount => (_prefs?.getString('remember_password') ?? '').isNotEmpty;
String get rememberedEmail => _prefs?.getString('remember_email') ?? '';
void _set(AuthStatus s) {
_status = s;
if (!_statusController.isClosed) _statusController.add(s);
}
/// Restores a persisted session (if any) on app start.
Future<void> init() async {
_prefs = await SharedPreferences.getInstance();
_token = _prefs!.getString('api_token');
_email = _prefs!.getString('api_email') ?? '';
if (isAuthed) _set(AuthStatus.signedIn);
}
Future<void> signIn({
required String serverUrl,
required String email,
required String password,
}) async {
final String base = _normalize(serverUrl);
_set(AuthStatus.signingIn);
try {
final Map<String, dynamic> result = await _postLogin(base, email.trim(), password);
final String? token = result['token'] as String?;
if (token == null || token.isEmpty) {
throw const _AuthException('Unexpected response from the API server.');
}
_token = token;
_email = ((result['record'] as Map?)?['email'] as String?) ?? email.trim();
await _prefs?.setString('api_url', base);
await _prefs?.setString('api_token', _token!);
await _prefs?.setString('api_email', _email);
// Remember the credentials so a later biometric/face check can replay them.
// NOTE: stored in plain SharedPreferences like the token above — move to
// flutter_secure_storage (Keystore) when hardening.
await _prefs?.setString('remember_email', email.trim());
await _prefs?.setString('remember_password', password);
_set(AuthStatus.signedIn);
} catch (e) {
_set(AuthStatus.error);
rethrow;
}
}
/// Replays the remembered credentials — call this only after the caller has
/// passed a device biometric / face check.
Future<void> signInWithRememberedCredentials() async {
final String email = _prefs?.getString('remember_email') ?? '';
final String password = _prefs?.getString('remember_password') ?? '';
if (password.isEmpty) {
throw const _AuthException('No remembered account. Sign in with your password once first.');
}
await signIn(serverUrl: serverUrl, email: email, password: password);
}
/// Clears the remembered credentials (disables biometric quick-login) without
/// necessarily ending the current session.
Future<void> forgetAccount() async {
await _prefs?.remove('remember_email');
await _prefs?.remove('remember_password');
}
Future<Map<String, dynamic>> _postLogin(String base, String email, String password) async {
final HttpClient http = HttpClient()..connectionTimeout = const Duration(seconds: 10);
try {
final HttpClientRequest req = await http.postUrl(Uri.parse('$base/api/auth/login'));
req.headers.contentType = ContentType.json;
req.add(utf8.encode(jsonEncode(<String, String>{'email': email, 'password': password})));
final HttpClientResponse resp = await req.close().timeout(const Duration(seconds: 12));
final String text = await resp.transform(utf8.decoder).join();
final Map<String, dynamic> body =
text.isNotEmpty ? (jsonDecode(text) as Map).cast<String, dynamic>() : <String, dynamic>{};
if (resp.statusCode == 200) return body;
if (resp.statusCode == 400) throw const _AuthException('Invalid email or password.');
if (resp.statusCode == 502) throw const _AuthException("API server can't reach PocketBase.");
throw _AuthException(
(body['message'] ?? body['error'] ?? 'Login failed (${resp.statusCode})').toString(),
);
} finally {
http.close(force: true);
}
}
Future<void> signOut() async {
_token = null;
_email = '';
await _prefs?.remove('api_token');
await _prefs?.remove('api_email');
_set(AuthStatus.signedOut);
}
String _normalize(String url) {
String u = url.trim();
if (u.isEmpty) return defaultServer;
if (!u.startsWith('http://') && !u.startsWith('https://')) u = 'http://$u';
if (u.endsWith('/')) u = u.substring(0, u.length - 1);
return u;
}
}
class _AuthException implements Exception {
const _AuthException(this.message);
final String message;
@override
String toString() => message;
}
/// App-wide authentication instance.
final PbAuth auth = PbAuth();