feat(flutter/admin): users + invites mutations

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>
This commit is contained in:
2026-05-08 21:56:34 -04:00
parent 3e905db906
commit 7d207e56f5
+97 -4
View File
@@ -8,6 +8,7 @@ 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));
@@ -92,16 +93,73 @@ final adminRequestsProvider =
AsyncNotifierProvider<AdminRequestsController, List<AdminRequest>>(
AdminRequestsController.new);
/// Read side defined here so the Requests screen can join requester
/// UUID → username for display. Mutation methods (setAdmin,
/// setAutoApprove, resetPassword, delete) are appended in Slice 3
/// alongside the Users screen.
/// 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 =
@@ -142,3 +200,38 @@ class AdminQuarantineController
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);