added support for multiple accounts, guardian login bugfixes, ui changes
This commit is contained in:
@@ -1,23 +1,38 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../api/marianumcloud/app_password/delete_app_password.dart';
|
||||
import '../api/marianumconnect/auth/auth_interceptor.dart';
|
||||
import '../api/marianumconnect/auth/token_storage.dart';
|
||||
import '../api/marianumconnect/queries/auth_logout/auth_logout.dart';
|
||||
import '../api/marianumconnect/queries/auth_me/auth_me.dart';
|
||||
import '../auth_link/guardian_link_listener.dart';
|
||||
import '../background/widget_background_task.dart';
|
||||
import '../push/chat_thread_store.dart';
|
||||
import '../push/nid_store.dart';
|
||||
import '../push/push_registration.dart';
|
||||
import '../storage/account_storage.dart';
|
||||
import '../widget_data/widget_sync.dart';
|
||||
import 'session.dart';
|
||||
import 'session_manager.dart';
|
||||
|
||||
abstract final class SessionLifecycle {
|
||||
/// Why the last sign-out happened, when the user did not ask for it. The
|
||||
/// login screen shows it once and clears it — without it an expired session
|
||||
/// just drops the user on the login screen with no explanation.
|
||||
/// login screen (or, when another account takes over, the app) shows it
|
||||
/// once and clears it.
|
||||
static final ValueNotifier<String?> signOutNotice = ValueNotifier(null);
|
||||
|
||||
/// Account to return to while "add account" runs; null otherwise.
|
||||
static String? _addReturnId;
|
||||
|
||||
/// Ordered teardown: unregister push and revoke the Nextcloud app passwords
|
||||
/// (while those credentials still exist), then revoke the MC bearer token,
|
||||
/// finally wipe the local session. Each step is best-effort so an offline
|
||||
/// sign-out still reaches a clean local state.
|
||||
static Future<void> signOut({String? notice}) async {
|
||||
/// finally wipe the local session and its data. Each step is best-effort so
|
||||
/// an offline sign-out still reaches a clean local state. Another signed-in
|
||||
/// account takes over when there is one; returns its id.
|
||||
static Future<String?> signOut({String? notice}) async {
|
||||
signOutNotice.value = notice;
|
||||
try {
|
||||
await PushRegistration().logoutCleanup();
|
||||
@@ -25,9 +40,165 @@ abstract final class SessionLifecycle {
|
||||
log('Sign-out: push cleanup failed: $e');
|
||||
}
|
||||
await AuthLogout().run();
|
||||
await SessionManager().signOut();
|
||||
final removed = await SessionManager().signOut();
|
||||
// A login link that arrived while signed in must not be replayed on the
|
||||
// login screen that follows.
|
||||
GuardianLinkListener.clear();
|
||||
await _clearPushStores();
|
||||
if (removed != null) await AccountStorage.delete(removed.namespace);
|
||||
|
||||
for (
|
||||
var next = SessionManager().accounts.value.mostRecentExcept(null);
|
||||
next != null;
|
||||
next = SessionManager().accounts.value.mostRecentExcept(null)
|
||||
) {
|
||||
try {
|
||||
await SessionManager().activate(next.id);
|
||||
break;
|
||||
} on Object catch (e) {
|
||||
log('Sign-out: taking over ${next.id} failed: $e');
|
||||
await SessionManager().forget(next.id);
|
||||
}
|
||||
}
|
||||
await AccountStorage.activate(SessionManager().activeAccount);
|
||||
if (SessionManager().isSignedIn) await _resetWidget();
|
||||
return SessionManager().activeAccount?.id;
|
||||
}
|
||||
|
||||
/// Makes the stored account [id] the active one. Push moves along: the
|
||||
/// previous account is unregistered here, the new one registers once the
|
||||
/// app has remounted for it.
|
||||
static Future<void> switchTo(String id) async {
|
||||
final previous = SessionManager().activeAccount;
|
||||
if (previous?.id == id) return;
|
||||
await MarianumConnectAuthInterceptor.idle();
|
||||
try {
|
||||
await PushRegistration().deactivate();
|
||||
} on Object catch (e) {
|
||||
log('Switch: push deactivation failed: $e');
|
||||
}
|
||||
await SessionManager().stashActive();
|
||||
try {
|
||||
await SessionManager().activate(id);
|
||||
} on Object {
|
||||
if (previous != null) await SessionManager().activate(previous.id);
|
||||
rethrow;
|
||||
} finally {
|
||||
await _clearPushStores();
|
||||
await AccountStorage.activate(SessionManager().activeAccount);
|
||||
}
|
||||
await _resetWidget();
|
||||
}
|
||||
|
||||
/// Parks the active account before the login screen signs in another one.
|
||||
static Future<void> beginAddAccount() async {
|
||||
await SessionManager().stashActive();
|
||||
_addReturnId = SessionManager().activeAccount?.id;
|
||||
}
|
||||
|
||||
static bool get isAddingAccount => _addReturnId != null;
|
||||
|
||||
/// Leaves "add account" without a new account: a half-finished sign-in is
|
||||
/// dropped and the previous account restored.
|
||||
static Future<void> cancelAddAccount() async {
|
||||
final returnId = _addReturnId;
|
||||
_addReturnId = null;
|
||||
if (returnId == null) return;
|
||||
if (SessionManager().activeAccount?.id != returnId &&
|
||||
SessionManager().isSignedIn) {
|
||||
await SessionManager().signOut();
|
||||
}
|
||||
await SessionManager().activate(returnId);
|
||||
}
|
||||
|
||||
/// Called once a login finished. After "add account" the previous account's
|
||||
/// push is unregistered with its own credentials before the new one takes
|
||||
/// over. Returns the id of the now active account.
|
||||
static Future<String> finishLogin() async {
|
||||
final returnId = _addReturnId;
|
||||
_addReturnId = null;
|
||||
final added = SessionManager().activeAccount!;
|
||||
if (returnId != null && returnId != added.id) {
|
||||
await SessionManager().stashActive();
|
||||
await SessionManager().activate(returnId);
|
||||
try {
|
||||
await PushRegistration().deactivate();
|
||||
} on Object catch (e) {
|
||||
log('Add account: push deactivation failed: $e');
|
||||
}
|
||||
await SessionManager().activate(added.id);
|
||||
await _clearPushStores();
|
||||
}
|
||||
await AccountStorage.activate(SessionManager().activeAccount);
|
||||
return added.id;
|
||||
}
|
||||
|
||||
/// Refreshes the stored real name of the active account (best-effort).
|
||||
static Future<void> refreshDisplayName() async {
|
||||
if (SessionManager().current case final session? when !session.isDemo) {
|
||||
try {
|
||||
final user = await AuthMe().user();
|
||||
final name = '${user.firstName} ${user.lastName}'.trim();
|
||||
if (name.isNotEmpty) await SessionManager().setDisplayName(name);
|
||||
} on Object catch (e) {
|
||||
log('Display name refresh failed: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Signs out an account that is not active: revokes its tokens with the
|
||||
/// stored credentials (best-effort) and deletes its local data. Its push
|
||||
/// was already unregistered when it stopped being active.
|
||||
static Future<void> removeInactive(String id) async {
|
||||
final entry = SessionManager().accounts.value.byId(id);
|
||||
if (entry == null || entry.id == SessionManager().activeAccount?.id) return;
|
||||
final (session, fields) = await SessionManager().readVault(id);
|
||||
if (session != null && !session.isDemo) {
|
||||
await _revoke(session, fields[MarianumConnectTokenStorage.bearerKey]);
|
||||
}
|
||||
await SessionManager().forget(id);
|
||||
await AccountStorage.delete(entry.namespace);
|
||||
}
|
||||
|
||||
static Future<void> _revoke(Session session, String? token) async {
|
||||
if (token != null && token.isNotEmpty) await AuthLogout.revoke(token);
|
||||
final nextcloud = session.nextcloud;
|
||||
if (nextcloud == null) return;
|
||||
try {
|
||||
if (nextcloud.hasAppPassword) {
|
||||
await DeleteAppPassword().run(
|
||||
authorizationHeader: nextcloud.basicAuthHeader,
|
||||
);
|
||||
}
|
||||
if (nextcloud.hasAppPasswordTalk) {
|
||||
await DeleteAppPassword().run(
|
||||
authorizationHeader: nextcloud.talkBasicAuthHeader,
|
||||
);
|
||||
}
|
||||
} on Object catch (e) {
|
||||
log('Remove account: app password revoke failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// The widget still shows the previous account's plan; the app republishes
|
||||
/// once it mounted, the refresh covers a backgrounded app.
|
||||
static Future<void> _resetWidget() async {
|
||||
try {
|
||||
await WidgetSync.clear();
|
||||
await WidgetSync.triggerUpdate();
|
||||
unawaited(WidgetBackgroundTask.requestImmediateRefresh());
|
||||
} on Object catch (e) {
|
||||
log('Widget reset failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Tray bookkeeping of the previous account's pushes.
|
||||
static Future<void> _clearPushStores() async {
|
||||
try {
|
||||
await NidStore().clear();
|
||||
await ChatThreadStore().clearAll();
|
||||
} on Object catch (e) {
|
||||
log('Push store cleanup failed: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user