added support for multiple accounts, guardian login bugfixes, ui changes

This commit is contained in:
2026-09-23 20:50:12 +02:00
parent 630497abdd
commit 84098af7e2
37 changed files with 1759 additions and 376 deletions
@@ -16,10 +16,15 @@ class DeleteAppPassword {
Future<void> run({String? authorizationHeader}) async {
await _client.delete(
NextcloudOcs.uri('core/apppassword'),
headers: {
...NextcloudOcs.headers(),
'Authorization': ?authorizationHeader,
},
// An explicit header may belong to an inactive account, so the active
// session's headers must not be required then.
headers: authorizationHeader == null
? NextcloudOcs.headers()
: {
'Accept': 'application/json',
'OCS-APIRequest': 'true',
'Authorization': authorizationHeader,
},
);
}
}
+2 -1
View File
@@ -18,7 +18,8 @@ abstract class WebdavApi<T> {
/// changes (app password minted/renewed, account switch) so it never keeps
/// authenticating with stale credentials.
static Future<WebDavClient> get webdav {
final secret = SessionManager().requireNextcloud().secret;
final nextcloud = SessionManager().requireNextcloud();
final secret = '${nextcloud.username}:${nextcloud.secret}';
if (_webdav == null || _webdavSecret != secret) {
_webdavSecret = secret;
_webdav = establishWebdavConnection();
@@ -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,
};