diff --git a/lib/api/api_result.dart b/lib/api/api_result.dart index 9aa42d9..9ec234b 100644 --- a/lib/api/api_result.dart +++ b/lib/api/api_result.dart @@ -23,7 +23,7 @@ class ApiResult { if (ok) { return data!; } else { - return throw error!; + return throw (status, error!); } } diff --git a/lib/api/client.dart b/lib/api/client.dart index f4cb7ff..d2924f1 100644 --- a/lib/api/client.dart +++ b/lib/api/client.dart @@ -69,6 +69,20 @@ enum SortByGid { abstract class _EHApi { factory _EHApi(Dio dio, {required String baseUrl}) = __EHApi; + @POST('/user/change_name') + @MultiPart() + Future> changeUserName( + @Part(name: "username") String username, + {@CancelRequest() CancelToken? cancel}); + @POST('/user/change_password') + @MultiPart() + // ignore: unused_element + Future> _changeUserPassword( + @Part(name: "old") String oldPassword, + @Part(name: "t") int t, + @Part(name: "new") String newPassword, + // ignore: unused_element + {@CancelRequest() CancelToken? cancel}); @PUT('/user') @MultiPart() Future> createUser( @@ -364,4 +378,16 @@ class EHApi extends __EHApi { .replace(queryParameters: queries); return newUri.toString(); } + + Future> changeUserPassword( + String oldPassword, String newPassword, + {CancelToken? cancel}) async { + int t = DateTime.now().millisecondsSinceEpoch; + final p = await _pbkdf2a.deriveKeyFromPassword( + password: oldPassword, nonce: _salt); + final p2 = await _pbkdf2b.deriveKey( + secretKey: p, nonce: _utf8Encoder.convert(t.toString())); + final p3 = base64Encode(await p2.extractBytes()); + return await _changeUserPassword(p3, t, newPassword, cancel: cancel); + } } diff --git a/lib/api/client.g.dart b/lib/api/client.g.dart index bcf706d..1f829e9 100644 --- a/lib/api/client.g.dart +++ b/lib/api/client.g.dart @@ -18,6 +18,96 @@ class __EHApi implements _EHApi { String? baseUrl; + @override + Future> changeUserName( + String username, { + CancelToken? cancel, + }) async { + final _extra = {}; + final queryParameters = {}; + queryParameters.removeWhere((k, v) => v == null); + final _headers = {}; + final _data = FormData(); + _data.fields.add(MapEntry( + 'username', + username, + )); + final _result = await _dio + .fetch>(_setStreamType>(Options( + method: 'POST', + headers: _headers, + extra: _extra, + contentType: 'multipart/form-data', + ) + .compose( + _dio.options, + '/user/change_name', + 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> _changeUserPassword( + String oldPassword, + int t, + String newPassword, { + CancelToken? cancel, + }) async { + final _extra = {}; + final queryParameters = {}; + queryParameters.removeWhere((k, v) => v == null); + final _headers = {}; + final _data = FormData(); + _data.fields.add(MapEntry( + 'old', + oldPassword, + )); + _data.fields.add(MapEntry( + 't', + t.toString(), + )); + _data.fields.add(MapEntry( + 'new', + newPassword, + )); + final _result = await _dio + .fetch>(_setStreamType>(Options( + method: 'POST', + headers: _headers, + extra: _extra, + contentType: 'multipart/form-data', + ) + .compose( + _dio.options, + '/user/change_password', + 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> createUser( String name, diff --git a/lib/auth.dart b/lib/auth.dart index 35a10a9..7da46d4 100644 --- a/lib/auth.dart +++ b/lib/auth.dart @@ -107,4 +107,11 @@ class AuthInfo { _isChecking = false; } } + + void setUpdatedUser(BUser u) { + _user = u; + _log.info( + "User updated: ${u.username} (${u.id}), isAdmin: ${u.isAdmin}. permissions: ${u.permissions}"); + listener.tryEmit("user_logined", null); + } } diff --git a/lib/components/user_permissions_chips.dart b/lib/components/user_permissions_chips.dart index 24cdc9a..817da2f 100644 --- a/lib/components/user_permissions_chips.dart +++ b/lib/components/user_permissions_chips.dart @@ -3,9 +3,11 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import '../api/user.dart'; class UserPermissionsChips extends StatefulWidget { - const UserPermissionsChips({super.key, this.permissions, this.onChanged}); + const UserPermissionsChips( + {super.key, this.permissions, this.onChanged, this.readOnly = false}); final UserPermissions? permissions; final ValueChanged? onChanged; + final bool readOnly; @override State createState() => _UserPermissionsChips(); @@ -31,32 +33,36 @@ class _UserPermissionsChips extends State { FilterChip( label: Text(i18n.allPermissions), selected: _permissions.isAll, - onSelected: (bool value) { - setState(() { - if (value) { - _permissions.code = userPermissionAll; - } else { - _permissions.code = 0; - } - }); - _onChanged(); - }, + onSelected: widget.readOnly + ? null + : (bool value) { + setState(() { + if (value) { + _permissions.code = userPermissionAll; + } else { + _permissions.code = 0; + } + }); + _onChanged(); + }, ) ]; for (var flag in UserPermission.values) { list.add(FilterChip( label: Text(flag.localText(context)), selected: _permissions.has(flag), - onSelected: (bool value) { - setState(() { - if (value) { - _permissions.add(flag); - } else { - _permissions.remove(flag); - } - }); - _onChanged(); - }, + onSelected: widget.readOnly + ? null + : (bool value) { + setState(() { + if (value) { + _permissions.add(flag); + } else { + _permissions.remove(flag); + } + }); + _onChanged(); + }, )); } return Wrap(spacing: 5.0, children: list); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index addc390..c58f839 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -227,5 +227,15 @@ "failedDeleteUser": "Failed to delete user: ", "deleteUser": "Delete user", "display": "Display", - "cache": "Cache" + "cache": "Cache", + "cancel": "Cancel", + "changeUsername": "Change username", + "failedChangeUsername": "Failed to change username: ", + "usernameIsAlreadyUsed": "The username is already used by another user.", + "changePassword": "Change password", + "failedChangePassword": "Failed to change password: ", + "oldPassword": "Old password", + "newPassword": "New password", + "incorrectPassword": "Incorrect password.", + "changedPasswordSuccessfully": "Changed password successfully." } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index d5affed..17528e7 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -227,5 +227,15 @@ "failedDeleteUser": "删除用户失败:", "deleteUser": "删除用户", "display": "显示", - "cache": "缓存" + "cache": "缓存", + "cancel": "取消", + "changeUsername": "修改用户名", + "failedChangeUsername": "修改用户名失败:", + "usernameIsAlreadyUsed": "此用户名已被其他用户占用。", + "changePassword": "修改密码", + "failedChangePassword": "修改密码失败:", + "oldPassword": "旧密码", + "newPassword": "新密码", + "incorrectPassword": "不正确的密码。", + "changedPasswordSuccessfully": "修改密码成功。" } diff --git a/lib/main.dart b/lib/main.dart index 3cf2319..06a7da8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -26,6 +26,7 @@ import 'pages/settings/cache.dart'; import 'pages/settings/display.dart'; import 'pages/settings/server.dart'; import 'pages/settings/server_url.dart'; +import 'pages/settings/user.dart'; import 'pages/task_manager.dart'; import 'pages/users.dart'; import 'utils.dart'; @@ -249,6 +250,10 @@ final _router = GoRouter( path: CacheSettingsPage.routeName, builder: (context, state) => CacheSettingsPage(key: state.pageKey), ), + GoRoute( + path: UserSettingsPage.routeName, + builder: (context, state) => UserSettingsPage(key: state.pageKey), + ), ], ); diff --git a/lib/pages/settings.dart b/lib/pages/settings.dart index 67f6360..a25e960 100644 --- a/lib/pages/settings.dart +++ b/lib/pages/settings.dart @@ -65,6 +65,14 @@ class _SettingsPage extends State context.push("/settings/server/url"); }) : Container(), + auth.isAuthed + ? ListTile( + leading: const Icon(Icons.account_circle), + title: Text(i18n.user), + onTap: () { + context.push("/settings/user"); + }) + : Container(), ListTile( leading: const Icon(Icons.display_settings), title: Text(i18n.display), diff --git a/lib/pages/settings/user.dart b/lib/pages/settings/user.dart new file mode 100644 index 0000000..d6665a0 --- /dev/null +++ b/lib/pages/settings/user.dart @@ -0,0 +1,294 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:go_router/go_router.dart'; +import 'package:logging/logging.dart'; +import '../../components/user_permissions_chips.dart'; +import '../../globals.dart'; + +final _log = Logger("UserSettingsPage"); + +Future _changeUserName(String username, AppLocalizations i18n) async { + try { + final user = (await api.changeUserName(username)).unwrap(); + auth.setUpdatedUser(user); + } catch (e, stack) { + String errmsg = "${i18n.failedChangeUsername}$e"; + if (e is (int, String)) { + _log.warning("Failed to change user name: $e"); + if (e.$1 == 4) { + errmsg = "${i18n.failedChangeUsername}${i18n.usernameIsAlreadyUsed}"; + } + } else { + _log.severe("Failed to change user name: $e\n$stack"); + } + final snack = SnackBar(content: Text(errmsg)); + rootScaffoldMessengerKey.currentState?.showSnackBar(snack); + } +} + +Future _changeUserPassword( + String old, String n, AppLocalizations i18n) async { + try { + (await api.changeUserPassword(old, n)).unwrap(); + final snack = SnackBar(content: Text(i18n.changedPasswordSuccessfully)); + rootScaffoldMessengerKey.currentState?.showSnackBar(snack); + } catch (e, stack) { + String errmsg = "${i18n.failedChangePassword}$e"; + if (e is (int, String)) { + _log.warning("Failed to change password: $e"); + if (e.$1 == 5) { + errmsg = "${i18n.failedChangePassword}${i18n.incorrectPassword}"; + } + } else { + _log.severe("Failed to change password: $e\n$stack"); + } + final snack = SnackBar(content: Text(errmsg)); + rootScaffoldMessengerKey.currentState?.showSnackBar(snack); + } +} + +class _ChangeUsernameDialog extends StatefulWidget { + const _ChangeUsernameDialog({this.username}); + + final String? username; + + @override + State createState() => _ChangeUsernameDialogState(); +} + +class _ChangeUsernameDialogState extends State<_ChangeUsernameDialog> { + final _formKey = GlobalKey(); + late String _username; + + @override + void initState() { + _username = widget.username ?? ""; + super.initState(); + } + + @override + Widget build(BuildContext context) { + final i18n = AppLocalizations.of(context)!; + return AlertDialog( + title: Text(i18n.changeUsername), + content: Form( + key: _formKey, + child: TextFormField( + initialValue: _username, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: i18n.username, + ), + onChanged: (s) { + setState(() { + _username = s; + }); + }, + )), + actions: [ + TextButton( + onPressed: _username != auth.user!.username && _username.isNotEmpty + ? () { + _changeUserName(_username, i18n); + context.pop(); + } + : null, + child: Text(i18n.changeUsername)), + TextButton( + onPressed: () { + context.pop(); + }, + child: Text(i18n.cancel)), + ], + ); + } +} + +class _ChangePasswordDialog extends StatefulWidget { + const _ChangePasswordDialog(); + + @override + State createState() => _ChangePasswordDialogState(); +} + +class _ChangePasswordDialogState extends State<_ChangePasswordDialog> { + final _formKey = GlobalKey(); + String _oldPassword = ""; + bool _oldPasswordVisible = false; + String _newPassword = ""; + bool _newPasswordVisible = false; + Widget _buildWithVecticalPadding(Widget child) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 8), + child: child, + ); + } + + @override + Widget build(BuildContext context) { + final i18n = AppLocalizations.of(context)!; + return AlertDialog( + title: Text(i18n.changePassword), + content: Form( + key: _formKey, + child: Column(mainAxisSize: MainAxisSize.min, children: [ + _buildWithVecticalPadding(TextFormField( + initialValue: _oldPassword, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: i18n.oldPassword, + suffixIcon: IconButton( + icon: Icon( + _oldPasswordVisible + ? Icons.visibility + : Icons.visibility_off, + color: Theme.of(context).primaryColorDark, + ), + onPressed: () { + setState(() { + _oldPasswordVisible = !_oldPasswordVisible; + }); + }, + ), + ), + onChanged: (s) { + setState(() { + _oldPassword = s; + }); + }, + obscureText: !_oldPasswordVisible, + )), + _buildWithVecticalPadding(TextFormField( + initialValue: _newPassword, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: i18n.newPassword, + suffixIcon: IconButton( + icon: Icon( + _newPasswordVisible + ? Icons.visibility + : Icons.visibility_off, + color: Theme.of(context).primaryColorDark, + ), + onPressed: () { + setState(() { + _newPasswordVisible = !_newPasswordVisible; + }); + }, + ), + ), + onChanged: (s) { + setState(() { + _newPassword = s; + }); + }, + obscureText: !_newPasswordVisible, + )), + ])), + actions: [ + TextButton( + onPressed: _oldPassword.isNotEmpty && + _newPassword.isNotEmpty && + _oldPassword != _newPassword + ? () { + _changeUserPassword(_oldPassword, _newPassword, i18n); + context.pop(); + } + : null, + child: Text(i18n.changePassword)), + TextButton( + onPressed: () { + context.pop(); + }, + child: Text(i18n.cancel)), + ], + ); + } +} + +class UserSettingsPage extends StatefulWidget { + const UserSettingsPage({super.key}); + + static const String routeName = '/settings/user'; + + @override + State createState() => _UserSettingsPage(); +} + +class _UserSettingsPage extends State with ThemeModeWidget { + void _onStateChanged(dynamic _) { + setState(() {}); + } + + Widget _buildMain(BuildContext context) { + if (!tryInitApi(context)) { + return const Center(child: CircularProgressIndicator()); + } + if (!auth.isAuthed) { + return const Center(child: CircularProgressIndicator()); + } + final i18n = AppLocalizations.of(context)!; + return SingleChildScrollView( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + ListTile( + leading: const Icon(Icons.badge), + title: Text(i18n.username), + onTap: () => showDialog( + context: context, + builder: (context) => + _ChangeUsernameDialog(username: auth.user!.username)), + subtitle: Text(auth.user!.username)), + ListTile( + leading: const Icon(Icons.password), + title: Text(i18n.password), + onTap: () => showDialog( + context: context, + builder: (context) => const _ChangePasswordDialog())), + Padding( + padding: const EdgeInsets.only(left: 10), + child: CheckboxMenuButton( + value: auth.user!.isAdmin, + onChanged: null, + child: Text(i18n.admin))), + Padding( + padding: const EdgeInsets.only(left: 16), + child: UserPermissionsChips( + permissions: auth.user!.permissions, readOnly: true)), + ])); + } + + @override + void initState() { + listener.on("user_logined", _onStateChanged); + super.initState(); + } + + @override + void dispose() { + listener.removeEventListener("user_logined", _onStateChanged); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final i18n = AppLocalizations.of(context)!; + setCurrentTitle("${i18n.settings} - ${i18n.user}", + Theme.of(context).primaryColor.value); + return Scaffold( + appBar: AppBar( + leading: IconButton( + onPressed: () { + context.canPop() ? context.pop() : context.go("/settings"); + }, + icon: const Icon(Icons.arrow_back), + ), + title: Text(i18n.user), + actions: [ + buildThemeModeIcon(context), + buildMoreVertSettingsButon(context), + ], + ), + body: _buildMain(context), + ); + } +}