halkyon/packages/halkyon_app/lib/src/auth/bloc/session_bloc.dart

270 lines
9 KiB
Dart
Raw Normal View History

import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/src/auth/data/auth_repository.dart';
import 'package:halkyon_app/src/auth/model/auth_status.dart';
import 'package:halkyon_app/src/auth/model/github_sign_in.dart';
/// Keeps a person signed in: follows who is signed in, and signs out on
/// request. Nobody signed in (at a first start, after signing out or
/// deleting the account, or once the server ended the session), it asks
/// how to start: every account has a way in, and nothing is made until the
/// person chooses (ADR 0020, D4). An anonymous account's session left from
/// before they went ends as the app starts, the account deleted (D25).
class SessionBloc(final AuthRepository _auth)
extends Bloc<SessionEvent, SessionState> {
this : super(const SessionStateLoading()) {
on<SessionEventStarted>(_onStarted);
on<SessionEventGitHubRequested>(_onGitHubRequested);
on<SessionEventPasswordOfferDone>(
(event, emit) => emit(_stateOf(_auth.status)),
);
on<SessionEventSignedOut>(
(event, emit) => emit(SessionStateSignedOut(problem: event.problem)),
);
on<SessionEventSignOutRequested>(_onSignOutRequested);
on<SessionEventDeleteRequested>(_onDeleteRequested);
}
Future<void> _onStarted(
SessionEventStarted event,
Emitter<SessionState> emit,
) async {
if (_auth.isAnonymous) {
try {
await _auth.endAnonymousSession();
} on Object catch (error, st) {
// Only the device's own storage fails it: the session stays, but
// nothing works in it any more ([_stateOf]).
addError(error, st);
}
}
emit(_stateOf(_auth.status));
await emit.forEach<AuthStatus>(_auth.statusChanges, onData: _stateOf);
}
/// Signing in with GitHub: the GitHub account's person, if there is one.
Future<void> _onGitHubRequested(
SessionEventGitHubRequested event,
Emitter<SessionState> emit,
) async {
emit(const SessionStateSignedOut(busy: true));
// The person signed in with GitHub is offered a password first (ADR
// 0017, D2: "at their next sign-in"), while that sign-in is recent
// enough for the instance to take one (the fourth reading).
_offerPassword = true;
try {
if (!await _auth.switchToGitHub()) {
_offerPassword = false;
emit(const SessionStateSignedOut());
}
// Signed in: the status change shows the offer, then the person's
// app.
} on GitHubSignInFailed catch (e) {
_offerPassword = false;
emit(
SessionStateSignedOut(
problem: switch (e.problem) {
GitHubSignInProblem.unknownAccount =>
SignedOutProblem.noGitHubAccount,
GitHubSignInProblem.usePassword => SignedOutProblem.usePassword,
GitHubSignInProblem.ended => SignedOutProblem.gitHubEnded,
GitHubSignInProblem.deprecated ||
GitHubSignInProblem.refused => SignedOutProblem.failed,
},
),
);
} on Object catch (error, st) {
_offerPassword = false;
addError(error, st);
emit(const SessionStateSignedOut(problem: SignedOutProblem.failed));
}
}
/// Whether the next person signed in comes from a GitHub sign-in, and is
/// offered a password before their app ([SessionStateGitHubSignedIn]).
bool _offerPassword = false;
Future<void> _onSignOutRequested(
SessionEventSignOutRequested event,
Emitter<SessionState> emit,
) => _onAccountAction(
SessionAction.signOut,
// The instance out of reach signs out on this device all the same
// (Serverpod's `signOutDevice`), as signing out of a borrowed device
// must work offline; only the device's own storage fails it.
_auth.signOut,
SessionProblem.signOutFailed,
emit,
);
Future<void> _onDeleteRequested(
SessionEventDeleteRequested event,
Emitter<SessionState> emit,
) => _onAccountAction(
SessionAction.delete,
_auth.deleteAccount,
SessionProblem.deleteFailed,
emit,
);
/// Runs [action] for the signed-in person's account, their app in place
/// meanwhile, [busy] saying what is under way: never through the launch
/// screen ([SessionStateLoading]), which would close the app and, should
/// [action] fail, open it again with no word of why. Done, the status
/// change to signed out asks how to start; failed, the person stays,
/// told so by [problem].
Future<void> _onAccountAction(
SessionAction busy,
Future<void> Function() action,
SessionProblem problem,
Emitter<SessionState> emit,
) async {
if (_auth.status case final SignedIn person) {
emit(SessionStateActive(person, busy: busy));
}
try {
await action();
} on Object catch (error, st) {
addError(error, st);
emit(switch (_auth.status) {
final SignedIn person => SessionStateActive(person, problem: problem),
SignedOut() => const SessionStateSignedOut(),
});
}
}
SessionState _stateOf(AuthStatus status) => switch (status) {
// Nothing works in an anonymous account any more (D25): only one that
// began before the app started could be signed in, and it has ended.
SignedIn(isIdentified: false) => const SessionStateSignedOut(),
SignedIn() when _offerPassword => _offered(status),
SignedIn() => SessionStateActive(status),
SignedOut() => const SessionStateSignedOut(),
};
SessionState _offered(SignedIn person) {
_offerPassword = false;
return SessionStateGitHubSignedIn(person);
}
}
/// What happens to the session.
sealed class const SessionEvent() with Equatable {
@override
List<Object?> get props => const [];
}
/// The app starts: follow who is signed in, and end an anonymous account's
/// session left from before (ADR 0020, D25).
final class const SessionEventStarted() extends SessionEvent;
/// Sign in with GitHub, once signed out.
final class const SessionEventGitHubRequested() extends SessionEvent;
/// Signed out, [problem] if any saying why the last way to start failed.
final class const SessionEventSignedOut({final SignedOutProblem? problem})
extends SessionEvent {
@override
List<Object?> get props => [problem];
}
/// The password offered after a GitHub sign-in was set up, put off, or not
/// needed: on to the person's app.
final class const SessionEventPasswordOfferDone() extends SessionEvent;
/// The person confirmed that they want to sign out.
final class const SessionEventSignOutRequested() extends SessionEvent;
/// Delete the signed-in person with everything they have; the person has
/// confirmed it.
final class const SessionEventDeleteRequested() extends SessionEvent;
/// Whether the app has a signed-in person to work for.
sealed class const SessionState() with Equatable;
/// Finding out who is signed in.
final class const SessionStateLoading() extends SessionState {
@override
List<Object?> get props => const [];
}
/// A person is signed in.
final class const SessionStateActive(
/// Who is signed in.
final SignedIn person, {
/// What is under way for the account (signing out, deleting it), while
/// the person's app stays; null for nothing.
final SessionAction? busy,
/// Why the last of those failed, the person still signed in; null for
/// none.
final SessionProblem? problem,
}) extends SessionState {
@override
List<Object?> get props => [person, busy, problem];
}
/// What the person asked for their account, under way while their app
/// stays.
enum SessionAction() {
/// Signing out.
signOut,
/// Deleting the account.
delete,
}
/// Why signing out or deleting the account failed, the person still signed
/// in.
enum SessionProblem() {
/// Signing out failed on this device: the instance not reached signs out
/// here all the same, so only the device's own storage fails it.
signOutFailed,
/// Deleting the account failed, the instance not reached or refusing:
/// nothing deleted.
deleteFailed,
}
/// [person] signed in with GitHub just now: before their app, a password
/// is offered where the instance offers one and they have none (ADR 0017,
/// D2), while the sign-in is recent enough to set it.
final class const SessionStateGitHubSignedIn(
/// Who signed in.
final SignedIn person,
) extends SessionState {
@override
List<Object?> get props => [person];
}
/// Nobody is signed in: the app asks how to start.
final class const SessionStateSignedOut({
/// Whether a way to start is under way.
final bool busy = false,
/// Why the last way to start failed; null for none.
final SignedOutProblem? problem,
}) extends SessionState {
@override
List<Object?> get props => [busy, problem];
}
/// Why a way to start failed.
enum SignedOutProblem() {
/// No account has the GitHub account: GitHub makes none (ADR 0020, D25),
/// so the person creates one where the instance offers a way.
noGitHubAccount,
/// The GitHub account's person signs in with their address and password
/// now (ADR 0017).
usePassword,
/// GitHub signs nobody in here any more.
gitHubEnded,
/// Anything else, as no connection.
failed,
}