diff --git a/flutter_client/lib/admin/admin_users_screen.dart b/flutter_client/lib/admin/admin_users_screen.dart new file mode 100644 index 00000000..d12226e2 --- /dev/null +++ b/flutter_client/lib/admin/admin_users_screen.dart @@ -0,0 +1,200 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../shared/widgets/main_app_bar_actions.dart'; +import '../theme/theme_extension.dart'; +import 'admin_providers.dart'; +import 'widgets/admin_user_edit_sheet.dart'; +import 'widgets/admin_user_row.dart'; +import 'widgets/invite_row.dart'; + +class AdminUsersScreen extends ConsumerWidget { + const AdminUsersScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final fs = Theme.of(context).extension()!; + final users = ref.watch(adminUsersProvider); + final invites = ref.watch(adminInvitesProvider); + return Scaffold( + backgroundColor: fs.obsidian, + appBar: AppBar( + backgroundColor: fs.obsidian, + elevation: 0, + title: Text('Users', style: TextStyle(color: fs.parchment)), + actions: const [MainAppBarActions(currentRoute: '/admin/users')], + ), + body: SafeArea( + child: RefreshIndicator( + onRefresh: () async { + ref.invalidate(adminUsersProvider); + ref.invalidate(adminInvitesProvider); + }, + child: ListView( + children: [ + const _SectionHeader(text: 'Users'), + users.when( + loading: () => const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ), + error: (e, _) => Padding( + padding: const EdgeInsets.all(16), + child: Text('$e', style: TextStyle(color: fs.error)), + ), + data: (rows) => Column( + children: rows + .map((u) => AdminUserRow( + key: Key('admin_user_row_${u.id}'), + user: u, + onTap: () => AdminUserEditSheet.show(context, u), + )) + .toList(), + ), + ), + const Divider(), + _SectionHeader( + text: 'Invites', + trailing: TextButton.icon( + key: const Key('invite_generate_button'), + onPressed: () => _showGenerateInvite(context, ref), + icon: Icon(Icons.add, color: fs.parchment), + label: Text('Generate', + style: TextStyle(color: fs.parchment)), + ), + ), + invites.when( + loading: () => const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ), + error: (e, _) => Padding( + padding: const EdgeInsets.all(16), + child: Text('$e', style: TextStyle(color: fs.error)), + ), + data: (rows) => Column( + children: rows + .map((i) => InviteRow( + key: Key('invite_row_${i.token}'), + invite: i, + onRevoke: () => ref + .read(adminInvitesProvider.notifier) + .revoke(i.token), + )) + .toList(), + ), + ), + ], + ), + ), + ), + ); + } + + Future _showGenerateInvite(BuildContext context, WidgetRef ref) async { + final controller = TextEditingController(); + final note = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Generate invite'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Token expires in 24 hours.'), + const SizedBox(height: 12), + TextField( + controller: controller, + decoration: const InputDecoration( + labelText: 'Note (optional)', + helperText: 'e.g. "for alice"', + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, controller.text), + child: const Text('Generate'), + ), + ], + ), + ); + if (note == null || !context.mounted) return; + try { + final invite = await ref + .read(adminInvitesProvider.notifier) + .create(note: note.isEmpty ? null : note); + if (!context.mounted) return; + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Invite created'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Share this token with the new user:'), + const SizedBox(height: 8), + SelectableText( + invite.token, + style: const TextStyle(fontFamily: 'JetBrainsMono'), + ), + ], + ), + actions: [ + TextButton( + onPressed: () { + Clipboard.setData(ClipboardData(text: invite.token)); + Navigator.pop(ctx); + }, + child: const Text('Copy'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Close'), + ), + ], + ), + ); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('$e'))); + } + } + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader({required this.text, this.trailing}); + final String text; + final Widget? trailing; + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row( + children: [ + Text( + text, + style: TextStyle( + color: fs.parchment, + fontFamily: 'Fraunces', + fontSize: 18, + ), + ), + const Spacer(), + if (trailing != null) trailing!, + ], + ), + ); + } +} diff --git a/flutter_client/lib/admin/widgets/invite_row.dart b/flutter_client/lib/admin/widgets/invite_row.dart new file mode 100644 index 00000000..6b74a6a5 --- /dev/null +++ b/flutter_client/lib/admin/widgets/invite_row.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../models/invite.dart'; +import '../../theme/theme_extension.dart'; + +class InviteRow extends StatelessWidget { + const InviteRow({ + super.key, + required this.invite, + required this.onRevoke, + }); + + final Invite invite; + final VoidCallback onRevoke; + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + return ListTile( + title: Row( + children: [ + Expanded( + child: SelectableText( + invite.token, + style: TextStyle( + color: fs.parchment, + fontFamily: 'JetBrainsMono', + fontSize: 13, + ), + ), + ), + IconButton( + icon: Icon(Icons.copy, color: fs.ash, size: 18), + tooltip: 'Copy', + onPressed: () => + Clipboard.setData(ClipboardData(text: invite.token)), + ), + ], + ), + subtitle: Wrap( + spacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text('expires ${invite.expiresAt}', + style: TextStyle(color: fs.ash, fontSize: 12)), + if (invite.note != null) + Text('ยท ${invite.note}', + style: TextStyle(color: fs.ash, fontSize: 12)), + if (invite.isRedeemed) + Container( + padding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: fs.ash, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + 'redeemed', + style: TextStyle(color: fs.obsidian, fontSize: 10), + ), + ), + ], + ), + trailing: IconButton( + icon: Icon(Icons.delete_outline, color: fs.oxblood), + tooltip: 'Revoke', + onPressed: onRevoke, + ), + ); + } +} diff --git a/flutter_client/test/admin/admin_users_screen_test.dart b/flutter_client/test/admin/admin_users_screen_test.dart new file mode 100644 index 00000000..ff296b05 --- /dev/null +++ b/flutter_client/test/admin/admin_users_screen_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:minstrel/admin/admin_providers.dart'; +import 'package:minstrel/admin/admin_users_screen.dart'; +import 'package:minstrel/auth/auth_provider.dart'; +import 'package:minstrel/models/admin_user.dart'; +import 'package:minstrel/models/invite.dart'; +import 'package:minstrel/models/user.dart'; +import 'package:minstrel/theme/theme_data.dart'; + +class _StubAuth extends AuthController { + @override + Future build() async => + const User(id: 'u1', username: 'admin', isAdmin: true); +} + +class _StubUsers extends AdminUsersController { + _StubUsers(this._initial); + final List _initial; + @override + Future> build() async => _initial; +} + +class _StubInvites extends AdminInvitesController { + _StubInvites(this._initial); + final List _initial; + @override + Future> build() async => _initial; +} + +const _userRow = AdminUser( + id: 'u2', + username: 'alice', + displayName: null, + isAdmin: false, + autoApproveRequests: true, + createdAt: '2026-05-01T00:00:00Z', +); + +const _inviteRow = Invite( + token: 'INV-ABC123', + invitedBy: 'u1', + note: 'for alice', + createdAt: '2026-05-08T00:00:00Z', + expiresAt: '2026-05-09T00:00:00Z', +); + +Widget _harness({ + required List users, + required List invites, +}) => + ProviderScope( + overrides: [ + authControllerProvider.overrideWith(() => _StubAuth()), + adminUsersProvider.overrideWith(() => _StubUsers(users)), + adminInvitesProvider.overrideWith(() => _StubInvites(invites)), + ], + child: MaterialApp( + theme: buildThemeData(), + home: const AdminUsersScreen(), + ), + ); + +void main() { + testWidgets('renders user rows with badges', (t) async { + await t.pumpWidget(_harness(users: const [_userRow], invites: const [])); + await t.pumpAndSettle(); + expect(find.byKey(const Key('admin_user_row_u2')), findsOneWidget); + expect(find.text('alice'), findsOneWidget); + expect(find.text('auto-approve'), findsOneWidget); + }); + + testWidgets('renders invite rows + Generate button', (t) async { + await t.pumpWidget(_harness(users: const [], invites: const [_inviteRow])); + await t.pumpAndSettle(); + expect(find.byKey(const Key('invite_row_INV-ABC123')), findsOneWidget); + expect(find.byKey(const Key('invite_generate_button')), findsOneWidget); + expect(find.textContaining('for alice'), findsOneWidget); + }); +}