diff --git a/lib/api/client.dart b/lib/api/client.dart index d290dd8..beef9f4 100644 --- a/lib/api/client.dart +++ b/lib/api/client.dart @@ -187,9 +187,20 @@ abstract class _EHApi { Future> deleteToken( {@Part(name: "token") String? token, @CancelRequest() CancelToken? cancel}); + @DELETE('/token/manage') + @MultiPart() + Future> deleteTokenById(@Part(name: "id") int id, + {@CancelRequest() CancelToken? cancel}); @GET('/token') Future> getToken( {@Query("token") String? token, @CancelRequest() CancelToken? cancel}); + @GET('/token/manage') + Future>> getTokens( + {@Query("uid") int? uid, + @Query("offset") int? offset, + @Query("limit") int? limit, + @Query("all_user") bool? allUser, + @CancelRequest() CancelToken? cancel}); @GET('/shared_token') Future> getSharedToken( {@CancelRequest() CancelToken? cancel}); diff --git a/lib/api/client.g.dart b/lib/api/client.g.dart index 846c5fc..11696d6 100644 --- a/lib/api/client.g.dart +++ b/lib/api/client.g.dart @@ -689,6 +689,52 @@ class __EHApi implements _EHApi { return _value; } + @override + Future> deleteTokenById( + int id, { + CancelToken? cancel, + }) async { + final _extra = {}; + final queryParameters = {}; + queryParameters.removeWhere((k, v) => v == null); + final _headers = {}; + final _data = FormData(); + _data.fields.add(MapEntry( + 'id', + id.toString(), + )); + final _options = _setStreamType>(Options( + method: 'DELETE', + headers: _headers, + extra: _extra, + contentType: 'multipart/form-data', + ) + .compose( + _dio.options, + '/token/manage', + queryParameters: queryParameters, + data: _data, + cancelToken: cancel, + ) + .copyWith( + baseUrl: _combineBaseUrls( + _dio.options.baseUrl, + baseUrl, + ))); + final _result = await _dio.fetch>(_options); + late ApiResult _value; + try { + _value = ApiResult.fromJson( + _result.data!, + (json) => json as bool, + ); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options); + rethrow; + } + return _value; + } + @override Future> getToken({ String? token, @@ -730,6 +776,60 @@ class __EHApi implements _EHApi { return _value; } + @override + Future>> getTokens({ + int? uid, + int? offset, + int? limit, + bool? allUser, + CancelToken? cancel, + }) async { + final _extra = {}; + final queryParameters = { + r'uid': uid, + r'offset': offset, + r'limit': limit, + r'all_user': allUser, + }; + queryParameters.removeWhere((k, v) => v == null); + final _headers = {}; + const Map? _data = null; + final _options = _setStreamType>>(Options( + method: 'GET', + headers: _headers, + extra: _extra, + ) + .compose( + _dio.options, + '/token/manage', + queryParameters: queryParameters, + data: _data, + cancelToken: cancel, + ) + .copyWith( + baseUrl: _combineBaseUrls( + _dio.options.baseUrl, + baseUrl, + ))); + final _result = await _dio.fetch>(_options); + late ApiResult> _value; + try { + _value = ApiResult>.fromJson( + _result.data!, + (json) => json is List + ? json + .map((i) => + TokenWithoutToken.fromJson(i as Map)) + .toList() + : List.empty(), + ); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options); + rethrow; + } + return _value; + } + @override Future> getSharedToken({CancelToken? cancel}) async { final _extra = {}; diff --git a/lib/api/token.dart b/lib/api/token.dart index eb9806f..0b080e6 100644 --- a/lib/api/token.dart +++ b/lib/api/token.dart @@ -40,6 +40,42 @@ class Token { Map toJson() => _$TokenToJson(this); } +@JsonSerializable() +class TokenWithoutToken { + const TokenWithoutToken({ + required this.id, + required this.uid, + required this.expired, + required this.httpOnly, + required this.secure, + required this.lastUsed, + this.client, + this.device, + this.clientVersion, + this.clientPlatform, + }); + final int id; + final int uid; + @JsonKey(fromJson: _fromJson, toJson: _toJson) + final DateTime expired; + @JsonKey(name: 'http_only') + final bool httpOnly; + final bool secure; + @JsonKey(fromJson: _fromJson, toJson: _toJson, name: 'last_used') + final DateTime lastUsed; + final String? client; + final String? device; + @JsonKey(name: 'client_version') + final String? clientVersion; + @JsonKey(name: 'client_platform') + final String? clientPlatform; + static DateTime _fromJson(String d) => DateTime.parse(d); + static String _toJson(DateTime d) => d.toIso8601String(); + factory TokenWithoutToken.fromJson(Map json) => + _$TokenWithoutTokenFromJson(json); + Map toJson() => _$TokenWithoutTokenToJson(this); +} + @JsonSerializable() class TokenWithUserInfo { const TokenWithUserInfo({ diff --git a/lib/api/token.g.dart b/lib/api/token.g.dart index aaf97fe..7d5a264 100644 --- a/lib/api/token.g.dart +++ b/lib/api/token.g.dart @@ -34,6 +34,34 @@ Map _$TokenToJson(Token instance) => { 'client_platform': instance.clientPlatform, }; +TokenWithoutToken _$TokenWithoutTokenFromJson(Map json) => + TokenWithoutToken( + id: (json['id'] as num).toInt(), + uid: (json['uid'] as num).toInt(), + expired: TokenWithoutToken._fromJson(json['expired'] as String), + httpOnly: json['http_only'] as bool, + secure: json['secure'] as bool, + lastUsed: TokenWithoutToken._fromJson(json['last_used'] as String), + client: json['client'] as String?, + device: json['device'] as String?, + clientVersion: json['client_version'] as String?, + clientPlatform: json['client_platform'] as String?, + ); + +Map _$TokenWithoutTokenToJson(TokenWithoutToken instance) => + { + 'id': instance.id, + 'uid': instance.uid, + 'expired': TokenWithoutToken._toJson(instance.expired), + 'http_only': instance.httpOnly, + 'secure': instance.secure, + 'last_used': TokenWithoutToken._toJson(instance.lastUsed), + 'client': instance.client, + 'device': instance.device, + 'client_version': instance.clientVersion, + 'client_platform': instance.clientPlatform, + }; + TokenWithUserInfo _$TokenWithUserInfoFromJson(Map json) => TokenWithUserInfo( token: Token.fromJson(json['token'] as Map), diff --git a/lib/auth.dart b/lib/auth.dart index 9b096a5..a9acd6a 100644 --- a/lib/auth.dart +++ b/lib/auth.dart @@ -60,6 +60,7 @@ class AuthInfo { Future checkSessionInfo() async { final data = (await api.getToken()).unwrap(); _token = data.token; + listener.tryEmit("auth_token_updated", null); final d = await device; final cv = await clientVersion; final cp = clientPlatform; @@ -87,6 +88,7 @@ class AuthInfo { clientVersion: ecv, clientPlatform: ecp); _token = re.unwrap(); + listener.tryEmit("auth_token_updated", null); } catch (e) { _log.warning("Failed to update token:", e); } diff --git a/lib/components/session_card.dart b/lib/components/session_card.dart new file mode 100644 index 0000000..d984c47 --- /dev/null +++ b/lib/components/session_card.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; +import 'package:logging/logging.dart'; +import '../api/token.dart'; +import '../api/user.dart'; +import '../globals.dart'; +import '../main.dart'; + +final _log = Logger("SessionCard"); + +Future _deleteSession(int id, String errmsg) async { + try { + (await api.deleteTokenById(id)).unwrap(); + listener.tryEmit("delete_session", id); + } catch (e) { + _log.severe("Failed to delete session $id: $e"); + final snack = SnackBar(content: Text("$errmsg$e")); + rootScaffoldMessengerKey.currentState?.showSnackBar(snack); + } +} + +class SessionCard extends StatelessWidget { + const SessionCard(this.token, {this.user, super.key}); + final BUser? user; + final TokenWithoutToken token; + String get device { + var s = ""; + if (token.device != null) { + s = token.device!; + } + var c = ""; + if (token.client != null) { + c = token.client!; + } + if (token.clientPlatform != null) { + if (c.isNotEmpty) { + c += " ${token.clientPlatform!}"; + } + } + if (token.clientVersion != null) { + if (c.isNotEmpty) { + c += " ${token.clientVersion!}"; + } + } + if (s.isEmpty) { + s = c; + } else if (c.isNotEmpty) { + s = "$s($c)"; + } + return s; + } + + @override + Widget build(BuildContext context) { + final i18n = AppLocalizations.of(context)!; + final expiredTime = + DateFormat.yMd(MainApp.of(context).lang.toLocale().toString()) + .add_jms() + .format(token.expired); + final lastUsed = + DateFormat.yMd(MainApp.of(context).lang.toLocale().toString()) + .add_jms() + .format(token.lastUsed); + return Card.outlined( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row(children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text("${i18n.sessionId}${i18n.colon}${token.id}"), + Text("${i18n.expireTime}${i18n.colon}$expiredTime"), + Text("${i18n.lastUsedTime}${i18n.colon}$lastUsed"), + device.isEmpty + ? Container() + : Text("${i18n.device}${i18n.colon}$device"), + user != null + ? Text("${i18n.username}${i18n.colon}${user!.username}") + : Container(), + ], + )), + IconButton( + onPressed: token.id != auth.token?.id + ? () => showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text(i18n.deleteSession), + content: Text(user != null + ? i18n.deleteSessionForUserConfirm( + user!.username) + : i18n.deleteSessionConfirm), + actions: [ + TextButton( + onPressed: () { + _deleteSession( + token.id, i18n.failedDeleteSession); + context.pop(); + }, + child: Text(i18n.yes)), + TextButton( + onPressed: () { + context.pop(); + }, + child: Text(i18n.no)), + ]); + }) + : null, + tooltip: i18n.delete, + icon: const Icon(Icons.delete)) + ]))); + } +} diff --git a/lib/globals.dart b/lib/globals.dart index c1a153d..9f7a619 100644 --- a/lib/globals.dart +++ b/lib/globals.dart @@ -176,6 +176,7 @@ enum MoreVertSettings { markAsAd, markAsNonAd, shareGallery, + sessions, } void onMoreVertSettingsSelected(BuildContext context, MoreVertSettings value) { @@ -207,6 +208,9 @@ void onMoreVertSettingsSelected(BuildContext context, MoreVertSettings value) { context.push("/dialog/gallery/share/$gid"); } break; + case MoreVertSettings.sessions: + context.push("/sessions"); + break; default: break; } @@ -234,6 +238,10 @@ List> buildMoreVertSettings( list.add(PopupMenuItem( value: MoreVertSettings.taskManager, child: Text(i18n.taskManager))); } + if (path != "/sessions") { + list.add(PopupMenuItem( + value: MoreVertSettings.sessions, child: Text(i18n.sessionManagemant))); + } if (path == "/gallery/:gid" && auth.canShareGallery == true) { list.add(PopupMenuItem( value: MoreVertSettings.shareGallery, child: Text(i18n.shareGallery))); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 5b83727..7e4511e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -339,5 +339,21 @@ "ok": "Ok", "changeSettings": "Change settings", "createUpdateMeiliSearchDataTask": "Create sync meilisearch server's data task", - "updateMeiliSearchDataGidHelp": "If gallery id is not empty, only specified gallery will sync to meilisearch server." + "updateMeiliSearchDataGidHelp": "If gallery id is not empty, only specified gallery will sync to meilisearch server.", + "sessionManagemant": "Session Management", + "deleteSession": "Delete session", + "deleteSessionConfirm": "Do you want to delete session?", + "deleteSessionForUserConfirm": "Do you want to delete session for user {user}?", + "@deleteSessionForUserConfirm": { + "placeholders": { + "user": { + "type": "String" + } + } + }, + "sessionId": "Session ID", + "lastUsedTime": "Last used time", + "device": "Device", + "failedDeleteSession": "Failed to delete session: ", + "allUser": "All users" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 6461134..c7ec932 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -339,5 +339,21 @@ "ok": "确定", "changeSettings": "修改设置", "createUpdateMeiliSearchDataTask": "创建同步meilisearch服务器数据任务", - "updateMeiliSearchDataGidHelp": "如果画廊ID非空,只有指定的画廊会同步至meilisearch服务器。" + "updateMeiliSearchDataGidHelp": "如果画廊ID非空,只有指定的画廊会同步至meilisearch服务器。", + "sessionManagemant": "会话管理", + "deleteSession": "删除会话", + "deleteSessionConfirm": "是否删除会话?", + "deleteSessionForUserConfirm": "是否为用户 {user} 删除会话?", + "@deleteSessionForUserConfirm": { + "placeholders": { + "user": { + "type": "String" + } + } + }, + "sessionId": "会话 ID", + "lastUsedTime": "上次使用时间", + "device": "设备", + "failedDeleteSession": "删除会话失败:", + "allUser": "所有用户" } diff --git a/lib/main.dart b/lib/main.dart index e956bf9..e9e12fc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -25,6 +25,7 @@ import 'pages/galleries.dart'; import 'pages/gallery.dart'; import 'pages/home.dart'; import 'pages/login.dart'; +import 'pages/sessions.dart'; import 'pages/settings.dart'; import 'pages/settings/cache.dart'; import 'pages/settings/display.dart'; @@ -356,6 +357,10 @@ final _router = GoRouter( return NewUpdateMeiliSearchDataTaskPage(gid: gid); }); }), + GoRoute( + path: SessionsPage.routeName, + builder: (context, state) => SessionsPage(key: state.pageKey), + ), ], observers: [ _NavigatorObserver(), diff --git a/lib/pages/sessions.dart b/lib/pages/sessions.dart new file mode 100644 index 0000000..7f08c95 --- /dev/null +++ b/lib/pages/sessions.dart @@ -0,0 +1,312 @@ +import 'dart:ui'; +import 'package:dio/dio.dart'; +import 'package:eh_downloader_flutter/api/token.dart'; +import 'package:flutter/foundation.dart'; +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 '../api/user.dart'; +import '../../components/labeled_checkbox.dart'; +import '../components/session_card.dart'; +import '../globals.dart'; +import '../platform/media_query.dart'; +import '../utils.dart'; + +final _log = Logger("SessionsPage"); + +class SessionsPage extends StatefulWidget { + const SessionsPage({super.key}); + + static const String routeName = '/sessions'; + + @override + State createState() => _SessionsPage(); +} + +class _SessionsPage extends State + with ThemeModeWidget, IsTopWidget2 { + final GlobalKey _refreshIndicatorKey = + GlobalKey(); + final _formKey = GlobalKey(); + int? _uid; + bool _allUser = false; + List? _users; + List? _tokens; + bool _isLoading = false; + bool _isLoadingUsers = false; + CancelToken? _cancel; + CancelToken? _cancel2; + Object? _error; + Future _fetchUserData() async { + try { + _cancel = CancelToken(); + _isLoadingUsers = true; + final users = (await api.getUsers(all: true, cancel: _cancel)).unwrap(); + if (!_cancel!.isCancelled) { + setState(() { + _users = users; + _isLoadingUsers = false; + }); + } + } catch (e) { + if (!_cancel!.isCancelled) { + _log.severe("Failed to load user list:", e); + setState(() { + _error = e; + _isLoadingUsers = false; + }); + } + } + } + + Future _fetchData() async { + try { + _cancel2 = CancelToken(); + _isLoading = true; + final tokens = + (await api.getTokens(uid: _uid, allUser: _allUser, cancel: _cancel2)) + .unwrap(); + if (!_cancel2!.isCancelled) { + setState(() { + _tokens = tokens; + _isLoading = false; + }); + } + } catch (e) { + if (!_cancel2!.isCancelled) { + _log.severe("Failed to load token list:", e); + setState(() { + _error = e; + _isLoading = false; + }); + } + } + } + + @override + void initState() { + listener.on("user_logined", _onStateChanged); + listener.on("auth_token_updated", _onStateChanged); + listener.on("delete_session", _onDeleteSession); + super.initState(); + } + + @override + void dispose() { + _cancel?.cancel(); + _cancel2?.cancel(); + listener.removeEventListener("user_logined", _onStateChanged); + listener.removeEventListener("auth_token_updated", _onStateChanged); + listener.removeEventListener("delete_session", _onDeleteSession); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (!tryInitApi(context)) { + return Container(); + } + final isLoadingUsers = + auth.isAdmin == true && _users == null && _error == null; + if (isLoadingUsers && !_isLoadingUsers) _fetchUserData(); + final isLoading = _tokens == null && _error == null; + if (isLoading && !_isLoading) _fetchData(); + final i18n = AppLocalizations.of(context)!; + final th = Theme.of(context); + if (isTop(context)) { + setCurrentTitle(i18n.sessionManagemant, th.primaryColor.value); + } + return Scaffold( + appBar: _tokens == null && (auth.isAdmin != true || _users == null) + ? AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + context.canPop() ? context.pop() : context.go("/"); + }, + ), + title: Text(i18n.sessionManagemant), + actions: [ + buildThemeModeIcon(context), + buildMoreVertSettingsButon(context), + ], + ) + : null, + body: isLoading || isLoadingUsers + ? const Center(child: CircularProgressIndicator()) + : _tokens != null + ? _buildMain(context) + : Center( + child: Text("Error: $_error"), + )); + } + + Widget _buildMain(BuildContext context) { + final size = MediaQuery.of(context).size; + return Stack(children: [ + RefreshIndicator( + key: _refreshIndicatorKey, + onRefresh: () async { + return await _fetchData(); + }, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith( + dragDevices: { + PointerDeviceKind.touch, + PointerDeviceKind.mouse, + PointerDeviceKind.trackpad, + }, + ), + child: _buildTokenList(context))), + Positioned( + bottom: size.height / 10, + right: size.width / 10, + child: _buildIconList(context)), + ]); + } + + Widget _buildRefreshIcon(BuildContext context) { + final i18n = AppLocalizations.of(context)!; + return IconButton( + onPressed: () { + _refreshIndicatorKey.currentState?.show(); + }, + tooltip: i18n.refresh, + icon: const Icon(Icons.refresh)); + } + + Widget _buildIconList(BuildContext context) { + return Row(children: [ + isDesktop || (kIsWeb && pointerIsMouse) + ? _buildRefreshIcon(context) + : Container(), + ]); + } + + Widget _buildTokenList(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.sessionManagemant), + actions: [ + buildThemeModeIcon(context), + buildMoreVertSettingsButon(context), + ], + floating: true, + ), + _buildUserSelect(context), + _buildSliverGrid(context), + ]); + } + + Widget _buildAllUserCheckbox(BuildContext context) { + if (auth.isRoot != true) return Container(); + final i18n = AppLocalizations.of(context)!; + return Container( + padding: const EdgeInsets.symmetric(vertical: 8), + constraints: const BoxConstraints(maxWidth: 500), + child: LabeledCheckbox( + label: Text(i18n.allUser), + value: _allUser, + onChanged: (v) { + if (v != null) { + setState(() { + _allUser = v; + _fetchData(); + }); + } + })); + } + + Widget _buildUserSelectBox(BuildContext context) { + var userList = _users!; + if (auth.isRoot != true) { + userList.removeWhere((e) => e.isAdmin); + } + var items = userList + .map((e) => DropdownMenuItem(value: e.id, child: Text(e.username))) + .toList(); + final i18n = AppLocalizations.of(context)!; + return Container( + padding: const EdgeInsets.symmetric(vertical: 8), + constraints: const BoxConstraints(maxWidth: 500), + child: DropdownButtonFormField( + items: items, + onChanged: (v) { + setState(() { + _uid = v; + _fetchData(); + }); + }, + value: _uid ?? auth.user?.id, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: i18n.user, + )), + ); + } + + Widget _buildUserSelect(BuildContext context) { + if (auth.isAdmin != true || _users == null) { + return SliverToBoxAdapter(child: Container()); + } + final cs = Theme.of(context).colorScheme; + final maxWidth = MediaQuery.of(context).size.width; + return PinnedHeaderSliver( + child: Container( + color: cs.surface, + child: Form( + key: _formKey, + child: auth.isRoot != true || maxWidth >= 500 + ? Row(children: [ + _buildAllUserCheckbox(context), + Expanded(child: _buildUserSelectBox(context)), + ]) + : Column(children: [ + _buildAllUserCheckbox(context), + _buildUserSelectBox(context), + ])))); + } + + Widget _buildSliverGrid(BuildContext context) { + return SliverGrid( + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 370.0, + mainAxisSpacing: 10.0, + crossAxisSpacing: 10.0, + mainAxisExtent: 200.0, + ), + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + final token = _tokens![index]!; + if (_allUser || (_uid != null && _uid != auth.user?.id)) { + final ind = _users?.indexWhere((e) => e.id == token.uid); + if (ind != null && ind > -1) { + return SessionCard(token, user: _users![ind!]); + } + } + return SessionCard(token); + }, + childCount: _tokens!.length, + ), + ); + } + + void _onStateChanged(dynamic _) { + setState(() {}); + } + + void _onDeleteSession(dynamic arg) { + final id = arg as int; + setState(() { + _tokens?.removeWhere((e) => e.id == id); + }); + } +}