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>
148 lines
5.6 KiB
Dart
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();
|