diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json index 15c27d1..5b57083 100644 --- a/assets/translations/en-US.json +++ b/assets/translations/en-US.json @@ -810,5 +810,8 @@ "serviceNamePassport": "Authorization and Authentication", "accountActionEvent": "Action Events", "accountActionEventDescription": "View your action event logs.", - "eventMetadata": "Metadata" + "eventMetadata": "Metadata", + "authTicketCreatedAt": "Issued at {}", + "authTicketExpiredAt": "Expired at {}", + "authTicketLastGrantAt": "Last granted at {}" } diff --git a/assets/translations/zh-CN.json b/assets/translations/zh-CN.json index 3e0c867..1e13166 100644 --- a/assets/translations/zh-CN.json +++ b/assets/translations/zh-CN.json @@ -808,5 +808,10 @@ "serviceNamePassport": "身份验证与授权", "accountActionEvent": "操作日志", "accountActionEventDescription": "查看你的操作日志。", - "eventMetadata": "元数据" + "eventMetadata": "元数据", + "accountAuthTickets": "授权会话", + "accountAuthTicketsDescription": "查看和管理你的授权会话。", + "authTicketCreatedAt": "签发于 {}", + "authTicketExpiredAt": "到期于 {}", + "authTicketLastGrantAt": "上次刷新于 {}" } diff --git a/assets/translations/zh-HK.json b/assets/translations/zh-HK.json index 13d9388..a887fcc 100644 --- a/assets/translations/zh-HK.json +++ b/assets/translations/zh-HK.json @@ -805,5 +805,13 @@ "serviceNameMatrix": "矩陣市場", "serviceNamePaperclip": "附件", "serviceNameWallet": "源點錢包", - "serviceNamePassport": "身份驗證與授權" + "serviceNamePassport": "身份驗證與授權", + "accountActionEvent": "操作日誌", + "accountActionEventDescription": "查看你的操作日誌。", + "eventMetadata": "元數據", + "accountAuthTickets": "授權會話", + "accountAuthTicketsDescription": "查看和管理你的授權會話。", + "authTicketCreatedAt": "簽發於 {}", + "authTicketExpiredAt": "到期於 {}", + "authTicketLastGrantAt": "上次刷新於 {}" } diff --git a/assets/translations/zh-TW.json b/assets/translations/zh-TW.json index dab4194..ba23621 100644 --- a/assets/translations/zh-TW.json +++ b/assets/translations/zh-TW.json @@ -805,5 +805,13 @@ "serviceNameMatrix": "矩陣市場", "serviceNamePaperclip": "附件", "serviceNameWallet": "源點錢包", - "serviceNamePassport": "身份驗證與授權" + "serviceNamePassport": "身份驗證與授權", + "accountActionEvent": "操作日誌", + "accountActionEventDescription": "查看你的操作日誌。", + "eventMetadata": "元數據", + "accountAuthTickets": "授權會話", + "accountAuthTicketsDescription": "查看和管理你的授權會話。", + "authTicketCreatedAt": "簽發於 {}", + "authTicketExpiredAt": "到期於 {}", + "authTicketLastGrantAt": "上次刷新於 {}" } diff --git a/lib/router.dart b/lib/router.dart index 3ec55ea..f4f2d0f 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -13,6 +13,7 @@ import 'package:surface/screens/account/profile_edit.dart'; import 'package:surface/screens/account/publishers/publisher_edit.dart'; import 'package:surface/screens/account/publishers/publisher_new.dart'; import 'package:surface/screens/account/publishers/publishers.dart'; +import 'package:surface/screens/account/tickets.dart'; import 'package:surface/screens/album.dart'; import 'package:surface/screens/auth/login.dart'; import 'package:surface/screens/auth/register.dart'; @@ -130,6 +131,11 @@ final _appRoutes = [ name: 'accountActionEvents', builder: (context, state) => const ActionEventScreen(), ), + GoRoute( + path: '/tickets', + name: 'accountAuthTickets', + builder: (context, state) => const AccountAuthTicket(), + ), GoRoute( path: '/badges', name: 'accountBadges', diff --git a/lib/screens/account.dart b/lib/screens/account.dart index 7f5542c..cb5284b 100644 --- a/lib/screens/account.dart +++ b/lib/screens/account.dart @@ -217,6 +217,16 @@ class _AuthorizedAccountScreen extends StatelessWidget { GoRouter.of(context).pushNamed('accountActionEvents'); }, ), + ListTile( + title: Text('accountAuthTickets').tr(), + subtitle: Text('accountAuthTicketsDescription').tr(), + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + leading: const Icon(Symbols.confirmation_number), + trailing: const Icon(Symbols.chevron_right), + onTap: () { + GoRouter.of(context).pushNamed('accountAuthTickets'); + }, + ), ListTile( title: Text('accountSettings').tr(), subtitle: Text('accountSettingsSubtitle').tr(), diff --git a/lib/screens/account/action_events.dart b/lib/screens/account/action_events.dart index 8236e89..d891a62 100644 --- a/lib/screens/account/action_events.dart +++ b/lib/screens/account/action_events.dart @@ -2,10 +2,9 @@ import 'dart:convert'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_map/flutter_map.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; +import 'package:relative_time/relative_time.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:surface/providers/sn_network.dart'; import 'package:surface/types/account.dart'; @@ -71,12 +70,12 @@ class _ActionEventScreenState extends State { child: RefreshIndicator( onRefresh: () { _totalCount = null; - _actionEvents.clear(); return _fetchActionEvents(); }, child: InfiniteList( padding: EdgeInsets.only(left: 20, right: 8), itemCount: _actionEvents.length, + isLoading: _isBusy, hasReachedMax: _totalCount != null && _actionEvents.length >= _totalCount!, onFetchData: _fetchActionEvents, @@ -105,32 +104,26 @@ class _ActionEventScreenState extends State { if (event.ipAddress.isNotEmpty) Text( event.ipAddress, - style: GoogleFonts.robotoMono(fontSize: 12), + style: TextStyle(fontSize: 13), ), if (event.location?.isNotEmpty ?? false) - Text(event.location!) + Text(event.location!), + Row( + children: [ + Text(DateFormat() + .format(event.createdAt.toLocal())) + .fontSize(12), + Text(' · ') + .fontSize(12) + .padding(horizontal: 4), + Text(RelativeTime(context) + .format(event.createdAt.toLocal())) + .fontSize(12), + ], + ).opacity(0.75).padding(top: 4), ], ), ), - if (event.location?.isNotEmpty ?? false) - SizedBox( - height: 180, - child: FlutterMap( - options: MapOptions( - initialCenter: LatLng( - event.coordinateX!, - event.coordinateY!, - ), - ), - children: [ - TileLayer( - urlTemplate: - 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - userAgentPackageName: 'dev.solsynth.solian', - ), - ], - ), - ).padding(bottom: 6), if (event.metadata != null) ExpansionTile( minTileHeight: 40, diff --git a/lib/screens/account/tickets.dart b/lib/screens/account/tickets.dart new file mode 100644 index 0000000..5b7b11e --- /dev/null +++ b/lib/screens/account/tickets.dart @@ -0,0 +1,170 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; +import 'package:styled_widget/styled_widget.dart'; +import 'package:surface/providers/sn_network.dart'; +import 'package:surface/types/auth.dart'; +import 'package:surface/widgets/dialog.dart'; +import 'package:surface/widgets/loading_indicator.dart'; +import 'package:surface/widgets/navigation/app_scaffold.dart'; +import 'package:very_good_infinite_list/very_good_infinite_list.dart'; + +const Map kAuthTicketIcon = { + 'ios': Symbols.ios, + 'android': Symbols.android, + 'macos': Symbols.computer, + 'windows nt': Symbols.laptop_windows, + 'linux': Symbols.laptop, +}; + +class AccountAuthTicket extends StatefulWidget { + const AccountAuthTicket({super.key}); + + @override + State createState() => _AccountAuthTicketState(); +} + +class _AccountAuthTicketState extends State { + bool _isBusy = false; + int? _totalCount; + final List _authTickets = List.empty(growable: true); + + Future _fetchAuthTickets() async { + setState(() => _isBusy = true); + try { + final sn = context.read(); + final resp = await sn.client.get( + '/cgi/id/users/me/tickets', + queryParameters: { + 'take': 10, + 'offset': _authTickets.length, + }, + ); + _totalCount = resp.data['count']; + _authTickets.addAll( + (resp.data['data'] as List) + .map((e) => SnAuthTicket.fromJson(e)), + ); + } catch (err) { + if (!mounted) return; + context.showErrorDialog(err); + } finally { + setState(() => _isBusy = false); + } + } + + Future _deleteAuthTicket(SnAuthTicket ticket) async { + setState(() => _isBusy = true); + try { + final sn = context.read(); + await sn.client.delete( + '/cgi/id/users/me/tickets/${ticket.id}', + ); + setState(() { + _authTickets.remove(ticket); + }); + } catch (err) { + if (!mounted) return; + context.showErrorDialog(err); + } finally { + setState(() => _isBusy = false); + } + } + + @override + void initState() { + super.initState(); + _fetchAuthTickets(); + } + + @override + Widget build(BuildContext context) { + return AppScaffold( + appBar: AppBar( + leading: const PageBackButton(), + title: Text('accountAuthTickets').tr(), + ), + body: Column( + children: [ + LoadingIndicator(isActive: _isBusy), + Expanded( + child: RefreshIndicator( + onRefresh: () { + _totalCount = null; + return _fetchAuthTickets(); + }, + child: InfiniteList( + padding: EdgeInsets.zero, + onFetchData: _fetchAuthTickets, + isLoading: _isBusy, + hasReachedMax: + _totalCount != null && _authTickets.length >= _totalCount!, + itemCount: _authTickets.length, + itemBuilder: (context, idx) { + final ticket = _authTickets[idx]; + final platform = RegExp(r'\(([^;]+);') + .firstMatch(ticket.userAgent) + ?.group(1); + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + kAuthTicketIcon[platform!.toLowerCase()] ?? Symbols.web, + ), + const Gap(12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + ticket.ipAddress, + style: TextStyle(fontSize: 15), + ), + Text(ticket.userAgent).opacity(0.8), + if (ticket.location?.isNotEmpty ?? false) + const Gap(4), + if (ticket.location?.isNotEmpty ?? false) + Text(ticket.location!).opacity(0.8), + const Gap(4), + Text('authTicketCreatedAt'.tr(args: [ + (DateFormat().format(ticket.createdAt.toLocal())) + ])).fontSize(12).opacity(0.75), + if (ticket.expiredAt != null) + Text('authTicketExpiredAt'.tr(args: [ + (DateFormat() + .format(ticket.expiredAt!.toLocal())) + ])).fontSize(12).opacity(0.75), + if (ticket.lastGrantAt != null) + Text('authTicketLastGrantAt'.tr(args: [ + (DateFormat() + .format(ticket.lastGrantAt!.toLocal())) + ])).fontSize(12).opacity(0.75), + const Gap(4), + Text('#${ticket.id}').fontSize(11).opacity(0.75), + ], + ), + ), + IconButton( + iconSize: 20, + visualDensity: + VisualDensity(horizontal: -4, vertical: -4), + constraints: const BoxConstraints(), + padding: EdgeInsets.zero, + icon: const Icon(Symbols.logout), + onPressed: () { + _deleteAuthTicket(ticket); + }, + ), + ], + ).padding(horizontal: 16, vertical: 12); + }, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/types/auth.dart b/lib/types/auth.dart index 513b516..e622728 100644 --- a/lib/types/auth.dart +++ b/lib/types/auth.dart @@ -26,7 +26,9 @@ abstract class SnAuthTicket with _$SnAuthTicket { required String? accessToken, required String? refreshToken, required String ipAddress, - required String location, + required String? location, + required double? coordinateX, + required double? coordinateY, required String userAgent, required DateTime? expiredAt, required DateTime? lastGrantAt, diff --git a/lib/types/auth.freezed.dart b/lib/types/auth.freezed.dart index ec66113..f574c22 100644 --- a/lib/types/auth.freezed.dart +++ b/lib/types/auth.freezed.dart @@ -217,7 +217,9 @@ mixin _$SnAuthTicket { String? get accessToken; String? get refreshToken; String get ipAddress; - String get location; + String? get location; + double? get coordinateX; + double? get coordinateY; String get userAgent; DateTime? get expiredAt; DateTime? get lastGrantAt; @@ -261,6 +263,10 @@ mixin _$SnAuthTicket { other.ipAddress == ipAddress) && (identical(other.location, location) || other.location == location) && + (identical(other.coordinateX, coordinateX) || + other.coordinateX == coordinateX) && + (identical(other.coordinateY, coordinateY) || + other.coordinateY == coordinateY) && (identical(other.userAgent, userAgent) || other.userAgent == userAgent) && (identical(other.expiredAt, expiredAt) || @@ -278,29 +284,32 @@ mixin _$SnAuthTicket { @JsonKey(includeFromJson: false, includeToJson: false) @override - int get hashCode => Object.hash( - runtimeType, - id, - createdAt, - updatedAt, - deletedAt, - stepRemain, - grantToken, - accessToken, - refreshToken, - ipAddress, - location, - userAgent, - expiredAt, - lastGrantAt, - availableAt, - nonce, - accountId, - const DeepCollectionEquality().hash(factorTrail)); + int get hashCode => Object.hashAll([ + runtimeType, + id, + createdAt, + updatedAt, + deletedAt, + stepRemain, + grantToken, + accessToken, + refreshToken, + ipAddress, + location, + coordinateX, + coordinateY, + userAgent, + expiredAt, + lastGrantAt, + availableAt, + nonce, + accountId, + const DeepCollectionEquality().hash(factorTrail) + ]); @override String toString() { - return 'SnAuthTicket(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, stepRemain: $stepRemain, grantToken: $grantToken, accessToken: $accessToken, refreshToken: $refreshToken, ipAddress: $ipAddress, location: $location, userAgent: $userAgent, expiredAt: $expiredAt, lastGrantAt: $lastGrantAt, availableAt: $availableAt, nonce: $nonce, accountId: $accountId, factorTrail: $factorTrail)'; + return 'SnAuthTicket(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, stepRemain: $stepRemain, grantToken: $grantToken, accessToken: $accessToken, refreshToken: $refreshToken, ipAddress: $ipAddress, location: $location, coordinateX: $coordinateX, coordinateY: $coordinateY, userAgent: $userAgent, expiredAt: $expiredAt, lastGrantAt: $lastGrantAt, availableAt: $availableAt, nonce: $nonce, accountId: $accountId, factorTrail: $factorTrail)'; } } @@ -320,7 +329,9 @@ abstract mixin class $SnAuthTicketCopyWith<$Res> { String? accessToken, String? refreshToken, String ipAddress, - String location, + String? location, + double? coordinateX, + double? coordinateY, String userAgent, DateTime? expiredAt, DateTime? lastGrantAt, @@ -351,7 +362,9 @@ class _$SnAuthTicketCopyWithImpl<$Res> implements $SnAuthTicketCopyWith<$Res> { Object? accessToken = freezed, Object? refreshToken = freezed, Object? ipAddress = null, - Object? location = null, + Object? location = freezed, + Object? coordinateX = freezed, + Object? coordinateY = freezed, Object? userAgent = null, Object? expiredAt = freezed, Object? lastGrantAt = freezed, @@ -397,10 +410,18 @@ class _$SnAuthTicketCopyWithImpl<$Res> implements $SnAuthTicketCopyWith<$Res> { ? _self.ipAddress : ipAddress // ignore: cast_nullable_to_non_nullable as String, - location: null == location + location: freezed == location ? _self.location : location // ignore: cast_nullable_to_non_nullable - as String, + as String?, + coordinateX: freezed == coordinateX + ? _self.coordinateX + : coordinateX // ignore: cast_nullable_to_non_nullable + as double?, + coordinateY: freezed == coordinateY + ? _self.coordinateY + : coordinateY // ignore: cast_nullable_to_non_nullable + as double?, userAgent: null == userAgent ? _self.userAgent : userAgent // ignore: cast_nullable_to_non_nullable @@ -447,6 +468,8 @@ class _SnAuthTicket implements SnAuthTicket { required this.refreshToken, required this.ipAddress, required this.location, + required this.coordinateX, + required this.coordinateY, required this.userAgent, required this.expiredAt, required this.lastGrantAt, @@ -477,7 +500,11 @@ class _SnAuthTicket implements SnAuthTicket { @override final String ipAddress; @override - final String location; + final String? location; + @override + final double? coordinateX; + @override + final double? coordinateY; @override final String userAgent; @override @@ -538,6 +565,10 @@ class _SnAuthTicket implements SnAuthTicket { other.ipAddress == ipAddress) && (identical(other.location, location) || other.location == location) && + (identical(other.coordinateX, coordinateX) || + other.coordinateX == coordinateX) && + (identical(other.coordinateY, coordinateY) || + other.coordinateY == coordinateY) && (identical(other.userAgent, userAgent) || other.userAgent == userAgent) && (identical(other.expiredAt, expiredAt) || @@ -555,29 +586,32 @@ class _SnAuthTicket implements SnAuthTicket { @JsonKey(includeFromJson: false, includeToJson: false) @override - int get hashCode => Object.hash( - runtimeType, - id, - createdAt, - updatedAt, - deletedAt, - stepRemain, - grantToken, - accessToken, - refreshToken, - ipAddress, - location, - userAgent, - expiredAt, - lastGrantAt, - availableAt, - nonce, - accountId, - const DeepCollectionEquality().hash(_factorTrail)); + int get hashCode => Object.hashAll([ + runtimeType, + id, + createdAt, + updatedAt, + deletedAt, + stepRemain, + grantToken, + accessToken, + refreshToken, + ipAddress, + location, + coordinateX, + coordinateY, + userAgent, + expiredAt, + lastGrantAt, + availableAt, + nonce, + accountId, + const DeepCollectionEquality().hash(_factorTrail) + ]); @override String toString() { - return 'SnAuthTicket(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, stepRemain: $stepRemain, grantToken: $grantToken, accessToken: $accessToken, refreshToken: $refreshToken, ipAddress: $ipAddress, location: $location, userAgent: $userAgent, expiredAt: $expiredAt, lastGrantAt: $lastGrantAt, availableAt: $availableAt, nonce: $nonce, accountId: $accountId, factorTrail: $factorTrail)'; + return 'SnAuthTicket(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, stepRemain: $stepRemain, grantToken: $grantToken, accessToken: $accessToken, refreshToken: $refreshToken, ipAddress: $ipAddress, location: $location, coordinateX: $coordinateX, coordinateY: $coordinateY, userAgent: $userAgent, expiredAt: $expiredAt, lastGrantAt: $lastGrantAt, availableAt: $availableAt, nonce: $nonce, accountId: $accountId, factorTrail: $factorTrail)'; } } @@ -599,7 +633,9 @@ abstract mixin class _$SnAuthTicketCopyWith<$Res> String? accessToken, String? refreshToken, String ipAddress, - String location, + String? location, + double? coordinateX, + double? coordinateY, String userAgent, DateTime? expiredAt, DateTime? lastGrantAt, @@ -631,7 +667,9 @@ class __$SnAuthTicketCopyWithImpl<$Res> Object? accessToken = freezed, Object? refreshToken = freezed, Object? ipAddress = null, - Object? location = null, + Object? location = freezed, + Object? coordinateX = freezed, + Object? coordinateY = freezed, Object? userAgent = null, Object? expiredAt = freezed, Object? lastGrantAt = freezed, @@ -677,10 +715,18 @@ class __$SnAuthTicketCopyWithImpl<$Res> ? _self.ipAddress : ipAddress // ignore: cast_nullable_to_non_nullable as String, - location: null == location + location: freezed == location ? _self.location : location // ignore: cast_nullable_to_non_nullable - as String, + as String?, + coordinateX: freezed == coordinateX + ? _self.coordinateX + : coordinateX // ignore: cast_nullable_to_non_nullable + as double?, + coordinateY: freezed == coordinateY + ? _self.coordinateY + : coordinateY // ignore: cast_nullable_to_non_nullable + as double?, userAgent: null == userAgent ? _self.userAgent : userAgent // ignore: cast_nullable_to_non_nullable diff --git a/lib/types/auth.g.dart b/lib/types/auth.g.dart index 90c105f..5f43334 100644 --- a/lib/types/auth.g.dart +++ b/lib/types/auth.g.dart @@ -33,7 +33,9 @@ _SnAuthTicket _$SnAuthTicketFromJson(Map json) => accessToken: json['access_token'] as String?, refreshToken: json['refresh_token'] as String?, ipAddress: json['ip_address'] as String, - location: json['location'] as String, + location: json['location'] as String?, + coordinateX: (json['coordinate_x'] as num?)?.toDouble(), + coordinateY: (json['coordinate_y'] as num?)?.toDouble(), userAgent: json['user_agent'] as String, expiredAt: json['expired_at'] == null ? null @@ -64,6 +66,8 @@ Map _$SnAuthTicketToJson(_SnAuthTicket instance) => 'refresh_token': instance.refreshToken, 'ip_address': instance.ipAddress, 'location': instance.location, + 'coordinate_x': instance.coordinateX, + 'coordinate_y': instance.coordinateY, 'user_agent': instance.userAgent, 'expired_at': instance.expiredAt?.toIso8601String(), 'last_grant_at': instance.lastGrantAt?.toIso8601String(),