Add session manage page

This commit is contained in:
2024-10-29 06:34:33 +00:00
committed by GitHub
parent c2d722836b
commit 57f54d319b
11 changed files with 653 additions and 2 deletions

View File

@@ -187,9 +187,20 @@ abstract class _EHApi {
Future<ApiResult<bool>> deleteToken(
{@Part(name: "token") String? token,
@CancelRequest() CancelToken? cancel});
@DELETE('/token/manage')
@MultiPart()
Future<ApiResult<bool>> deleteTokenById(@Part(name: "id") int id,
{@CancelRequest() CancelToken? cancel});
@GET('/token')
Future<ApiResult<TokenWithUserInfo>> getToken(
{@Query("token") String? token, @CancelRequest() CancelToken? cancel});
@GET('/token/manage')
Future<ApiResult<List<TokenWithoutToken>>> 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<ApiResult<SharedToken>> getSharedToken(
{@CancelRequest() CancelToken? cancel});

View File

@@ -689,6 +689,52 @@ class __EHApi implements _EHApi {
return _value;
}
@override
Future<ApiResult<bool>> deleteTokenById(
int id, {
CancelToken? cancel,
}) async {
final _extra = <String, dynamic>{};
final queryParameters = <String, dynamic>{};
queryParameters.removeWhere((k, v) => v == null);
final _headers = <String, dynamic>{};
final _data = FormData();
_data.fields.add(MapEntry(
'id',
id.toString(),
));
final _options = _setStreamType<ApiResult<bool>>(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<Map<String, dynamic>>(_options);
late ApiResult<bool> _value;
try {
_value = ApiResult<bool>.fromJson(
_result.data!,
(json) => json as bool,
);
} on Object catch (e, s) {
errorLogger?.logError(e, s, _options);
rethrow;
}
return _value;
}
@override
Future<ApiResult<TokenWithUserInfo>> getToken({
String? token,
@@ -730,6 +776,60 @@ class __EHApi implements _EHApi {
return _value;
}
@override
Future<ApiResult<List<TokenWithoutToken>>> getTokens({
int? uid,
int? offset,
int? limit,
bool? allUser,
CancelToken? cancel,
}) async {
final _extra = <String, dynamic>{};
final queryParameters = <String, dynamic>{
r'uid': uid,
r'offset': offset,
r'limit': limit,
r'all_user': allUser,
};
queryParameters.removeWhere((k, v) => v == null);
final _headers = <String, dynamic>{};
const Map<String, dynamic>? _data = null;
final _options = _setStreamType<ApiResult<List<TokenWithoutToken>>>(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<Map<String, dynamic>>(_options);
late ApiResult<List<TokenWithoutToken>> _value;
try {
_value = ApiResult<List<TokenWithoutToken>>.fromJson(
_result.data!,
(json) => json is List<dynamic>
? json
.map<TokenWithoutToken>((i) =>
TokenWithoutToken.fromJson(i as Map<String, dynamic>))
.toList()
: List.empty(),
);
} on Object catch (e, s) {
errorLogger?.logError(e, s, _options);
rethrow;
}
return _value;
}
@override
Future<ApiResult<SharedToken>> getSharedToken({CancelToken? cancel}) async {
final _extra = <String, dynamic>{};

View File

@@ -40,6 +40,42 @@ class Token {
Map<String, dynamic> 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<String, dynamic> json) =>
_$TokenWithoutTokenFromJson(json);
Map<String, dynamic> toJson() => _$TokenWithoutTokenToJson(this);
}
@JsonSerializable()
class TokenWithUserInfo {
const TokenWithUserInfo({

View File

@@ -34,6 +34,34 @@ Map<String, dynamic> _$TokenToJson(Token instance) => <String, dynamic>{
'client_platform': instance.clientPlatform,
};
TokenWithoutToken _$TokenWithoutTokenFromJson(Map<String, dynamic> 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<String, dynamic> _$TokenWithoutTokenToJson(TokenWithoutToken instance) =>
<String, dynamic>{
'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<String, dynamic> json) =>
TokenWithUserInfo(
token: Token.fromJson(json['token'] as Map<String, dynamic>),

View File

@@ -60,6 +60,7 @@ class AuthInfo {
Future<void> 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);
}

View File

@@ -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<void> _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))
])));
}
}

View File

@@ -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<PopupMenuEntry<MoreVertSettings>> 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)));

View File

@@ -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"
}

View File

@@ -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": "所有用户"
}

View File

@@ -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(),

312
lib/pages/sessions.dart Normal file
View File

@@ -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<SessionsPage> createState() => _SessionsPage();
}
class _SessionsPage extends State<SessionsPage>
with ThemeModeWidget, IsTopWidget2 {
final GlobalKey<RefreshIndicatorState> _refreshIndicatorKey =
GlobalKey<RefreshIndicatorState>();
final _formKey = GlobalKey<FormState>();
int? _uid;
bool _allUser = false;
List<BUser>? _users;
List<TokenWithoutToken>? _tokens;
bool _isLoading = false;
bool _isLoadingUsers = false;
CancelToken? _cancel;
CancelToken? _cancel2;
Object? _error;
Future<void> _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<void> _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<int>(
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);
});
}
}