feat(flutter/auth): server URL screen + auth provider + secure storage
AuthController is an AsyncNotifier holding the currently-logged-in User. server_url, session_token, current_user all live in flutter_secure_storage. ServerUrlScreen probes /healthz before saving the URL so we don't store a bogus base.
This commit is contained in:
@@ -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<Map<String, String>> check() async {
|
||||||
|
final r = await _dio.get<Map<String, dynamic>>('/healthz');
|
||||||
|
return (r.data ?? const <String, dynamic>{})
|
||||||
|
.map((k, v) => MapEntry(k, v.toString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<FlutterSecureStorage>(
|
||||||
|
(ref) => const FlutterSecureStorage(),
|
||||||
|
);
|
||||||
|
|
||||||
|
final serverUrlProvider = FutureProvider<String?>((ref) async {
|
||||||
|
return ref.watch(secureStorageProvider).read(key: _kServerUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
final sessionTokenProvider = FutureProvider<String?>((ref) async {
|
||||||
|
return ref.watch(secureStorageProvider).read(key: _kSessionToken);
|
||||||
|
});
|
||||||
|
|
||||||
|
class AuthController extends AsyncNotifier<User?> {
|
||||||
|
late FlutterSecureStorage _storage;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<User?> 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<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setServerUrl(String url) async {
|
||||||
|
await _storage.write(key: _kServerUrl, value: url);
|
||||||
|
ref.invalidate(serverUrlProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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<String, dynamic>));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearSession() async {
|
||||||
|
await _storage.delete(key: _kSessionToken);
|
||||||
|
await _storage.delete(key: _kCurrentUser);
|
||||||
|
ref.invalidate(sessionTokenProvider);
|
||||||
|
state = const AsyncData(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final authControllerProvider =
|
||||||
|
AsyncNotifierProvider<AuthController, User?>(AuthController.new);
|
||||||
@@ -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<ServerUrlScreen> createState() => _ServerUrlScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ServerUrlScreenState extends ConsumerState<ServerUrlScreen> {
|
||||||
|
final _ctrl = TextEditingController();
|
||||||
|
bool _busy = false;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
Future<void> _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<FabledSwordTheme>()!;
|
||||||
|
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'),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user