From b4992a08db8fcb38aa2593f4aa093a63b17143b7 Mon Sep 17 00:00:00 2001 From: lifegpc Date: Tue, 28 May 2024 08:05:58 +0000 Subject: [PATCH] Add edit user page --- lib/api/client.dart | 16 ++ lib/api/client.g.dart | 126 +++++++++++++++ lib/components/user_card.dart | 14 +- lib/dialog/edit_user_page.dart | 286 +++++++++++++++++++++++++++++++++ lib/l10n/app_en.arb | 5 +- lib/l10n/app_zh.arb | 5 +- lib/main.dart | 21 +++ lib/pages/users.dart | 15 ++ 8 files changed, 484 insertions(+), 4 deletions(-) create mode 100644 lib/dialog/edit_user_page.dart diff --git a/lib/api/client.dart b/lib/api/client.dart index b32776b..f4cb7ff 100644 --- a/lib/api/client.dart +++ b/lib/api/client.dart @@ -76,6 +76,12 @@ abstract class _EHApi { {@Part(name: "is_admin") bool? isAdmin, @Part(name: "permissions") int? permissions, @CancelRequest() CancelToken? cancel}); + @DELETE('/user') + @MultiPart() + Future> deleteUser( + {@Part(name: "id") int? id, + @Part(name: "username") String? username, + @CancelRequest() CancelToken? cancel}); @GET('/user') Future> getUser( {@Query("id") int? id, @@ -87,6 +93,16 @@ abstract class _EHApi { @Query("offset") int? offset, @Query("limit") int? limit, @CancelRequest() CancelToken? cancel}); + @PATCH('/user') + @MultiPart() + Future> updateUser( + {@Part(name: "id") int? id, + @Part(name: "username") String? username, + @Part(name: "password") String? password, + @Part(name: "is_admin") bool? isAdmin, + @Part(name: "revoke_token") bool? revokeToken, + @Part(name: "permissions") int? permissions, + @CancelRequest() CancelToken? cancel}); @GET('/status') Future> getStatus( diff --git a/lib/api/client.g.dart b/lib/api/client.g.dart index a61ed9f..bcf706d 100644 --- a/lib/api/client.g.dart +++ b/lib/api/client.g.dart @@ -77,6 +77,55 @@ class __EHApi implements _EHApi { return value; } + @override + Future> deleteUser({ + int? id, + String? username, + CancelToken? cancel, + }) async { + final _extra = {}; + final queryParameters = {}; + queryParameters.removeWhere((k, v) => v == null); + final _headers = {}; + final _data = FormData(); + if (id != null) { + _data.fields.add(MapEntry( + 'id', + id.toString(), + )); + } + if (username != null) { + _data.fields.add(MapEntry( + 'username', + username, + )); + } + final _result = await _dio + .fetch>(_setStreamType>(Options( + method: 'DELETE', + headers: _headers, + extra: _extra, + contentType: 'multipart/form-data', + ) + .compose( + _dio.options, + '/user', + queryParameters: queryParameters, + data: _data, + cancelToken: cancel, + ) + .copyWith( + baseUrl: _combineBaseUrls( + _dio.options.baseUrl, + baseUrl, + )))); + final value = ApiResult.fromJson( + _result.data!, + (json) => json as dynamic, + ); + return value; + } + @override Future> getUser({ int? id, @@ -161,6 +210,83 @@ class __EHApi implements _EHApi { return value; } + @override + Future> updateUser({ + int? id, + String? username, + String? password, + bool? isAdmin, + bool? revokeToken, + int? permissions, + CancelToken? cancel, + }) async { + final _extra = {}; + final queryParameters = {}; + queryParameters.removeWhere((k, v) => v == null); + final _headers = {}; + final _data = FormData(); + if (id != null) { + _data.fields.add(MapEntry( + 'id', + id.toString(), + )); + } + if (username != null) { + _data.fields.add(MapEntry( + 'username', + username, + )); + } + if (password != null) { + _data.fields.add(MapEntry( + 'password', + password, + )); + } + if (isAdmin != null) { + _data.fields.add(MapEntry( + 'is_admin', + isAdmin.toString(), + )); + } + if (revokeToken != null) { + _data.fields.add(MapEntry( + 'revoke_token', + revokeToken.toString(), + )); + } + if (permissions != null) { + _data.fields.add(MapEntry( + 'permissions', + permissions.toString(), + )); + } + final _result = await _dio + .fetch>(_setStreamType>(Options( + method: 'PATCH', + headers: _headers, + extra: _extra, + contentType: 'multipart/form-data', + ) + .compose( + _dio.options, + '/user', + queryParameters: queryParameters, + data: _data, + cancelToken: cancel, + ) + .copyWith( + baseUrl: _combineBaseUrls( + _dio.options.baseUrl, + baseUrl, + )))); + final value = ApiResult.fromJson( + _result.data!, + (json) => BUser.fromJson(json as Map), + ); + return value; + } + @override Future> getStatus({CancelToken? cancel}) async { final _extra = {}; diff --git a/lib/components/user_card.dart b/lib/components/user_card.dart index c011b7e..e98c21b 100644 --- a/lib/components/user_card.dart +++ b/lib/components/user_card.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:go_router/go_router.dart'; import '../api/user.dart'; +import '../dialog/edit_user_page.dart'; import '../globals.dart'; class UserCard extends StatelessWidget { @@ -26,13 +28,21 @@ class UserCard extends StatelessWidget { style: TextStyle( fontWeight: FontWeight.bold, color: cs.primary), ), - Text(user.isAdmin ? i18n.admin : i18n.user, + Text( + user.id == 0 + ? i18n.rootUser + : user.isAdmin + ? i18n.admin + : i18n.user, style: TextStyle(color: cs.secondary)) ], )), !user.isAdmin || (user.isAdmin && auth.isRoot == true) ? IconButton( - onPressed: () {}, + onPressed: () { + context.push("/dialog/user/edit/${user.id}", + extra: EditUserPageExtra(user: user)); + }, tooltip: i18n.edit, icon: const Icon(Icons.edit)) : Container(), diff --git a/lib/dialog/edit_user_page.dart b/lib/dialog/edit_user_page.dart new file mode 100644 index 0000000..2dff885 --- /dev/null +++ b/lib/dialog/edit_user_page.dart @@ -0,0 +1,286 @@ +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/labeled_checkbox.dart'; +import '../components/user_permissions_chips.dart'; +import '../globals.dart'; + +final _log = Logger("EditUserPage"); + +class EditUserPageExtra { + const EditUserPageExtra({this.user}); + final BUser? user; +} + +class EditUserPage extends StatefulWidget { + const EditUserPage(this.uid, {super.key, this.user}); + final int uid; + final BUser? user; + + static const routeName = "/dialog/user/edit/:uid"; + + @override + State createState() => _EditUserPage(); +} + +class _EditUserPage extends State { + final _formKey = GlobalKey(); + BUser? _user; + CancelToken? _fetchCancel; + CancelToken? _requestCancel; + bool _isLoading = false; + bool _isRequesting = false; + Object? _error; + String? _username; + String? _password; + bool? _isAdmin; + bool _revokeToken = false; + bool _passwordVisible = false; + UserPermissions? _permissions; + Widget _buildWithVecticalPadding(Widget child) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 8), + child: child, + ); + } + + @override + void initState() { + _user = widget.user; + super.initState(); + } + + @override + void dispose() { + _fetchCancel?.cancel(); + _requestCancel?.cancel(); + super.dispose(); + } + + Future _fetchData() async { + try { + _fetchCancel = CancelToken(); + _isLoading = true; + final user = + (await api.getUser(id: widget.uid, cancel: _fetchCancel)).unwrap(); + if (!_fetchCancel!.isCancelled) { + setState(() { + _user = user; + _isLoading = false; + }); + } + } catch (e) { + if (!_fetchCancel!.isCancelled) { + _log.severe("Failed to load user ${widget.uid}: $e"); + setState(() { + _error = e; + _isLoading = false; + }); + } + } + } + + Future _request() async { + setState(() { + _isRequesting = true; + }); + try { + _requestCancel = CancelToken(); + // 关闭对话框不中断连接 + final user = (await api.updateUser( + id: widget.uid, + username: _username, + password: _password, + isAdmin: _isAdmin, + revokeToken: _revokeToken, + permissions: _permissions?.code)) + .unwrap(); + if (!_requestCancel!.isCancelled) { + setState(() { + _isRequesting = false; + _user = user; + _username = null; + _password = null; + _isAdmin = null; + _revokeToken = false; + _permissions = null; + }); + } + listener.tryEmit("update_user", user); + } catch (e) { + _log.severe("Failed to update user ${widget.uid}: $e"); + } + } + + @override + Widget build(BuildContext context) { + if (!tryInitApi(context)) { + return Container(); + } + if (auth.isAdmin == false) { + SchedulerBinding.instance.addPostFrameCallback((_) { + context.go("/"); + }); + return Container(); + } + if (_user != null && _user!.isAdmin && auth.isRoot == false) { + SchedulerBinding.instance.addPostFrameCallback((_) { + context.go("/users"); + }); + return Container(); + } + final isLoading = _user == null && _error == null; + if (isLoading && !_isLoading) _fetchData(); + final i18n = AppLocalizations.of(context)!; + final maxWidth = MediaQuery.of(context).size.width; + return Container( + padding: maxWidth < 400 + ? const EdgeInsets.symmetric(vertical: 20, horizontal: 10) + : const EdgeInsets.all(20), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(10)), + child: isLoading + ? const Center(child: CircularProgressIndicator()) + : _error != null + ? SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text("Error: $_error"), + ElevatedButton.icon( + onPressed: () { + _fetchData(); + setState(() { + _error = null; + }); + }, + icon: const Icon(Icons.refresh), + label: Text(i18n.retry)), + ], + )) + : SingleChildScrollView( + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Stack( + alignment: Alignment.center, + children: [ + Text( + i18n.editUser, + 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 ?? _user?.username, + onChanged: (value) { + setState(() { + _username = + value.isEmpty || value == _user!.username + ? null + : 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; + }); + }, + ), + ), + onChanged: (value) { + setState(() { + _password = value.isEmpty ? null : value; + }); + }, + obscureText: !_passwordVisible, + )), + auth.isRoot == true && widget.uid != 0 + ? _buildWithVecticalPadding(LabeledCheckbox( + value: _isAdmin ?? _user!.isAdmin, + onChanged: (b) { + if (b != null) { + setState(() { + _isAdmin = + b == _user!.isAdmin ? null : b; + }); + } + }, + label: Text(i18n.admin))) + : Container(), + _isAdmin == false || + (_isAdmin == null && !_user!.isAdmin) + ? _buildWithVecticalPadding( + UserPermissionsChips( + permissions: _permissions ?? + UserPermissions( + _user!.permissions.code), + onChanged: (v) { + setState(() { + _permissions = v.code == + _user!.permissions.code + ? null + : v; + }); + })) + : Container(), + _buildWithVecticalPadding(LabeledCheckbox( + value: _revokeToken, + onChanged: (b) { + if (b != null) { + setState(() { + _revokeToken = b; + }); + } + }, + label: Text(i18n.revokeToken))), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: !_isRequesting && + (_username != null || + _password != null || + _isAdmin != null || + _permissions != null || + _revokeToken) + ? () { + _request(); + } + : null, + child: Text(i18n.update)) + ]), + ]))), + ); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 39a4348..303339f 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -212,5 +212,8 @@ "deleteGallery": "Delete gallery", "manageTasks": "Manage tasks", "allPermissions": "All permissions", - "userPermissions": "User permissions" + "userPermissions": "User permissions", + "revokeToken": "Revoke all login sessions", + "editUser": "Edit user", + "rootUser": "Root user" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 727b0a0..6d36c20 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -212,5 +212,8 @@ "deleteGallery": "删除画廊", "manageTasks": "管理任务", "allPermissions": "所有权限", - "userPermissions": "用户权限" + "userPermissions": "用户权限", + "revokeToken": "撤销所有登录的会话", + "editUser": "编辑用户", + "rootUser": "根用户" } diff --git a/lib/main.dart b/lib/main.dart index a045778..b500a3a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,6 +9,7 @@ import 'package:window_manager/window_manager.dart'; import 'api/client.dart'; import 'dialog/dialog_page.dart'; import 'dialog/download_zip_page.dart'; +import 'dialog/edit_user_page.dart'; import 'dialog/gallery_details_page.dart'; import 'dialog/new_download_task_page.dart'; import 'dialog/new_user_page.dart'; @@ -218,6 +219,26 @@ final _router = GoRouter( return const NewUserPage(); }); }), + GoRoute( + path: EditUserPage.routeName, + pageBuilder: (context, state) { + final extra = state.extra as EditUserPageExtra?; + final uid = int.parse(state.pathParameters["uid"]!); + return DialogPage( + key: state.pageKey, + builder: (context) { + return EditUserPage(uid, user: extra?.user); + }); + }, + redirect: (context, state) { + try { + int.parse(state.pathParameters["uid"]!); + return null; + } catch (e) { + _routerLog.warning("Failed to parse uid:", e); + return "/users"; + } + }), ], ); diff --git a/lib/pages/users.dart b/lib/pages/users.dart index c66fc9d..5611d88 100644 --- a/lib/pages/users.dart +++ b/lib/pages/users.dart @@ -161,12 +161,14 @@ class _UsersPage extends State with ThemeModeWidget, IsTopWidget2 { _cancel?.cancel(); _cancel2?.cancel(); listener.removeEventListener("new_user", _onNewUser); + listener.removeEventListener("update_user", _onUpdateUser); super.dispose(); } @override void initState() { listener.on("new_user", _onNewUser); + listener.on("update_user", _onUpdateUser); super.initState(); } @@ -189,4 +191,17 @@ class _UsersPage extends State with ThemeModeWidget, IsTopWidget2 { } } } + + void _onUpdateUser(dynamic arg) { + final user = arg as BUser; + if (_users == null) return; + final index = _users!.indexWhere((v) => v.id == user.id); + setState(() { + if (index == -1) { + _users?.add(user); + } else { + _users![index] = user; + } + }); + } }