diff --git a/flutter_client/lib/api/endpoints/health.dart b/flutter_client/lib/api/endpoints/health.dart new file mode 100644 index 00000000..5f43b117 --- /dev/null +++ b/flutter_client/lib/api/endpoints/health.dart @@ -0,0 +1,14 @@ +import 'package:dio/dio.dart'; + +class HealthApi { + HealthApi(this._dio); + final Dio _dio; + + /// Returns {status, min_client_version}. Hits the unauthenticated + /// `/healthz` endpoint at the configured base URL. + Future> check() async { + final r = await _dio.get>('/healthz'); + return (r.data ?? const {}) + .map((k, v) => MapEntry(k, v.toString())); + } +} diff --git a/flutter_client/lib/auth/auth_provider.dart b/flutter_client/lib/auth/auth_provider.dart new file mode 100644 index 00000000..af8ca625 --- /dev/null +++ b/flutter_client/lib/auth/auth_provider.dart @@ -0,0 +1,56 @@ +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import '../models/user.dart'; + +const _kServerUrl = 'server_url'; +const _kSessionToken = 'session_token'; +const _kCurrentUser = 'current_user'; + +final secureStorageProvider = Provider( + (ref) => const FlutterSecureStorage(), +); + +final serverUrlProvider = FutureProvider((ref) async { + return ref.watch(secureStorageProvider).read(key: _kServerUrl); +}); + +final sessionTokenProvider = FutureProvider((ref) async { + return ref.watch(secureStorageProvider).read(key: _kSessionToken); +}); + +class AuthController extends AsyncNotifier { + late FlutterSecureStorage _storage; + + @override + Future build() async { + _storage = ref.watch(secureStorageProvider); + final raw = await _storage.read(key: _kCurrentUser); + if (raw == null) return null; + return User.fromJson(jsonDecode(raw) as Map); + } + + Future setServerUrl(String url) async { + await _storage.write(key: _kServerUrl, value: url); + ref.invalidate(serverUrlProvider); + } + + Future setSession({required String token, required String userJson}) async { + await _storage.write(key: _kSessionToken, value: token); + await _storage.write(key: _kCurrentUser, value: userJson); + ref.invalidate(sessionTokenProvider); + state = AsyncData(User.fromJson(jsonDecode(userJson) as Map)); + } + + Future clearSession() async { + await _storage.delete(key: _kSessionToken); + await _storage.delete(key: _kCurrentUser); + ref.invalidate(sessionTokenProvider); + state = const AsyncData(null); + } +} + +final authControllerProvider = + AsyncNotifierProvider(AuthController.new); diff --git a/flutter_client/lib/auth/server_url_screen.dart b/flutter_client/lib/auth/server_url_screen.dart new file mode 100644 index 00000000..f2caa62b --- /dev/null +++ b/flutter_client/lib/auth/server_url_screen.dart @@ -0,0 +1,81 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../api/endpoints/health.dart'; +import '../theme/theme_extension.dart'; +import 'auth_provider.dart'; + +class ServerUrlScreen extends ConsumerStatefulWidget { + const ServerUrlScreen({super.key}); + + @override + ConsumerState createState() => _ServerUrlScreenState(); +} + +class _ServerUrlScreenState extends ConsumerState { + final _ctrl = TextEditingController(); + bool _busy = false; + String? _error; + + Future _connect() async { + final url = _ctrl.text.trim(); + if (url.isEmpty) { + setState(() => _error = 'Enter a server URL.'); + return; + } + setState(() { + _busy = true; + _error = null; + }); + try { + final dio = Dio(BaseOptions(baseUrl: url, connectTimeout: const Duration(seconds: 5))); + final body = await HealthApi(dio).check(); + if (body['status'] != 'ok') { + throw StateError('unhealthy'); + } + await ref.read(authControllerProvider.notifier).setServerUrl(url); + if (!mounted) return; + Navigator.of(context).pushReplacementNamed('/login'); + } on DioException catch (_) { + setState(() => _error = "Couldn't reach that server."); + } catch (_) { + setState(() => _error = "Couldn't reach that server."); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + return Scaffold( + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + const SizedBox(height: 48), + Text('Connect to your Minstrel', style: TextStyle(fontFamily: fs.display.fontFamily, fontSize: 28)), + const SizedBox(height: 24), + TextField( + controller: _ctrl, + keyboardType: TextInputType.url, + decoration: const InputDecoration(labelText: 'Server URL', hintText: 'https://music.example.com'), + ), + if (_error != null) Padding( + padding: const EdgeInsets.only(top: 8), + child: Text(_error!, style: TextStyle(color: fs.error)), + ), + const SizedBox(height: 16), + FilledButton( + onPressed: _busy ? null : _connect, + child: _busy + ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('Connect'), + ), + ]), + ), + ), + ); + } +} diff --git a/flutter_client/test/auth/auth_provider_test.dart b/flutter_client/test/auth/auth_provider_test.dart new file mode 100644 index 00000000..1d899441 --- /dev/null +++ b/flutter_client/test/auth/auth_provider_test.dart @@ -0,0 +1,52 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:minstrel/auth/auth_provider.dart'; + +class _MockStorage extends Mock implements FlutterSecureStorage {} + +void main() { + late _MockStorage storage; + + setUpAll(() { + registerFallbackValue(''); + }); + + setUp(() { + storage = _MockStorage(); + // Default reads return null; default writes/deletes succeed. + when(() => storage.read(key: any(named: 'key'))).thenAnswer((_) async => null); + when(() => storage.write(key: any(named: 'key'), value: any(named: 'value'))).thenAnswer((_) async {}); + when(() => storage.delete(key: any(named: 'key'))).thenAnswer((_) async {}); + }); + + test('saves server URL via setServerUrl', () async { + final container = ProviderContainer(overrides: [ + secureStorageProvider.overrideWithValue(storage), + ]); + addTearDown(container.dispose); + + await container.read(authControllerProvider.notifier).setServerUrl('http://localhost:8080'); + + verify(() => storage.write(key: 'server_url', value: 'http://localhost:8080')).called(1); + }); + + test('setSession then clearSession transitions auth state to null', () async { + final container = ProviderContainer(overrides: [ + secureStorageProvider.overrideWithValue(storage), + ]); + addTearDown(container.dispose); + + final ctrl = container.read(authControllerProvider.notifier); + await ctrl.setSession(token: 't', userJson: '{"id":"u","username":"u","is_admin":false}'); + expect((await container.read(authControllerProvider.future))?.username, 'u'); + + // clearSession must trigger storage deletes for both keys. + await ctrl.clearSession(); + verify(() => storage.delete(key: 'session_token')).called(1); + verify(() => storage.delete(key: 'current_user')).called(1); + expect(await container.read(authControllerProvider.future), isNull); + }); +}