You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
decky/lib/services/api_auth_service.dart

258 lines
8.3 KiB

import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import '../env.dart';
import '../utils/connection_error.dart';
/// Logged-in user from the API.
class ApiUser {
final String id;
final String? email;
final String? displayName;
final String? avatarUrl;
const ApiUser({
required this.id,
this.email,
this.displayName,
this.avatarUrl,
});
ApiUser copyWith({
String? id,
String? email,
String? displayName,
String? avatarUrl,
}) {
return ApiUser(
id: id ?? this.id,
email: email ?? this.email,
displayName: displayName ?? this.displayName,
avatarUrl: avatarUrl ?? this.avatarUrl,
);
}
factory ApiUser.fromJson(Map<String, dynamic> json) {
final metadata = json['user_metadata'] as Map<String, dynamic>?;
final avatar = json['avatar_url'] as String?;
final avatarTrimmed = avatar?.trim();
return ApiUser(
id: json['id'] as String? ?? '',
email: json['email'] as String?,
displayName: json['display_name'] as String? ??
metadata?['display_name'] as String?,
avatarUrl: (avatarTrimmed != null && avatarTrimmed.isNotEmpty)
? avatarTrimmed
: null,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
if (email != null) 'email': email,
if (displayName != null) 'display_name': displayName,
if (avatarUrl != null) 'avatar_url': avatarUrl,
};
}
}
/// Auth service that uses the backend REST API (login, token storage).
class ApiAuthService {
ApiAuthService._();
static final ApiAuthService _instance = ApiAuthService._();
static ApiAuthService get instance => _instance;
static const String _tokenKey = 'api_auth_token';
static const String _userKey = 'api_auth_user';
final ValueNotifier<ApiUser?> currentUser = ValueNotifier<ApiUser?>(null);
String? _token;
Future<SharedPreferences> get _prefs async =>
await SharedPreferences.getInstance();
/// Call after app start to restore session from stored token.
Future<void> init() async {
final prefs = await _prefs;
_token = prefs.getString(_tokenKey);
final userJson = prefs.getString(_userKey);
if (_token != null && userJson != null) {
try {
final map = jsonDecode(userJson) as Map<String, dynamic>;
currentUser.value = ApiUser.fromJson(map);
} catch (_) {
await logout();
}
}
if (_token != null && currentUser.value == null) {
final ok = await _fetchSession();
if (!ok) await logout();
}
if (_token != null && currentUser.value != null) {
await _refreshProfile();
}
}
Future<bool> _fetchSession() async {
if (_token == null) return false;
try {
final uri = Uri.parse('$apiBaseUrl/api/auth/session');
final res = await http.get(
uri,
headers: {'Authorization': 'Bearer $_token'},
);
if (res.statusCode != 200) return false;
final data = jsonDecode(res.body) as Map<String, dynamic>;
final userJson = data['user'] as Map<String, dynamic>?;
if (userJson == null) return false;
currentUser.value = ApiUser.fromJson(userJson);
final prefs = await _prefs;
await prefs.setString(_userKey, jsonEncode(userJson));
return true;
} catch (_) {
return false;
}
}
/// GET /api/auth/profile — fetches profile (display_name, email, avatar_url) and updates currentUser.
Future<void> _refreshProfile() async {
if (_token == null) return;
try {
final uri = Uri.parse('$apiBaseUrl/api/auth/profile');
final res = await http.get(
uri,
headers: {'Authorization': 'Bearer $_token'},
);
if (res.statusCode != 200) return;
final data = jsonDecode(res.body) as Map<String, dynamic>;
final id = data['id'] as String? ?? currentUser.value?.id ?? '';
final displayName = data['display_name'] as String?;
final email = data['email'] as String?;
final avatarUrl = data['avatar_url'] as String?;
final trimmedAvatar = avatarUrl?.trim();
final newUser = (currentUser.value ?? ApiUser(id: id)).copyWith(
displayName: displayName ?? currentUser.value?.displayName,
email: email ?? currentUser.value?.email,
avatarUrl: (trimmedAvatar != null && trimmedAvatar.isNotEmpty)
? trimmedAvatar
: currentUser.value?.avatarUrl,
);
currentUser.value = newUser;
final prefs = await _prefs;
await prefs.setString(_userKey, jsonEncode(newUser.toJson()));
} catch (_) {
// Keep existing user if profile fetch fails
}
}
/// Returns the current Bearer token for API requests, or null if not logged in.
String? get token => _token;
/// Login with email or username and password.
/// Returns null on success, or an error message string.
Future<String?> login(String emailOrUsername, String password) async {
final trimmed = emailOrUsername.trim();
if (trimmed.isEmpty) return 'Enter your email or username.';
if (password.isEmpty) return 'Enter your password.';
try {
final uri = Uri.parse('$apiBaseUrl/api/auth/login');
final res = await http.post(
uri,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'email_or_username': trimmed,
'password': password,
}),
);
if (res.statusCode == 401) {
final data = jsonDecode(res.body) as Map<String, dynamic>?;
return data?['error'] as String? ??
data?['message'] as String? ??
'Invalid email or password.';
}
if (res.statusCode != 200) {
final data = jsonDecode(res.body) as Map<String, dynamic>?;
return data?['error'] as String? ??
data?['message'] as String? ??
'Login failed. Please try again.';
}
final data = jsonDecode(res.body) as Map<String, dynamic>;
_token = data['access_token'] as String?;
final userJson = data['user'] as Map<String, dynamic>?;
if (_token == null || userJson == null) {
return 'Invalid response from server.';
}
currentUser.value = ApiUser.fromJson(userJson);
final prefs = await _prefs;
await prefs.setString(_tokenKey, _token!);
await prefs.setString(_userKey, jsonEncode(userJson));
await _refreshProfile();
return null;
} catch (e) {
return connectionErrorMessage(e);
}
}
/// Register with email, password, and optional display name.
/// Returns null on success, or an error message string.
Future<String?> register(String email, String password, String displayName) async {
final trimmedEmail = email.trim();
if (trimmedEmail.isEmpty) return 'Enter your email.';
if (password.isEmpty) return 'Enter your password.';
try {
final uri = Uri.parse('$apiBaseUrl/api/auth/register');
final res = await http.post(
uri,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'email': trimmedEmail,
'password': password,
'displayName': displayName.trim().isEmpty ? null : displayName.trim(),
}),
);
if (res.statusCode == 400 || res.statusCode == 422) {
final data = jsonDecode(res.body) as Map<String, dynamic>?;
return data?['message'] as String? ?? 'Registration failed.';
}
if (res.statusCode != 200) {
return 'Registration failed. Please try again.';
}
final data = jsonDecode(res.body) as Map<String, dynamic>;
_token = data['access_token'] as String?;
final userJson = data['user'] as Map<String, dynamic>?;
if (_token == null || userJson == null) {
return 'Invalid response from server.';
}
currentUser.value = ApiUser.fromJson(userJson);
final prefs = await _prefs;
await prefs.setString(_tokenKey, _token!);
await prefs.setString(_userKey, jsonEncode(userJson));
await _refreshProfile();
return null;
} catch (e) {
return connectionErrorMessage(e);
}
}
/// Clears session (token + user) and persists. Call this to log out.
Future<void> logout() async {
final prefs = await _prefs;
await prefs.remove(_tokenKey);
await prefs.remove(_userKey);
_token = null;
currentUser.value = null;
}
}

Powered by TurnKey Linux.