added support for multiple accounts, guardian login bugfixes, ui changes
This commit is contained in:
@@ -20,6 +20,15 @@ class MarianumConnectAuthInterceptor extends Interceptor {
|
||||
// each spawning a fresh row in api_tokens.
|
||||
Future<bool>? _pendingReLogin;
|
||||
|
||||
static Future<bool>? _anyPendingReLogin;
|
||||
|
||||
/// Resolves once no silent re-login is running. An account switch waits for
|
||||
/// it — the renewed token would otherwise land in the next account's slot.
|
||||
static Future<void> idle() async {
|
||||
final pending = _anyPendingReLogin;
|
||||
if (pending != null) await pending;
|
||||
}
|
||||
|
||||
MarianumConnectAuthInterceptor({
|
||||
MarianumConnectTokenStorage tokenStorage =
|
||||
const MarianumConnectTokenStorage(),
|
||||
@@ -85,8 +94,10 @@ class MarianumConnectAuthInterceptor extends Interceptor {
|
||||
if (inFlight != null) return inFlight;
|
||||
final fresh = _performReLogin();
|
||||
_pendingReLogin = fresh;
|
||||
_anyPendingReLogin = fresh;
|
||||
fresh.whenComplete(() {
|
||||
if (identical(_pendingReLogin, fresh)) _pendingReLogin = null;
|
||||
if (identical(_anyPendingReLogin, fresh)) _anyPendingReLogin = null;
|
||||
});
|
||||
return fresh;
|
||||
}
|
||||
|
||||
@@ -10,10 +10,10 @@ import '../queries/auth_verify/auth_verify.dart';
|
||||
/// Credential probe. For password accounts a server-side password rotation
|
||||
/// forces a re-login on the next cold start even when the bearer token would
|
||||
/// still be accepted; for guardians it confirms a rejected token before the
|
||||
/// session is dropped.
|
||||
/// session is dropped. Another stored account then takes over.
|
||||
class SessionValidator {
|
||||
static Future<void> probeStored({
|
||||
required Future<void> Function() onInvalidated,
|
||||
required Future<void> Function(String? nextAccountId) onInvalidated,
|
||||
}) async {
|
||||
final session = SessionManager().current;
|
||||
// The probes use their own dio (bypassing the demo interceptor), so a demo
|
||||
@@ -29,7 +29,7 @@ class SessionValidator {
|
||||
} on AuthException catch (e) {
|
||||
if (e.statusCode != 401) return;
|
||||
log('MC: stored session rejected — forcing re-login');
|
||||
await SessionLifecycle.signOut(
|
||||
final next = await SessionLifecycle.signOut(
|
||||
notice: switch (session) {
|
||||
CredentialSession() =>
|
||||
'Deine Zugangsdaten wurden vom Server abgelehnt. Vermutlich '
|
||||
@@ -38,7 +38,7 @@ class SessionValidator {
|
||||
'Deine Anmeldung ist abgelaufen. Bitte melde dich erneut an.',
|
||||
},
|
||||
);
|
||||
await onInvalidated();
|
||||
await onInvalidated(next);
|
||||
} catch (e) {
|
||||
log('MC: background session check failed (transient): $e');
|
||||
}
|
||||
|
||||
@@ -60,6 +60,26 @@ class MarianumConnectTokenStorage {
|
||||
);
|
||||
}
|
||||
|
||||
static const bearerKey = _tokenKey;
|
||||
static const fieldKeys = [_tokenKey, _tokenIdKey, _expiresAtKey];
|
||||
|
||||
/// Raw stored fields, for parking the token of an inactive account.
|
||||
Future<Map<String, String>> readAll() async => {
|
||||
for (final key in fieldKeys) key: ?await _storage.read(key: key),
|
||||
};
|
||||
|
||||
/// Restores fields from [readAll]; missing ones are deleted.
|
||||
Future<void> writeAll(Map<String, String> fields) async {
|
||||
for (final key in fieldKeys) {
|
||||
final value = fields[key];
|
||||
if (value == null) {
|
||||
await _storage.delete(key: key);
|
||||
} else {
|
||||
await _storage.write(key: key, value: value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
await _storage.delete(key: _tokenKey);
|
||||
await _storage.delete(key: _tokenIdKey);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../auth/token_storage.dart';
|
||||
import '../../marianumconnect_api.dart';
|
||||
import '../../marianumconnect_endpoint.dart';
|
||||
import '../../marianumconnect_query.dart';
|
||||
|
||||
/// Revokes the stored MC bearer token both server-side and locally. Best-effort
|
||||
@@ -24,4 +26,17 @@ class AuthLogout extends MarianumConnectQuery {
|
||||
await _tokenStorage.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Revokes the token of an inactive account; the stored (active) token is
|
||||
/// left alone. Best-effort.
|
||||
static Future<void> revoke(String token) async {
|
||||
try {
|
||||
await MarianumConnectApi.plainDio().post<void>(
|
||||
MarianumConnectEndpoint.resolve('auth/logout'),
|
||||
options: Options(headers: {'Authorization': 'Bearer $token'}),
|
||||
);
|
||||
} on DioException catch (_) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import '../../../errors/auth_exception.dart';
|
||||
import '../../auth/token_storage.dart';
|
||||
import '../../marianumconnect_api.dart';
|
||||
import '../../marianumconnect_query.dart';
|
||||
import '../auth_login/auth_login_response.dart';
|
||||
|
||||
/// Probes that the stored bearer token is still accepted. Used for accounts
|
||||
/// without a password (guardians), whose token cannot be renewed silently.
|
||||
@@ -22,9 +23,18 @@ class AuthMe extends MarianumConnectQuery {
|
||||
|
||||
/// Throws [AuthException] when the token is missing or rejected.
|
||||
Future<void> run() async {
|
||||
await user();
|
||||
}
|
||||
|
||||
/// The signed-in user (names, type). Throws like [run].
|
||||
Future<AuthLoginUser> user() async {
|
||||
final options = await _tokenStorage.requireBearerOptions('AuthMe');
|
||||
return guard(() async {
|
||||
await dio.get<void>(endpoint('auth/me'), options: options);
|
||||
final response = await dio.get<Map<String, dynamic>>(
|
||||
endpoint('auth/me'),
|
||||
options: options,
|
||||
);
|
||||
return AuthLoginUser.fromJson(response.data!);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ abstract class GuardianChild with _$GuardianChild {
|
||||
required String firstName,
|
||||
required String lastName,
|
||||
@Default('') String className,
|
||||
|
||||
/// Login of the child, also its Nextcloud id (avatar). Null on servers
|
||||
/// that do not send it yet.
|
||||
String? username,
|
||||
}) = _GuardianChild;
|
||||
|
||||
factory GuardianChild.fromJson(Map<String, Object?> json) =>
|
||||
|
||||
@@ -16,7 +16,9 @@ T _$identity<T>(T value) => value;
|
||||
/// @nodoc
|
||||
mixin _$GuardianChild {
|
||||
|
||||
String get id; String get firstName; String get lastName; String get className;
|
||||
String get id; String get firstName; String get lastName; String get className;/// Login of the child, also its Nextcloud id (avatar). Null on servers
|
||||
/// that do not send it yet.
|
||||
String? get username;
|
||||
/// Create a copy of GuardianChild
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@@ -30,20 +32,20 @@ $GuardianChildCopyWith<GuardianChild> get copyWith => _$GuardianChildCopyWithImp
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
final _this = this as GuardianChild;
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is GuardianChild&&(identical(other.id, _this.id) || other.id == _this.id)&&(identical(other.firstName, _this.firstName) || other.firstName == _this.firstName)&&(identical(other.lastName, _this.lastName) || other.lastName == _this.lastName)&&(identical(other.className, _this.className) || other.className == _this.className));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is GuardianChild&&(identical(other.id, _this.id) || other.id == _this.id)&&(identical(other.firstName, _this.firstName) || other.firstName == _this.firstName)&&(identical(other.lastName, _this.lastName) || other.lastName == _this.lastName)&&(identical(other.className, _this.className) || other.className == _this.className)&&(identical(other.username, _this.username) || other.username == _this.username));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode {
|
||||
final _this = this as GuardianChild;
|
||||
return Object.hash(runtimeType,_this.id,_this.firstName,_this.lastName,_this.className);
|
||||
return Object.hash(runtimeType,_this.id,_this.firstName,_this.lastName,_this.className,_this.username);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final _this = this as GuardianChild;
|
||||
return 'GuardianChild(id: ${_this.id}, firstName: ${_this.firstName}, lastName: ${_this.lastName}, className: ${_this.className})';
|
||||
return 'GuardianChild(id: ${_this.id}, firstName: ${_this.firstName}, lastName: ${_this.lastName}, className: ${_this.className}, username: ${_this.username})';
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +56,7 @@ abstract mixin class $GuardianChildCopyWith<$Res> {
|
||||
factory $GuardianChildCopyWith(GuardianChild value, $Res Function(GuardianChild) _then) = _$GuardianChildCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String id, String firstName, String lastName, String className
|
||||
String id, String firstName, String lastName, String className, String? username
|
||||
});
|
||||
|
||||
|
||||
@@ -71,13 +73,14 @@ class _$GuardianChildCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of GuardianChild
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? firstName = null,Object? lastName = null,Object? className = null,}) {
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? firstName = null,Object? lastName = null,Object? className = null,Object? username = freezed,}) {
|
||||
return _then(GuardianChild(
|
||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||
as String,firstName: null == firstName ? _self.firstName : firstName // ignore: cast_nullable_to_non_nullable
|
||||
as String,lastName: null == lastName ? _self.lastName : lastName // ignore: cast_nullable_to_non_nullable
|
||||
as String,className: null == className ? _self.className : className // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
as String,username: freezed == username ? _self.username : username // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -162,10 +165,10 @@ return $default(_that);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String firstName, String lastName, String className)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String firstName, String lastName, String className, String? username)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _GuardianChild() when $default != null:
|
||||
return $default(_that.id,_that.firstName,_that.lastName,_that.className);case _:
|
||||
return $default(_that.id,_that.firstName,_that.lastName,_that.className,_that.username);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
@@ -183,10 +186,10 @@ return $default(_that.id,_that.firstName,_that.lastName,_that.className);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String firstName, String lastName, String className) $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String firstName, String lastName, String className, String? username) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _GuardianChild():
|
||||
return $default(_that.id,_that.firstName,_that.lastName,_that.className);case _:
|
||||
return $default(_that.id,_that.firstName,_that.lastName,_that.className,_that.username);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
@@ -203,10 +206,10 @@ return $default(_that.id,_that.firstName,_that.lastName,_that.className);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String firstName, String lastName, String className)? $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String firstName, String lastName, String className, String? username)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _GuardianChild() when $default != null:
|
||||
return $default(_that.id,_that.firstName,_that.lastName,_that.className);case _:
|
||||
return $default(_that.id,_that.firstName,_that.lastName,_that.className,_that.username);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
@@ -218,13 +221,16 @@ return $default(_that.id,_that.firstName,_that.lastName,_that.className);case _:
|
||||
@JsonSerializable()
|
||||
|
||||
class _GuardianChild extends GuardianChild {
|
||||
const _GuardianChild({required this.id, required this.firstName, required this.lastName, this.className = ''}): super._();
|
||||
const _GuardianChild({required this.id, required this.firstName, required this.lastName, this.className = '', this.username}): super._();
|
||||
factory _GuardianChild.fromJson(Map<String, dynamic> json) => _$GuardianChildFromJson(json);
|
||||
|
||||
@override final String id;
|
||||
@override final String firstName;
|
||||
@override final String lastName;
|
||||
@override@JsonKey() final String className;
|
||||
/// Login of the child, also its Nextcloud id (avatar). Null on servers
|
||||
/// that do not send it yet.
|
||||
@override final String? username;
|
||||
|
||||
/// Create a copy of GuardianChild
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@@ -239,18 +245,18 @@ Map<String, dynamic> toJson() {
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _GuardianChild&&(identical(other.id, id) || other.id == id)&&(identical(other.firstName, firstName) || other.firstName == firstName)&&(identical(other.lastName, lastName) || other.lastName == lastName)&&(identical(other.className, className) || other.className == className));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _GuardianChild&&(identical(other.id, id) || other.id == id)&&(identical(other.firstName, firstName) || other.firstName == firstName)&&(identical(other.lastName, lastName) || other.lastName == lastName)&&(identical(other.className, className) || other.className == className)&&(identical(other.username, username) || other.username == username));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hash(runtimeType,id,firstName,lastName,className);
|
||||
return Object.hash(runtimeType,id,firstName,lastName,className,username);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'GuardianChild(id: $id, firstName: $firstName, lastName: $lastName, className: $className)';
|
||||
return 'GuardianChild(id: $id, firstName: $firstName, lastName: $lastName, className: $className, username: $username)';
|
||||
}
|
||||
|
||||
|
||||
@@ -261,7 +267,7 @@ abstract mixin class _$GuardianChildCopyWith<$Res> implements $GuardianChildCopy
|
||||
factory _$GuardianChildCopyWith(_GuardianChild value, $Res Function(_GuardianChild) _then) = __$GuardianChildCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String id, String firstName, String lastName, String className
|
||||
String id, String firstName, String lastName, String className, String? username
|
||||
});
|
||||
|
||||
|
||||
@@ -278,13 +284,14 @@ class __$GuardianChildCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of GuardianChild
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? firstName = null,Object? lastName = null,Object? className = null,}) {
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? firstName = null,Object? lastName = null,Object? className = null,Object? username = freezed,}) {
|
||||
return _then(_GuardianChild(
|
||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||
as String,firstName: null == firstName ? _self.firstName : firstName // ignore: cast_nullable_to_non_nullable
|
||||
as String,lastName: null == lastName ? _self.lastName : lastName // ignore: cast_nullable_to_non_nullable
|
||||
as String,className: null == className ? _self.className : className // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
as String,username: freezed == username ? _self.username : username // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ _GuardianChild _$GuardianChildFromJson(Map<String, dynamic> json) =>
|
||||
firstName: json['firstName'] as String,
|
||||
lastName: json['lastName'] as String,
|
||||
className: json['className'] as String? ?? '',
|
||||
username: json['username'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$GuardianChildToJson(_GuardianChild instance) =>
|
||||
@@ -20,4 +21,5 @@ Map<String, dynamic> _$GuardianChildToJson(_GuardianChild instance) =>
|
||||
'firstName': instance.firstName,
|
||||
'lastName': instance.lastName,
|
||||
'className': instance.className,
|
||||
'username': instance.username,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user