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
22 changed files with 572 additions and 45 deletions

View file

@ -15,13 +15,23 @@ void main() {
/// The home tab. /// The home tab.
final home = find.byType(HomeScreen); final home = find.byType(HomeScreen);
Future<void> showApp(WidgetTester tester) => /// Shows the app, on an instance offering passwords unless [passwords] is
pumpApp(tester, testDependencies(authGateway: gateway)); /// false.
Future<void> showApp(WidgetTester tester, {bool passwords = true}) => pumpApp(
tester,
testDependencies(
authGateway: gateway,
passwordGateway: FakePasswordGateway(gateway)..offered = passwords,
),
);
/// Shows the app and opens settings, from the profile tab, down to the /// Shows the app and opens settings, from the profile tab, down to the
/// account. /// account.
Future<void> openSettings(WidgetTester tester) async { Future<void> openSettings(
await showApp(tester); WidgetTester tester, {
bool passwords = true,
}) async {
await showApp(tester, passwords: passwords);
await openTab(tester, 'Profile'); await openTab(tester, 'Profile');
await tester.tap(find.byTooltip('Settings')); await tester.tap(find.byTooltip('Settings'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@ -156,7 +166,8 @@ void main() {
}); });
testWidgets('from there, a GitHub account nobody here signs in with is said ' testWidgets('from there, a GitHub account nobody here signs in with is said '
'so, with where Learn adds GitHub', (tester) async { 'so, with an account to create in its place where passwords are '
'offered (ADR 0017)', (tester) async {
await openSettings(tester); await openSettings(tester);
await tester.tap(find.text('Sign out')); await tester.tap(find.text('Sign out'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@ -169,6 +180,31 @@ void main() {
await tester.tap(find.text('Sign in with GitHub')); await tester.tap(find.text('Sign in with GitHub'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(
find.text(
'No account here signs in with this GitHub account yet. Create an '
'account with your email address and a password instead.',
),
findsOneWidget,
);
expect(gateway.status, const SignedOut());
});
testWidgets('where no passwords are offered, with where Learn adds GitHub', (
tester,
) async {
await openSettings(tester, passwords: false);
await tester.tap(find.text('Sign out'));
await tester.pumpAndSettle();
await tester.tap(find.text('Sign out').last);
await tester.pumpAndSettle();
gateway.gitHubError = const GitHubSignInFailed(
GitHubSignInProblem.unknownAccount,
);
await tester.tap(find.text('Sign in with GitHub'));
await tester.pumpAndSettle();
expect( expect(
find.text( find.text(
'No account here signs in with this GitHub account yet. Start ' 'No account here signs in with this GitHub account yet. Start '
@ -176,7 +212,6 @@ void main() {
), ),
findsOneWidget, findsOneWidget,
); );
expect(gateway.status, const SignedOut());
}); });
testWidgets('deleting the account asks first; Cancel keeps it', ( testWidgets('deleting the account asks first; Cancel keeps it', (

View file

@ -28,6 +28,7 @@ void main() {
WidgetTester tester, { WidgetTester tester, {
bool usedBefore = false, bool usedBefore = false,
bool wasSignedOut = false, bool wasSignedOut = false,
bool passwords = true,
}) async { }) async {
tester.view.physicalSize = const Size(412, 900); tester.view.physicalSize = const Size(412, 900);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
@ -50,7 +51,7 @@ void main() {
auth: AuthRepository(gateway, addresses), auth: AuthRepository(gateway, addresses),
signedOut: signedOut, signedOut: signedOut,
passwords: PasswordRepository( passwords: PasswordRepository(
FakePasswordGateway(gateway), FakePasswordGateway(gateway)..offered = passwords,
FakeAccountCrypto(), FakeAccountCrypto(),
MemoryAccountKeyStore(), MemoryAccountKeyStore(),
gateway, gateway,
@ -113,7 +114,8 @@ void main() {
}); });
testWidgets('signed out, it asks how to start; a GitHub account nobody here ' testWidgets('signed out, it asks how to start; a GitHub account nobody here '
"signs in with is said so, with Todo's ways in", (tester) async { 'signs in with is said so, with an account to create in its place '
'where passwords are offered (ADR 0017)', (tester) async {
await start(tester, usedBefore: true, wasSignedOut: true); await start(tester, usedBefore: true, wasSignedOut: true);
expect(find.text('Sign in with GitHub'), findsOneWidget); expect(find.text('Sign in with GitHub'), findsOneWidget);
@ -123,6 +125,26 @@ void main() {
await tester.tap(find.text('Sign in with GitHub')); await tester.tap(find.text('Sign in with GitHub'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(
find.text(
'No account here signs in with this GitHub account yet. Create an '
'account with your email address and a password instead.',
),
findsOneWidget,
);
});
testWidgets("where no passwords are offered, with Todo's ways in", (
tester,
) async {
await start(tester, usedBefore: true, wasSignedOut: true, passwords: false);
gateway.gitHubError = const GitHubSignInFailed(
GitHubSignInProblem.unknownAccount,
);
await tester.tap(find.text('Sign in with GitHub'));
await tester.pumpAndSettle();
expect( expect(
find.text( find.text(
'No account here signs in with this GitHub account yet. Start ' 'No account here signs in with this GitHub account yet. Start '

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/session_grant.dart';
export 'src/auth/model/sign_in_ways.dart'; export 'src/auth/model/sign_in_ways.dart';
export 'src/auth/widget/account_tiles.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/session_gate.dart';
export 'src/auth/widget/start_screen.dart'; export 'src/auth/widget/start_screen.dart';
export 'src/base/base_client.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.", "startAnonymous": "Dein Fortschritt wird auf diesem Gerät gespeichert. Melde dich an, um ihn zu behalten.",
"startGitHubFailed": "Anmeldung mit GitHub fehlgeschlagen.", "startGitHubFailed": "Anmeldung mit GitHub fehlgeschlagen.",
"startNoGitHubAccount": "Noch kein Konto hier meldet sich mit diesem GitHub-Konto an.", "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", "startOtherInstance": "Andere Instanz verwenden",
"syncTitle": "Synchronisierung", "syncTitle": "Synchronisierung",
"syncNow": "Jetzt synchronisieren", "syncNow": "Jetzt synchronisieren",

View file

@ -167,6 +167,17 @@
"startAnonymous": "Progress is saved on this device. Sign in to keep it.", "startAnonymous": "Progress is saved on this device. Sign in to keep it.",
"startGitHubFailed": "Couldn't sign in with GitHub.", "startGitHubFailed": "Couldn't sign in with GitHub.",
"startNoGitHubAccount": "No account here signs in with this GitHub account yet.", "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", "startOtherInstance": "Use another instance",
"syncTitle": "Sync", "syncTitle": "Sync",
"syncNow": "Sync now", "syncNow": "Sync now",

View file

@ -403,6 +403,36 @@ abstract class HalkyonAppLocalizations {
/// **'No account here signs in with this GitHub account yet.'** /// **'No account here signs in with this GitHub account yet.'**
String get startNoGitHubAccount; 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. /// No description provided for @startOtherInstance.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -195,6 +195,26 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
String get startNoGitHubAccount => String get startNoGitHubAccount =>
'Noch kein Konto hier meldet sich mit diesem GitHub-Konto an.'; '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 @override
String get startOtherInstance => 'Andere Instanz verwenden'; String get startOtherInstance => 'Andere Instanz verwenden';

View file

@ -193,6 +193,25 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
String get startNoGitHubAccount => String get startNoGitHubAccount =>
'No account here signs in with this GitHub account yet.'; '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 @override
String get startOtherInstance => 'Use another instance'; 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:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/src/auth/data/auth_repository.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_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, /// 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 /// 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 /// the person confirms that this device's content stays behind. Someone
/// identified is never switched, as their own account would be left: they /// identified is never switched, as their own account would be left: they
/// are told the GitHub account is another's. /// 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( class GitHubSignInBloc(
final AuthRepository _auth, { final AuthRepository _auth, {
@ -26,7 +32,7 @@ class GitHubSignInBloc(
on<GitHubSignInEventRequested>(_onRequested); on<GitHubSignInEventRequested>(_onRequested);
on<GitHubSignInEventSwitchConfirmed>(_onSwitchConfirmed); on<GitHubSignInEventSwitchConfirmed>(_onSwitchConfirmed);
on<GitHubSignInEventSwitchDeclined>( on<GitHubSignInEventSwitchDeclined>(
(event, emit) => emit(const GitHubSignInStateReady()), (event, emit) => emit(GitHubSignInStateReady(linking: _linking)),
); );
// The sign-in in progress then ends as cancelled. // The sign-in in progress then ends as cancelled.
on<GitHubSignInEventCancelled>((event, emit) => _auth.cancelGitHub()); on<GitHubSignInEventCancelled>((event, emit) => _auth.cancelGitHub());
@ -43,14 +49,20 @@ class GitHubSignInBloc(
await super.close(); 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( Future<void> _onStarted(
GitHubSignInEventStarted event, GitHubSignInEventStarted event,
Emitter<GitHubSignInState> emit, Emitter<GitHubSignInState> emit,
) async { ) async {
try { try {
final offer = await _auth.gitHubOffer();
_linking = offer.linking;
emit( emit(
await _auth.canSignInWithGitHub() offer.signIn
? const GitHubSignInStateReady() ? GitHubSignInStateReady(linking: _linking)
: const GitHubSignInStateUnavailable(), : const GitHubSignInStateUnavailable(),
); );
} on Exception catch (error, st) { } on Exception catch (error, st) {
@ -70,7 +82,7 @@ class GitHubSignInBloc(
case GitHubLinkOutcome.linked: case GitHubLinkOutcome.linked:
emit(const GitHubSignInStateLinked()); emit(const GitHubSignInStateLinked());
case GitHubLinkOutcome.cancelled: case GitHubLinkOutcome.cancelled:
emit(const GitHubSignInStateReady()); emit(GitHubSignInStateReady(linking: _linking));
case GitHubLinkOutcome.takenByAnotherPerson when _auth.isIdentified: case GitHubLinkOutcome.takenByAnotherPerson when _auth.isIdentified:
emit(const GitHubSignInStateTaken()); emit(const GitHubSignInStateTaken());
case GitHubLinkOutcome.takenByAnotherPerson: case GitHubLinkOutcome.takenByAnotherPerson:
@ -81,8 +93,7 @@ class GitHubSignInBloc(
} }
} }
} on Exception catch (error, st) { } on Exception catch (error, st) {
addError(error, st); _failed(emit, error, st);
emit(const GitHubSignInStateReady(failed: true));
} }
} }
@ -94,16 +105,34 @@ class GitHubSignInBloc(
try { try {
await _switch(emit); await _switch(emit);
} on Exception catch (error, st) { } on Exception catch (error, st) {
addError(error, st); _failed(emit, error, st);
emit(const GitHubSignInStateReady(failed: true));
} }
} }
/// 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 // After a switch, the previous person's whole subtree goes away, this bloc
// with it, so nothing is emitted then. // with it, so nothing is emitted then.
Future<void> _switch(Emitter<GitHubSignInState> emit) async { Future<void> _switch(Emitter<GitHubSignInState> emit) async {
if (!await _auth.switchToGitHub()) { 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. /// Ready to sign in with GitHub.
final class const GitHubSignInStateReady({ 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. /// Whether the last attempt failed.
final bool failed = false, final bool failed = false,
/// Why it failed, where the instance said; null otherwise.
final GitHubSignInProblem? problem,
}) extends GitHubSignInState { }) extends GitHubSignInState {
@override @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. /// 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; _offerPassword = false;
emit( emit(
SessionStateSignedOut( SessionStateSignedOut(
problem: e.problem == GitHubSignInProblem.unknownAccount problem: switch (e.problem) {
? SignedOutProblem.noGitHubAccount GitHubSignInProblem.unknownAccount =>
: SignedOutProblem.failed, SignedOutProblem.noGitHubAccount,
GitHubSignInProblem.usePassword => SignedOutProblem.usePassword,
GitHubSignInProblem.ended => SignedOutProblem.gitHubEnded,
GitHubSignInProblem.deprecated ||
GitHubSignInProblem.refused => SignedOutProblem.failed,
},
), ),
); );
} on Object catch (error, st) { } on Object catch (error, st) {
@ -246,9 +251,16 @@ final class const SessionStateSignedOut({
/// Why a way to start failed. /// Why a way to start failed.
enum SignedOutProblem() { enum SignedOutProblem() {
/// No account has the GitHub account yet: start without one, then add /// No account has the GitHub account yet: start without one, then add
/// GitHub. /// GitHub, or, where passwords are offered, create an account.
noGitHubAccount, 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. /// Anything else, as no connection.
failed, failed,
} }

View file

@ -1,5 +1,6 @@
import 'package:halkyon_app/src/auth/model/auth_status.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_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/session_grant.dart';
import 'package:halkyon_app/src/auth/model/sign_in_ways.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. /// and so on all their devices, and forgets the session here.
Future<void> deleteAccount(); Future<void> deleteAccount();
/// The client ID of this instance's GitHub OAuth app; null when GitHub /// What this instance offers of GitHub: signing in, and adding it to
/// sign-in is off on this instance. /// people as a way in.
Future<String?> gitHubClientId(); Future<GitHubOffer> gitHubOffer();
/// Authorizes with GitHub in the browser and links the GitHub account to the /// Authorizes with GitHub in the browser and links the GitHub account to the
/// signed-in person: the lossless upgrade from an anonymous account. /// 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/data/sign_in_addresses.dart';
import 'package:halkyon_app/src/auth/model/auth_status.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_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'; 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. /// 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. /// Whether this instance offers signing in with GitHub.
Future<bool> canSignInWithGitHub() async => 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 /// Links a GitHub account to the signed-in person; the person, and all
/// their data, stay the same. /// their data, stay the same.

View file

@ -109,7 +109,18 @@ class ServerpodAuthGateway(
} }
@override @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; (await _base.instance.basics()).gitHubClientId;
@override @override
@ -197,7 +208,7 @@ class ServerpodAuthGateway(
/// cancelled it. /// cancelled it.
Future<GitHubSignInResult?> _authorizeWithGitHub() async { Future<GitHubSignInResult?> _authorizeWithGitHub() async {
_gitHubCancelled = false; _gitHubCancelled = false;
final clientId = await gitHubClientId(); final clientId = await _gitHubClientId();
if (clientId == null) { if (clientId == null) {
throw StateError('This instance offers no GitHub sign-in.'); 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]. /// The instance's refusal of a GitHub sign-in as [GitHubSignInFailed].
static GitHubSignInFailed _gitHubFailure(GitHubSignInRefusedException e) => static GitHubSignInFailed _gitHubFailure(GitHubSignInRefusedException e) =>
GitHubSignInFailed( GitHubSignInFailed(gitHubSignInProblemOf(e.reason));
e.reason == GitHubSignInRefusal.unknownAccount
? GitHubSignInProblem.unknownAccount
: GitHubSignInProblem.refused,
);
/// Deletes the anonymous account this device leaves behind when switching: /// Deletes the anonymous account this device leaves behind when switching:
/// nobody could ever sign into it again. It signs in as that account from /// 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; _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. /// Why signing in with GitHub didn't go through, beyond a cancel.
enum GitHubSignInProblem() { enum GitHubSignInProblem() {
/// No person has this GitHub account yet; only adding it to one makes it /// No person has this GitHub account yet; only adding it to one makes it
/// a way in. /// a way in.
unknownAccount, 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 /// The instance refused it otherwise: it offers no GitHub sign-in, or the
/// person already has a different GitHub account. /// person already has a different GitHub account.
refused, refused,
@ -15,3 +28,17 @@ final class const GitHubSignInFailed(final GitHubSignInProblem problem)
@override @override
String toString() => 'GitHubSignInFailed(${problem.name})'; 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), const SizedBox(height: 16),
Text( Text(
switch (problem) { 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 => SignedOutProblem.noGitHubAccount =>
'${l10n.startNoGitHubAccount} ' '${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, SignedOutProblem.failed => l10n.startGitHubFailed,
}, },
textAlign: TextAlign.center, 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/data/auth_gateway.dart';
import 'package:halkyon_app/src/auth/model/auth_status.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_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/session_grant.dart';
import 'package:halkyon_app/src/auth/model/sign_in_ways.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. /// Whether the instance offers GitHub sign-in.
bool offersGitHub = true; 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. /// How the next GitHub links end.
GitHubLinkOutcome gitHubLinkOutcome = GitHubLinkOutcome.linked; GitHubLinkOutcome gitHubLinkOutcome = GitHubLinkOutcome.linked;
/// Thrown by the next GitHub sign-ins while set. /// Thrown by the next GitHub sign-ins while set.
Exception? gitHubError; 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. /// How often this device switched to a GitHub person.
int gitHubSwitches = 0; int gitHubSwitches = 0;
@ -93,8 +102,8 @@ class FakeAuthGateway({var AuthStatus _status = const SignedOut()})
void revokeSession() => _set(const SignedOut()); void revokeSession() => _set(const SignedOut());
@override @override
Future<String?> gitHubClientId() async => Future<GitHubOffer> gitHubOffer() async =>
offersGitHub ? 'github-client-id' : null; GitHubOffer(signIn: offersGitHub, linking: offersGitHub && linksGitHub);
@override @override
Future<GitHubLinkOutcome> linkGitHub() async { Future<GitHubLinkOutcome> linkGitHub() async {
@ -120,6 +129,9 @@ class FakeAuthGateway({var AuthStatus _status = const SignedOut()})
if (!await _authorize()) { if (!await _authorize()) {
return false; return false;
} }
if (gitHubSwitchError case final error?) {
throw error;
}
gitHubSwitches++; gitHubSwitches++;
gitHubPeople.add('github-person'); gitHubPeople.add('github-person');
_set(const SignedIn(authUserId: 'github-person', isIdentified: true)); _set(const SignedIn(authUserId: 'github-person', isIdentified: true));

View file

@ -38,6 +38,21 @@ void main() {
await expectLater(bloc.stream, emits(const GitHubSignInStateUnavailable())); 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 { test('links the GitHub account to the person', () async {
final bloc = await ready(); 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 { test('says so when signing in fails', () async {
final bloc = await ready(); final bloc = await ready();
gateway.gitHubError = Exception('No connection'); gateway.gitHubError = Exception('No connection');

View file

@ -154,6 +154,37 @@ void main() {
expect(gateway.gitHubSwitches, 0); 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 { test('a session the server ended asks how to start too', () async {
const stored = SignedIn(authUserId: 'github-1', isIdentified: true); const stored = SignedIn(authUserId: 'github-1', isIdentified: true);
start(status: stored); 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 /// An instance's halkyon_base as the network shows it: anonymous sign-ins
/// get `anonymous-token`, its basics offer GitHub and passwords, and a /// get `anonymous-token`, its basics offer GitHub (and passwords while
/// sign-up makes Ben, signed in with `ben-token`. /// [passwords]), and a sign-up makes Ben, signed in with `ben-token`.
final class _Instance() { final class _Instance() {
/// Whether its basics offer password sign-in.
bool passwords = false;
static final anonymous = UuidValue.withValidation( static final anonymous = UuidValue.withValidation(
'0191f0c4-1111-7000-8000-000000000001', '0191f0c4-1111-7000-8000-000000000001',
); );
@ -50,6 +53,7 @@ final class _Instance() {
emailSignIn: true, emailSignIn: true,
sourceCodeUrl: 'https://code.example.org/learn', sourceCodeUrl: 'https://code.example.org/learn',
minimumAppBuild: 1, minimumAppBuild: 1,
passwordSignIn: passwords,
).toJson(), ).toJson(),
), ),
200, 200,
@ -116,8 +120,44 @@ void main() {
}, },
); );
test('tells what the instance offers from its basics', () async { test('tells what the instance offers from its basics: GitHub sign-in, '
expect(await gateway.gitHubClientId(), 'github-client'); '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 ' test('a session granted elsewhere (a sign-up with a password, without '

Some files were not shown because too many files have changed in this diff Show more