7d207e56f5
Extends AdminUsersController (build-only since slice 2) with setAdmin, setAutoApprove, delete, resetPassword. is_admin/auto_approve toggles optimistically swap the row and roll back on server error (including the last-admin guard 4xx). resetPassword is admin-supplied; no auto-generation mode exists server-side. Adds AdminInvitesController + adminInvitesProvider with create (prepends new invite to the list, returns it for the copy-once dialog) and revoke (optimistic remove + rollback). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
238 lines
7.8 KiB
Dart
238 lines
7.8 KiB
Dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../api/endpoints/admin_invites.dart';
|
|
import '../api/endpoints/admin_quarantine.dart';
|
|
import '../api/endpoints/admin_requests.dart';
|
|
import '../api/endpoints/admin_users.dart';
|
|
import '../library/library_providers.dart' show dioProvider;
|
|
import '../models/admin_quarantine_item.dart';
|
|
import '../models/admin_request.dart';
|
|
import '../models/admin_user.dart';
|
|
import '../models/invite.dart';
|
|
|
|
final adminRequestsApiProvider = FutureProvider<AdminRequestsApi>((ref) async {
|
|
return AdminRequestsApi(await ref.watch(dioProvider.future));
|
|
});
|
|
|
|
final adminQuarantineApiProvider =
|
|
FutureProvider<AdminQuarantineApi>((ref) async {
|
|
return AdminQuarantineApi(await ref.watch(dioProvider.future));
|
|
});
|
|
|
|
final adminUsersApiProvider = FutureProvider<AdminUsersApi>((ref) async {
|
|
return AdminUsersApi(await ref.watch(dioProvider.future));
|
|
});
|
|
|
|
final adminInvitesApiProvider = FutureProvider<AdminInvitesApi>((ref) async {
|
|
return AdminInvitesApi(await ref.watch(dioProvider.future));
|
|
});
|
|
|
|
class AdminCounts {
|
|
const AdminCounts({
|
|
required this.requests,
|
|
required this.quarantine,
|
|
required this.users,
|
|
});
|
|
final int requests;
|
|
final int quarantine;
|
|
final int users;
|
|
}
|
|
|
|
/// Fans out to the three list endpoints in parallel and rolls them up
|
|
/// into `{requests, quarantine, users}` for the landing screen badges.
|
|
final adminCountsProvider = FutureProvider<AdminCounts>((ref) async {
|
|
final requestsApi = await ref.watch(adminRequestsApiProvider.future);
|
|
final quarantineApi = await ref.watch(adminQuarantineApiProvider.future);
|
|
final usersApi = await ref.watch(adminUsersApiProvider.future);
|
|
final results = await Future.wait([
|
|
requestsApi.list(),
|
|
quarantineApi.list(),
|
|
usersApi.list(),
|
|
]);
|
|
return AdminCounts(
|
|
requests: (results[0] as List).length,
|
|
quarantine: (results[1] as List).length,
|
|
users: (results[2] as List).length,
|
|
);
|
|
});
|
|
|
|
class AdminRequestsController extends AsyncNotifier<List<AdminRequest>> {
|
|
@override
|
|
Future<List<AdminRequest>> build() async {
|
|
final api = await ref.watch(adminRequestsApiProvider.future);
|
|
return api.list();
|
|
}
|
|
|
|
Future<void> approve(String id) async {
|
|
await _decide(id, (api) => api.approve(id));
|
|
}
|
|
|
|
Future<void> reject(String id) async {
|
|
await _decide(id, (api) => api.reject(id));
|
|
}
|
|
|
|
Future<void> _decide(
|
|
String id,
|
|
Future<void> Function(AdminRequestsApi api) action,
|
|
) async {
|
|
final api = await ref.read(adminRequestsApiProvider.future);
|
|
final current = state.value ?? const <AdminRequest>[];
|
|
state = AsyncData(current.where((r) => r.id != id).toList());
|
|
try {
|
|
await action(api);
|
|
// Refresh landing badge.
|
|
ref.invalidate(adminCountsProvider);
|
|
} catch (e, st) {
|
|
state = AsyncData(current);
|
|
Error.throwWithStackTrace(e, st);
|
|
}
|
|
}
|
|
}
|
|
|
|
final adminRequestsProvider =
|
|
AsyncNotifierProvider<AdminRequestsController, List<AdminRequest>>(
|
|
AdminRequestsController.new);
|
|
|
|
/// Read side originally defined for the Requests screen's
|
|
/// requester-UUID → username join. Mutation methods (setAdmin,
|
|
/// setAutoApprove, resetPassword, delete) added alongside the Users
|
|
/// screen in Slice 3. resetPassword is admin-supplies; the server
|
|
/// has no auto-generation mode.
|
|
class AdminUsersController extends AsyncNotifier<List<AdminUser>> {
|
|
@override
|
|
Future<List<AdminUser>> build() async {
|
|
final api = await ref.watch(adminUsersApiProvider.future);
|
|
return api.list();
|
|
}
|
|
|
|
/// Optimistic flip of `is_admin` on the user row; rolls back on
|
|
/// server error (including the last-admin guard 4xx).
|
|
Future<void> setAdmin(String id, bool isAdmin) =>
|
|
_patch(id, (u) => _copy(u, isAdmin: isAdmin),
|
|
(api) => api.setAdmin(id, isAdmin));
|
|
|
|
Future<void> setAutoApprove(String id, bool autoApprove) =>
|
|
_patch(id, (u) => _copy(u, autoApproveRequests: autoApprove),
|
|
(api) => api.setAutoApprove(id, autoApprove));
|
|
|
|
Future<void> delete(String id) async {
|
|
final api = await ref.read(adminUsersApiProvider.future);
|
|
final current = state.value ?? const <AdminUser>[];
|
|
state = AsyncData(current.where((u) => u.id != id).toList());
|
|
try {
|
|
await api.delete(id);
|
|
ref.invalidate(adminCountsProvider);
|
|
} catch (e, st) {
|
|
state = AsyncData(current);
|
|
Error.throwWithStackTrace(e, st);
|
|
}
|
|
}
|
|
|
|
Future<void> resetPassword(String id, String newPassword) async {
|
|
final api = await ref.read(adminUsersApiProvider.future);
|
|
await api.resetPassword(id, newPassword);
|
|
}
|
|
|
|
Future<void> _patch(
|
|
String id,
|
|
AdminUser Function(AdminUser) mutate,
|
|
Future<void> Function(AdminUsersApi api) action,
|
|
) async {
|
|
final api = await ref.read(adminUsersApiProvider.future);
|
|
final current = state.value ?? const <AdminUser>[];
|
|
state = AsyncData([
|
|
for (final u in current) u.id == id ? mutate(u) : u,
|
|
]);
|
|
try {
|
|
await action(api);
|
|
} catch (e, st) {
|
|
state = AsyncData(current);
|
|
Error.throwWithStackTrace(e, st);
|
|
}
|
|
}
|
|
|
|
AdminUser _copy(AdminUser u, {bool? isAdmin, bool? autoApproveRequests}) =>
|
|
AdminUser(
|
|
id: u.id,
|
|
username: u.username,
|
|
displayName: u.displayName,
|
|
isAdmin: isAdmin ?? u.isAdmin,
|
|
autoApproveRequests: autoApproveRequests ?? u.autoApproveRequests,
|
|
createdAt: u.createdAt,
|
|
);
|
|
}
|
|
|
|
final adminUsersProvider =
|
|
AsyncNotifierProvider<AdminUsersController, List<AdminUser>>(
|
|
AdminUsersController.new);
|
|
|
|
class AdminQuarantineController
|
|
extends AsyncNotifier<List<AdminQuarantineItem>> {
|
|
@override
|
|
Future<List<AdminQuarantineItem>> build() async {
|
|
final api = await ref.watch(adminQuarantineApiProvider.future);
|
|
return api.list();
|
|
}
|
|
|
|
Future<void> resolve(String trackId) =>
|
|
_act(trackId, (api) => api.resolve(trackId));
|
|
Future<void> deleteFile(String trackId) =>
|
|
_act(trackId, (api) => api.deleteFile(trackId));
|
|
Future<void> deleteViaLidarr(String trackId) =>
|
|
_act(trackId, (api) => api.deleteViaLidarr(trackId));
|
|
|
|
Future<void> _act(
|
|
String trackId,
|
|
Future<void> Function(AdminQuarantineApi api) action,
|
|
) async {
|
|
final api = await ref.read(adminQuarantineApiProvider.future);
|
|
final current = state.value ?? const <AdminQuarantineItem>[];
|
|
state = AsyncData(current.where((q) => q.trackId != trackId).toList());
|
|
try {
|
|
await action(api);
|
|
ref.invalidate(adminCountsProvider);
|
|
} catch (e, st) {
|
|
state = AsyncData(current);
|
|
Error.throwWithStackTrace(e, st);
|
|
}
|
|
}
|
|
}
|
|
|
|
final adminQuarantineProvider = AsyncNotifierProvider<AdminQuarantineController,
|
|
List<AdminQuarantineItem>>(AdminQuarantineController.new);
|
|
|
|
class AdminInvitesController extends AsyncNotifier<List<Invite>> {
|
|
@override
|
|
Future<List<Invite>> build() async {
|
|
final api = await ref.watch(adminInvitesApiProvider.future);
|
|
return api.list();
|
|
}
|
|
|
|
/// Returns the freshly-minted invite so the screen can show the
|
|
/// token in a copy-once dialog. The new invite is also prepended
|
|
/// to the local list so it shows up without a refresh.
|
|
Future<Invite> create({String? note}) async {
|
|
final api = await ref.read(adminInvitesApiProvider.future);
|
|
final invite = await api.create(note: note);
|
|
final current = state.value ?? const <Invite>[];
|
|
state = AsyncData([invite, ...current]);
|
|
return invite;
|
|
}
|
|
|
|
Future<void> revoke(String token) async {
|
|
final api = await ref.read(adminInvitesApiProvider.future);
|
|
final current = state.value ?? const <Invite>[];
|
|
state = AsyncData(current.where((i) => i.token != token).toList());
|
|
try {
|
|
await api.revoke(token);
|
|
} catch (e, st) {
|
|
state = AsyncData(current);
|
|
Error.throwWithStackTrace(e, st);
|
|
}
|
|
}
|
|
}
|
|
|
|
final adminInvitesProvider =
|
|
AsyncNotifierProvider<AdminInvitesController, List<Invite>>(
|
|
AdminInvitesController.new);
|