fix(app): GitHub's refusals say why, and no hint adds GitHub where passwords are offered

Where an instance offers password sign-in, GitHub is added to nobody (ADR
0017, D2), yet the start screen still said "then add GitHub" after a GitHub
account nobody has, and every refusal but an unknown account read
"Couldn't sign in with GitHub." (the end-to-end test of 21 Oct).

- The gateway tells from one read of the basics whether GitHub signs in and
  whether it is added to people (`gitHubOffer`, in place of
  `gitHubClientId`); the GitHub bloc keeps it (`Ready.linking`) for the
  profile, with why an attempt failed (`Ready.problem`).
- The instance's `usePassword`, `deprecated` and `ended` become problems of
  their own (`gitHubSignInProblemOf`), worded by `gitHubProblemText` and on
  the start screen; an anonymous person refused a link is told the GitHub
  account is nobody's and offered the password instead.
- Where passwords are offered, the start screen offers an account to create
  in place of the app's hint on adding GitHub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-21 19:19:28 +02:00
commit 19aabb0266
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
20 changed files with 572 additions and 45 deletions

View file

@ -45,6 +45,7 @@ export 'src/auth/model/github_sign_in.dart';
export 'src/auth/model/session_grant.dart';
export 'src/auth/model/sign_in_ways.dart';
export 'src/auth/widget/account_tiles.dart';
export 'src/auth/widget/github_texts.dart';
export 'src/auth/widget/session_gate.dart';
export 'src/auth/widget/start_screen.dart';
export 'src/base/base_client.dart';

View file

@ -50,6 +50,11 @@
"startAnonymous": "Dein Fortschritt wird auf diesem Gerät gespeichert. Melde dich an, um ihn zu behalten.",
"startGitHubFailed": "Anmeldung mit GitHub fehlgeschlagen.",
"startNoGitHubAccount": "Noch kein Konto hier meldet sich mit diesem GitHub-Konto an.",
"gitHubCreateAccountInstead": "Erstelle stattdessen ein Konto mit deiner E-Mail-Adresse und einem Passwort.",
"gitHubNoAccountSetUpPassword": "Noch kein Konto hier meldet sich mit diesem GitHub-Konto an. Richte stattdessen ein Passwort ein, um deinen Fortschritt zu behalten.",
"gitHubUsePassword": "Dieses Konto meldet sich jetzt mit seiner E-Mail-Adresse und seinem Passwort an, nicht mehr mit GitHub.",
"gitHubDeprecated": "GitHub lässt sich nicht mehr als Anmeldeweg hinzufügen: Deine E-Mail-Adresse und dein Passwort sind es.",
"gitHubEnded": "Die Anmeldung mit GitHub ist auf dieser Instanz beendet.",
"startOtherInstance": "Andere Instanz verwenden",
"syncTitle": "Synchronisierung",
"syncNow": "Jetzt synchronisieren",

View file

@ -167,6 +167,17 @@
"startAnonymous": "Progress is saved on this device. Sign in to keep it.",
"startGitHubFailed": "Couldn't sign in with GitHub.",
"startNoGitHubAccount": "No account here signs in with this GitHub account yet.",
"gitHubCreateAccountInstead": "Create an account with your email address and a password instead.",
"@gitHubCreateAccountInstead": {
"description": "Said after a GitHub account nobody on the instance signs in with, where people sign in with a password and GitHub is added to nobody (ADR 0017)."
},
"gitHubNoAccountSetUpPassword": "No account here signs in with this GitHub account yet. To keep your progress, set up a password instead.",
"@gitHubNoAccountSetUpPassword": {
"description": "An anonymous person signed in with a GitHub account nobody here has, where GitHub is added to nobody: the password set up below keeps their account."
},
"gitHubUsePassword": "This account signs in with its email address and password now, not with GitHub.",
"gitHubDeprecated": "GitHub can't be added as a way to sign in any more: your email address and password are.",
"gitHubEnded": "Sign-in with GitHub has ended on this instance.",
"startOtherInstance": "Use another instance",
"syncTitle": "Sync",
"syncNow": "Sync now",

View file

@ -403,6 +403,36 @@ abstract class HalkyonAppLocalizations {
/// **'No account here signs in with this GitHub account yet.'**
String get startNoGitHubAccount;
/// Said after a GitHub account nobody on the instance signs in with, where people sign in with a password and GitHub is added to nobody (ADR 0017).
///
/// In en, this message translates to:
/// **'Create an account with your email address and a password instead.'**
String get gitHubCreateAccountInstead;
/// An anonymous person signed in with a GitHub account nobody here has, where GitHub is added to nobody: the password set up below keeps their account.
///
/// In en, this message translates to:
/// **'No account here signs in with this GitHub account yet. To keep your progress, set up a password instead.'**
String get gitHubNoAccountSetUpPassword;
/// No description provided for @gitHubUsePassword.
///
/// In en, this message translates to:
/// **'This account signs in with its email address and password now, not with GitHub.'**
String get gitHubUsePassword;
/// No description provided for @gitHubDeprecated.
///
/// In en, this message translates to:
/// **'GitHub can\'t be added as a way to sign in any more: your email address and password are.'**
String get gitHubDeprecated;
/// No description provided for @gitHubEnded.
///
/// In en, this message translates to:
/// **'Sign-in with GitHub has ended on this instance.'**
String get gitHubEnded;
/// No description provided for @startOtherInstance.
///
/// In en, this message translates to:

View file

@ -195,6 +195,26 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
String get startNoGitHubAccount =>
'Noch kein Konto hier meldet sich mit diesem GitHub-Konto an.';
@override
String get gitHubCreateAccountInstead =>
'Erstelle stattdessen ein Konto mit deiner E-Mail-Adresse und einem Passwort.';
@override
String get gitHubNoAccountSetUpPassword =>
'Noch kein Konto hier meldet sich mit diesem GitHub-Konto an. Richte stattdessen ein Passwort ein, um deinen Fortschritt zu behalten.';
@override
String get gitHubUsePassword =>
'Dieses Konto meldet sich jetzt mit seiner E-Mail-Adresse und seinem Passwort an, nicht mehr mit GitHub.';
@override
String get gitHubDeprecated =>
'GitHub lässt sich nicht mehr als Anmeldeweg hinzufügen: Deine E-Mail-Adresse und dein Passwort sind es.';
@override
String get gitHubEnded =>
'Die Anmeldung mit GitHub ist auf dieser Instanz beendet.';
@override
String get startOtherInstance => 'Andere Instanz verwenden';

View file

@ -193,6 +193,25 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
String get startNoGitHubAccount =>
'No account here signs in with this GitHub account yet.';
@override
String get gitHubCreateAccountInstead =>
'Create an account with your email address and a password instead.';
@override
String get gitHubNoAccountSetUpPassword =>
'No account here signs in with this GitHub account yet. To keep your progress, set up a password instead.';
@override
String get gitHubUsePassword =>
'This account signs in with its email address and password now, not with GitHub.';
@override
String get gitHubDeprecated =>
'GitHub can\'t be added as a way to sign in any more: your email address and password are.';
@override
String get gitHubEnded => 'Sign-in with GitHub has ended on this instance.';
@override
String get startOtherInstance => 'Use another instance';

View file

@ -2,6 +2,7 @@ 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/github_link_outcome.dart';
import 'package:halkyon_app/src/auth/model/github_sign_in.dart';
/// Signs an anonymous person in with GitHub, keeping everything they have,
/// or adds GitHub as a way in for someone who signs in with an email
@ -13,6 +14,11 @@ import 'package:halkyon_app/src/auth/model/github_link_outcome.dart';
/// the person confirms that this device's content stays behind. Someone
/// identified is never switched, as their own account would be left: they
/// are told the GitHub account is another's.
///
/// Where people sign in with a password, GitHub is added to nobody (ADR
/// 0017, D2): [GitHubSignInStateReady.linking] says so, and an anonymous
/// person reaches only the GitHub account someone has already, by the same
/// switch.
class GitHubSignInBloc(
final AuthRepository _auth, {
@ -26,7 +32,7 @@ class GitHubSignInBloc(
on<GitHubSignInEventRequested>(_onRequested);
on<GitHubSignInEventSwitchConfirmed>(_onSwitchConfirmed);
on<GitHubSignInEventSwitchDeclined>(
(event, emit) => emit(const GitHubSignInStateReady()),
(event, emit) => emit(GitHubSignInStateReady(linking: _linking)),
);
// The sign-in in progress then ends as cancelled.
on<GitHubSignInEventCancelled>((event, emit) => _auth.cancelGitHub());
@ -43,14 +49,20 @@ class GitHubSignInBloc(
await super.close();
}
/// Whether this instance adds GitHub to people as a way in, as it said
/// when the dialog started.
bool _linking = true;
Future<void> _onStarted(
GitHubSignInEventStarted event,
Emitter<GitHubSignInState> emit,
) async {
try {
final offer = await _auth.gitHubOffer();
_linking = offer.linking;
emit(
await _auth.canSignInWithGitHub()
? const GitHubSignInStateReady()
offer.signIn
? GitHubSignInStateReady(linking: _linking)
: const GitHubSignInStateUnavailable(),
);
} on Exception catch (error, st) {
@ -70,7 +82,7 @@ class GitHubSignInBloc(
case GitHubLinkOutcome.linked:
emit(const GitHubSignInStateLinked());
case GitHubLinkOutcome.cancelled:
emit(const GitHubSignInStateReady());
emit(GitHubSignInStateReady(linking: _linking));
case GitHubLinkOutcome.takenByAnotherPerson when _auth.isIdentified:
emit(const GitHubSignInStateTaken());
case GitHubLinkOutcome.takenByAnotherPerson:
@ -81,8 +93,7 @@ class GitHubSignInBloc(
}
}
} on Exception catch (error, st) {
addError(error, st);
emit(const GitHubSignInStateReady(failed: true));
_failed(emit, error, st);
}
}
@ -94,16 +105,34 @@ class GitHubSignInBloc(
try {
await _switch(emit);
} on Exception catch (error, st) {
addError(error, st);
emit(const GitHubSignInStateReady(failed: true));
_failed(emit, error, st);
}
}
/// Ready again after [error]: why, where the instance said so.
void _failed(
Emitter<GitHubSignInState> emit,
Exception error,
StackTrace st,
) {
addError(error, st);
emit(
GitHubSignInStateReady(
linking: _linking,
failed: true,
problem: switch (error) {
GitHubSignInFailed(:final problem) => problem,
_ => null,
},
),
);
}
// After a switch, the previous person's whole subtree goes away, this bloc
// with it, so nothing is emitted then.
Future<void> _switch(Emitter<GitHubSignInState> emit) async {
if (!await _auth.switchToGitHub()) {
emit(const GitHubSignInStateReady());
emit(GitHubSignInStateReady(linking: _linking));
}
}
}
@ -144,11 +173,19 @@ final class const GitHubSignInStateUnavailable() extends GitHubSignInState;
/// Ready to sign in with GitHub.
final class const GitHubSignInStateReady({
/// Whether GitHub is added to people as a way in here: not where they
/// sign in with a password (ADR 0017, D2), where someone identified is
/// offered no Add.
final bool linking = true,
/// Whether the last attempt failed.
final bool failed = false,
/// Why it failed, where the instance said; null otherwise.
final GitHubSignInProblem? problem,
}) extends GitHubSignInState {
@override
List<Object?> get props => [failed];
List<Object?> get props => [linking, failed, problem];
}
/// Signing in: GitHub's page is open, or the server is being asked.

View file

@ -102,9 +102,14 @@ class SessionBloc(final AuthRepository _auth, final SignedOutMark _mark)
_offerPassword = false;
emit(
SessionStateSignedOut(
problem: e.problem == GitHubSignInProblem.unknownAccount
? SignedOutProblem.noGitHubAccount
: SignedOutProblem.failed,
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) {
@ -246,9 +251,16 @@ final class const SessionStateSignedOut({
/// Why a way to start failed.
enum SignedOutProblem() {
/// No account has the GitHub account yet: start without one, then add
/// GitHub.
/// GitHub, or, where passwords are offered, create an account.
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,
}

View file

@ -1,5 +1,6 @@
import 'package:halkyon_app/src/auth/model/auth_status.dart';
import 'package:halkyon_app/src/auth/model/github_link_outcome.dart';
import 'package:halkyon_app/src/auth/model/github_sign_in.dart';
import 'package:halkyon_app/src/auth/model/session_grant.dart';
import 'package:halkyon_app/src/auth/model/sign_in_ways.dart';
@ -22,9 +23,9 @@ abstract interface class AuthGateway() {
/// and so on all their devices, and forgets the session here.
Future<void> deleteAccount();
/// The client ID of this instance's GitHub OAuth app; null when GitHub
/// sign-in is off on this instance.
Future<String?> gitHubClientId();
/// What this instance offers of GitHub: signing in, and adding it to
/// people as a way in.
Future<GitHubOffer> gitHubOffer();
/// Authorizes with GitHub in the browser and links the GitHub account to the
/// signed-in person: the lossless upgrade from an anonymous account.

View file

@ -4,6 +4,7 @@ import 'package:halkyon_app/src/auth/data/auth_gateway.dart';
import 'package:halkyon_app/src/auth/data/sign_in_addresses.dart';
import 'package:halkyon_app/src/auth/model/auth_status.dart';
import 'package:halkyon_app/src/auth/model/github_link_outcome.dart';
import 'package:halkyon_app/src/auth/model/github_sign_in.dart';
import 'package:halkyon_app/src/auth/model/sign_in_ways.dart';
/// Who is signed in, and signing in and out, as the features use it.
@ -61,7 +62,11 @@ class AuthRepository(
/// Whether this instance offers signing in with GitHub.
Future<bool> canSignInWithGitHub() async =>
await _gateway.gitHubClientId() != null;
(await _gateway.gitHubOffer()).signIn;
/// What this instance offers of GitHub: signing in, and adding it to
/// people as a way in (not where they sign in with a password).
Future<GitHubOffer> gitHubOffer() => _gateway.gitHubOffer();
/// Links a GitHub account to the signed-in person; the person, and all
/// their data, stay the same.

View file

@ -109,7 +109,18 @@ class ServerpodAuthGateway(
}
@override
Future<String?> gitHubClientId() async =>
Future<GitHubOffer> gitHubOffer() async {
final basics = await _base.instance.basics();
final signIn = basics.gitHubClientId != null;
return GitHubOffer(
signIn: signIn,
linking: signIn && !basics.passwordSignIn,
);
}
/// The client ID of this instance's GitHub OAuth app; null when GitHub
/// sign-in is off on this instance.
Future<String?> _gitHubClientId() async =>
(await _base.instance.basics()).gitHubClientId;
@override
@ -197,7 +208,7 @@ class ServerpodAuthGateway(
/// cancelled it.
Future<GitHubSignInResult?> _authorizeWithGitHub() async {
_gitHubCancelled = false;
final clientId = await gitHubClientId();
final clientId = await _gitHubClientId();
if (clientId == null) {
throw StateError('This instance offers no GitHub sign-in.');
}
@ -247,11 +258,7 @@ class ServerpodAuthGateway(
/// The instance's refusal of a GitHub sign-in as [GitHubSignInFailed].
static GitHubSignInFailed _gitHubFailure(GitHubSignInRefusedException e) =>
GitHubSignInFailed(
e.reason == GitHubSignInRefusal.unknownAccount
? GitHubSignInProblem.unknownAccount
: GitHubSignInProblem.refused,
);
GitHubSignInFailed(gitHubSignInProblemOf(e.reason));
/// Deletes the anonymous account this device leaves behind when switching:
/// nobody could ever sign into it again. It signs in as that account from
@ -301,3 +308,17 @@ final class _MemoryAuthStorage() implements ClientAuthSuccessStorage {
_session = data;
}
}
/// The instance's refusal [reason] of a GitHub sign-in as the app's problem,
/// which the screens put into words.
GitHubSignInProblem gitHubSignInProblemOf(GitHubSignInRefusal reason) =>
switch (reason) {
GitHubSignInRefusal.unknownAccount => GitHubSignInProblem.unknownAccount,
GitHubSignInRefusal.usePassword => GitHubSignInProblem.usePassword,
GitHubSignInRefusal.deprecated => GitHubSignInProblem.deprecated,
GitHubSignInRefusal.ended => GitHubSignInProblem.ended,
GitHubSignInRefusal.unavailable ||
GitHubSignInRefusal.takenByAnotherPerson ||
GitHubSignInRefusal.otherGitHubAccountLinked =>
GitHubSignInProblem.refused,
};

View file

@ -1,9 +1,22 @@
import 'package:equatable/equatable.dart';
/// Why signing in with GitHub didn't go through, beyond a cancel.
enum GitHubSignInProblem() {
/// No person has this GitHub account yet; only adding it to one makes it
/// a way in.
unknownAccount,
/// The account signs in with its address and password now, not with
/// GitHub (ADR 0017).
usePassword,
/// GitHub is added to nobody where people sign in with a password
/// (ADR 0017, D2).
deprecated,
/// GitHub signs nobody in here any more: its time ended.
ended,
/// The instance refused it otherwise: it offers no GitHub sign-in, or the
/// person already has a different GitHub account.
refused,
@ -15,3 +28,17 @@ final class const GitHubSignInFailed(final GitHubSignInProblem problem)
@override
String toString() => 'GitHubSignInFailed(${problem.name})';
}
/// What an instance offers of GitHub, from one look at its basics.
final class const GitHubOffer({
/// Whether people sign in with GitHub here.
required final bool signIn,
/// Whether GitHub is added to people as a way in: not where they sign in
/// with a password (ADR 0017, D2), where only those who have it already
/// sign in with it.
required final bool linking,
}) with Equatable {
@override
List<Object?> get props => [signIn, linking];
}

View file

@ -0,0 +1,24 @@
import 'package:halkyon_app/l10n/halkyon_app_localizations.dart';
import 'package:halkyon_app/src/auth/model/github_sign_in.dart';
/// What [problem] says to the person signing in with GitHub; null where the
/// screen's own "Couldn't sign in with GitHub." says it (no problem known,
/// or one the instance gave no reason of its own for).
///
/// For an [anonymous] person, [GitHubSignInProblem.deprecated] means the
/// GitHub account is nobody's here (the instance says first when it is
/// someone's), so the password that keeps their account is offered in its
/// place.
String? gitHubProblemText(
HalkyonAppLocalizations l10n,
GitHubSignInProblem? problem, {
required bool anonymous,
}) => switch (problem) {
GitHubSignInProblem.unknownAccount => l10n.startNoGitHubAccount,
GitHubSignInProblem.usePassword => l10n.gitHubUsePassword,
GitHubSignInProblem.deprecated when anonymous =>
l10n.gitHubNoAccountSetUpPassword,
GitHubSignInProblem.deprecated => l10n.gitHubDeprecated,
GitHubSignInProblem.ended => l10n.gitHubEnded,
GitHubSignInProblem.refused || null => null,
};

View file

@ -182,10 +182,16 @@ class _StartScreenState() extends State<StartScreen> {
const SizedBox(height: 16),
Text(
switch (problem) {
// Whole sentences: the app's names where.
// Whole sentences: where passwords are offered,
// GitHub is added to nobody (ADR 0017), so an
// account is created in its place; elsewhere the
// app's names where GitHub is added.
SignedOutProblem.noGitHubAccount =>
'${l10n.startNoGitHubAccount} '
'${AppFrameScope.of(context).thenAddGitHub(context)}',
'${password ? l10n.gitHubCreateAccountInstead : AppFrameScope.of(context).thenAddGitHub(context)}',
SignedOutProblem.usePassword =>
l10n.gitHubUsePassword,
SignedOutProblem.gitHubEnded => l10n.gitHubEnded,
SignedOutProblem.failed => l10n.startGitHubFailed,
},
textAlign: TextAlign.center,

View file

@ -3,6 +3,7 @@ import 'dart:async';
import 'package:halkyon_app/src/auth/data/auth_gateway.dart';
import 'package:halkyon_app/src/auth/model/auth_status.dart';
import 'package:halkyon_app/src/auth/model/github_link_outcome.dart';
import 'package:halkyon_app/src/auth/model/github_sign_in.dart';
import 'package:halkyon_app/src/auth/model/session_grant.dart';
import 'package:halkyon_app/src/auth/model/sign_in_ways.dart';
@ -34,12 +35,20 @@ class FakeAuthGateway({var AuthStatus _status = const SignedOut()})
/// Whether the instance offers GitHub sign-in.
bool offersGitHub = true;
/// Whether the instance adds GitHub to people as a way in: not where they
/// sign in with a password (ADR 0017).
bool linksGitHub = true;
/// How the next GitHub links end.
GitHubLinkOutcome gitHubLinkOutcome = GitHubLinkOutcome.linked;
/// Thrown by the next GitHub sign-ins while set.
Exception? gitHubError;
/// Thrown by the next switches to a GitHub person while set, once a link
/// found the GitHub account taken.
Exception? gitHubSwitchError;
/// How often this device switched to a GitHub person.
int gitHubSwitches = 0;
@ -93,8 +102,8 @@ class FakeAuthGateway({var AuthStatus _status = const SignedOut()})
void revokeSession() => _set(const SignedOut());
@override
Future<String?> gitHubClientId() async =>
offersGitHub ? 'github-client-id' : null;
Future<GitHubOffer> gitHubOffer() async =>
GitHubOffer(signIn: offersGitHub, linking: offersGitHub && linksGitHub);
@override
Future<GitHubLinkOutcome> linkGitHub() async {
@ -120,6 +129,9 @@ class FakeAuthGateway({var AuthStatus _status = const SignedOut()})
if (!await _authorize()) {
return false;
}
if (gitHubSwitchError case final error?) {
throw error;
}
gitHubSwitches++;
gitHubPeople.add('github-person');
_set(const SignedIn(authUserId: 'github-person', isIdentified: true));

View file

@ -38,6 +38,21 @@ void main() {
await expectLater(bloc.stream, emits(const GitHubSignInStateUnavailable()));
});
test('offers linking no GitHub account where the instance signs people in '
'with a password (ADR 0017)', () async {
gateway.linksGitHub = false;
final bloc = GitHubSignInBloc(
AuthRepository(gateway, MemorySignInAddresses()),
deviceHoldsContent: () async => holdsContent,
)..add(const GitHubSignInEventStarted());
addTearDown(bloc.close);
await expectLater(
bloc.stream,
emits(const GitHubSignInStateReady(linking: false)),
);
});
test('links the GitHub account to the person', () async {
final bloc = await ready();
@ -228,6 +243,60 @@ void main() {
});
});
test('says why the instance refused, keeping whether it links', () async {
gateway.linksGitHub = false;
final bloc = GitHubSignInBloc(
AuthRepository(gateway, MemorySignInAddresses()),
deviceHoldsContent: () async => holdsContent,
)..add(const GitHubSignInEventStarted());
addTearDown(bloc.close);
await expectLater(
bloc.stream,
emits(const GitHubSignInStateReady(linking: false)),
);
gateway.gitHubError = const GitHubSignInFailed(
GitHubSignInProblem.deprecated,
);
bloc.add(const GitHubSignInEventRequested());
await expectLater(
bloc.stream,
emitsInOrder([
const GitHubSignInStateBusy(),
const GitHubSignInStateReady(
linking: false,
failed: true,
problem: GitHubSignInProblem.deprecated,
),
]),
);
});
test('an anonymous person whose GitHub account now signs in with its '
'password is told so, and stays', () async {
gateway
..gitHubLinkOutcome = GitHubLinkOutcome.takenByAnotherPerson
..gitHubSwitchError = const GitHubSignInFailed(
GitHubSignInProblem.usePassword,
);
final bloc = await ready();
bloc.add(const GitHubSignInEventRequested());
await expectLater(
bloc.stream,
emitsInOrder([
const GitHubSignInStateBusy(),
const GitHubSignInStateReady(
failed: true,
problem: GitHubSignInProblem.usePassword,
),
]),
);
expect(gateway.status, anonymous);
});
test('says so when signing in fails', () async {
final bloc = await ready();
gateway.gitHubError = Exception('No connection');

View file

@ -154,6 +154,37 @@ void main() {
expect(gateway.gitHubSwitches, 0);
});
test('an account that signs in with its password now, and GitHub having '
'ended, are each said so, signing nobody in', () async {
start(signedOut: true);
bloc.add(const SessionEventStarted());
await expectLater(bloc.stream, emits(const SessionStateSignedOut()));
gateway.gitHubError = const GitHubSignInFailed(
GitHubSignInProblem.usePassword,
);
bloc.add(const SessionEventGitHubRequested());
await expectLater(
bloc.stream,
emitsInOrder([
const SessionStateSignedOut(busy: true),
const SessionStateSignedOut(problem: SignedOutProblem.usePassword),
]),
);
gateway.gitHubError = const GitHubSignInFailed(GitHubSignInProblem.ended);
bloc.add(const SessionEventGitHubRequested());
await expectLater(
bloc.stream,
emitsInOrder([
const SessionStateSignedOut(busy: true),
const SessionStateSignedOut(problem: SignedOutProblem.gitHubEnded),
]),
);
expect(gateway.signIns, 0);
expect(gateway.gitHubSwitches, 0);
});
test('a session the server ended asks how to start too', () async {
const stored = SignedIn(authUserId: 'github-1', isIdentified: true);
start(status: stored);

View file

@ -20,9 +20,12 @@ final class _MemoryStorage() implements ClientAuthSuccessStorage {
}
/// An instance's halkyon_base as the network shows it: anonymous sign-ins
/// get `anonymous-token`, its basics offer GitHub and passwords, and a
/// sign-up makes Ben, signed in with `ben-token`.
/// get `anonymous-token`, its basics offer GitHub (and passwords while
/// [passwords]), and a sign-up makes Ben, signed in with `ben-token`.
final class _Instance() {
/// Whether its basics offer password sign-in.
bool passwords = false;
static final anonymous = UuidValue.withValidation(
'0191f0c4-1111-7000-8000-000000000001',
);
@ -50,6 +53,7 @@ final class _Instance() {
emailSignIn: true,
sourceCodeUrl: 'https://code.example.org/learn',
minimumAppBuild: 1,
passwordSignIn: passwords,
).toJson(),
),
200,
@ -116,8 +120,44 @@ void main() {
},
);
test('tells what the instance offers from its basics', () async {
expect(await gateway.gitHubClientId(), 'github-client');
test('tells what the instance offers from its basics: GitHub sign-in, '
'and linking GitHub to people only where passwords are not', () async {
expect(
await gateway.gitHubOffer(),
const GitHubOffer(signIn: true, linking: true),
);
instance.passwords = true;
expect(
await gateway.gitHubOffer(),
const GitHubOffer(signIn: true, linking: false),
);
expect(
instance.calls.where(
(each) => each.call == 'halkyon_base.instance/basics',
),
hasLength(2),
reason: 'one read of the basics each',
);
});
test("puts the instance's refusals of GitHub as the app's problems", () {
expect(
{
for (final reason in GitHubSignInRefusal.values)
reason: gitHubSignInProblemOf(reason),
},
{
GitHubSignInRefusal.unavailable: GitHubSignInProblem.refused,
GitHubSignInRefusal.takenByAnotherPerson: GitHubSignInProblem.refused,
GitHubSignInRefusal.otherGitHubAccountLinked:
GitHubSignInProblem.refused,
GitHubSignInRefusal.unknownAccount: GitHubSignInProblem.unknownAccount,
GitHubSignInRefusal.usePassword: GitHubSignInProblem.usePassword,
GitHubSignInRefusal.deprecated: GitHubSignInProblem.deprecated,
GitHubSignInRefusal.ended: GitHubSignInProblem.ended,
},
);
});
test('a session granted elsewhere (a sign-up with a password, without '

View file

@ -0,0 +1,45 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/l10n/halkyon_app_localizations_en.dart';
void main() {
final en = HalkyonAppLocalizationsEn();
String? textOf(GitHubSignInProblem? problem, {bool anonymous = false}) =>
gitHubProblemText(en, problem, anonymous: anonymous);
test('each refusal the instance gives a reason for says it', () {
expect(
textOf(GitHubSignInProblem.unknownAccount),
'No account here signs in with this GitHub account yet.',
);
expect(
textOf(GitHubSignInProblem.usePassword),
'This account signs in with its email address and password now, not '
'with GitHub.',
);
expect(
textOf(GitHubSignInProblem.deprecated),
"GitHub can't be added as a way to sign in any more: your email "
'address and password are.',
);
expect(
textOf(GitHubSignInProblem.ended),
'Sign-in with GitHub has ended on this instance.',
);
});
test('for an anonymous person, linking refused means the GitHub account is '
"nobody's: a password keeps the account instead", () {
expect(
textOf(GitHubSignInProblem.deprecated, anonymous: true),
'No account here signs in with this GitHub account yet. To keep your '
'progress, set up a password instead.',
);
});
test("leaves the rest to the screen's own failure", () {
expect(textOf(GitHubSignInProblem.refused), isNull);
expect(textOf(null), isNull);
});
}

View file

@ -98,7 +98,8 @@ void main() {
});
testWidgets('from there, GitHub signs in its person; a GitHub account '
'nobody here has yet is said so', (tester) async {
'nobody here has yet is said so, with an account to create in its '
'place where passwords are offered (ADR 0017)', (tester) async {
await dependencies.signedOut.write(signedOut: true);
await start(tester);
@ -109,12 +110,16 @@ void main() {
await tester.pumpAndSettle();
expect(
find.text(
// The package's sentence, then the app's on where GitHub is added.
'No account here signs in with this GitHub account yet. Start '
'without an account, then add GitHub in Settings.',
'No account here signs in with this GitHub account yet. Create an '
'account with your email address and a password instead.',
),
findsOneWidget,
);
expect(
find.textContaining('add GitHub'),
findsNothing,
reason: 'GitHub is added to nobody where passwords are offered',
);
gateway.gitHubError = null;
await tester.tap(find.text('Sign in with GitHub'));
@ -140,6 +145,55 @@ void main() {
expect(find.text('App of github-person'), findsOneWidget);
});
testWidgets('where no passwords are offered, a GitHub account nobody here '
'has yet is said so with where the app adds GitHub', (tester) async {
dependencies.passwordGateway.offered = false;
await dependencies.signedOut.write(signedOut: true);
await start(tester);
gateway.gitHubError = const GitHubSignInFailed(
GitHubSignInProblem.unknownAccount,
);
await tester.tap(find.text('Sign in with GitHub'));
await tester.pumpAndSettle();
expect(
find.text(
// The package's sentence, then the app's on where GitHub is added.
'No account here signs in with this GitHub account yet. Start '
'without an account, then add GitHub in Settings.',
),
findsOneWidget,
);
});
testWidgets('an account that signs in with its password, and GitHub having '
'ended, are each said so', (tester) async {
await dependencies.signedOut.write(signedOut: true);
await start(tester);
gateway.gitHubError = const GitHubSignInFailed(
GitHubSignInProblem.usePassword,
);
await tester.tap(find.text('Sign in with GitHub'));
await tester.pumpAndSettle();
expect(
find.text(
'This account signs in with its email address and password now, '
'not with GitHub.',
),
findsOneWidget,
);
gateway.gitHubError = const GitHubSignInFailed(GitHubSignInProblem.ended);
await tester.tap(find.text('Sign in with GitHub'));
await tester.pumpAndSettle();
expect(
find.text('Sign-in with GitHub has ended on this instance.'),
findsOneWidget,
);
expect(find.text("Couldn't sign in with GitHub."), findsNothing);
});
testWidgets('offers only the ways the instance has', (tester) async {
gateway.offersGitHub = false;
dependencies.passwordGateway.offered = false;