feat: Add initial backend server with user authentication, session management, and playlist CRUD functionality.

This commit is contained in:
Michael committed 2025-12-06 01:51:07 +01:00
1 parent a1ce183cd3
commit ac4f274031
19 files changed
+1324 -75

No files matched your search

+83
View File
@@ -0,0 +1,83 @@
import 'package:dio/dio.dart';
import 'dart:html' as html;
/// API Client for communicating with the backend
class ApiClient {
static final ApiClient _instance = ApiClient._internal();
factory ApiClient() => _instance;
late final Dio _dio;
String? _token;
ApiClient._internal() {
_dio = Dio(BaseOptions(
baseUrl: _getBaseUrl(),
headers: {'Content-Type': 'application/json'},
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
));
// Add interceptor for logging
_dio.interceptors.add(LogInterceptor(
requestBody: true,
responseBody: true,
));
}
/// Get base URL (same origin for production)
String _getBaseUrl() {
// Use current origin for API calls
return '';
}
/// Set authentication token
void setToken(String? token) {
_token = token;
if (token != null) {
_dio.options.headers['Authorization'] = 'Bearer $token';
// Store in localStorage for persistence
html.window.localStorage['auth_token'] = token;
} else {
_dio.options.headers.remove('Authorization');
html.window.localStorage.remove('auth_token');
}
}
/// Get stored token from localStorage
String? getStoredToken() {
return html.window.localStorage['auth_token'];
}
/// Restore token from localStorage
void restoreToken() {
final storedToken = getStoredToken();
if (storedToken != null) {
setToken(storedToken);
}
}
/// Clear token
void clearToken() {
setToken(null);
}
/// GET request
Future<Response> get(String path) async {
return _dio.get(path);
}
/// POST request
Future<Response> post(String path, {dynamic data}) async {
return _dio.post(path, data: data);
}
/// PUT request
Future<Response> put(String path, {dynamic data}) async {
return _dio.put(path, data: data);
}
/// DELETE request
Future<Response> delete(String path) async {
return _dio.delete(path);
}
}
+17 -1
View File
@@ -9,15 +9,20 @@ import '../../features/admin/screens/admin_panel.dart';
import '../models/playlist_config.dart';
final routerProvider = Provider<GoRouter>((ref) {
final authNotifier = ref.watch(authProvider.notifier);
final authState = ref.watch(authProvider);
return GoRouter(
initialLocation: '/login',
refreshListenable: RouterRefreshNotifier(ref),
redirect: (context, state) {
final isLoggedIn = authState.isAuthenticated;
final isLoginRoute = state.matchedLocation == '/login';
// Wait for initial auth check to complete
if (!authState.isInitialized) {
return null;
}
// Redirect to login if not authenticated
if (!isLoggedIn && !isLoginRoute) {
return '/login';
@@ -67,3 +72,14 @@ final routerProvider = Provider<GoRouter>((ref) {
],
);
});
/// Notifier that triggers router refresh when auth state changes
class RouterRefreshNotifier extends ChangeNotifier {
RouterRefreshNotifier(this._ref) {
_ref.listen(authProvider, (_, __) {
notifyListeners();
});
}
final Ref _ref;
}
+98
View File
@@ -0,0 +1,98 @@
import '../api/api_client.dart';
import '../models/app_user.dart';
/// Service for authentication via API
class AuthApiService {
final ApiClient _api = ApiClient();
/// Login with username and password
Future<AuthResult> login(String username, String password) async {
try {
final response = await _api.post('/api/auth/login', data: {
'username': username,
'password': password,
});
final data = response.data as Map<String, dynamic>;
if (data['success'] == true) {
final token = data['token'] as String;
final userData = data['user'] as Map<String, dynamic>;
_api.setToken(token);
return AuthResult(
success: true,
user: AppUser(
id: userData['id'] as String,
username: userData['username'] as String,
passwordHash: '', // Not needed for API auth
isAdmin: userData['isAdmin'] as bool? ?? false,
createdAt: DateTime.now(),
),
token: token,
);
} else {
return AuthResult(
success: false,
error: data['error'] as String? ?? 'Login failed',
);
}
} catch (e) {
return AuthResult(
success: false,
error: 'Network error: $e',
);
}
}
/// Logout
Future<void> logout() async {
try {
await _api.post('/api/auth/logout');
} finally {
_api.clearToken();
}
}
/// Get current user from token
Future<AppUser?> getCurrentUser() async {
_api.restoreToken();
if (_api.getStoredToken() == null) {
return null;
}
try {
final response = await _api.get('/api/auth/me');
final data = response.data as Map<String, dynamic>;
final userData = data['user'] as Map<String, dynamic>;
return AppUser(
id: userData['id'] as String,
username: userData['username'] as String,
passwordHash: '',
isAdmin: userData['isAdmin'] as bool? ?? false,
createdAt: DateTime.now(),
);
} catch (e) {
_api.clearToken();
return null;
}
}
}
/// Result of authentication attempt
class AuthResult {
final bool success;
final AppUser? user;
final String? token;
final String? error;
AuthResult({
required this.success,
this.user,
this.token,
this.error,
});
}
+116
View File
@@ -0,0 +1,116 @@
import '../api/api_client.dart';
import '../models/playlist_config.dart';
/// Service for managing playlists via API
class PlaylistApiService {
final ApiClient _api = ApiClient();
/// Get all playlists for current user
Future<List<PlaylistConfig>> getPlaylists() async {
try {
final response = await _api.get('/api/playlists');
final data = response.data as Map<String, dynamic>;
final playlistsData = data['playlists'] as List<dynamic>;
return playlistsData.map((p) {
final playlist = p as Map<String, dynamic>;
return PlaylistConfig(
id: playlist['id'] as String,
name: playlist['name'] as String,
dns: playlist['serverUrl'] as String? ?? playlist['dns'] as String,
username: playlist['username'] as String,
password: playlist['password'] as String,
createdAt: DateTime.tryParse(playlist['createdAt'] as String? ?? '') ?? DateTime.now(),
);
}).toList();
} catch (e) {
print('Error fetching playlists: $e');
return [];
}
}
/// Create a new playlist
Future<PlaylistConfig?> createPlaylist({
required String name,
required String dns,
required String username,
required String password,
}) async {
try {
final response = await _api.post('/api/playlists', data: {
'name': name,
'serverUrl': dns,
'username': username,
'password': password,
'dns': dns,
});
final data = response.data as Map<String, dynamic>;
if (data['success'] == true) {
final playlist = data['playlist'] as Map<String, dynamic>;
return PlaylistConfig(
id: playlist['id'] as String,
name: playlist['name'] as String,
dns: playlist['serverUrl'] as String? ?? playlist['dns'] as String,
username: playlist['username'] as String,
password: playlist['password'] as String,
createdAt: DateTime.tryParse(playlist['createdAt'] as String? ?? '') ?? DateTime.now(),
);
}
return null;
} catch (e) {
print('Error creating playlist: $e');
return null;
}
}
/// Update a playlist
Future<PlaylistConfig?> updatePlaylist({
required String id,
required String name,
required String dns,
required String username,
required String password,
}) async {
try {
final response = await _api.put('/api/playlists/$id', data: {
'name': name,
'serverUrl': dns,
'username': username,
'password': password,
'dns': dns,
});
final data = response.data as Map<String, dynamic>;
if (data['success'] == true) {
final playlist = data['playlist'] as Map<String, dynamic>;
return PlaylistConfig(
id: playlist['id'] as String,
name: playlist['name'] as String,
dns: playlist['serverUrl'] as String? ?? playlist['dns'] as String,
username: playlist['username'] as String,
password: playlist['password'] as String,
createdAt: DateTime.tryParse(playlist['createdAt'] as String? ?? '') ?? DateTime.now(),
);
}
return null;
} catch (e) {
print('Error updating playlist: $e');
return null;
}
}
/// Delete a playlist
Future<bool> deletePlaylist(String id) async {
try {
final response = await _api.delete('/api/playlists/$id');
final data = response.data as Map<String, dynamic>;
return data['success'] == true;
} catch (e) {
print('Error deleting playlist: $e');
return false;
}
}
}