diff --git a/lib/api/client.dart b/lib/api/client.dart index 46cd3a5..2644158 100644 --- a/lib/api/client.dart +++ b/lib/api/client.dart @@ -3,7 +3,6 @@ import 'dart:convert'; import 'package:cryptography/cryptography.dart'; import 'package:dio/dio.dart'; import 'package:eh_downloader_flutter/api/file.dart'; -import 'package:retrofit/dio.dart'; import 'package:retrofit/retrofit.dart'; import 'api_result.dart'; @@ -82,6 +81,12 @@ abstract class _EHApi { {@Query("id") int? id, @Query("username") String? username, @CancelRequest() CancelToken? cancel}); + @GET('/user/list') + Future>> getUsers( + {@Query("all") bool? all, + @Query("offset") int? offset, + @Query("limit") int? limit, + @CancelRequest() CancelToken? cancel}); @GET('/status') Future> getStatus( diff --git a/lib/api/client.g.dart b/lib/api/client.g.dart index 0d0a7ac..a61ed9f 100644 --- a/lib/api/client.g.dart +++ b/lib/api/client.g.dart @@ -116,6 +116,51 @@ class __EHApi implements _EHApi { return value; } + @override + Future>> getUsers({ + bool? all, + int? offset, + int? limit, + CancelToken? cancel, + }) async { + final _extra = {}; + final queryParameters = { + r'all': all, + r'offset': offset, + r'limit': limit, + }; + queryParameters.removeWhere((k, v) => v == null); + final _headers = {}; + const Map? _data = null; + final _result = await _dio.fetch>( + _setStreamType>>(Options( + method: 'GET', + headers: _headers, + extra: _extra, + ) + .compose( + _dio.options, + '/user/list', + queryParameters: queryParameters, + data: _data, + cancelToken: cancel, + ) + .copyWith( + baseUrl: _combineBaseUrls( + _dio.options.baseUrl, + baseUrl, + )))); + final value = ApiResult>.fromJson( + _result.data!, + (json) => json is List + ? json + .map((i) => BUser.fromJson(i as Map)) + .toList() + : List.empty(), + ); + return value; + } + @override Future> getStatus({CancelToken? cancel}) async { final _extra = {}; diff --git a/lib/auth.dart b/lib/auth.dart index 78fef11..6254505 100644 --- a/lib/auth.dart +++ b/lib/auth.dart @@ -21,6 +21,7 @@ class AuthInfo { bool _isChecking = false; bool get isChecking => _isChecking; bool? get isAdmin => _user?.isAdmin; + bool? get isRoot => _user != null ? _user!.id == 0 : null; bool? get isDocker => _status?.isDocker; bool? get canReadGallery => _user?.permissions.has(UserPermission.readGallery); diff --git a/lib/components/user_card.dart b/lib/components/user_card.dart new file mode 100644 index 0000000..c66eb78 --- /dev/null +++ b/lib/components/user_card.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import '../api/user.dart'; +import '../globals.dart'; + +class UserCard extends StatelessWidget { + const UserCard(this.user, {super.key}); + final BUser user; + + @override + Widget build(BuildContext context) { + final i18n = AppLocalizations.of(context)!; + final cs = Theme.of(context).colorScheme; + return Card.outlined( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row(children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SelectableText( + user.username, + maxLines: 1, + style: TextStyle( + fontWeight: FontWeight.bold, color: cs.primary), + ), + Text(user.isAdmin ? i18n.admin : i18n.user, + style: TextStyle(color: cs.secondary)) + ], + )), + IconButton( + onPressed: () {}, + tooltip: i18n.edit, + icon: const Icon(Icons.edit)), + !user.isAdmin || + (user.isAdmin && auth.isRoot == true && user.id != 0) + ? IconButton( + onPressed: () {}, + tooltip: i18n.delete, + icon: const Icon(Icons.delete)) + : Container(), + ]))); + } +} diff --git a/lib/dialog/download_zip_page.dart b/lib/dialog/download_zip_page.dart index 3f7729b..9720983 100644 --- a/lib/dialog/download_zip_page.dart +++ b/lib/dialog/download_zip_page.dart @@ -13,6 +13,8 @@ class DownloadZipPage extends StatefulWidget { const DownloadZipPage(this.gid, {super.key}); final int gid; + static const routeName = '/dialog/download/zip/:gid'; + @override State createState() => _DownloadZipPage(); } diff --git a/lib/dialog/gallery_details_page.dart b/lib/dialog/gallery_details_page.dart index 9e870d0..d1a815e 100644 --- a/lib/dialog/gallery_details_page.dart +++ b/lib/dialog/gallery_details_page.dart @@ -42,6 +42,8 @@ class GalleryDetailsPage extends StatefulWidget { final int gid; final GMeta? meta; + static const routeName = '/dialog/gallery/details/:gid'; + @override State createState() => _GalleryDetailsPage(); } diff --git a/lib/dialog/new_download_task_page.dart b/lib/dialog/new_download_task_page.dart index 4ba9a0a..c70c063 100644 --- a/lib/dialog/new_download_task_page.dart +++ b/lib/dialog/new_download_task_page.dart @@ -16,6 +16,8 @@ class NewDownloadTaskPage extends StatefulWidget { final int? gid; final String? token; + static const routeName = "/dialog/new_download_task"; + @override State createState() => _NewDownloadTaskPage(); } diff --git a/lib/dialog/new_user_page.dart b/lib/dialog/new_user_page.dart new file mode 100644 index 0000000..327e4c8 --- /dev/null +++ b/lib/dialog/new_user_page.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:go_router/go_router.dart'; +import '../components/labeled_checkbox.dart'; +import '../globals.dart'; + +class NewUserPage extends StatefulWidget { + const NewUserPage({super.key}); + + static const routeName = "/dialog/user/new"; + + @override + State createState() => _NewUserPage(); +} + +class _NewUserPage extends State { + final _formKey = GlobalKey(); + String _username = ""; + String _password = ""; + bool _isAdmin = false; + bool _passwordVisible = false; + + Widget _buildWithVecticalPadding(Widget child) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 8), + child: child, + ); + } + + @override + Widget build(BuildContext context) { + if (!tryInitApi(context)) { + return Container(); + } + if (auth.isAdmin == false) { + SchedulerBinding.instance.addPostFrameCallback((_) { + context.go("/"); + }); + return Container(); + } + final i18n = AppLocalizations.of(context)!; + final maxWidth = MediaQuery.of(context).size.width; + return Container( + padding: maxWidth < 400 + ? const EdgeInsets.symmetric(vertical: 20, horizontal: 5) + : const EdgeInsets.all(20), + width: maxWidth < 810 ? null : 800, + decoration: BoxDecoration(borderRadius: BorderRadius.circular(10)), + child: SingleChildScrollView( + child: Form( + key: _formKey, + child: Column( + children: [ + Stack( + alignment: Alignment.center, + children: [ + Text( + i18n.createNewUser, + style: Theme.of(context).textTheme.headlineSmall, + ), + Align( + alignment: Alignment.centerRight, + child: IconButton( + onPressed: () => context.canPop() + ? context.pop() + : context.go("/users"), + icon: const Icon(Icons.close), + )), + ], + ), + _buildWithVecticalPadding(TextFormField( + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: i18n.username, + ), + initialValue: _username, + onChanged: (value) { + setState(() { + _username = value; + }); + }, + )), + _buildWithVecticalPadding(TextFormField( + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: i18n.password, + suffixIcon: IconButton( + icon: Icon( + _passwordVisible + ? Icons.visibility + : Icons.visibility_off, + color: Theme.of(context).primaryColorDark, + ), + onPressed: () { + setState(() { + _passwordVisible = !_passwordVisible; + }); + }, + ), + ), + initialValue: _password, + onChanged: (value) { + setState(() { + _password = value; + }); + }, + obscureText: !_passwordVisible, + )), + _buildWithVecticalPadding(LabeledCheckbox( + value: _isAdmin, + onChanged: (b) { + if (b != null) { + setState(() { + _isAdmin = b; + }); + } + }, + label: Text(i18n.admin))), + ], + ))), + ); + } +} diff --git a/lib/dialog/task_page.dart b/lib/dialog/task_page.dart index a80ffef..b45a0ed 100644 --- a/lib/dialog/task_page.dart +++ b/lib/dialog/task_page.dart @@ -39,6 +39,8 @@ class TaskPage extends StatefulWidget { const TaskPage(this.id, {super.key}); final int id; + static const routeName = "/dialog/task/:id"; + @override State createState() => _TaskPage(); } diff --git a/lib/globals.dart b/lib/globals.dart index 6b770c9..c72dc05 100644 --- a/lib/globals.dart +++ b/lib/globals.dart @@ -355,8 +355,10 @@ void checkAuth(BuildContext context) { if (!auth.isAuthed && !auth.checked && !auth.isChecking) { auth.checkAuth().then((re) { if (!re) { - if (auth.status!.noUser && prefs.getBool("skipCreateRootUser") == true) + if (auth.status!.noUser && + prefs.getBool("skipCreateRootUser") == true) { return; + } final loc = auth.status!.noUser ? "/create_root_user" : "/login"; final path = GoRouterState.of(context).path; if (path != loc) { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 0c98a87..92fd4ab 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -200,5 +200,11 @@ "cachedFileSize": "Cached file size", "update": "Update", "updateFileSize": "Update file size", - "clearCaches": "Clear caches" + "clearCaches": "Clear caches", + "userManagemant": "User Management", + "admin": "Administrator", + "user": "User", + "edit": "Edit", + "delete": "Delete", + "createNewUser": "Create new user" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 23690a7..007be1e 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -200,5 +200,11 @@ "cachedFileSize": "已缓存文件大小", "update": "更新", "updateFileSize": "更新文件大小", - "clearCaches": "清除缓存" + "clearCaches": "清除缓存", + "userManagemant": "用户管理", + "admin": "管理员", + "user": "用户", + "edit": "编辑", + "delete": "删除", + "createNewUser": "新建用户" } diff --git a/lib/main.dart b/lib/main.dart index 75545fc..a045778 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,6 +11,7 @@ import 'dialog/dialog_page.dart'; import 'dialog/download_zip_page.dart'; import 'dialog/gallery_details_page.dart'; import 'dialog/new_download_task_page.dart'; +import 'dialog/new_user_page.dart'; import 'dialog/task_page.dart'; import 'globals.dart'; import 'logs/file.dart'; @@ -23,6 +24,7 @@ import 'pages/server_settings.dart'; import 'pages/set_server.dart'; import 'pages/settings.dart'; import 'pages/task_manager.dart'; +import 'pages/users.dart'; import 'utils.dart'; import 'viewer/single.dart'; @@ -97,7 +99,7 @@ final _router = GoRouter( redirect: (context, state) => "/galleries", ), GoRoute( - path: '/dialog/download/zip/:gid', + path: DownloadZipPage.routeName, pageBuilder: (context, state) => DialogPage( key: state.pageKey, builder: (context) { @@ -140,7 +142,7 @@ final _router = GoRouter( } }), GoRoute( - path: '/dialog/gallery/details/:gid', + path: GalleryDetailsPage.routeName, pageBuilder: (context, state) { final extra = state.extra as GalleryDetailsPageExtra?; return DialogPage( @@ -169,7 +171,7 @@ final _router = GoRouter( builder: (context, state) => TaskManagerPage(key: state.pageKey), ), GoRoute( - path: "/dialog/new_download_task", + path: NewDownloadTaskPage.routeName, pageBuilder: (context, state) { int? gid; String? token; @@ -186,7 +188,7 @@ final _router = GoRouter( }); }), GoRoute( - path: "/dialog/task/:id", + path: TaskPage.routeName, pageBuilder: (context, state) { return DialogPage( key: state.pageKey, @@ -203,6 +205,19 @@ final _router = GoRouter( return "/task_manager"; } }), + GoRoute( + path: UsersPage.routeName, + builder: (context, state) => const UsersPage(), + ), + GoRoute( + path: NewUserPage.routeName, + pageBuilder: (context, state) { + return DialogPage( + key: state.pageKey, + builder: (context) { + return const NewUserPage(); + }); + }), ], ); diff --git a/lib/pages/home.dart b/lib/pages/home.dart index 3e2335e..682bdd5 100644 --- a/lib/pages/home.dart +++ b/lib/pages/home.dart @@ -13,6 +13,7 @@ class HomeDrawer extends StatelessWidget { @override Widget build(BuildContext context) { + final i18n = AppLocalizations.of(context)!; return Drawer( child: ListView( children: [ @@ -26,7 +27,7 @@ class HomeDrawer extends StatelessWidget { ), ListTile( leading: const Icon(Icons.collections), - title: Text(AppLocalizations.of(context)!.galleries), + title: Text(i18n.galleries), onTap: () { Scaffold.of(context).closeDrawer(); context.push("/galleries"); @@ -35,7 +36,7 @@ class HomeDrawer extends StatelessWidget { auth.isAdmin == true ? ListTile( leading: const Icon(Icons.admin_panel_settings), - title: Text(AppLocalizations.of(context)!.serverSettings), + title: Text(i18n.serverSettings), onTap: () { Scaffold.of(context).closeDrawer(); context.push("/server_settings"); @@ -45,13 +46,22 @@ class HomeDrawer extends StatelessWidget { auth.canManageTasks == true ? ListTile( leading: const Icon(Icons.task), - title: Text(AppLocalizations.of(context)!.taskManager), + title: Text(i18n.taskManager), onTap: () { Scaffold.of(context).closeDrawer(); context.push("/task_manager"); }, ) : Container(), + auth.isAdmin == true + ? ListTile( + leading: const Icon(Icons.manage_accounts), + title: Text(i18n.userManagemant), + onTap: () { + Scaffold.of(context).closeDrawer(); + context.push("/users"); + }) + : Container(), ListTile( leading: const Icon(Icons.settings), title: Text(AppLocalizations.of(context)!.settings), diff --git a/lib/pages/users.dart b/lib/pages/users.dart new file mode 100644 index 0000000..7ebacf3 --- /dev/null +++ b/lib/pages/users.dart @@ -0,0 +1,156 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:go_router/go_router.dart'; +import 'package:logging/logging.dart'; +import '../api/user.dart'; +import '../components/user_card.dart'; +import '../globals.dart'; + +final _log = Logger("UsersPage"); + +class UsersPage extends StatefulWidget { + const UsersPage({super.key}); + + static const String routeName = '/users'; + + @override + State createState() => _UsersPage(); +} + +class _UsersPage extends State with ThemeModeWidget, IsTopWidget2 { + List? _users; + bool _isLoading = false; + CancelToken? _cancel; + Object? _error; + Future _fetchData() async { + try { + _cancel = CancelToken(); + _isLoading = true; + final users = (await api.getUsers(all: true)).unwrap(); + if (!_cancel!.isCancelled) { + setState(() { + _users = users; + _isLoading = false; + }); + } + } catch (e) { + if (!_cancel!.isCancelled) { + _log.severe("Failed to load user list:", e); + setState(() { + _error = e; + _isLoading = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + if (!tryInitApi(context)) { + return Container(); + } + if (auth.isAdmin == false) { + SchedulerBinding.instance.addPostFrameCallback((_) { + context.go("/"); + }); + return Container(); + } + final isLoading = _users == null && _error == null; + if (isLoading && !_isLoading) _fetchData(); + final i18n = AppLocalizations.of(context)!; + final th = Theme.of(context); + if (isTop(context)) { + setCurrentTitle(i18n.userManagemant, th.primaryColor.value); + } + return Scaffold( + appBar: _users == null + ? AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + context.canPop() ? context.pop() : context.go("/"); + }, + ), + title: Text(i18n.userManagemant), + actions: [ + buildThemeModeIcon(context), + buildMoreVertSettingsButon(context), + ], + ) + : null, + body: isLoading + ? const Center(child: CircularProgressIndicator()) + : _users != null + ? _buildMain(context) + : Center( + child: Text("Error: $_error"), + )); + } + + Widget _buildMain(BuildContext context) { + final size = MediaQuery.of(context).size; + return Stack(children: [ + _buildUserList(context), + Positioned( + bottom: size.height / 10, + right: size.width / 10, + child: _buildIconList(context)), + ]); + } + + Widget _buildAddIcon(BuildContext context) { + final i18n = AppLocalizations.of(context)!; + return IconButton( + onPressed: () { + context.push("/dialog/user/new"); + }, + tooltip: i18n.create, + icon: const Icon(Icons.add)); + } + + Widget _buildIconList(BuildContext context) { + return Row(children: [ + _buildAddIcon(context), + ]); + } + + Widget _buildUserList(BuildContext context) { + final i18n = AppLocalizations.of(context)!; + return CustomScrollView(slivers: [ + SliverAppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + context.canPop() ? context.pop() : context.go("/"); + }, + ), + title: Text(i18n.userManagemant), + actions: [ + buildThemeModeIcon(context), + buildMoreVertSettingsButon(context), + ], + floating: true, + ), + _buildSliverGrid(context), + ]); + } + + Widget _buildSliverGrid(BuildContext context) { + return SliverGrid( + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 360.0, + mainAxisSpacing: 10.0, + crossAxisSpacing: 10.0, + childAspectRatio: 4.0, + ), + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + return UserCard(_users![index]!); + }, + childCount: _users!.length, + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index a5c92b4..bafe70d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -62,7 +62,6 @@ dev_dependencies: build_runner: ^2.4.6 retrofit_generator: ^8.1.0 json_serializable: ^6.7.1 - sqflite_common_ffi: ^2.3.3 flutter: generate: true