From 3dd7c8a5b272f730a39b23a5c1295da076b739eb Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sat, 7 Jun 2025 22:56:25 +0800 Subject: [PATCH] :sparkles: Account settings auth devices --- assets/i18n/en-US.json | 17 +- ios/Runner.xcodeproj/project.pbxproj | 10 +- lib/models/auth.dart | 46 +- lib/models/auth.freezed.dart | 403 ++++++++++++++++-- lib/models/auth.g.dart | 74 +++- lib/screens/account/me/settings.dart | 16 + lib/screens/auth/login.dart | 68 ++- .../account/account_session_sheet.dart | 246 +++++++++++ .../account/account_session_sheet.g.dart | 29 ++ 9 files changed, 861 insertions(+), 48 deletions(-) create mode 100644 lib/widgets/account/account_session_sheet.dart create mode 100644 lib/widgets/account/account_session_sheet.g.dart diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index 391431d..917c513 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -338,5 +338,20 @@ "confirm": "Confirm", "authFactorAdditional": "One more step", "authFactorHint": "Contact method hint", - "authFactorHintHelper": "You need provide a part of your contact method and we will send the verification code to that contact method if it matched our records" + "authFactorHintHelper": "You need provide a part of your contact method and we will send the verification code to that contact method if it matched our records", + "authSessions": "Active Sessions", + "authSessionsDescription": "See devices you currently logged in.", + "authSessionsCount": { + "one": "{} session", + "other": "{} sessions" + }, + "authDeviceCurrent": "Current device", + "lastActiveAt": "Last active at {}", + "authDeviceLogout": "Logout", + "authDeviceLogoutHint": "Are you sure you want to logout this device? This will also disable the push notification to this device.", + "authDeviceEditLabel": "Edit Label", + "authDeviceLabelTitle": "Edit Device Label", + "authDeviceLabelHint": "Enter a name for this device", + "authDeviceSwipeEditHint": "Swipe left to edit label", + "authDeviceSwipeLogoutHint": "Swipe right to logout device" } diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 4166bc2..d03e7fc 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 77; objects = { /* Begin PBXBuildFile section */ @@ -448,10 +448,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; @@ -487,10 +491,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; diff --git a/lib/models/auth.dart b/lib/models/auth.dart index f5ae09d..2874c06 100644 --- a/lib/models/auth.dart +++ b/lib/models/auth.dart @@ -18,13 +18,18 @@ sealed class SnAuthChallenge with _$SnAuthChallenge { required DateTime expiredAt, required int stepRemain, required int stepTotal, + required int failedAttempts, + required int platform, + required int type, required List blacklistFactors, - required List audiences, - required List scopes, + required List audiences, + required List scopes, required String ipAddress, required String userAgent, - required String? deviceId, + required String deviceId, required String? nonce, + required String? location, + required String accountId, required DateTime createdAt, required DateTime updatedAt, required DateTime? deletedAt, @@ -34,6 +39,25 @@ sealed class SnAuthChallenge with _$SnAuthChallenge { _$SnAuthChallengeFromJson(json); } +@freezed +sealed class SnAuthSession with _$SnAuthSession { + const factory SnAuthSession({ + required String id, + required String? label, + required DateTime lastGrantedAt, + required DateTime expiredAt, + required String accountId, + required String challengeId, + required SnAuthChallenge challenge, + required DateTime createdAt, + required DateTime updatedAt, + required DateTime? deletedAt, + }) = _SnAuthSession; + + factory SnAuthSession.fromJson(Map json) => + _$SnAuthSessionFromJson(json); +} + @freezed sealed class SnAuthFactor with _$SnAuthFactor { const factory SnAuthFactor({ @@ -51,3 +75,19 @@ sealed class SnAuthFactor with _$SnAuthFactor { factory SnAuthFactor.fromJson(Map json) => _$SnAuthFactorFromJson(json); } + +@freezed +sealed class SnAuthDevice with _$SnAuthDevice { + const factory SnAuthDevice({ + required dynamic label, + required String userAgent, + required String deviceId, + required int platform, + required List sessions, + // Not from backend, used for UI + @Default(false) bool isCurrent, + }) = _SnAuthDevice; + + factory SnAuthDevice.fromJson(Map json) => + _$SnAuthDeviceFromJson(json); +} diff --git a/lib/models/auth.freezed.dart b/lib/models/auth.freezed.dart index c4bab8d..8f6babe 100644 --- a/lib/models/auth.freezed.dart +++ b/lib/models/auth.freezed.dart @@ -149,7 +149,7 @@ as String, /// @nodoc mixin _$SnAuthChallenge { - String get id; DateTime get expiredAt; int get stepRemain; int get stepTotal; List get blacklistFactors; List get audiences; List get scopes; String get ipAddress; String get userAgent; String? get deviceId; String? get nonce; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; + String get id; DateTime get expiredAt; int get stepRemain; int get stepTotal; int get failedAttempts; int get platform; int get type; List get blacklistFactors; List get audiences; List get scopes; String get ipAddress; String get userAgent; String get deviceId; String? get nonce; String? get location; String get accountId; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; /// Create a copy of SnAuthChallenge /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -162,16 +162,16 @@ $SnAuthChallengeCopyWith get copyWith => _$SnAuthChallengeCopyW @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is SnAuthChallenge&&(identical(other.id, id) || other.id == id)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&(identical(other.stepRemain, stepRemain) || other.stepRemain == stepRemain)&&(identical(other.stepTotal, stepTotal) || other.stepTotal == stepTotal)&&const DeepCollectionEquality().equals(other.blacklistFactors, blacklistFactors)&&const DeepCollectionEquality().equals(other.audiences, audiences)&&const DeepCollectionEquality().equals(other.scopes, scopes)&&(identical(other.ipAddress, ipAddress) || other.ipAddress == ipAddress)&&(identical(other.userAgent, userAgent) || other.userAgent == userAgent)&&(identical(other.deviceId, deviceId) || other.deviceId == deviceId)&&(identical(other.nonce, nonce) || other.nonce == nonce)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is SnAuthChallenge&&(identical(other.id, id) || other.id == id)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&(identical(other.stepRemain, stepRemain) || other.stepRemain == stepRemain)&&(identical(other.stepTotal, stepTotal) || other.stepTotal == stepTotal)&&(identical(other.failedAttempts, failedAttempts) || other.failedAttempts == failedAttempts)&&(identical(other.platform, platform) || other.platform == platform)&&(identical(other.type, type) || other.type == type)&&const DeepCollectionEquality().equals(other.blacklistFactors, blacklistFactors)&&const DeepCollectionEquality().equals(other.audiences, audiences)&&const DeepCollectionEquality().equals(other.scopes, scopes)&&(identical(other.ipAddress, ipAddress) || other.ipAddress == ipAddress)&&(identical(other.userAgent, userAgent) || other.userAgent == userAgent)&&(identical(other.deviceId, deviceId) || other.deviceId == deviceId)&&(identical(other.nonce, nonce) || other.nonce == nonce)&&(identical(other.location, location) || other.location == location)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,id,expiredAt,stepRemain,stepTotal,const DeepCollectionEquality().hash(blacklistFactors),const DeepCollectionEquality().hash(audiences),const DeepCollectionEquality().hash(scopes),ipAddress,userAgent,deviceId,nonce,createdAt,updatedAt,deletedAt); +int get hashCode => Object.hashAll([runtimeType,id,expiredAt,stepRemain,stepTotal,failedAttempts,platform,type,const DeepCollectionEquality().hash(blacklistFactors),const DeepCollectionEquality().hash(audiences),const DeepCollectionEquality().hash(scopes),ipAddress,userAgent,deviceId,nonce,location,accountId,createdAt,updatedAt,deletedAt]); @override String toString() { - return 'SnAuthChallenge(id: $id, expiredAt: $expiredAt, stepRemain: $stepRemain, stepTotal: $stepTotal, blacklistFactors: $blacklistFactors, audiences: $audiences, scopes: $scopes, ipAddress: $ipAddress, userAgent: $userAgent, deviceId: $deviceId, nonce: $nonce, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; + return 'SnAuthChallenge(id: $id, expiredAt: $expiredAt, stepRemain: $stepRemain, stepTotal: $stepTotal, failedAttempts: $failedAttempts, platform: $platform, type: $type, blacklistFactors: $blacklistFactors, audiences: $audiences, scopes: $scopes, ipAddress: $ipAddress, userAgent: $userAgent, deviceId: $deviceId, nonce: $nonce, location: $location, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; } @@ -182,7 +182,7 @@ abstract mixin class $SnAuthChallengeCopyWith<$Res> { factory $SnAuthChallengeCopyWith(SnAuthChallenge value, $Res Function(SnAuthChallenge) _then) = _$SnAuthChallengeCopyWithImpl; @useResult $Res call({ - String id, DateTime expiredAt, int stepRemain, int stepTotal, List blacklistFactors, List audiences, List scopes, String ipAddress, String userAgent, String? deviceId, String? nonce, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt + String id, DateTime expiredAt, int stepRemain, int stepTotal, int failedAttempts, int platform, int type, List blacklistFactors, List audiences, List scopes, String ipAddress, String userAgent, String deviceId, String? nonce, String? location, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt }); @@ -199,20 +199,25 @@ class _$SnAuthChallengeCopyWithImpl<$Res> /// Create a copy of SnAuthChallenge /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? expiredAt = null,Object? stepRemain = null,Object? stepTotal = null,Object? blacklistFactors = null,Object? audiences = null,Object? scopes = null,Object? ipAddress = null,Object? userAgent = null,Object? deviceId = freezed,Object? nonce = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? expiredAt = null,Object? stepRemain = null,Object? stepTotal = null,Object? failedAttempts = null,Object? platform = null,Object? type = null,Object? blacklistFactors = null,Object? audiences = null,Object? scopes = null,Object? ipAddress = null,Object? userAgent = null,Object? deviceId = null,Object? nonce = freezed,Object? location = freezed,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { return _then(_self.copyWith( id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable as String,expiredAt: null == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable as DateTime,stepRemain: null == stepRemain ? _self.stepRemain : stepRemain // ignore: cast_nullable_to_non_nullable as int,stepTotal: null == stepTotal ? _self.stepTotal : stepTotal // ignore: cast_nullable_to_non_nullable +as int,failedAttempts: null == failedAttempts ? _self.failedAttempts : failedAttempts // ignore: cast_nullable_to_non_nullable +as int,platform: null == platform ? _self.platform : platform // ignore: cast_nullable_to_non_nullable +as int,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable as int,blacklistFactors: null == blacklistFactors ? _self.blacklistFactors : blacklistFactors // ignore: cast_nullable_to_non_nullable as List,audiences: null == audiences ? _self.audiences : audiences // ignore: cast_nullable_to_non_nullable -as List,scopes: null == scopes ? _self.scopes : scopes // ignore: cast_nullable_to_non_nullable -as List,ipAddress: null == ipAddress ? _self.ipAddress : ipAddress // ignore: cast_nullable_to_non_nullable +as List,scopes: null == scopes ? _self.scopes : scopes // ignore: cast_nullable_to_non_nullable +as List,ipAddress: null == ipAddress ? _self.ipAddress : ipAddress // ignore: cast_nullable_to_non_nullable as String,userAgent: null == userAgent ? _self.userAgent : userAgent // ignore: cast_nullable_to_non_nullable -as String,deviceId: freezed == deviceId ? _self.deviceId : deviceId // ignore: cast_nullable_to_non_nullable -as String?,nonce: freezed == nonce ? _self.nonce : nonce // ignore: cast_nullable_to_non_nullable -as String?,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable +as String,deviceId: null == deviceId ? _self.deviceId : deviceId // ignore: cast_nullable_to_non_nullable +as String,nonce: freezed == nonce ? _self.nonce : nonce // ignore: cast_nullable_to_non_nullable +as String?,location: freezed == location ? _self.location : location // ignore: cast_nullable_to_non_nullable +as String?,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable +as String,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable as DateTime?, @@ -226,13 +231,16 @@ as DateTime?, @JsonSerializable() class _SnAuthChallenge implements SnAuthChallenge { - const _SnAuthChallenge({required this.id, required this.expiredAt, required this.stepRemain, required this.stepTotal, required final List blacklistFactors, required final List audiences, required final List scopes, required this.ipAddress, required this.userAgent, required this.deviceId, required this.nonce, required this.createdAt, required this.updatedAt, required this.deletedAt}): _blacklistFactors = blacklistFactors,_audiences = audiences,_scopes = scopes; + const _SnAuthChallenge({required this.id, required this.expiredAt, required this.stepRemain, required this.stepTotal, required this.failedAttempts, required this.platform, required this.type, required final List blacklistFactors, required final List audiences, required final List scopes, required this.ipAddress, required this.userAgent, required this.deviceId, required this.nonce, required this.location, required this.accountId, required this.createdAt, required this.updatedAt, required this.deletedAt}): _blacklistFactors = blacklistFactors,_audiences = audiences,_scopes = scopes; factory _SnAuthChallenge.fromJson(Map json) => _$SnAuthChallengeFromJson(json); @override final String id; @override final DateTime expiredAt; @override final int stepRemain; @override final int stepTotal; +@override final int failedAttempts; +@override final int platform; +@override final int type; final List _blacklistFactors; @override List get blacklistFactors { if (_blacklistFactors is EqualUnmodifiableListView) return _blacklistFactors; @@ -240,15 +248,15 @@ class _SnAuthChallenge implements SnAuthChallenge { return EqualUnmodifiableListView(_blacklistFactors); } - final List _audiences; -@override List get audiences { + final List _audiences; +@override List get audiences { if (_audiences is EqualUnmodifiableListView) return _audiences; // ignore: implicit_dynamic_type return EqualUnmodifiableListView(_audiences); } - final List _scopes; -@override List get scopes { + final List _scopes; +@override List get scopes { if (_scopes is EqualUnmodifiableListView) return _scopes; // ignore: implicit_dynamic_type return EqualUnmodifiableListView(_scopes); @@ -256,8 +264,10 @@ class _SnAuthChallenge implements SnAuthChallenge { @override final String ipAddress; @override final String userAgent; -@override final String? deviceId; +@override final String deviceId; @override final String? nonce; +@override final String? location; +@override final String accountId; @override final DateTime createdAt; @override final DateTime updatedAt; @override final DateTime? deletedAt; @@ -275,16 +285,16 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnAuthChallenge&&(identical(other.id, id) || other.id == id)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&(identical(other.stepRemain, stepRemain) || other.stepRemain == stepRemain)&&(identical(other.stepTotal, stepTotal) || other.stepTotal == stepTotal)&&const DeepCollectionEquality().equals(other._blacklistFactors, _blacklistFactors)&&const DeepCollectionEquality().equals(other._audiences, _audiences)&&const DeepCollectionEquality().equals(other._scopes, _scopes)&&(identical(other.ipAddress, ipAddress) || other.ipAddress == ipAddress)&&(identical(other.userAgent, userAgent) || other.userAgent == userAgent)&&(identical(other.deviceId, deviceId) || other.deviceId == deviceId)&&(identical(other.nonce, nonce) || other.nonce == nonce)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnAuthChallenge&&(identical(other.id, id) || other.id == id)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&(identical(other.stepRemain, stepRemain) || other.stepRemain == stepRemain)&&(identical(other.stepTotal, stepTotal) || other.stepTotal == stepTotal)&&(identical(other.failedAttempts, failedAttempts) || other.failedAttempts == failedAttempts)&&(identical(other.platform, platform) || other.platform == platform)&&(identical(other.type, type) || other.type == type)&&const DeepCollectionEquality().equals(other._blacklistFactors, _blacklistFactors)&&const DeepCollectionEquality().equals(other._audiences, _audiences)&&const DeepCollectionEquality().equals(other._scopes, _scopes)&&(identical(other.ipAddress, ipAddress) || other.ipAddress == ipAddress)&&(identical(other.userAgent, userAgent) || other.userAgent == userAgent)&&(identical(other.deviceId, deviceId) || other.deviceId == deviceId)&&(identical(other.nonce, nonce) || other.nonce == nonce)&&(identical(other.location, location) || other.location == location)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,id,expiredAt,stepRemain,stepTotal,const DeepCollectionEquality().hash(_blacklistFactors),const DeepCollectionEquality().hash(_audiences),const DeepCollectionEquality().hash(_scopes),ipAddress,userAgent,deviceId,nonce,createdAt,updatedAt,deletedAt); +int get hashCode => Object.hashAll([runtimeType,id,expiredAt,stepRemain,stepTotal,failedAttempts,platform,type,const DeepCollectionEquality().hash(_blacklistFactors),const DeepCollectionEquality().hash(_audiences),const DeepCollectionEquality().hash(_scopes),ipAddress,userAgent,deviceId,nonce,location,accountId,createdAt,updatedAt,deletedAt]); @override String toString() { - return 'SnAuthChallenge(id: $id, expiredAt: $expiredAt, stepRemain: $stepRemain, stepTotal: $stepTotal, blacklistFactors: $blacklistFactors, audiences: $audiences, scopes: $scopes, ipAddress: $ipAddress, userAgent: $userAgent, deviceId: $deviceId, nonce: $nonce, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; + return 'SnAuthChallenge(id: $id, expiredAt: $expiredAt, stepRemain: $stepRemain, stepTotal: $stepTotal, failedAttempts: $failedAttempts, platform: $platform, type: $type, blacklistFactors: $blacklistFactors, audiences: $audiences, scopes: $scopes, ipAddress: $ipAddress, userAgent: $userAgent, deviceId: $deviceId, nonce: $nonce, location: $location, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; } @@ -295,7 +305,7 @@ abstract mixin class _$SnAuthChallengeCopyWith<$Res> implements $SnAuthChallenge factory _$SnAuthChallengeCopyWith(_SnAuthChallenge value, $Res Function(_SnAuthChallenge) _then) = __$SnAuthChallengeCopyWithImpl; @override @useResult $Res call({ - String id, DateTime expiredAt, int stepRemain, int stepTotal, List blacklistFactors, List audiences, List scopes, String ipAddress, String userAgent, String? deviceId, String? nonce, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt + String id, DateTime expiredAt, int stepRemain, int stepTotal, int failedAttempts, int platform, int type, List blacklistFactors, List audiences, List scopes, String ipAddress, String userAgent, String deviceId, String? nonce, String? location, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt }); @@ -312,20 +322,25 @@ class __$SnAuthChallengeCopyWithImpl<$Res> /// Create a copy of SnAuthChallenge /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? expiredAt = null,Object? stepRemain = null,Object? stepTotal = null,Object? blacklistFactors = null,Object? audiences = null,Object? scopes = null,Object? ipAddress = null,Object? userAgent = null,Object? deviceId = freezed,Object? nonce = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? expiredAt = null,Object? stepRemain = null,Object? stepTotal = null,Object? failedAttempts = null,Object? platform = null,Object? type = null,Object? blacklistFactors = null,Object? audiences = null,Object? scopes = null,Object? ipAddress = null,Object? userAgent = null,Object? deviceId = null,Object? nonce = freezed,Object? location = freezed,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { return _then(_SnAuthChallenge( id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable as String,expiredAt: null == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable as DateTime,stepRemain: null == stepRemain ? _self.stepRemain : stepRemain // ignore: cast_nullable_to_non_nullable as int,stepTotal: null == stepTotal ? _self.stepTotal : stepTotal // ignore: cast_nullable_to_non_nullable +as int,failedAttempts: null == failedAttempts ? _self.failedAttempts : failedAttempts // ignore: cast_nullable_to_non_nullable +as int,platform: null == platform ? _self.platform : platform // ignore: cast_nullable_to_non_nullable +as int,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable as int,blacklistFactors: null == blacklistFactors ? _self._blacklistFactors : blacklistFactors // ignore: cast_nullable_to_non_nullable as List,audiences: null == audiences ? _self._audiences : audiences // ignore: cast_nullable_to_non_nullable -as List,scopes: null == scopes ? _self._scopes : scopes // ignore: cast_nullable_to_non_nullable -as List,ipAddress: null == ipAddress ? _self.ipAddress : ipAddress // ignore: cast_nullable_to_non_nullable +as List,scopes: null == scopes ? _self._scopes : scopes // ignore: cast_nullable_to_non_nullable +as List,ipAddress: null == ipAddress ? _self.ipAddress : ipAddress // ignore: cast_nullable_to_non_nullable as String,userAgent: null == userAgent ? _self.userAgent : userAgent // ignore: cast_nullable_to_non_nullable -as String,deviceId: freezed == deviceId ? _self.deviceId : deviceId // ignore: cast_nullable_to_non_nullable -as String?,nonce: freezed == nonce ? _self.nonce : nonce // ignore: cast_nullable_to_non_nullable -as String?,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable +as String,deviceId: null == deviceId ? _self.deviceId : deviceId // ignore: cast_nullable_to_non_nullable +as String,nonce: freezed == nonce ? _self.nonce : nonce // ignore: cast_nullable_to_non_nullable +as String?,location: freezed == location ? _self.location : location // ignore: cast_nullable_to_non_nullable +as String?,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable +as String,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable as DateTime?, @@ -336,6 +351,184 @@ as DateTime?, } +/// @nodoc +mixin _$SnAuthSession { + + String get id; String? get label; DateTime get lastGrantedAt; DateTime get expiredAt; String get accountId; String get challengeId; SnAuthChallenge get challenge; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; +/// Create a copy of SnAuthSession +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SnAuthSessionCopyWith get copyWith => _$SnAuthSessionCopyWithImpl(this as SnAuthSession, _$identity); + + /// Serializes this SnAuthSession to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SnAuthSession&&(identical(other.id, id) || other.id == id)&&(identical(other.label, label) || other.label == label)&&(identical(other.lastGrantedAt, lastGrantedAt) || other.lastGrantedAt == lastGrantedAt)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.challengeId, challengeId) || other.challengeId == challengeId)&&(identical(other.challenge, challenge) || other.challenge == challenge)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,label,lastGrantedAt,expiredAt,accountId,challengeId,challenge,createdAt,updatedAt,deletedAt); + +@override +String toString() { + return 'SnAuthSession(id: $id, label: $label, lastGrantedAt: $lastGrantedAt, expiredAt: $expiredAt, accountId: $accountId, challengeId: $challengeId, challenge: $challenge, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; +} + + +} + +/// @nodoc +abstract mixin class $SnAuthSessionCopyWith<$Res> { + factory $SnAuthSessionCopyWith(SnAuthSession value, $Res Function(SnAuthSession) _then) = _$SnAuthSessionCopyWithImpl; +@useResult +$Res call({ + String id, String? label, DateTime lastGrantedAt, DateTime expiredAt, String accountId, String challengeId, SnAuthChallenge challenge, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt +}); + + +$SnAuthChallengeCopyWith<$Res> get challenge; + +} +/// @nodoc +class _$SnAuthSessionCopyWithImpl<$Res> + implements $SnAuthSessionCopyWith<$Res> { + _$SnAuthSessionCopyWithImpl(this._self, this._then); + + final SnAuthSession _self; + final $Res Function(SnAuthSession) _then; + +/// Create a copy of SnAuthSession +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? label = freezed,Object? lastGrantedAt = null,Object? expiredAt = null,Object? accountId = null,Object? challengeId = null,Object? challenge = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,label: freezed == label ? _self.label : label // ignore: cast_nullable_to_non_nullable +as String?,lastGrantedAt: null == lastGrantedAt ? _self.lastGrantedAt : lastGrantedAt // ignore: cast_nullable_to_non_nullable +as DateTime,expiredAt: null == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable +as DateTime,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable +as String,challengeId: null == challengeId ? _self.challengeId : challengeId // ignore: cast_nullable_to_non_nullable +as String,challenge: null == challenge ? _self.challenge : challenge // ignore: cast_nullable_to_non_nullable +as SnAuthChallenge,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable +as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable +as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable +as DateTime?, + )); +} +/// Create a copy of SnAuthSession +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$SnAuthChallengeCopyWith<$Res> get challenge { + + return $SnAuthChallengeCopyWith<$Res>(_self.challenge, (value) { + return _then(_self.copyWith(challenge: value)); + }); +} +} + + +/// @nodoc +@JsonSerializable() + +class _SnAuthSession implements SnAuthSession { + const _SnAuthSession({required this.id, required this.label, required this.lastGrantedAt, required this.expiredAt, required this.accountId, required this.challengeId, required this.challenge, required this.createdAt, required this.updatedAt, required this.deletedAt}); + factory _SnAuthSession.fromJson(Map json) => _$SnAuthSessionFromJson(json); + +@override final String id; +@override final String? label; +@override final DateTime lastGrantedAt; +@override final DateTime expiredAt; +@override final String accountId; +@override final String challengeId; +@override final SnAuthChallenge challenge; +@override final DateTime createdAt; +@override final DateTime updatedAt; +@override final DateTime? deletedAt; + +/// Create a copy of SnAuthSession +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$SnAuthSessionCopyWith<_SnAuthSession> get copyWith => __$SnAuthSessionCopyWithImpl<_SnAuthSession>(this, _$identity); + +@override +Map toJson() { + return _$SnAuthSessionToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnAuthSession&&(identical(other.id, id) || other.id == id)&&(identical(other.label, label) || other.label == label)&&(identical(other.lastGrantedAt, lastGrantedAt) || other.lastGrantedAt == lastGrantedAt)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.challengeId, challengeId) || other.challengeId == challengeId)&&(identical(other.challenge, challenge) || other.challenge == challenge)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,label,lastGrantedAt,expiredAt,accountId,challengeId,challenge,createdAt,updatedAt,deletedAt); + +@override +String toString() { + return 'SnAuthSession(id: $id, label: $label, lastGrantedAt: $lastGrantedAt, expiredAt: $expiredAt, accountId: $accountId, challengeId: $challengeId, challenge: $challenge, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; +} + + +} + +/// @nodoc +abstract mixin class _$SnAuthSessionCopyWith<$Res> implements $SnAuthSessionCopyWith<$Res> { + factory _$SnAuthSessionCopyWith(_SnAuthSession value, $Res Function(_SnAuthSession) _then) = __$SnAuthSessionCopyWithImpl; +@override @useResult +$Res call({ + String id, String? label, DateTime lastGrantedAt, DateTime expiredAt, String accountId, String challengeId, SnAuthChallenge challenge, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt +}); + + +@override $SnAuthChallengeCopyWith<$Res> get challenge; + +} +/// @nodoc +class __$SnAuthSessionCopyWithImpl<$Res> + implements _$SnAuthSessionCopyWith<$Res> { + __$SnAuthSessionCopyWithImpl(this._self, this._then); + + final _SnAuthSession _self; + final $Res Function(_SnAuthSession) _then; + +/// Create a copy of SnAuthSession +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? label = freezed,Object? lastGrantedAt = null,Object? expiredAt = null,Object? accountId = null,Object? challengeId = null,Object? challenge = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { + return _then(_SnAuthSession( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,label: freezed == label ? _self.label : label // ignore: cast_nullable_to_non_nullable +as String?,lastGrantedAt: null == lastGrantedAt ? _self.lastGrantedAt : lastGrantedAt // ignore: cast_nullable_to_non_nullable +as DateTime,expiredAt: null == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable +as DateTime,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable +as String,challengeId: null == challengeId ? _self.challengeId : challengeId // ignore: cast_nullable_to_non_nullable +as String,challenge: null == challenge ? _self.challenge : challenge // ignore: cast_nullable_to_non_nullable +as SnAuthChallenge,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable +as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable +as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable +as DateTime?, + )); +} + +/// Create a copy of SnAuthSession +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$SnAuthChallengeCopyWith<$Res> get challenge { + + return $SnAuthChallengeCopyWith<$Res>(_self.challenge, (value) { + return _then(_self.copyWith(challenge: value)); + }); +} +} + + /// @nodoc mixin _$SnAuthFactor { @@ -498,6 +691,162 @@ as Map?, } +} + + +/// @nodoc +mixin _$SnAuthDevice { + + dynamic get label; String get userAgent; String get deviceId; int get platform; List get sessions;// Not from backend, used for UI + bool get isCurrent; +/// Create a copy of SnAuthDevice +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SnAuthDeviceCopyWith get copyWith => _$SnAuthDeviceCopyWithImpl(this as SnAuthDevice, _$identity); + + /// Serializes this SnAuthDevice to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SnAuthDevice&&const DeepCollectionEquality().equals(other.label, label)&&(identical(other.userAgent, userAgent) || other.userAgent == userAgent)&&(identical(other.deviceId, deviceId) || other.deviceId == deviceId)&&(identical(other.platform, platform) || other.platform == platform)&&const DeepCollectionEquality().equals(other.sessions, sessions)&&(identical(other.isCurrent, isCurrent) || other.isCurrent == isCurrent)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(label),userAgent,deviceId,platform,const DeepCollectionEquality().hash(sessions),isCurrent); + +@override +String toString() { + return 'SnAuthDevice(label: $label, userAgent: $userAgent, deviceId: $deviceId, platform: $platform, sessions: $sessions, isCurrent: $isCurrent)'; +} + + +} + +/// @nodoc +abstract mixin class $SnAuthDeviceCopyWith<$Res> { + factory $SnAuthDeviceCopyWith(SnAuthDevice value, $Res Function(SnAuthDevice) _then) = _$SnAuthDeviceCopyWithImpl; +@useResult +$Res call({ + dynamic label, String userAgent, String deviceId, int platform, List sessions, bool isCurrent +}); + + + + +} +/// @nodoc +class _$SnAuthDeviceCopyWithImpl<$Res> + implements $SnAuthDeviceCopyWith<$Res> { + _$SnAuthDeviceCopyWithImpl(this._self, this._then); + + final SnAuthDevice _self; + final $Res Function(SnAuthDevice) _then; + +/// Create a copy of SnAuthDevice +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? label = freezed,Object? userAgent = null,Object? deviceId = null,Object? platform = null,Object? sessions = null,Object? isCurrent = null,}) { + return _then(_self.copyWith( +label: freezed == label ? _self.label : label // ignore: cast_nullable_to_non_nullable +as dynamic,userAgent: null == userAgent ? _self.userAgent : userAgent // ignore: cast_nullable_to_non_nullable +as String,deviceId: null == deviceId ? _self.deviceId : deviceId // ignore: cast_nullable_to_non_nullable +as String,platform: null == platform ? _self.platform : platform // ignore: cast_nullable_to_non_nullable +as int,sessions: null == sessions ? _self.sessions : sessions // ignore: cast_nullable_to_non_nullable +as List,isCurrent: null == isCurrent ? _self.isCurrent : isCurrent // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + +} + + +/// @nodoc +@JsonSerializable() + +class _SnAuthDevice implements SnAuthDevice { + const _SnAuthDevice({required this.label, required this.userAgent, required this.deviceId, required this.platform, required final List sessions, this.isCurrent = false}): _sessions = sessions; + factory _SnAuthDevice.fromJson(Map json) => _$SnAuthDeviceFromJson(json); + +@override final dynamic label; +@override final String userAgent; +@override final String deviceId; +@override final int platform; + final List _sessions; +@override List get sessions { + if (_sessions is EqualUnmodifiableListView) return _sessions; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_sessions); +} + +// Not from backend, used for UI +@override@JsonKey() final bool isCurrent; + +/// Create a copy of SnAuthDevice +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$SnAuthDeviceCopyWith<_SnAuthDevice> get copyWith => __$SnAuthDeviceCopyWithImpl<_SnAuthDevice>(this, _$identity); + +@override +Map toJson() { + return _$SnAuthDeviceToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnAuthDevice&&const DeepCollectionEquality().equals(other.label, label)&&(identical(other.userAgent, userAgent) || other.userAgent == userAgent)&&(identical(other.deviceId, deviceId) || other.deviceId == deviceId)&&(identical(other.platform, platform) || other.platform == platform)&&const DeepCollectionEquality().equals(other._sessions, _sessions)&&(identical(other.isCurrent, isCurrent) || other.isCurrent == isCurrent)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(label),userAgent,deviceId,platform,const DeepCollectionEquality().hash(_sessions),isCurrent); + +@override +String toString() { + return 'SnAuthDevice(label: $label, userAgent: $userAgent, deviceId: $deviceId, platform: $platform, sessions: $sessions, isCurrent: $isCurrent)'; +} + + +} + +/// @nodoc +abstract mixin class _$SnAuthDeviceCopyWith<$Res> implements $SnAuthDeviceCopyWith<$Res> { + factory _$SnAuthDeviceCopyWith(_SnAuthDevice value, $Res Function(_SnAuthDevice) _then) = __$SnAuthDeviceCopyWithImpl; +@override @useResult +$Res call({ + dynamic label, String userAgent, String deviceId, int platform, List sessions, bool isCurrent +}); + + + + +} +/// @nodoc +class __$SnAuthDeviceCopyWithImpl<$Res> + implements _$SnAuthDeviceCopyWith<$Res> { + __$SnAuthDeviceCopyWithImpl(this._self, this._then); + + final _SnAuthDevice _self; + final $Res Function(_SnAuthDevice) _then; + +/// Create a copy of SnAuthDevice +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? label = freezed,Object? userAgent = null,Object? deviceId = null,Object? platform = null,Object? sessions = null,Object? isCurrent = null,}) { + return _then(_SnAuthDevice( +label: freezed == label ? _self.label : label // ignore: cast_nullable_to_non_nullable +as dynamic,userAgent: null == userAgent ? _self.userAgent : userAgent // ignore: cast_nullable_to_non_nullable +as String,deviceId: null == deviceId ? _self.deviceId : deviceId // ignore: cast_nullable_to_non_nullable +as String,platform: null == platform ? _self.platform : platform // ignore: cast_nullable_to_non_nullable +as int,sessions: null == sessions ? _self._sessions : sessions // ignore: cast_nullable_to_non_nullable +as List,isCurrent: null == isCurrent ? _self.isCurrent : isCurrent // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + + } // dart format on diff --git a/lib/models/auth.g.dart b/lib/models/auth.g.dart index 41423e0..2c524c0 100644 --- a/lib/models/auth.g.dart +++ b/lib/models/auth.g.dart @@ -19,18 +19,21 @@ _SnAuthChallenge _$SnAuthChallengeFromJson(Map json) => expiredAt: DateTime.parse(json['expired_at'] as String), stepRemain: (json['step_remain'] as num).toInt(), stepTotal: (json['step_total'] as num).toInt(), + failedAttempts: (json['failed_attempts'] as num).toInt(), + platform: (json['platform'] as num).toInt(), + type: (json['type'] as num).toInt(), blacklistFactors: (json['blacklist_factors'] as List) .map((e) => e as String) .toList(), - audiences: - (json['audiences'] as List).map((e) => e as String).toList(), - scopes: - (json['scopes'] as List).map((e) => e as String).toList(), + audiences: json['audiences'] as List, + scopes: json['scopes'] as List, ipAddress: json['ip_address'] as String, userAgent: json['user_agent'] as String, - deviceId: json['device_id'] as String?, + deviceId: json['device_id'] as String, nonce: json['nonce'] as String?, + location: json['location'] as String?, + accountId: json['account_id'] as String, createdAt: DateTime.parse(json['created_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String), deletedAt: @@ -45,6 +48,9 @@ Map _$SnAuthChallengeToJson(_SnAuthChallenge instance) => 'expired_at': instance.expiredAt.toIso8601String(), 'step_remain': instance.stepRemain, 'step_total': instance.stepTotal, + 'failed_attempts': instance.failedAttempts, + 'platform': instance.platform, + 'type': instance.type, 'blacklist_factors': instance.blacklistFactors, 'audiences': instance.audiences, 'scopes': instance.scopes, @@ -52,6 +58,41 @@ Map _$SnAuthChallengeToJson(_SnAuthChallenge instance) => 'user_agent': instance.userAgent, 'device_id': instance.deviceId, 'nonce': instance.nonce, + 'location': instance.location, + 'account_id': instance.accountId, + 'created_at': instance.createdAt.toIso8601String(), + 'updated_at': instance.updatedAt.toIso8601String(), + 'deleted_at': instance.deletedAt?.toIso8601String(), + }; + +_SnAuthSession _$SnAuthSessionFromJson(Map json) => + _SnAuthSession( + id: json['id'] as String, + label: json['label'] as String?, + lastGrantedAt: DateTime.parse(json['last_granted_at'] as String), + expiredAt: DateTime.parse(json['expired_at'] as String), + accountId: json['account_id'] as String, + challengeId: json['challenge_id'] as String, + challenge: SnAuthChallenge.fromJson( + json['challenge'] as Map, + ), + createdAt: DateTime.parse(json['created_at'] as String), + updatedAt: DateTime.parse(json['updated_at'] as String), + deletedAt: + json['deleted_at'] == null + ? null + : DateTime.parse(json['deleted_at'] as String), + ); + +Map _$SnAuthSessionToJson(_SnAuthSession instance) => + { + 'id': instance.id, + 'label': instance.label, + 'last_granted_at': instance.lastGrantedAt.toIso8601String(), + 'expired_at': instance.expiredAt.toIso8601String(), + 'account_id': instance.accountId, + 'challenge_id': instance.challengeId, + 'challenge': instance.challenge.toJson(), 'created_at': instance.createdAt.toIso8601String(), 'updated_at': instance.updatedAt.toIso8601String(), 'deleted_at': instance.deletedAt?.toIso8601String(), @@ -91,3 +132,26 @@ Map _$SnAuthFactorToJson(_SnAuthFactor instance) => 'trustworthy': instance.trustworthy, 'created_response': instance.createdResponse, }; + +_SnAuthDevice _$SnAuthDeviceFromJson(Map json) => + _SnAuthDevice( + label: json['label'], + userAgent: json['user_agent'] as String, + deviceId: json['device_id'] as String, + platform: (json['platform'] as num).toInt(), + sessions: + (json['sessions'] as List) + .map((e) => SnAuthSession.fromJson(e as Map)) + .toList(), + isCurrent: json['is_current'] as bool? ?? false, + ); + +Map _$SnAuthDeviceToJson(_SnAuthDevice instance) => + { + 'label': instance.label, + 'user_agent': instance.userAgent, + 'device_id': instance.deviceId, + 'platform': instance.platform, + 'sessions': instance.sessions.map((e) => e.toJson()).toList(), + 'is_current': instance.isCurrent, + }; diff --git a/lib/screens/account/me/settings.dart b/lib/screens/account/me/settings.dart index ee69d24..f2790d4 100644 --- a/lib/screens/account/me/settings.dart +++ b/lib/screens/account/me/settings.dart @@ -16,6 +16,7 @@ import 'package:island/pods/userinfo.dart'; import 'package:island/screens/auth/captcha.dart'; import 'package:island/screens/auth/login.dart'; import 'package:island/services/responsive.dart'; +import 'package:island/widgets/account/account_session_sheet.dart'; import 'package:island/widgets/alert.dart'; import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/content/sheet.dart'; @@ -90,6 +91,21 @@ class AccountSettingsScreen extends HookConsumerWidget { // Group settings into categories for better organization final securitySettings = [ + ListTile( + minLeadingWidth: 48, + leading: const Icon(Symbols.devices), + title: Text('authSessions').tr(), + subtitle: Text('authSessionsDescription').tr().fontSize(12), + contentPadding: const EdgeInsets.only(left: 24, right: 17), + trailing: const Icon(Symbols.chevron_right), + onTap: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => const AccountSessionSheet(), + ); + }, + ), ExpansionTile( leading: const Icon( Symbols.security, diff --git a/lib/screens/auth/login.dart b/lib/screens/auth/login.dart index 757c7df..d5befae 100644 --- a/lib/screens/auth/login.dart +++ b/lib/screens/auth/login.dart @@ -1,8 +1,11 @@ import 'dart:convert'; +import 'dart:io'; import 'dart:math' as math; import 'package:animations/animations.dart'; import 'package:auto_route/auto_route.dart'; +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:dio/dio.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -63,6 +66,19 @@ class LoginScreen extends HookConsumerWidget { LinearProgressIndicator( minHeight: 4, borderRadius: BorderRadius.zero, + trackGap: 0, + stopIndicatorRadius: 0, + ) + else if (currentTicket.value != null) + LinearProgressIndicator( + minHeight: 4, + borderRadius: BorderRadius.zero, + trackGap: 0, + stopIndicatorRadius: 0, + value: + 1 - + (currentTicket.value!.stepRemain / + currentTicket.value!.stepTotal), ) else const Gap(4), @@ -121,17 +137,8 @@ class LoginScreen extends HookConsumerWidget { ).padding(all: 24), ).center(), ), - if (currentTicket.value != null) - LinearProgressIndicator( - minHeight: 4, - borderRadius: BorderRadius.zero, - value: - 1 - - (currentTicket.value!.stepRemain / - currentTicket.value!.stepTotal), - ) - else - const Gap(4), + + const Gap(4), ], ), ), @@ -170,6 +177,7 @@ class _LoginCheckScreen extends HookConsumerWidget { if (pwd.isEmpty) return; isBusy.value = true; try { + // Pass challenge final client = ref.watch(apiClientProvider); final resp = await client.patch( '/auth/challenge/${challenge!.id}', @@ -181,6 +189,8 @@ class _LoginCheckScreen extends HookConsumerWidget { onNext(); return; } + + // Get token if challenge is completed final tokenResp = await client.post( '/auth/token', data: {'grant_type': 'authorization_code', 'code': result.id}, @@ -189,6 +199,8 @@ class _LoginCheckScreen extends HookConsumerWidget { setToken(ref.watch(sharedPreferencesProvider), token); ref.invalidate(tokenProvider); if (!context.mounted) return; + + // Do post login tasks final userNotifier = ref.read(userInfoProvider.notifier); userNotifier.fetchUser().then((_) { final apiClient = ref.read(apiClientProvider); @@ -197,6 +209,31 @@ class _LoginCheckScreen extends HookConsumerWidget { wsNotifier.connect(); if (context.mounted) Navigator.pop(context, true); }); + + // Update the sessions' device name is available + if (!kIsWeb) { + String? name; + if (Platform.isIOS) { + return; + // TODO waiting for apple to respond to grant my access to com.apple.developer.device-information.user-assigned-device-name + // ignore: dead_code + final deviceInfo = await DeviceInfoPlugin().iosInfo; + name = deviceInfo.name; + } else if (Platform.isAndroid) { + final deviceInfo = await DeviceInfoPlugin().androidInfo; + name = deviceInfo.name; + } else if (Platform.isWindows) { + final deviceInfo = await DeviceInfoPlugin().windowsInfo; + name = deviceInfo.computerName; + } + if (name != null) { + final client = ref.watch(apiClientProvider); + await client.patch( + '/accounts/me/sessions/current/label', + data: jsonEncode(name), + ); + } + } } catch (err) { showErrorAlert(err); return; @@ -336,6 +373,14 @@ class _LoginPickerScreen extends HookConsumerWidget { onPickFactor(factors!.where((x) => x == factorPicked.value).first); onNext(); } catch (err) { + if (err is DioException && err.response?.statusCode == 400) { + onPickFactor(factors!.where((x) => x == factorPicked.value).first); + onNext(); + if (context.mounted) { + showSnackBar(context, err.response!.data.toString()); + } + return; + } showErrorAlert(err); return; } finally { @@ -404,6 +449,7 @@ class _LoginPickerScreen extends HookConsumerWidget { TextField( controller: hintController, decoration: InputDecoration( + isDense: true, border: const OutlineInputBorder(), labelText: 'authFactorHint'.tr(), helperText: 'authFactorHintHelper'.tr(), diff --git a/lib/widgets/account/account_session_sheet.dart b/lib/widgets/account/account_session_sheet.dart new file mode 100644 index 0000000..462f185 --- /dev/null +++ b/lib/widgets/account/account_session_sheet.dart @@ -0,0 +1,246 @@ +import 'dart:convert'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/models/auth.dart'; +import 'package:island/pods/network.dart'; +import 'package:island/services/responsive.dart'; +import 'package:island/widgets/alert.dart'; +import 'package:island/widgets/content/sheet.dart'; +import 'package:island/widgets/response.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:styled_widget/styled_widget.dart'; + +part 'account_session_sheet.g.dart'; + +@riverpod +Future> authDevices(Ref ref) async { + final resp = await ref.watch(apiClientProvider).get('/accounts/me/devices'); + final sessionId = resp.headers.value('x-auth-session'); + final data = + resp.data.map((e) { + final ele = SnAuthDevice.fromJson(e); + return ele.copyWith(isCurrent: ele.sessions.first.id == sessionId); + }).toList(); + return data; +} + +class _DeviceListTile extends StatelessWidget { + final SnAuthDevice device; + final Function(String) updateDeviceLabel; + final Function(String) logoutDevice; + + const _DeviceListTile({ + required this.device, + required this.updateDeviceLabel, + required this.logoutDevice, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + isThreeLine: true, + contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + leading: Icon(switch (device.platform) { + 0 => Icons.device_unknown, // Unidentified + 1 => Icons.web, // Web + 2 => Icons.phone_iphone, // iOS + 3 => Icons.phone_android, // Android + 4 => Icons.laptop_mac, // macOS + 5 => Icons.window, // Windows + 6 => Icons.computer, // Linux + _ => Icons.device_unknown, // fallback + }).padding(top: 4), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('authSessionsCount'.plural(device.sessions.length)), + Text( + 'lastActiveAt'.tr( + args: [ + DateFormat().format( + device.sessions.first.lastGrantedAt.toLocal(), + ), + ], + ), + ), + Text(device.sessions.first.challenge.ipAddress), + if (device.isCurrent) + Row( + children: [ + Badge( + backgroundColor: Theme.of(context).colorScheme.primary, + label: Text( + 'authDeviceCurrent'.tr(), + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimary, + ), + ), + ), + ], + ).padding(top: 4), + ], + ), + title: Text(device.label ?? device.sessions.first.challenge.userAgent), + trailing: + isWideScreen(context) + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: Icon(Icons.edit), + tooltip: 'authDeviceEditLabel'.tr(), + onPressed: + () => updateDeviceLabel(device.sessions.first.id), + ), + if (!device.isCurrent) + IconButton( + icon: Icon(Icons.logout), + tooltip: 'authDeviceLogout'.tr(), + onPressed: () => logoutDevice(device.sessions.first.id), + ), + ], + ) + : null, + ); + } +} + +class AccountSessionSheet extends HookConsumerWidget { + const AccountSessionSheet({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final authDevices = ref.watch(authDevicesProvider); + + void logoutDevice(String sessionId) async { + final confirm = await showConfirmAlert( + 'authDeviceLogoutHint'.tr(), + 'authDeviceLogout'.tr(), + ); + if (!confirm || !context.mounted) return; + try { + final apiClient = ref.watch(apiClientProvider); + await apiClient.delete('/accounts/me/sessions/$sessionId'); + ref.invalidate(authDevicesProvider); + } catch (err) { + showErrorAlert(err); + } + } + + void updateDeviceLabel(String sessionId) async { + final controller = TextEditingController(); + final label = await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: Text('authDeviceLabelTitle'.tr()), + content: TextField( + controller: controller, + decoration: InputDecoration( + isDense: true, + border: const OutlineInputBorder(), + hintText: 'authDeviceLabelHint'.tr(), + ), + autofocus: true, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text('cancel'.tr()), + ), + TextButton( + onPressed: () => Navigator.pop(context, controller.text), + child: Text('confirm'.tr()), + ), + ], + ), + ); + if (label == null || label.isEmpty || !context.mounted) return; + try { + final apiClient = ref.watch(apiClientProvider); + await apiClient.patch( + '/accounts/me/sessions/$sessionId/label', + data: jsonEncode(label), + ); + ref.invalidate(authDevicesProvider); + } catch (err) { + showErrorAlert(err); + } + } + + final wideScreen = isWideScreen(context); + + return SheetScaffold( + titleText: 'authSessions'.tr(), + child: authDevices.when( + data: + (data) => RefreshIndicator( + onRefresh: + () => Future.sync(() => ref.invalidate(authDevicesProvider)), + child: ListView.builder( + padding: EdgeInsets.zero, + itemCount: data.length, + itemBuilder: (context, index) { + final device = data[index]; + if (wideScreen) { + return _DeviceListTile( + device: device, + updateDeviceLabel: updateDeviceLabel, + logoutDevice: logoutDevice, + ); + } else { + return Dismissible( + key: Key('device-${device.sessions.first.id}'), + direction: + device.isCurrent + ? DismissDirection.startToEnd + : DismissDirection.horizontal, + background: Container( + color: Colors.blue, + alignment: Alignment.centerLeft, + padding: EdgeInsets.symmetric(horizontal: 20), + child: Icon(Icons.edit, color: Colors.white), + ), + secondaryBackground: Container( + color: Colors.red, + alignment: Alignment.centerRight, + padding: EdgeInsets.symmetric(horizontal: 20), + child: Icon(Icons.logout, color: Colors.white), + ), + confirmDismiss: (direction) async { + if (direction == DismissDirection.startToEnd) { + updateDeviceLabel(device.sessions.first.id); + return false; + } else { + final confirm = await showConfirmAlert( + 'authDeviceLogoutHint'.tr(), + 'authDeviceLogout'.tr(), + ); + if (confirm && context.mounted) { + logoutDevice(device.sessions.first.id); + } + return false; // Don't dismiss + } + }, + child: _DeviceListTile( + device: device, + updateDeviceLabel: updateDeviceLabel, + logoutDevice: logoutDevice, + ), + ); + } + }, + ), + ), + error: + (err, _) => ResponseErrorWidget( + error: err, + onRetry: () => ref.invalidate(authDevicesProvider), + ), + loading: () => ResponseLoadingWidget(), + ), + ); + } +} diff --git a/lib/widgets/account/account_session_sheet.g.dart b/lib/widgets/account/account_session_sheet.g.dart new file mode 100644 index 0000000..d445f55 --- /dev/null +++ b/lib/widgets/account/account_session_sheet.g.dart @@ -0,0 +1,29 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'account_session_sheet.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$authDevicesHash() => r'9b8101167653991314efd37788d8416f414cb9e8'; + +/// See also [authDevices]. +@ProviderFor(authDevices) +final authDevicesProvider = + AutoDisposeFutureProvider>.internal( + authDevices, + name: r'authDevicesProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$authDevicesHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef AuthDevicesRef = AutoDisposeFutureProviderRef>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package