Auth tickets management

This commit is contained in:
2025-03-15 20:27:14 +08:00
parent 14ee6845ed
commit f03d80ba88
11 changed files with 335 additions and 80 deletions

View File

@ -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',

View File

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

View File

@ -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<ActionEventScreen> {
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<ActionEventScreen> {
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,

View File

@ -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<String, IconData> 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<AccountAuthTicket> createState() => _AccountAuthTicketState();
}
class _AccountAuthTicketState extends State<AccountAuthTicket> {
bool _isBusy = false;
int? _totalCount;
final List<SnAuthTicket> _authTickets = List.empty(growable: true);
Future<void> _fetchAuthTickets() async {
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
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<dynamic>)
.map((e) => SnAuthTicket.fromJson(e)),
);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
Future<void> _deleteAuthTicket(SnAuthTicket ticket) async {
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
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);
},
),
),
),
],
),
);
}
}

View File

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

View File

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

View File

@ -33,7 +33,9 @@ _SnAuthTicket _$SnAuthTicketFromJson(Map<String, dynamic> 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<String, dynamic> _$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(),