feat(auth)!: codes by mail no longer sign in

ADR 0017, step 3 (D4: nobody signs in with email codes yet, so the
password replaces them outright). `emailCode.verifyCode`,
`emailSignIn.signIn` and the switch token go, with the register's
`verifySignInCode` and `completeEmailSwitch` and their operations
between services. `emailCode.sendCode` stays, now without a session
(signing up and getting back in have none), as what proves an address
for a password's setup, a sign-up, a setup never finished and the
resets; `accountRecovery.sendCode`, which did that for the resets,
goes. The `switchChallengeId` column stays: Serverpod's challenges
still link a completion token, and older servers read it during a
deploy.

The apps: halkyon_app's email sign-in (its bloc, sheet, gateway calls
and the start screen's Continue with email) goes; Learn's profile and
Todo's account sheet show `AccountSecuritySection` (a password set up
by someone anonymous or signing in with GitHub, whose end it names;
the authenticators; changing the password; new recovery codes and
phrase), which reads anew after every change the repository makes.

BREAKING CHANGE: Learn's `minimumAppBuild` is 22: builds up to 21 sign
in with the endpoints that went. Todo was never released and stays at
build 1.

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-20 19:31:49 +02:00
commit ae57cbe864
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
27 changed files with 429 additions and 3835 deletions

View file

@ -11,11 +11,10 @@
/// - **The account and About**: `AccountTiles` for an app's settings,
/// `showAppAbout` with the source of what the instance runs, and
/// `resolveAppLocale`, the language every Halkyon app picks.
/// - **Signing in**: anonymously, with GitHub or with a code by mail
/// (`AuthRepository` on `ServerpodAuthGateway`), and the blocs and sheet
/// that switch a device to another account; with a password and TOTP
/// (ADR 0017: `PasswordRepository`, its sheets and
/// `AccountSecuritySection`), the account's keys on the device.
/// - **Signing in**: anonymously, with GitHub (`AuthRepository` on
/// `ServerpodAuthGateway`), and with a password and TOTP (ADR 0017:
/// `PasswordRepository`, its sheets and `AccountSecuritySection`), the
/// account's keys on the device.
/// - **Sync**: `SyncService` on triggers, the changes still waiting, and
/// where it stands (`SyncStatusBloc`, `SyncStatusCard`, `AppMismatchBanner`,
/// `SyncTroubleBadge`).
@ -31,7 +30,6 @@ export 'src/app/app_locale.dart';
export 'src/app/app_scope.dart';
export 'src/app/instance_gate.dart';
export 'src/app/person_scope.dart';
export 'src/auth/bloc/email_sign_in_bloc.dart';
export 'src/auth/bloc/github_sign_in_bloc.dart';
export 'src/auth/bloc/session_bloc.dart';
export 'src/auth/bloc/sign_in_ways_cubit.dart';
@ -42,13 +40,11 @@ export 'src/auth/data/serverpod_auth_gateway.dart';
export 'src/auth/data/sign_in_addresses.dart';
export 'src/auth/data/signed_out_mark.dart';
export 'src/auth/model/auth_status.dart';
export 'src/auth/model/email_sign_in.dart';
export 'src/auth/model/github_link_outcome.dart';
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/email_sign_in_sheet.dart';
export 'src/auth/widget/session_gate.dart';
export 'src/auth/widget/start_screen.dart';
export 'src/base/base_client.dart';

View file

@ -42,36 +42,11 @@
"startSignedOut": "Abgemeldet",
"startOn": "Starte auf {instance}:",
"startWithGitHub": "Mit GitHub anmelden",
"startWithEmail": "Mit E-Mail fortfahren",
"startWithoutAccount": "Ohne Konto starten",
"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.",
"startOtherInstance": "Andere Instanz verwenden",
"emailSignInTitle": "Mit E-Mail fortfahren",
"emailSignInIntro": "Wir schicken dir einen Code zum Anmelden. Diese Instanz behält nur einen Fingerabdruck deiner Adresse, nie die Adresse selbst, und schreibt ihr nur, wenn du einen Code anforderst.",
"emailSignInAddress": "E-Mail-Adresse",
"emailSignInSend": "Code senden",
"emailSignInCodeSent": "Wir haben einen Code an {address} geschickt. Er gilt 15 Minuten.",
"emailSignInResent": "Ein neuer Code ist unterwegs.",
"emailSignInCode": "Code",
"emailSignInContinue": "Weiter",
"emailSignInResend": "Neuen Code senden",
"emailSignInOtherAddress": "Andere Adresse verwenden",
"emailSignInSwitchTitle": "Zum Konto von {address} wechseln?",
"emailSignInSwitchConfirm": "Wechseln",
"emailSignInSwitchStay": "Anonym bleiben",
"emailSignInTaken": "Mit {address} meldet sich schon ein anderes Konto an. Um zu ihm zu wechseln, melde dich zuerst ab.",
"emailSignInUnavailable": "Diese Instanz bietet keine Anmeldung mit E-Mail an.",
"emailSignInNotAnAddress": "Das ist keine E-Mail-Adresse.",
"emailSignInTooManyCodes": "Zu viele Codes in der letzten Stunde. Versuche es später noch einmal.",
"emailSignInWrongCode": "Dieser Code stimmt nicht.",
"emailSignInTooManyTries": "Zu viele falsche Codes. Fordere einen neuen an.",
"emailSignInCodeExpired": "Dieser Code gilt nicht mehr. Fordere einen neuen an.",
"emailSignInOtherAddressLinked": "Du meldest dich schon mit einer anderen Adresse an.",
"emailSignInMailFailed": "Die Instanz konnte die Mail nicht senden. Versuche es später noch einmal.",
"emailSignInUsePassword": "Dieses Konto meldet sich mit seinem Passwort und einer Authenticator-App an.",
"emailSignInFailed": "Die Instanz war nicht erreichbar. Versuche es noch einmal.",
"syncTitle": "Synchronisierung",
"syncNow": "Jetzt synchronisieren",
"syncStateSyncing": "Wird synchronisiert…",

View file

@ -142,57 +142,11 @@
}
},
"startWithGitHub": "Sign in with GitHub",
"startWithEmail": "Continue with email",
"startWithoutAccount": "Start without an account",
"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.",
"startOtherInstance": "Use another instance",
"emailSignInTitle": "Continue with email",
"emailSignInIntro": "We mail you a code to sign in. This instance keeps only a fingerprint of your address, never the address itself, and writes to it only when you ask for a code.",
"emailSignInAddress": "Email address",
"emailSignInSend": "Send code",
"emailSignInCodeSent": "We mailed a code to {address}. It's valid for 15 minutes.",
"@emailSignInCodeSent": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"emailSignInResent": "A new code is on its way.",
"emailSignInCode": "Code",
"emailSignInContinue": "Continue",
"emailSignInResend": "Send a new code",
"emailSignInOtherAddress": "Use another address",
"emailSignInSwitchTitle": "Switch to the account of {address}?",
"@emailSignInSwitchTitle": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"emailSignInSwitchConfirm": "Switch",
"emailSignInSwitchStay": "Stay anonymous",
"emailSignInTaken": "{address} already signs in to another account. To switch to it, sign out first.",
"@emailSignInTaken": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"emailSignInUnavailable": "This instance doesn't offer signing in with email.",
"emailSignInNotAnAddress": "This isn't an email address.",
"emailSignInTooManyCodes": "Too many codes in the last hour. Try again later.",
"emailSignInWrongCode": "That code isn't right.",
"emailSignInTooManyTries": "Too many wrong codes. Ask for a new one.",
"emailSignInCodeExpired": "This code is no longer valid. Ask for a new one.",
"emailSignInOtherAddressLinked": "You already sign in with another address.",
"emailSignInMailFailed": "The instance couldn't send the mail. Try again later.",
"emailSignInUsePassword": "This account signs in with its password and an authenticator app.",
"emailSignInFailed": "Couldn't reach the instance. Try again.",
"syncTitle": "Sync",
"syncNow": "Sync now",
"syncStateSyncing": "Syncing…",

View file

@ -355,12 +355,6 @@ abstract class HalkyonAppLocalizations {
/// **'Sign in with GitHub'**
String get startWithGitHub;
/// No description provided for @startWithEmail.
///
/// In en, this message translates to:
/// **'Continue with email'**
String get startWithEmail;
/// No description provided for @startWithoutAccount.
///
/// In en, this message translates to:
@ -391,150 +385,6 @@ abstract class HalkyonAppLocalizations {
/// **'Use another instance'**
String get startOtherInstance;
/// No description provided for @emailSignInTitle.
///
/// In en, this message translates to:
/// **'Continue with email'**
String get emailSignInTitle;
/// No description provided for @emailSignInIntro.
///
/// In en, this message translates to:
/// **'We mail you a code to sign in. This instance keeps only a fingerprint of your address, never the address itself, and writes to it only when you ask for a code.'**
String get emailSignInIntro;
/// No description provided for @emailSignInAddress.
///
/// In en, this message translates to:
/// **'Email address'**
String get emailSignInAddress;
/// No description provided for @emailSignInSend.
///
/// In en, this message translates to:
/// **'Send code'**
String get emailSignInSend;
/// No description provided for @emailSignInCodeSent.
///
/// In en, this message translates to:
/// **'We mailed a code to {address}. It\'s valid for 15 minutes.'**
String emailSignInCodeSent(String address);
/// No description provided for @emailSignInResent.
///
/// In en, this message translates to:
/// **'A new code is on its way.'**
String get emailSignInResent;
/// No description provided for @emailSignInCode.
///
/// In en, this message translates to:
/// **'Code'**
String get emailSignInCode;
/// No description provided for @emailSignInContinue.
///
/// In en, this message translates to:
/// **'Continue'**
String get emailSignInContinue;
/// No description provided for @emailSignInResend.
///
/// In en, this message translates to:
/// **'Send a new code'**
String get emailSignInResend;
/// No description provided for @emailSignInOtherAddress.
///
/// In en, this message translates to:
/// **'Use another address'**
String get emailSignInOtherAddress;
/// No description provided for @emailSignInSwitchTitle.
///
/// In en, this message translates to:
/// **'Switch to the account of {address}?'**
String emailSignInSwitchTitle(String address);
/// No description provided for @emailSignInSwitchConfirm.
///
/// In en, this message translates to:
/// **'Switch'**
String get emailSignInSwitchConfirm;
/// No description provided for @emailSignInSwitchStay.
///
/// In en, this message translates to:
/// **'Stay anonymous'**
String get emailSignInSwitchStay;
/// No description provided for @emailSignInTaken.
///
/// In en, this message translates to:
/// **'{address} already signs in to another account. To switch to it, sign out first.'**
String emailSignInTaken(String address);
/// No description provided for @emailSignInUnavailable.
///
/// In en, this message translates to:
/// **'This instance doesn\'t offer signing in with email.'**
String get emailSignInUnavailable;
/// No description provided for @emailSignInNotAnAddress.
///
/// In en, this message translates to:
/// **'This isn\'t an email address.'**
String get emailSignInNotAnAddress;
/// No description provided for @emailSignInTooManyCodes.
///
/// In en, this message translates to:
/// **'Too many codes in the last hour. Try again later.'**
String get emailSignInTooManyCodes;
/// No description provided for @emailSignInWrongCode.
///
/// In en, this message translates to:
/// **'That code isn\'t right.'**
String get emailSignInWrongCode;
/// No description provided for @emailSignInTooManyTries.
///
/// In en, this message translates to:
/// **'Too many wrong codes. Ask for a new one.'**
String get emailSignInTooManyTries;
/// No description provided for @emailSignInCodeExpired.
///
/// In en, this message translates to:
/// **'This code is no longer valid. Ask for a new one.'**
String get emailSignInCodeExpired;
/// No description provided for @emailSignInOtherAddressLinked.
///
/// In en, this message translates to:
/// **'You already sign in with another address.'**
String get emailSignInOtherAddressLinked;
/// No description provided for @emailSignInMailFailed.
///
/// In en, this message translates to:
/// **'The instance couldn\'t send the mail. Try again later.'**
String get emailSignInMailFailed;
/// No description provided for @emailSignInUsePassword.
///
/// In en, this message translates to:
/// **'This account signs in with its password and an authenticator app.'**
String get emailSignInUsePassword;
/// No description provided for @emailSignInFailed.
///
/// In en, this message translates to:
/// **'Couldn\'t reach the instance. Try again.'**
String get emailSignInFailed;
/// No description provided for @syncTitle.
///
/// In en, this message translates to:

View file

@ -165,9 +165,6 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
@override
String get startWithGitHub => 'Mit GitHub anmelden';
@override
String get startWithEmail => 'Mit E-Mail fortfahren';
@override
String get startWithoutAccount => 'Ohne Konto starten';
@ -185,93 +182,6 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
@override
String get startOtherInstance => 'Andere Instanz verwenden';
@override
String get emailSignInTitle => 'Mit E-Mail fortfahren';
@override
String get emailSignInIntro =>
'Wir schicken dir einen Code zum Anmelden. Diese Instanz behält nur einen Fingerabdruck deiner Adresse, nie die Adresse selbst, und schreibt ihr nur, wenn du einen Code anforderst.';
@override
String get emailSignInAddress => 'E-Mail-Adresse';
@override
String get emailSignInSend => 'Code senden';
@override
String emailSignInCodeSent(String address) {
return 'Wir haben einen Code an $address geschickt. Er gilt 15 Minuten.';
}
@override
String get emailSignInResent => 'Ein neuer Code ist unterwegs.';
@override
String get emailSignInCode => 'Code';
@override
String get emailSignInContinue => 'Weiter';
@override
String get emailSignInResend => 'Neuen Code senden';
@override
String get emailSignInOtherAddress => 'Andere Adresse verwenden';
@override
String emailSignInSwitchTitle(String address) {
return 'Zum Konto von $address wechseln?';
}
@override
String get emailSignInSwitchConfirm => 'Wechseln';
@override
String get emailSignInSwitchStay => 'Anonym bleiben';
@override
String emailSignInTaken(String address) {
return 'Mit $address meldet sich schon ein anderes Konto an. Um zu ihm zu wechseln, melde dich zuerst ab.';
}
@override
String get emailSignInUnavailable =>
'Diese Instanz bietet keine Anmeldung mit E-Mail an.';
@override
String get emailSignInNotAnAddress => 'Das ist keine E-Mail-Adresse.';
@override
String get emailSignInTooManyCodes =>
'Zu viele Codes in der letzten Stunde. Versuche es später noch einmal.';
@override
String get emailSignInWrongCode => 'Dieser Code stimmt nicht.';
@override
String get emailSignInTooManyTries =>
'Zu viele falsche Codes. Fordere einen neuen an.';
@override
String get emailSignInCodeExpired =>
'Dieser Code gilt nicht mehr. Fordere einen neuen an.';
@override
String get emailSignInOtherAddressLinked =>
'Du meldest dich schon mit einer anderen Adresse an.';
@override
String get emailSignInMailFailed =>
'Die Instanz konnte die Mail nicht senden. Versuche es später noch einmal.';
@override
String get emailSignInUsePassword =>
'Dieses Konto meldet sich mit seinem Passwort und einer Authenticator-App an.';
@override
String get emailSignInFailed =>
'Die Instanz war nicht erreichbar. Versuche es noch einmal.';
@override
String get syncTitle => 'Synchronisierung';

View file

@ -163,9 +163,6 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
@override
String get startWithGitHub => 'Sign in with GitHub';
@override
String get startWithEmail => 'Continue with email';
@override
String get startWithoutAccount => 'Start without an account';
@ -183,92 +180,6 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
@override
String get startOtherInstance => 'Use another instance';
@override
String get emailSignInTitle => 'Continue with email';
@override
String get emailSignInIntro =>
'We mail you a code to sign in. This instance keeps only a fingerprint of your address, never the address itself, and writes to it only when you ask for a code.';
@override
String get emailSignInAddress => 'Email address';
@override
String get emailSignInSend => 'Send code';
@override
String emailSignInCodeSent(String address) {
return 'We mailed a code to $address. It\'s valid for 15 minutes.';
}
@override
String get emailSignInResent => 'A new code is on its way.';
@override
String get emailSignInCode => 'Code';
@override
String get emailSignInContinue => 'Continue';
@override
String get emailSignInResend => 'Send a new code';
@override
String get emailSignInOtherAddress => 'Use another address';
@override
String emailSignInSwitchTitle(String address) {
return 'Switch to the account of $address?';
}
@override
String get emailSignInSwitchConfirm => 'Switch';
@override
String get emailSignInSwitchStay => 'Stay anonymous';
@override
String emailSignInTaken(String address) {
return '$address already signs in to another account. To switch to it, sign out first.';
}
@override
String get emailSignInUnavailable =>
'This instance doesn\'t offer signing in with email.';
@override
String get emailSignInNotAnAddress => 'This isn\'t an email address.';
@override
String get emailSignInTooManyCodes =>
'Too many codes in the last hour. Try again later.';
@override
String get emailSignInWrongCode => 'That code isn\'t right.';
@override
String get emailSignInTooManyTries =>
'Too many wrong codes. Ask for a new one.';
@override
String get emailSignInCodeExpired =>
'This code is no longer valid. Ask for a new one.';
@override
String get emailSignInOtherAddressLinked =>
'You already sign in with another address.';
@override
String get emailSignInMailFailed =>
'The instance couldn\'t send the mail. Try again later.';
@override
String get emailSignInUsePassword =>
'This account signs in with its password and an authenticator app.';
@override
String get emailSignInFailed => 'Couldn\'t reach the instance. Try again.';
@override
String get syncTitle => 'Sync';

View file

@ -1,258 +0,0 @@
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/email_sign_in.dart';
/// Signs in with an email address, by a code mailed to it, as signing in
/// with GitHub does: a new address becomes the person's, who
/// keeps everything; an address that is another person's switches this
/// device to them, right away when it holds nothing
/// (`deviceHoldsContent`), otherwise once the person confirms that this
/// device's content stays behind. Someone identified is never switched:
/// they are told the address is another account's, as their own account
/// would otherwise be left.
class EmailSignInBloc(
final AuthRepository _auth, {
/// Whether this device holds content of the person signed in that a
/// switch would leave behind, as the app counts it (Halkyon Learn: its
/// studysets, those waiting in Recently deleted too).
required final Future<bool> Function() _deviceHoldsContent,
/// The language the mail is worded in (`en`, `de`).
required final String language,
}) extends Bloc<EmailSignInEvent, EmailSignInState> {
this : super(const EmailSignInStateAddress()) {
on<EmailSignInEventCodeRequested>(_onCodeRequested);
on<EmailSignInEventCodeEntered>(_onCodeEntered);
on<EmailSignInEventCodeResent>(_onCodeResent);
on<EmailSignInEventAddressReopened>(
(event, emit) => emit(const EmailSignInStateAddress()),
);
on<EmailSignInEventSwitchConfirmed>(_onSwitchConfirmed);
on<EmailSignInEventSwitchDeclined>(
(event, emit) => emit(const EmailSignInStateAddress()),
);
}
Future<void> _onCodeRequested(
EmailSignInEventCodeRequested event,
Emitter<EmailSignInState> emit,
) async {
// As the instance takes it, and as the person reads it back.
final address = event.address.trim().toLowerCase();
emit(const EmailSignInStateAddress(sending: true));
try {
emit(
EmailSignInStateCode(
address: address,
request: await _auth.sendEmailCode(address, language: language),
),
);
} on Exception catch (error, st) {
emit(EmailSignInStateAddress(problem: _problemOf(error, st)));
}
}
Future<void> _onCodeResent(
EmailSignInEventCodeResent event,
Emitter<EmailSignInState> emit,
) async {
if (state case final EmailSignInStateCode code) {
emit(code.checked());
try {
emit(
EmailSignInStateCode(
address: code.address,
request: await _auth.sendEmailCode(
code.address,
language: language,
),
resent: true,
),
);
} on Exception catch (error, st) {
emit(code.failed(_problemOf(error, st)));
}
}
}
Future<void> _onCodeEntered(
EmailSignInEventCodeEntered event,
Emitter<EmailSignInState> emit,
) async {
final code = state;
if (code is! EmailSignInStateCode) {
return;
}
emit(code.checked());
try {
switch (await _auth.verifyEmailCode(
code.request,
event.code,
address: code.address,
)) {
case EmailLinked():
emit(EmailSignInStateLinked(address: code.address));
case EmailTakenByAnotherPerson() when _auth.isIdentified:
emit(EmailSignInStateTaken(address: code.address));
case EmailTakenByAnotherPerson(:final switchToken):
if (await _deviceHoldsContent()) {
emit(
EmailSignInStateConfirmSwitch(
address: code.address,
switchToken: switchToken,
),
);
} else {
await _switch(code.address, switchToken, emit);
}
}
} on Exception catch (error, st) {
emit(code.failed(_problemOf(error, st)));
}
}
Future<void> _onSwitchConfirmed(
EmailSignInEventSwitchConfirmed event,
Emitter<EmailSignInState> emit,
) async {
if (state case EmailSignInStateConfirmSwitch(
:final address,
:final switchToken,
)) {
try {
await _switch(address, switchToken, emit);
} on Exception catch (error, st) {
emit(EmailSignInStateAddress(problem: _problemOf(error, st)));
}
}
}
// After a switch, the previous person's whole subtree goes away, this bloc
// with it, so nothing is emitted then.
Future<void> _switch(
String address,
String switchToken,
Emitter<EmailSignInState> emit,
) async {
emit(const EmailSignInStateSwitching());
await _auth.switchToEmail(switchToken, address: address);
}
EmailSignInProblem _problemOf(Exception error, StackTrace st) {
if (error is EmailSignInFailed) {
return error.problem;
}
addError(error, st);
return EmailSignInProblem.failed;
}
}
/// What happens to signing in with an email address.
sealed class const EmailSignInEvent() with Equatable {
@override
List<Object?> get props => const [];
}
/// Mail a code to [address].
final class const EmailSignInEventCodeRequested(final String address)
extends EmailSignInEvent {
@override
List<Object?> get props => [address];
}
/// The person typed [code], from the mail.
final class const EmailSignInEventCodeEntered(final String code)
extends EmailSignInEvent {
@override
List<Object?> get props => [code];
}
/// Mail a new code to the same address.
final class const EmailSignInEventCodeResent() extends EmailSignInEvent;
/// Type another address.
final class const EmailSignInEventAddressReopened() extends EmailSignInEvent;
/// Switch to the address's person; this device's content stays behind.
final class const EmailSignInEventSwitchConfirmed() extends EmailSignInEvent;
/// Stay anonymous.
final class const EmailSignInEventSwitchDeclined() extends EmailSignInEvent;
/// Signing in with an email address, as its sheet shows it.
sealed class const EmailSignInState() with Equatable {
@override
List<Object?> get props => const [];
}
/// Typing the address.
final class const EmailSignInStateAddress({
/// What went wrong the last time.
final EmailSignInProblem? problem,
/// Whether the code is being mailed.
final bool sending = false,
}) extends EmailSignInState {
@override
List<Object?> get props => [problem, sending];
}
/// Typing the code mailed to [address] for [request].
final class const EmailSignInStateCode({
required final String address,
required final String request,
/// What went wrong the last time.
final EmailSignInProblem? problem,
/// Whether the instance is being asked: about the code, or for a new one.
final bool checking = false,
/// Whether a new code was mailed on request.
final bool resent = false,
}) extends EmailSignInState {
/// This, while the instance is asked.
EmailSignInStateCode checked() =>
EmailSignInStateCode(address: address, request: request, checking: true);
/// This, once [problem] came back.
EmailSignInStateCode failed(EmailSignInProblem problem) =>
EmailSignInStateCode(
address: address,
request: request,
problem: problem,
);
@override
List<Object?> get props => [address, request, problem, checking, resent];
}
/// The address is another person's and this device holds content: switch
/// and leave it behind, or stay anonymous?
final class const EmailSignInStateConfirmSwitch({
required final String address,
required final String switchToken,
}) extends EmailSignInState {
@override
List<Object?> get props => [address, switchToken];
}
/// Switching this device to the address's person.
final class const EmailSignInStateSwitching() extends EmailSignInState;
/// The address is another account's, and the person signed in is
/// identified: nothing changed.
final class const EmailSignInStateTaken({required final String address})
extends EmailSignInState {
@override
List<Object?> get props => [address];
}
/// The address is now the person's.
final class const EmailSignInStateLinked({required final String address})
extends EmailSignInState {
@override
List<Object?> get props => [address];
}

View file

@ -19,7 +19,6 @@ class SessionBloc(final AuthRepository _auth, final SignedOutMark _mark)
on<SessionEventSignInRequested>(_onSignInRequested);
on<SessionEventStartedAnonymously>(_onStartedAnonymously);
on<SessionEventGitHubRequested>(_onGitHubRequested);
on<SessionEventEmailRequested>(_onEmailRequested);
on<SessionEventSignedOut>(
(event, emit) => emit(SessionStateSignedOut(problem: event.problem)),
);
@ -27,9 +26,6 @@ class SessionBloc(final AuthRepository _auth, final SignedOutMark _mark)
on<SessionEventDeleteRequested>(_onDeleteRequested);
}
/// Whether an email sign-in follows the anonymous start under way.
var _thenEmail = false;
Future<void> _onStarted(
SessionEventStarted event,
Emitter<SessionState> emit,
@ -49,7 +45,7 @@ class SessionBloc(final AuthRepository _auth, final SignedOutMark _mark)
return const SessionStateSignedOut();
}
unawaited(_mark.write(signedOut: false));
return _stateOf(status, signsInWithEmail: _thenEmail);
return _stateOf(status);
},
);
}
@ -64,15 +60,10 @@ class SessionBloc(final AuthRepository _auth, final SignedOutMark _mark)
return;
}
emit(const SessionStateLoading());
// Read before signing in: the status change on the way shows the app
// too, and must ask for the address as well.
final email = _thenEmail;
try {
await _auth.ensureSignedIn();
_thenEmail = false;
emit(_stateOf(_auth.status, signsInWithEmail: email));
emit(_stateOf(_auth.status));
} on Object catch (error, st) {
_thenEmail = false;
addError(error, st);
emit(const SessionStateFailed());
}
@ -112,17 +103,6 @@ class SessionBloc(final AuthRepository _auth, final SignedOutMark _mark)
}
}
/// Email codes are only taken from someone signed in: an anonymous start
/// first, then the address, which switches to its person if it has one.
Future<void> _onEmailRequested(
SessionEventEmailRequested event,
Emitter<SessionState> emit,
) async {
_thenEmail = true;
await _mark.write(signedOut: false);
add(const SessionEventSignInRequested());
}
Future<void> _onSignOutRequested(
SessionEventSignOutRequested event,
Emitter<SessionState> emit,
@ -151,14 +131,10 @@ class SessionBloc(final AuthRepository _auth, final SignedOutMark _mark)
}
}
SessionState _stateOf(AuthStatus status, {bool signsInWithEmail = false}) =>
switch (status) {
SignedIn() => SessionStateActive(
status,
signsInWithEmail: signsInWithEmail,
),
SignedOut() => const SessionStateSignedOut(),
};
SessionState _stateOf(AuthStatus status) => switch (status) {
SignedIn() => SessionStateActive(status),
SignedOut() => const SessionStateSignedOut(),
};
}
/// What happens to the session.
@ -180,9 +156,6 @@ final class const SessionEventStartedAnonymously() extends SessionEvent;
/// Sign in with GitHub, once signed out.
final class const SessionEventGitHubRequested() extends SessionEvent;
/// Sign in with an email address, once signed out.
final class const SessionEventEmailRequested() extends SessionEvent;
/// Signed out, [problem] if any saying why the last way to start failed.
final class const SessionEventSignedOut({final SignedOutProblem? problem})
extends SessionEvent {
@ -216,14 +189,10 @@ final class const SessionStateFailed() extends SessionState {
/// A person is signed in.
final class const SessionStateActive(
/// Who is signed in.
final SignedIn person, {
/// Whether they asked to sign in with an email address, which their app
/// asks for once it opens.
final bool signsInWithEmail = false,
}) extends SessionState {
final SignedIn person,
) extends SessionState {
@override
List<Object?> get props => [person, signsInWithEmail];
List<Object?> get props => [person];
}
/// Nobody is signed in since signing out: the app asks how to start.

View file

@ -3,9 +3,9 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/src/auth/data/auth_repository.dart';
import 'package:halkyon_app/src/auth/model/sign_in_ways.dart';
/// What the account card shows of signing in: whether this instance offers
/// email sign-in, how the person signs in, and the address they signed in
/// with on this device ([load] again after either changes).
/// What the account card shows of signing in: how the person signs in, and
/// the address they signed in with on this device ([load] again after
/// either changes).
class SignInWaysCubit(final AuthRepository _auth)
extends Cubit<SignInWaysState> {
this : super(const SignInWaysState());
@ -13,7 +13,6 @@ class SignInWaysCubit(final AuthRepository _auth)
/// Reads it all anew.
Future<void> load() async {
try {
final offersEmail = await _auth.canSignInWithEmail();
final ways = _auth.isIdentified
? await _auth.signInWays()
: SignInWays.none;
@ -22,7 +21,6 @@ class SignInWaysCubit(final AuthRepository _auth)
emit(
SignInWaysState(
loaded: true,
offersEmail: offersEmail,
ways: ways,
emailAddress: address,
),
@ -43,9 +41,6 @@ final class const SignInWaysState({
/// Whether it was read at all.
final bool loaded = false,
/// Whether this instance offers signing in with an email address.
final bool offersEmail = false,
/// How the person signs in.
final SignInWays ways = SignInWays.none,
@ -53,5 +48,5 @@ final class const SignInWaysState({
final String? emailAddress,
}) with Equatable {
@override
List<Object?> get props => [loaded, offersEmail, ways, emailAddress];
List<Object?> get props => [loaded, ways, emailAddress];
}

View file

@ -1,5 +1,4 @@
import 'package:halkyon_app/src/auth/model/auth_status.dart';
import 'package:halkyon_app/src/auth/model/email_sign_in.dart';
import 'package:halkyon_app/src/auth/model/github_link_outcome.dart';
import 'package:halkyon_app/src/auth/model/session_grant.dart';
import 'package:halkyon_app/src/auth/model/sign_in_ways.dart';
@ -45,22 +44,6 @@ abstract interface class AuthGateway() {
/// GitHub has answered.
Future<void> cancelGitHub();
/// Whether this instance offers signing in with an email address.
Future<bool> offersEmailSignIn();
/// Mails a code to [address], worded in [language] (`en`, `de`), and
/// returns the request it answers. Throws [EmailSignInFailed].
Future<String> sendEmailCode(String address, {required String language});
/// Accepts [code] for [request]: the address becomes the signed-in
/// person's, the lossless upgrade from an anonymous account, unless it is
/// another person's. Throws [EmailSignInFailed].
Future<EmailCodeOutcome> verifyEmailCode(String request, String code);
/// Switches this device to the person [switchToken] signs in as, and
/// returns them (their `AuthUser` id). Throws [EmailSignInFailed].
Future<String> switchToEmail(String switchToken);
/// How the signed-in person signs in.
Future<SignInWays> signInWays();

View file

@ -1,7 +1,6 @@
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/email_sign_in.dart';
import 'package:halkyon_app/src/auth/model/github_link_outcome.dart';
import 'package:halkyon_app/src/auth/model/sign_in_ways.dart';
@ -62,46 +61,6 @@ class AuthRepository(
/// Cancels the GitHub sign-in in progress while GitHub's page is open.
Future<void> cancelGitHub() => _gateway.cancelGitHub();
/// Whether this instance offers signing in with an email address.
Future<bool> canSignInWithEmail() => _gateway.offersEmailSignIn();
/// Mails a code to [address], worded in [language]; returns the request
/// it answers. Throws `EmailSignInFailed`.
Future<String> sendEmailCode(String address, {required String language}) =>
_gateway.sendEmailCode(address, language: language);
/// Accepts [code] for [request], mailed to [address]: the address becomes
/// the signed-in person's, who keeps everything (and this device
/// remembers it, to show), unless it is another person's. Throws
/// `EmailSignInFailed`.
Future<EmailCodeOutcome> verifyEmailCode(
String request,
String code, {
required String address,
}) async {
final person = _gateway.status;
final outcome = await _gateway.verifyEmailCode(request, code);
if ((outcome, person) case (
EmailLinked(),
SignedIn(:final authUserId),
)) {
await _addresses.remember(authUserId, address);
}
return outcome;
}
/// Switches this device to the person who signs in with [address], whom
/// [switchToken] names. An anonymous account left behind can't be signed
/// into again, and goes. Throws `EmailSignInFailed`.
Future<void> switchToEmail(
String switchToken, {
required String address,
}) async {
final person = await _gateway.switchToEmail(switchToken);
// Into the folder of the person switched to, which stays.
await _addresses.remember(person, address);
}
/// How the signed-in person signs in.
Future<SignInWays> signInWays() => _gateway.signInWays();

View file

@ -6,18 +6,13 @@ import 'package:flutter_web_auth_2_platform_interface/flutter_web_auth_2_platfor
import 'package:halkyon_app/src/auth/data/auth_gateway.dart';
import 'package:halkyon_app/src/auth/data/loopback_web_auth.dart';
import 'package:halkyon_app/src/auth/model/auth_status.dart';
import 'package:halkyon_app/src/auth/model/email_sign_in.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';
import 'package:halkyon_app/src/base/base_client.dart';
import 'package:halkyon_base_client/halkyon_base_client.dart'
show
EmailSignInRefusal,
EmailSignInRefusedException,
GitHubSignInRefusal,
GitHubSignInRefusedException;
show GitHubSignInRefusal, GitHubSignInRefusedException;
import 'package:halkyon_base_client/halkyon_base_client.dart' as base_client;
import 'package:http/http.dart' as http;
import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart';
@ -237,52 +232,6 @@ class ServerpodAuthGateway(
}
}
@override
Future<bool> offersEmailSignIn() async =>
(await _base.instance.basics()).emailSignIn;
@override
Future<String> sendEmailCode(
String address, {
required String language,
}) => _asEmailFailure(() async {
final request = await _base.emailCode.sendCode(
address: address,
language: language,
);
return request.uuid;
});
@override
Future<EmailCodeOutcome> verifyEmailCode(String request, String code) =>
_asEmailFailure(() async {
final accepted = await _base.emailCode.verifyCode(
request: UuidValue.withValidation(request),
code: code,
);
if (accepted.switchToken case final token?) {
return EmailTakenByAnotherPerson(token);
}
await _becomeIdentified();
return const EmailLinked();
});
@override
Future<String> switchToEmail(String switchToken) => _asEmailFailure(() async {
// As switchToGitHub: without a session.
final signedOut = _clientOfItsOwn();
final AuthSuccess person;
try {
person = await signedOut.modules.emailSignIn.signIn(
switchToken: switchToken,
);
} finally {
signedOut.close();
}
await _switchTo(person);
return person.authUserId.uuid;
});
@override
Future<SignInWays> signInWays() async {
final ways = await _base.account.signInWays();
@ -304,26 +253,6 @@ class ServerpodAuthGateway(
: GitHubSignInProblem.refused,
);
/// What [action] gives; the instance's refusals as [EmailSignInFailed].
Future<T> _asEmailFailure<T>(Future<T> Function() action) async {
try {
return await action();
} on EmailSignInRefusedException catch (refusal) {
throw EmailSignInFailed(switch (refusal.reason) {
EmailSignInRefusal.unavailable => EmailSignInProblem.unavailable,
EmailSignInRefusal.notAnAddress => EmailSignInProblem.notAnAddress,
EmailSignInRefusal.tooManyCodes => EmailSignInProblem.tooManyCodes,
EmailSignInRefusal.wrongCode => EmailSignInProblem.wrongCode,
EmailSignInRefusal.tooManyTries => EmailSignInProblem.tooManyTries,
EmailSignInRefusal.codeExpired => EmailSignInProblem.codeExpired,
EmailSignInRefusal.otherAddressLinked =>
EmailSignInProblem.otherAddressLinked,
EmailSignInRefusal.mailFailed => EmailSignInProblem.mailFailed,
EmailSignInRefusal.usePassword => EmailSignInProblem.usePassword,
});
}
}
/// Deletes the anonymous account this device leaves behind when switching:
/// nobody could ever sign into it again. It signs in as that account from
/// a client of its own; a failure only leaves the account to the pruning.

View file

@ -1,61 +0,0 @@
import 'package:equatable/equatable.dart';
/// Why signing in with an email address didn't go through, as the widgets
/// put it into words.
enum EmailSignInProblem() {
/// This instance offers no email sign-in.
unavailable,
/// What was typed isn't an email address.
notAnAddress,
/// Too many codes asked for in the last hour.
tooManyCodes,
/// The code typed is wrong.
wrongCode,
/// The code was typed wrong too often; a new one is needed.
tooManyTries,
/// The code is no longer valid: past its 15 minutes, used, or replaced.
codeExpired,
/// The person already signs in with another address.
otherAddressLinked,
/// The instance couldn't send the mail.
mailFailed,
/// The account signs in with its password and an authenticator: a code by
/// mail no longer signs into it.
usePassword,
/// The instance couldn't be reached, or answered otherwise.
failed,
}
/// Signing in with an email address didn't go through, for [problem].
final class const EmailSignInFailed(final EmailSignInProblem problem)
implements Exception {
@override
String toString() => 'EmailSignInFailed(${problem.name})';
}
/// How accepting a mailed code ended.
sealed class const EmailCodeOutcome() with Equatable {
@override
List<Object?> get props => const [];
}
/// The address is now the signed-in person's (or already was): they are
/// identified, keeping everything.
final class const EmailLinked() extends EmailCodeOutcome;
/// The address is another person's: [switchToken] switches this device to
/// them, once, within the code's 15 minutes.
final class const EmailTakenByAnotherPerson(final String switchToken)
extends EmailCodeOutcome {
@override
List<Object?> get props => [switchToken];
}

View file

@ -1,293 +0,0 @@
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/l10n/halkyon_app_localizations.dart';
import 'package:halkyon_app/src/app/app_frame.dart';
import 'package:halkyon_app/src/app/app_scope.dart';
import 'package:halkyon_app/src/auth/bloc/email_sign_in_bloc.dart';
import 'package:halkyon_app/src/auth/model/email_sign_in.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
/// Signs in with an email address in a sheet: the address, then the code
/// mailed to it. Returns the address once it is the person's; null when the
/// sheet closed otherwise, or the device switched to the address's person,
/// which starts the app afresh at home. [deviceHoldsContent] says whether a
/// switch would leave content of this device's behind, so the person is
/// asked first ([AppFrame.switchLeavesBehind] says what).
Future<String?> showEmailSignInSheet(
BuildContext context, {
required Future<bool> Function() deviceHoldsContent,
}) {
final auth = AppScope.of(context).auth;
final language = switch (Localizations.localeOf(context).languageCode) {
'de' => 'de',
_ => 'en',
};
return showAdaptiveSheet<String>(
context,
// Swiping away would cut off a code being checked, or a switch.
swipeable: false,
builder: (context) => BlocProvider(
create: (context) => EmailSignInBloc(
auth,
deviceHoldsContent: deviceHoldsContent,
language: language,
),
child: const _EmailSignIn(),
),
);
}
/// [problem] in [l10n]'s words.
String emailSignInProblemText(
HalkyonAppLocalizations l10n,
EmailSignInProblem problem,
) => switch (problem) {
EmailSignInProblem.unavailable => l10n.emailSignInUnavailable,
EmailSignInProblem.notAnAddress => l10n.emailSignInNotAnAddress,
EmailSignInProblem.tooManyCodes => l10n.emailSignInTooManyCodes,
EmailSignInProblem.wrongCode => l10n.emailSignInWrongCode,
EmailSignInProblem.tooManyTries => l10n.emailSignInTooManyTries,
EmailSignInProblem.codeExpired => l10n.emailSignInCodeExpired,
EmailSignInProblem.otherAddressLinked => l10n.emailSignInOtherAddressLinked,
EmailSignInProblem.mailFailed => l10n.emailSignInMailFailed,
EmailSignInProblem.usePassword => l10n.emailSignInUsePassword,
EmailSignInProblem.failed => l10n.emailSignInFailed,
};
class const _EmailSignIn() extends StatefulWidget {
@override
State<_EmailSignIn> createState() => _EmailSignInState();
}
class _EmailSignInState() extends State<_EmailSignIn> {
final _address = TextEditingController();
final _code = TextEditingController();
@override
void dispose() {
_address.dispose();
_code.dispose();
super.dispose();
}
void _send() => context.read<EmailSignInBloc>().add(
EmailSignInEventCodeRequested(_address.text),
);
void _check() => context.read<EmailSignInBloc>().add(
EmailSignInEventCodeEntered(_code.text),
);
@override
Widget build(BuildContext context) {
final l10n = HalkyonAppLocalizations.of(context);
return BlocConsumer<EmailSignInBloc, EmailSignInState>(
listener: (context, state) {
switch (state) {
case EmailSignInStateLinked(:final address):
Navigator.of(context).pop(address);
// A new code: the field waits for it.
case EmailSignInStateCode(problem: null, checking: false):
_code.clear();
case _:
}
},
builder: (context, state) {
final busy = switch (state) {
EmailSignInStateAddress(:final sending) => sending,
EmailSignInStateCode(:final checking) => checking,
EmailSignInStateSwitching() || EmailSignInStateLinked() => true,
_ => false,
};
return SheetScaffold(
title: l10n.emailSignInTitle,
busy: busy,
body: switch (state) {
EmailSignInStateAddress(:final problem) => _addressStep(
context,
problem: problem,
busy: busy,
),
final EmailSignInStateCode code => _codeStep(
context,
code,
busy: busy,
),
EmailSignInStateConfirmSwitch(:final address) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.emailSignInSwitchTitle(address),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(AppFrameScope.of(context).switchLeavesBehind(context)),
],
),
EmailSignInStateTaken(:final address) => Text(
l10n.emailSignInTaken(address),
),
EmailSignInStateSwitching() ||
EmailSignInStateLinked() => const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(child: AppLoadingIndicator()),
),
},
footer: switch (state) {
EmailSignInStateAddress() => FilledButton(
onPressed: busy ? null : _send,
child: busy
? const ButtonLoadingIndicator()
: Text(l10n.emailSignInSend),
),
EmailSignInStateCode() => FilledButton(
onPressed: busy ? null : _check,
child: busy
? const ButtonLoadingIndicator()
: Text(l10n.emailSignInContinue),
),
EmailSignInStateConfirmSwitch() => Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => context.read<EmailSignInBloc>().add(
const EmailSignInEventSwitchDeclined(),
),
child: Text(l10n.emailSignInSwitchStay),
),
const SizedBox(width: 8),
FilledButton(
onPressed: () => context.read<EmailSignInBloc>().add(
const EmailSignInEventSwitchConfirmed(),
),
child: Text(l10n.emailSignInSwitchConfirm),
),
],
),
EmailSignInStateTaken() => OutlinedButton(
onPressed: () => context.read<EmailSignInBloc>().add(
const EmailSignInEventAddressReopened(),
),
child: Text(l10n.emailSignInOtherAddress),
),
EmailSignInStateSwitching() || EmailSignInStateLinked() => null,
},
);
},
);
}
Widget _addressStep(
BuildContext context, {
required EmailSignInProblem? problem,
required bool busy,
}) {
final l10n = HalkyonAppLocalizations.of(context);
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.emailSignInIntro,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
ListenableBuilder(
listenable: _address,
builder: (context, _) => TextField(
controller: _address,
enabled: !busy,
autofocus: true,
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
autocorrect: false,
enableSuggestions: false,
textInputAction: TextInputAction.send,
onSubmitted: (_) => _send(),
maxLength: TextLimits.emailAddress,
buildCounter: nearLimitCounter,
decoration: requiredDecoration(
context,
InputDecoration(
labelText: l10n.emailSignInAddress,
errorText: problem == null
? null
: emailSignInProblemText(l10n, problem),
),
missing: _address.text.trim().isEmpty,
),
),
),
],
);
}
Widget _codeStep(
BuildContext context,
EmailSignInStateCode code, {
required bool busy,
}) {
final l10n = HalkyonAppLocalizations.of(context);
final bloc = context.read<EmailSignInBloc>();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(l10n.emailSignInCodeSent(code.address)),
if (code.resent) ...[
const SizedBox(height: 4),
Text(
l10n.emailSignInResent,
style: Theme.of(context).textTheme.bodySmall,
),
],
const SizedBox(height: 16),
TextField(
controller: _code,
enabled: !busy,
autofocus: true,
autofillHints: const [AutofillHints.oneTimeCode],
autocorrect: false,
enableSuggestions: false,
textCapitalization: TextCapitalization.characters,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _check(),
// Eight characters, a dash or space perhaps between them.
maxLength: 9,
buildCounter: nearLimitCounter,
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[0-9A-Za-z\- ]')),
],
decoration: InputDecoration(
labelText: l10n.emailSignInCode,
hintText: 'K7QX-M2PD',
errorText: switch (code.problem) {
final problem? => emailSignInProblemText(l10n, problem),
null => null,
},
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
TextButton(
onPressed: busy
? null
: () => bloc.add(const EmailSignInEventCodeResent()),
child: Text(l10n.emailSignInResend),
),
TextButton(
onPressed: busy
? null
: () => bloc.add(const EmailSignInEventAddressReopened()),
child: Text(l10n.emailSignInOtherAddress),
),
],
),
],
);
}
}

View file

@ -16,21 +16,16 @@ import 'package:material_ui/material_ui.dart';
/// How to start once signed out: on the instance in use, signing in with a
/// password or creating an account where it offers password sign-in (ADR
/// 0017), with GitHub (saying when it ends) or an email address where it
/// offers them, or without an account; or on another instance. Nothing is
/// made until the person chooses.
/// 0017), with GitHub where it offers it (saying when it ends), or without an
/// account; or on another instance. Nothing is made until the person
/// chooses.
class const StartScreen({required final SessionStateSignedOut state, super.key})
extends StatefulWidget {
@override
State<StartScreen> createState() => _StartScreenState();
}
typedef _Offers = ({
bool gitHub,
bool email,
bool password,
DateTime? gitHubEnds,
});
typedef _Offers = ({bool gitHub, bool password, DateTime? gitHubEnds});
class _StartScreenState() extends State<StartScreen> {
late final Future<_Offers> _offers = () async {
@ -44,14 +39,12 @@ class _StartScreenState() extends State<StartScreen> {
}
}
final (gitHub, email, password) = await (
final (gitHub, password) = await (
auth.canSignInWithGitHub(),
auth.canSignInWithEmail(),
passwords(),
).wait;
return (
gitHub: gitHub,
email: email,
password: password.offered,
gitHubEnds: password.gitHubEnds,
);
@ -98,14 +91,9 @@ class _StartScreenState() extends State<StartScreen> {
child: FutureBuilder(
future: _offers,
builder: (context, offers) {
final (:gitHub, :email, :password, :gitHubEnds) =
final (:gitHub, :password, :gitHubEnds) =
offers.data ??
(
gitHub: false,
email: false,
password: false,
gitHubEnds: null,
);
(gitHub: false, password: false, gitHubEnds: null);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
@ -174,15 +162,6 @@ class _StartScreenState() extends State<StartScreen> {
],
const SizedBox(height: 8),
],
if (email) ...[
OutlinedButton(
onPressed: busy
? null
: () => _send(const SessionEventEmailRequested()),
child: Text(l10n.startWithEmail),
),
const SizedBox(height: 8),
],
FilledButton.tonal(
onPressed: busy
? null

View file

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/src/password/data/password_repository.dart';
@ -16,11 +18,23 @@ final class const AccountSecurityState({
List<Object?> get props => [security, failed];
}
/// Reads what keeps the signed-in person's account ([load] again after a
/// change).
/// Reads what keeps the signed-in person's account, and again after every
/// change the repository makes to it, wherever it was made.
class AccountSecurityCubit(final PasswordRepository _passwords)
extends Cubit<AccountSecurityState> {
this : super(const AccountSecurityState());
this : super(const AccountSecurityState()) {
_changes = _passwords.changes.listen((_) => load());
}
late final StreamSubscription<void> _changes;
@override
Future<void> close() {
// Not awaited: a broadcast stream's cancel completes in the root zone,
// which a widget test's fake clock never reaches.
unawaited(_changes.cancel());
return super.close();
}
/// Reads it anew.
Future<void> load() async {

View file

@ -75,6 +75,20 @@ class PasswordRepository(
late final StreamSubscription<AuthStatus> _status;
final _changes = StreamController<void>.broadcast();
/// Every change to what keeps the signed-in person's account made through
/// this repository (a password set or changed, an authenticator confirmed
/// or removed, new recovery codes or phrase), for what shows it to read
/// anew.
Stream<void> get changes => _changes.stream;
void _changed() {
if (!_changes.isClosed) {
_changes.add(null);
}
}
/// How long a sign-in's challenge is used: the instance takes it for two
/// minutes; a little less leaves room for the trip.
static const _challengeFor = Duration(seconds: 90);
@ -92,6 +106,7 @@ class PasswordRepository(
// Not awaited: a broadcast stream's cancel completes in the root zone,
// which a widget test's fake clock never reaches.
unawaited(_status.cancel());
unawaited(_changes.close());
}
/// Whether the instance offers password sign-in, and when GitHub sign-in
@ -226,6 +241,7 @@ class PasswordRepository(
await _addresses.remember(person, address.trim());
}
await _auth.markIdentified();
_changed();
}
// Authenticators.
@ -246,17 +262,23 @@ class PasswordRepository(
String code, {
String? existingCode,
PendingSignIn? pending,
}) => _gateway.confirm(
id,
code.replaceAll(' ', ''),
existingCode: existingCode?.replaceAll(' ', ''),
as: pending?.grant,
);
}) async {
final codes = await _gateway.confirm(
id,
code.replaceAll(' ', ''),
existingCode: existingCode?.replaceAll(' ', ''),
as: pending?.grant,
);
_changed();
return codes;
}
/// Removes authenticator [id]: a confirmed one with a [code] from one of
/// the person's, while another stays.
Future<void> removeAuthenticator(String id, {String? code}) =>
_gateway.remove(id, code: code?.replaceAll(' ', ''));
Future<void> removeAuthenticator(String id, {String? code}) async {
await _gateway.remove(id, code: code?.replaceAll(' ', ''));
_changed();
}
/// New recovery codes in place of the old, for a [code]: shown once.
Future<List<String>> newRecoveryCodes(String code) =>
@ -306,6 +328,7 @@ class PasswordRepository(
recoveryCode: recoveryCode,
password: await _crypto.newPassword(newPassword, keys),
);
_changed();
}
/// A new recovery phrase for the signed-in person's keys, to show; the
@ -317,8 +340,13 @@ class PasswordRepository(
/// [phrase] in place of the old recovery phrase, for a [code]: the old
/// one opens nothing any more.
Future<void> keepPhrase(NewPhrase phrase, String code) =>
_gateway.setRecoveryKeys(code.replaceAll(' ', ''), phrase._recoveryKeys);
Future<void> keepPhrase(NewPhrase phrase, String code) async {
await _gateway.setRecoveryKeys(
code.replaceAll(' ', ''),
phrase._recoveryKeys,
);
_changed();
}
// Getting back in.

View file

@ -38,7 +38,7 @@ class ServerpodPasswordGateway(
@override
Future<String> sendCode(String address, {required String language}) =>
_signedOut(
(base) async => (await base.modules.accountRecovery.sendCode(
(base) async => (await base.modules.emailCode.sendCode(
address: address,
language: language,
)).uuid,

View file

@ -2,7 +2,6 @@ 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/email_sign_in.dart';
import 'package:halkyon_app/src/auth/model/github_link_outcome.dart';
import 'package:halkyon_app/src/auth/model/session_grant.dart';
import 'package:halkyon_app/src/auth/model/sign_in_ways.dart';
@ -10,10 +9,8 @@ import 'package:halkyon_app/src/auth/model/sign_in_ways.dart';
/// An [AuthGateway] without a server: signing in creates people named
/// `anonymous-1`, `anonymous-2`, …, and [signInError] makes it fail. GitHub
/// sign-in links the signed-in person, or switches to `github-person`; with
/// [gitHubPageOpens], it first waits for [answerGitHub] or a cancel. Email
/// sign-in mails `CODE-1`, `CODE-2`, … ([mailed]) and links the address,
/// unless [addressOwners] gives it to another person, whose token then
/// switches to them.
/// [gitHubPageOpens], it first waits for [answerGitHub] or a cancel. Who has
/// an email address is [addressOwners]'s.
class FakeAuthGateway({var AuthStatus _status = const SignedOut()})
implements AuthGateway {
final _changes = StreamController<AuthStatus>.broadcast();
@ -58,22 +55,6 @@ class FakeAuthGateway({var AuthStatus _status = const SignedOut()})
/// The people who sign in with GitHub.
final gitHubPeople = <String>{};
/// Whether the instance offers email sign-in.
bool offersEmail = true;
/// The codes mailed, in order.
final mailed =
<({String address, String language, String request, String code})>[];
/// Who signs in with which address (lower case), by the person's id.
final addressOwners = <String, String>{};
/// Thrown by the next email sign-in steps while set.
Exception? emailError;
/// How often this device switched to the person of an address.
int emailSwitches = 0;
@override
AuthStatus get status => _status;
@ -155,65 +136,6 @@ class FakeAuthGateway({var AuthStatus _status = const SignedOut()})
Future<bool> _authorize() =>
gitHubPageOpens ? (_gitHubPage = Completer()).future : Future.value(true);
@override
Future<bool> offersEmailSignIn() async => offersEmail;
@override
Future<String> sendEmailCode(
String address, {
required String language,
}) async {
if (emailError case final error?) {
throw error;
}
final to = address.trim().toLowerCase();
if (!to.contains('@')) {
throw const EmailSignInFailed(EmailSignInProblem.notAnAddress);
}
final number = mailed.length + 1;
mailed.add((
address: to,
language: language,
request: 'request-$number',
code: 'CODE-$number',
));
return 'request-$number';
}
@override
Future<EmailCodeOutcome> verifyEmailCode(String request, String code) async {
if (emailError case final error?) {
throw error;
}
final mail = mailed.where((each) => each.request == request).lastOrNull;
if (mail == null) {
throw const EmailSignInFailed(EmailSignInProblem.codeExpired);
}
if (mail.code != code.trim().toUpperCase()) {
throw const EmailSignInFailed(EmailSignInProblem.wrongCode);
}
final person = _status as SignedIn;
switch (addressOwners[mail.address]) {
case final owner? when owner != person.authUserId:
return EmailTakenByAnotherPerson('switch:${mail.address}');
case _:
addressOwners[mail.address] = person.authUserId;
_set(SignedIn(authUserId: person.authUserId, isIdentified: true));
return const EmailLinked();
}
}
@override
Future<String> switchToEmail(String switchToken) async {
if (emailError case final error?) {
throw error;
}
final owner = addressOwners[switchToken.substring('switch:'.length)]!;
emailSwitches++;
_set(SignedIn(authUserId: owner, isIdentified: true));
return owner;
}
@override
Future<SignInWays> signInWays() async {
final person = (_status as SignedIn).authUserId;
@ -223,6 +145,9 @@ class FakeAuthGateway({var AuthStatus _status = const SignedOut()})
);
}
/// Who signs in with which address (lower case), by the person's id.
final addressOwners = <String, String>{};
/// The sessions put in place, in order.
final usedSessions = <SessionGrant>[];

View file

@ -62,6 +62,10 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
/// The accounts, by address in lower case.
final accounts = <String, FakePasswordAccount>{};
/// The people who sign in with GitHub, by ID, with or without an account
/// here.
final gitHubPeople = <String>{};
/// The codes mailed, in order.
final mailed = <({String address, String language, String code})>[];
@ -465,9 +469,10 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
@override
Future<PasswordWays> ways() => _call('ways', () {
final account = _accountOfPerson(_signedIn());
final person = _signedIn();
final account = _accountOfPerson(person);
return (
gitHub: account?.gitHub ?? false,
gitHub: (account?.gitHub ?? false) || gitHubPeople.contains(person),
email: account != null,
password: account?.password != null,
authenticator: account?.finished ?? false,

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