fix(auth)!: a first password takes a fresh proof of the account's way in
The fourth reading's first finding: password.set took a session alone, so whoever took one made a GitHub-only account, or an address account without a password, theirs for good (their address or password, their authenticator; GitHub then refused the owner). - An account with an address proves it anew: a code mailed to that address for this (EmailCodePurpose.firstPassword, its own mail), never the session alone, a code asked for anything else, or another address. - An account that signs in with GitHub alone does it within 10 minutes of a GitHub sign-in (the session's method and creation time, where the session lives; members tell the register), refused signInAgain before any code is used; the address it adds is proved as before. - Anonymous people still become identified with a code to the address they add. The app mails the code to the person's own address (asking for it where the device doesn't know it), offers a password right after a GitHub sign-in (D2's "at their next sign-in") before the person's app, and asks for a GitHub sign-in anew when the instance refuses an older one, keeping the code typed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
1ada2e27f6
commit
f0413531ff
38 changed files with 762 additions and 66 deletions
|
|
@ -143,6 +143,9 @@ 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();
|
||||||
|
// A password is offered first (ADR 0017, D2); put off, the app.
|
||||||
|
await tester.tap(find.text('Later'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
gateway.status,
|
gateway.status,
|
||||||
|
|
|
||||||
|
|
@ -215,5 +215,10 @@
|
||||||
"passwordAddressUnknown": "Gib die E-Mail-Adresse deines Kontos ein: Dorthin geht der Code.",
|
"passwordAddressUnknown": "Gib die E-Mail-Adresse deines Kontos ein: Dorthin geht der Code.",
|
||||||
"passwordDeleteIntroByMail": "Dein Konto wird mit allem darin gelöscht, in jedem Dienst dieser Instanz. Keiner seiner Authenticators funktioniert gerade, daher bestätigt es ein Code per E-Mail: Gib dein Passwort ein, dann den Code, den wir an deine Adresse schicken.",
|
"passwordDeleteIntroByMail": "Dein Konto wird mit allem darin gelöscht, in jedem Dienst dieser Instanz. Keiner seiner Authenticators funktioniert gerade, daher bestätigt es ein Code per E-Mail: Gib dein Passwort ein, dann den Code, den wir an deine Adresse schicken.",
|
||||||
"passwordDeleteSendCode": "Code per E-Mail schicken",
|
"passwordDeleteSendCode": "Code per E-Mail schicken",
|
||||||
"passwordChangedSignInAgain": "Dein Passwort wurde geändert, aber dieser Dienst konnte dich gerade noch nicht anmelden. Melde dich wieder mit dem neuen Passwort an."
|
"passwordChangedSignInAgain": "Dein Passwort wurde geändert, aber dieser Dienst konnte dich gerade noch nicht anmelden. Melde dich wieder mit dem neuen Passwort an.",
|
||||||
|
"passwordSignInAgain": "Um einem Konto, das sich mit GitHub anmeldet, ein Passwort zu geben, melde dich erst wieder mit GitHub an: Es ist mehr als ein paar Minuten her.",
|
||||||
|
"passwordSignInWithGitHubAgain": "Wieder mit GitHub anmelden",
|
||||||
|
"passwordOfferTitle": "Richte ein Passwort ein",
|
||||||
|
"passwordOfferBody": "Die Anmeldung mit GitHub endet. Richte ein Passwort und eine Authenticator-App ein, um dein Konto zu behalten.",
|
||||||
|
"passwordOfferLater": "Später"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -462,5 +462,10 @@
|
||||||
"passwordAddressUnknown": "Enter your account's email address: the code goes there.",
|
"passwordAddressUnknown": "Enter your account's email address: the code goes there.",
|
||||||
"passwordDeleteIntroByMail": "Your account goes with everything in it, in every service of this instance. None of its authenticators works now, so a code by mail confirms it: type your password, then the code we email to your address.",
|
"passwordDeleteIntroByMail": "Your account goes with everything in it, in every service of this instance. None of its authenticators works now, so a code by mail confirms it: type your password, then the code we email to your address.",
|
||||||
"passwordDeleteSendCode": "Email me a code",
|
"passwordDeleteSendCode": "Email me a code",
|
||||||
"passwordChangedSignInAgain": "Your password was changed, but this service couldn't sign you in just yet. Sign in again with the new password."
|
"passwordChangedSignInAgain": "Your password was changed, but this service couldn't sign you in just yet. Sign in again with the new password.",
|
||||||
|
"passwordSignInAgain": "To give an account that signs in with GitHub its password, sign in with GitHub again first: it has been more than a few minutes.",
|
||||||
|
"passwordSignInWithGitHubAgain": "Sign in with GitHub again",
|
||||||
|
"passwordOfferTitle": "Set up a password",
|
||||||
|
"passwordOfferBody": "Sign-in with GitHub is ending. Set up a password and an authenticator app to keep your account.",
|
||||||
|
"passwordOfferLater": "Later"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1398,6 +1398,36 @@ abstract class HalkyonAppLocalizations {
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Your password was changed, but this service couldn\'t sign you in just yet. Sign in again with the new password.'**
|
/// **'Your password was changed, but this service couldn\'t sign you in just yet. Sign in again with the new password.'**
|
||||||
String get passwordChangedSignInAgain;
|
String get passwordChangedSignInAgain;
|
||||||
|
|
||||||
|
/// No description provided for @passwordSignInAgain.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'To give an account that signs in with GitHub its password, sign in with GitHub again first: it has been more than a few minutes.'**
|
||||||
|
String get passwordSignInAgain;
|
||||||
|
|
||||||
|
/// No description provided for @passwordSignInWithGitHubAgain.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Sign in with GitHub again'**
|
||||||
|
String get passwordSignInWithGitHubAgain;
|
||||||
|
|
||||||
|
/// No description provided for @passwordOfferTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Set up a password'**
|
||||||
|
String get passwordOfferTitle;
|
||||||
|
|
||||||
|
/// No description provided for @passwordOfferBody.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Sign-in with GitHub is ending. Set up a password and an authenticator app to keep your account.'**
|
||||||
|
String get passwordOfferBody;
|
||||||
|
|
||||||
|
/// No description provided for @passwordOfferLater.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Later'**
|
||||||
|
String get passwordOfferLater;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HalkyonAppLocalizationsDelegate
|
class _HalkyonAppLocalizationsDelegate
|
||||||
|
|
|
||||||
|
|
@ -811,4 +811,21 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get passwordChangedSignInAgain =>
|
String get passwordChangedSignInAgain =>
|
||||||
'Dein Passwort wurde geändert, aber dieser Dienst konnte dich gerade noch nicht anmelden. Melde dich wieder mit dem neuen Passwort an.';
|
'Dein Passwort wurde geändert, aber dieser Dienst konnte dich gerade noch nicht anmelden. Melde dich wieder mit dem neuen Passwort an.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordSignInAgain =>
|
||||||
|
'Um einem Konto, das sich mit GitHub anmeldet, ein Passwort zu geben, melde dich erst wieder mit GitHub an: Es ist mehr als ein paar Minuten her.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordSignInWithGitHubAgain => 'Wieder mit GitHub anmelden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordOfferTitle => 'Richte ein Passwort ein';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordOfferBody =>
|
||||||
|
'Die Anmeldung mit GitHub endet. Richte ein Passwort und eine Authenticator-App ein, um dein Konto zu behalten.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordOfferLater => 'Später';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -794,6 +794,23 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get passwordChangedSignInAgain =>
|
String get passwordChangedSignInAgain =>
|
||||||
'Your password was changed, but this service couldn\'t sign you in just yet. Sign in again with the new password.';
|
'Your password was changed, but this service couldn\'t sign you in just yet. Sign in again with the new password.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordSignInAgain =>
|
||||||
|
'To give an account that signs in with GitHub its password, sign in with GitHub again first: it has been more than a few minutes.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordSignInWithGitHubAgain => 'Sign in with GitHub again';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordOfferTitle => 'Set up a password';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordOfferBody =>
|
||||||
|
'Sign-in with GitHub is ending. Set up a password and an authenticator app to keep your account.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordOfferLater => 'Later';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,9 @@ class SessionBloc(final AuthRepository _auth, final SignedOutMark _mark)
|
||||||
on<SessionEventSignInRequested>(_onSignInRequested);
|
on<SessionEventSignInRequested>(_onSignInRequested);
|
||||||
on<SessionEventStartedAnonymously>(_onStartedAnonymously);
|
on<SessionEventStartedAnonymously>(_onStartedAnonymously);
|
||||||
on<SessionEventGitHubRequested>(_onGitHubRequested);
|
on<SessionEventGitHubRequested>(_onGitHubRequested);
|
||||||
|
on<SessionEventPasswordOfferDone>(
|
||||||
|
(event, emit) => emit(_stateOf(_auth.status)),
|
||||||
|
);
|
||||||
on<SessionEventSignedOut>(
|
on<SessionEventSignedOut>(
|
||||||
(event, emit) => emit(SessionStateSignedOut(problem: event.problem)),
|
(event, emit) => emit(SessionStateSignedOut(problem: event.problem)),
|
||||||
);
|
);
|
||||||
|
|
@ -84,12 +87,19 @@ class SessionBloc(final AuthRepository _auth, final SignedOutMark _mark)
|
||||||
Emitter<SessionState> emit,
|
Emitter<SessionState> emit,
|
||||||
) async {
|
) async {
|
||||||
emit(const SessionStateSignedOut(busy: true));
|
emit(const SessionStateSignedOut(busy: true));
|
||||||
|
// The person signed in with GitHub is offered a password first (ADR
|
||||||
|
// 0017, D2: "at their next sign-in"), while that sign-in is recent
|
||||||
|
// enough for the instance to take one (the fourth reading).
|
||||||
|
_offerPassword = true;
|
||||||
try {
|
try {
|
||||||
if (!await _auth.switchToGitHub()) {
|
if (!await _auth.switchToGitHub()) {
|
||||||
|
_offerPassword = false;
|
||||||
emit(const SessionStateSignedOut());
|
emit(const SessionStateSignedOut());
|
||||||
}
|
}
|
||||||
// Signed in: the status change shows the person's app.
|
// Signed in: the status change shows the offer, then the person's
|
||||||
|
// app.
|
||||||
} on GitHubSignInFailed catch (e) {
|
} on GitHubSignInFailed catch (e) {
|
||||||
|
_offerPassword = false;
|
||||||
emit(
|
emit(
|
||||||
SessionStateSignedOut(
|
SessionStateSignedOut(
|
||||||
problem: e.problem == GitHubSignInProblem.unknownAccount
|
problem: e.problem == GitHubSignInProblem.unknownAccount
|
||||||
|
|
@ -98,11 +108,16 @@ class SessionBloc(final AuthRepository _auth, final SignedOutMark _mark)
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} on Object catch (error, st) {
|
} on Object catch (error, st) {
|
||||||
|
_offerPassword = false;
|
||||||
addError(error, st);
|
addError(error, st);
|
||||||
emit(const SessionStateSignedOut(problem: SignedOutProblem.failed));
|
emit(const SessionStateSignedOut(problem: SignedOutProblem.failed));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the next person signed in comes from a GitHub sign-in, and is
|
||||||
|
/// offered a password before their app ([SessionStateGitHubSignedIn]).
|
||||||
|
bool _offerPassword = false;
|
||||||
|
|
||||||
Future<void> _onSignOutRequested(
|
Future<void> _onSignOutRequested(
|
||||||
SessionEventSignOutRequested event,
|
SessionEventSignOutRequested event,
|
||||||
Emitter<SessionState> emit,
|
Emitter<SessionState> emit,
|
||||||
|
|
@ -132,9 +147,15 @@ class SessionBloc(final AuthRepository _auth, final SignedOutMark _mark)
|
||||||
}
|
}
|
||||||
|
|
||||||
SessionState _stateOf(AuthStatus status) => switch (status) {
|
SessionState _stateOf(AuthStatus status) => switch (status) {
|
||||||
|
SignedIn() when _offerPassword => _offered(status),
|
||||||
SignedIn() => SessionStateActive(status),
|
SignedIn() => SessionStateActive(status),
|
||||||
SignedOut() => const SessionStateSignedOut(),
|
SignedOut() => const SessionStateSignedOut(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
SessionState _offered(SignedIn person) {
|
||||||
|
_offerPassword = false;
|
||||||
|
return SessionStateGitHubSignedIn(person);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What happens to the session.
|
/// What happens to the session.
|
||||||
|
|
@ -163,6 +184,10 @@ final class const SessionEventSignedOut({final SignedOutProblem? problem})
|
||||||
List<Object?> get props => [problem];
|
List<Object?> get props => [problem];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The password offered after a GitHub sign-in was set up, put off, or not
|
||||||
|
/// needed: on to the person's app.
|
||||||
|
final class const SessionEventPasswordOfferDone() extends SessionEvent;
|
||||||
|
|
||||||
/// The person confirmed that they want to sign out.
|
/// The person confirmed that they want to sign out.
|
||||||
final class const SessionEventSignOutRequested() extends SessionEvent;
|
final class const SessionEventSignOutRequested() extends SessionEvent;
|
||||||
|
|
||||||
|
|
@ -195,6 +220,17 @@ final class const SessionStateActive(
|
||||||
List<Object?> get props => [person];
|
List<Object?> get props => [person];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [person] signed in with GitHub just now: before their app, a password
|
||||||
|
/// is offered where the instance offers one and they have none (ADR 0017,
|
||||||
|
/// D2), while the sign-in is recent enough to set it.
|
||||||
|
final class const SessionStateGitHubSignedIn(
|
||||||
|
/// Who signed in.
|
||||||
|
final SignedIn person,
|
||||||
|
) extends SessionState {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [person];
|
||||||
|
}
|
||||||
|
|
||||||
/// Nobody is signed in since signing out: the app asks how to start.
|
/// Nobody is signed in since signing out: the app asks how to start.
|
||||||
final class const SessionStateSignedOut({
|
final class const SessionStateSignedOut({
|
||||||
/// Whether a way to start is under way.
|
/// Whether a way to start is under way.
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,12 @@ import 'package:halkyon_app/src/app/app_scope.dart';
|
||||||
import 'package:halkyon_app/src/auth/bloc/session_bloc.dart';
|
import 'package:halkyon_app/src/auth/bloc/session_bloc.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/widget/start_screen.dart';
|
import 'package:halkyon_app/src/auth/widget/start_screen.dart';
|
||||||
|
import 'package:halkyon_app/src/instance/instance_scope.dart';
|
||||||
|
import 'package:halkyon_app/src/password/bloc/account_setup_cubit.dart';
|
||||||
|
import 'package:halkyon_app/src/password/model/account_security.dart';
|
||||||
|
import 'package:halkyon_app/src/password/widget/account_setup_sheet.dart';
|
||||||
|
import 'package:halkyon_app/src/password/widget/password_texts.dart';
|
||||||
|
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
/// Shows the app of whoever is signed in, signing in anonymously at a
|
/// Shows the app of whoever is signed in, signing in anonymously at a
|
||||||
|
|
@ -46,6 +52,10 @@ class const SessionGate({
|
||||||
look: look(context),
|
look: look(context),
|
||||||
home: StartScreen(state: signedOut),
|
home: StartScreen(state: signedOut),
|
||||||
),
|
),
|
||||||
|
SessionStateGitHubSignedIn() => PlainApp(
|
||||||
|
look: look(context),
|
||||||
|
home: const _PasswordOffer(),
|
||||||
|
),
|
||||||
SessionStateActive(:final person) => KeyedSubtree(
|
SessionStateActive(:final person) => KeyedSubtree(
|
||||||
key: ValueKey(person.authUserId),
|
key: ValueKey(person.authUserId),
|
||||||
child: app(person),
|
child: app(person),
|
||||||
|
|
@ -78,3 +88,106 @@ class const _SignInFailed() extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// After a GitHub sign-in, a password for an account that has none, where
|
||||||
|
/// the instance offers them (ADR 0017, D2: GitHub sign-in ends), while the
|
||||||
|
/// sign-in is recent enough for the instance to take one; else, or once
|
||||||
|
/// set up or put off, on to the person's app.
|
||||||
|
class const _PasswordOffer() extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
State<_PasswordOffer> createState() => _PasswordOfferState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PasswordOfferState() extends State<_PasswordOffer> {
|
||||||
|
late final Future<AccountSecurity?> _security =
|
||||||
|
AppScope.of(
|
||||||
|
context,
|
||||||
|
).passwords.security().then<AccountSecurity?>(
|
||||||
|
(security) => security,
|
||||||
|
onError: (Object _) => null,
|
||||||
|
);
|
||||||
|
|
||||||
|
void _done() =>
|
||||||
|
context.read<SessionBloc>().add(const SessionEventPasswordOfferDone());
|
||||||
|
|
||||||
|
Future<void> _setUp(AccountSecurity security) async {
|
||||||
|
final address = security.hasAddress
|
||||||
|
? await AppScope.of(context).auth.emailAddress()
|
||||||
|
: null;
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await showAccountSetupSheet(
|
||||||
|
context,
|
||||||
|
mode: AccountSetupMode.setPassword,
|
||||||
|
instance:
|
||||||
|
InstanceScope.maybeOf(context)?.choice.current?.name ??
|
||||||
|
AppScope.of(context).serverName,
|
||||||
|
hasAddress: security.hasAddress,
|
||||||
|
address: address,
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
_done();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = HalkyonAppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return FutureBuilder<AccountSecurity?>(
|
||||||
|
future: _security,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState != ConnectionState.done) {
|
||||||
|
return const Scaffold(body: Center(child: AppLoadingIndicator()));
|
||||||
|
}
|
||||||
|
final security = snapshot.data;
|
||||||
|
if (security == null || !security.offered || security.hasPassword) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
_done();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return const Scaffold(body: Center(child: AppLoadingIndicator()));
|
||||||
|
}
|
||||||
|
return Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 480),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
l10n.passwordOfferTitle,
|
||||||
|
style: theme.textTheme.headlineSmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(switch (security.gitHubEnds) {
|
||||||
|
final ends? => l10n.passwordSectionGitHubEnds(
|
||||||
|
passwordMoment(context, ends),
|
||||||
|
),
|
||||||
|
null => l10n.passwordOfferBody,
|
||||||
|
}),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => _setUp(security),
|
||||||
|
child: Text(l10n.passwordSectionSetUp),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextButton(
|
||||||
|
onPressed: _done,
|
||||||
|
child: Text(l10n.passwordOfferLater),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,9 @@ enum AccountSetupMode() {
|
||||||
signUp,
|
signUp,
|
||||||
|
|
||||||
/// A password for the signed-in person (anonymous, or signing in with
|
/// A password for the signed-in person (anonymous, or signing in with
|
||||||
/// GitHub or codes by mail): they keep everything.
|
/// GitHub or codes by mail): they keep everything. A session alone sets
|
||||||
|
/// none: an address of theirs takes a code mailed to it, GitHub a recent
|
||||||
|
/// sign-in (the fourth reading).
|
||||||
setPassword,
|
setPassword,
|
||||||
|
|
||||||
/// The reset of a whole account, once due: a new password, and new keys
|
/// The reset of a whole account, once due: a new password, and new keys
|
||||||
|
|
@ -123,15 +125,16 @@ class AccountSetupCubit(
|
||||||
/// person's own); null where the setup asks for it.
|
/// person's own); null where the setup asks for it.
|
||||||
final String? address,
|
final String? address,
|
||||||
|
|
||||||
/// Whether the signed-in person has an address already, so no code is
|
/// Whether the signed-in person has an address already, which the code
|
||||||
/// needed ([AccountSetupMode.setPassword]).
|
/// then goes to as proof that they are its owner
|
||||||
|
/// ([AccountSetupMode.setPassword], [MailCodePurpose.firstPassword]).
|
||||||
final bool _hasAddress = false,
|
final bool _hasAddress = false,
|
||||||
final String? _phrase,
|
final String? _phrase,
|
||||||
}) extends Cubit<AccountSetupState> {
|
}) extends Cubit<AccountSetupState> {
|
||||||
this
|
this
|
||||||
: super(
|
: super(
|
||||||
AccountSetupState(
|
AccountSetupState(
|
||||||
step: address == null && !_hasAddress
|
step: address == null
|
||||||
? AccountSetupStep.address
|
? AccountSetupStep.address
|
||||||
: AccountSetupStep.password,
|
: AccountSetupStep.password,
|
||||||
address: address ?? '',
|
address: address ?? '',
|
||||||
|
|
@ -221,13 +224,9 @@ class AccountSetupCubit(
|
||||||
await _run(_toCode);
|
await _run(_toCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The code mailed to the address, where one is needed; else the setup
|
/// The code mailed to the address: nothing goes to the instance without
|
||||||
/// goes to the instance at once.
|
/// it.
|
||||||
Future<void> _toCode() async {
|
Future<void> _toCode() async {
|
||||||
if (_mode == AccountSetupMode.setPassword && _hasAddress) {
|
|
||||||
await _submit();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_request = await _passwords.sendCode(
|
_request = await _passwords.sendCode(
|
||||||
state.address,
|
state.address,
|
||||||
language: _language,
|
language: _language,
|
||||||
|
|
@ -237,9 +236,12 @@ class AccountSetupCubit(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the codes mailed here are for.
|
/// What the codes mailed here are for.
|
||||||
MailCodePurpose get _codePurpose => _mode == AccountSetupMode.finishReset
|
MailCodePurpose get _codePurpose => switch (_mode) {
|
||||||
? MailCodePurpose.accountReset
|
AccountSetupMode.finishReset => MailCodePurpose.accountReset,
|
||||||
: MailCodePurpose.signUp;
|
AccountSetupMode.setPassword when _hasAddress =>
|
||||||
|
MailCodePurpose.firstPassword,
|
||||||
|
_ => MailCodePurpose.signUp,
|
||||||
|
};
|
||||||
|
|
||||||
/// Mails a new code.
|
/// Mails a new code.
|
||||||
Future<void> mailCodeResent() => _run(() async {
|
Future<void> mailCodeResent() => _run(() async {
|
||||||
|
|
@ -273,7 +275,7 @@ class AccountSetupCubit(
|
||||||
case AccountSetupMode.setPassword:
|
case AccountSetupMode.setPassword:
|
||||||
await _passwords.setPassword(
|
await _passwords.setPassword(
|
||||||
material,
|
material,
|
||||||
address: _hasAddress ? null : state.address,
|
address: state.address,
|
||||||
request: _request,
|
request: _request,
|
||||||
code: code,
|
code: code,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,8 @@ class ServerpodPasswordGateway(
|
||||||
protocol.EmailCodePurpose.authenticatorReset,
|
protocol.EmailCodePurpose.authenticatorReset,
|
||||||
MailCodePurpose.accountReset => protocol.EmailCodePurpose.accountReset,
|
MailCodePurpose.accountReset => protocol.EmailCodePurpose.accountReset,
|
||||||
MailCodePurpose.deletion => protocol.EmailCodePurpose.deletion,
|
MailCodePurpose.deletion => protocol.EmailCodePurpose.deletion,
|
||||||
|
MailCodePurpose.firstPassword =>
|
||||||
|
protocol.EmailCodePurpose.firstPassword,
|
||||||
},
|
},
|
||||||
)).uuid,
|
)).uuid,
|
||||||
);
|
);
|
||||||
|
|
@ -449,6 +451,7 @@ PasswordProblem _problemOf(protocol.PasswordRefusal reason) => switch (reason) {
|
||||||
protocol.PasswordRefusal.codesWait => PasswordProblem.codesWait,
|
protocol.PasswordRefusal.codesWait => PasswordProblem.codesWait,
|
||||||
protocol.PasswordRefusal.sessionEnded => PasswordProblem.sessionEnded,
|
protocol.PasswordRefusal.sessionEnded => PasswordProblem.sessionEnded,
|
||||||
protocol.PasswordRefusal.passwordChanged => PasswordProblem.passwordChanged,
|
protocol.PasswordRefusal.passwordChanged => PasswordProblem.passwordChanged,
|
||||||
|
protocol.PasswordRefusal.signInAgain => PasswordProblem.signInAgain,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Keeps one session in memory, for a client used once.
|
/// Keeps one session in memory, for a client used once.
|
||||||
|
|
|
||||||
|
|
@ -17,4 +17,8 @@ enum MailCodePurpose() {
|
||||||
|
|
||||||
/// Deleting an account whose setup never finished.
|
/// Deleting an account whose setup never finished.
|
||||||
deletion,
|
deletion,
|
||||||
|
|
||||||
|
/// Proving, signed in, the account's own address to give it its first
|
||||||
|
/// password: a session alone sets none.
|
||||||
|
firstPassword,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,11 @@ enum PasswordProblem() {
|
||||||
/// session for it yet: sign in again with the new password.
|
/// session for it yet: sign in again with the new password.
|
||||||
passwordChanged,
|
passwordChanged,
|
||||||
|
|
||||||
|
/// An account that signs in with GitHub alone takes its first password
|
||||||
|
/// only within minutes of a GitHub sign-in: sign in with GitHub again,
|
||||||
|
/// then go on.
|
||||||
|
signInAgain,
|
||||||
|
|
||||||
/// No reset waits for the address.
|
/// No reset waits for the address.
|
||||||
noReset,
|
noReset,
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -152,10 +152,10 @@ class const _AccountSecurity() extends StatelessWidget {
|
||||||
final instance =
|
final instance =
|
||||||
InstanceScope.maybeOf(context)?.choice.current?.name ??
|
InstanceScope.maybeOf(context)?.choice.current?.name ??
|
||||||
AppScope.of(context).serverName;
|
AppScope.of(context).serverName;
|
||||||
// The address this device knows the person by, to name the account in
|
// The address this device knows the person by, which the code goes to;
|
||||||
// their authenticator app.
|
// the setup asks for it where the device doesn't know it.
|
||||||
final address = security.hasAddress
|
final address = security.hasAddress
|
||||||
? await AppScope.of(context).auth.emailAddress() ?? ''
|
? await AppScope.of(context).auth.emailAddress()
|
||||||
: null;
|
: null;
|
||||||
if (!context.mounted) {
|
if (!context.mounted) {
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:halkyon_app/l10n/halkyon_app_localizations.dart';
|
import 'package:halkyon_app/l10n/halkyon_app_localizations.dart';
|
||||||
import 'package:halkyon_app/src/app/app_scope.dart';
|
import 'package:halkyon_app/src/app/app_scope.dart';
|
||||||
import 'package:halkyon_app/src/password/bloc/account_setup_cubit.dart';
|
import 'package:halkyon_app/src/password/bloc/account_setup_cubit.dart';
|
||||||
|
import 'package:halkyon_app/src/password/model/password_failed.dart';
|
||||||
import 'package:halkyon_app/src/password/widget/password_texts.dart';
|
import 'package:halkyon_app/src/password/widget/password_texts.dart';
|
||||||
import 'package:halkyon_app/src/password/widget/password_widgets.dart';
|
import 'package:halkyon_app/src/password/widget/password_widgets.dart';
|
||||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||||
|
|
@ -9,8 +10,9 @@ import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
/// Sets up an account's password in a sheet (ADR 0017), as [mode] says: a
|
/// Sets up an account's password in a sheet (ADR 0017), as [mode] says: a
|
||||||
/// new account on [instance] from a device signed out, a password for the
|
/// new account on [instance] from a device signed out, a password for the
|
||||||
/// signed-in person ([hasAddress]: whether they have an address, which
|
/// signed-in person ([hasAddress]: whether they have an address, which the
|
||||||
/// then needs no code), or the reset of the account at [address] once due
|
/// code then goes to; GitHub's people sign in with GitHub again should the
|
||||||
|
/// instance ask), or the reset of the account at [address] once due
|
||||||
/// ([phrase]: the recovery phrase that keeps its keys, else new ones).
|
/// ([phrase]: the recovery phrase that keeps its keys, else new ones).
|
||||||
/// True once done; the sheet can't be swiped away, as an account half set
|
/// True once done; the sheet can't be swiped away, as an account half set
|
||||||
/// up would otherwise be left behind unnoticed.
|
/// up would otherwise be left behind unnoticed.
|
||||||
|
|
@ -76,6 +78,15 @@ class _AccountSetupState() extends State<_AccountSetup> {
|
||||||
|
|
||||||
AccountSetupCubit get _cubit => context.read<AccountSetupCubit>();
|
AccountSetupCubit get _cubit => context.read<AccountSetupCubit>();
|
||||||
|
|
||||||
|
/// A GitHub sign-in made anew, then the code again (the instance takes a
|
||||||
|
/// first password for GitHub's people only within minutes of one).
|
||||||
|
Future<void> _signInWithGitHubAgain() async {
|
||||||
|
final cubit = _cubit;
|
||||||
|
if (await AppScope.of(context).auth.switchToGitHub() && mounted) {
|
||||||
|
await cubit.mailCodeEntered(_code.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _submit(AccountSetupState state) {
|
void _submit(AccountSetupState state) {
|
||||||
switch (state.step) {
|
switch (state.step) {
|
||||||
case AccountSetupStep.address:
|
case AccountSetupStep.address:
|
||||||
|
|
@ -259,6 +270,14 @@ class _AccountSetupState() extends State<_AccountSetup> {
|
||||||
child: Text(l10n.passwordResendCode),
|
child: Text(l10n.passwordResendCode),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (state.problem?.problem == PasswordProblem.signInAgain)
|
||||||
|
Align(
|
||||||
|
alignment: AlignmentDirectional.centerStart,
|
||||||
|
child: FilledButton.tonal(
|
||||||
|
onPressed: enabled ? _signInWithGitHubAgain : null,
|
||||||
|
child: Text(l10n.passwordSignInWithGitHubAgain),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
AccountSetupStep.authenticator => AuthenticatorSetupView(
|
AccountSetupStep.authenticator => AuthenticatorSetupView(
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ String passwordProblemText(BuildContext context, PasswordFailed failed) {
|
||||||
},
|
},
|
||||||
PasswordProblem.sessionEnded => l10n.passwordSessionEnded,
|
PasswordProblem.sessionEnded => l10n.passwordSessionEnded,
|
||||||
PasswordProblem.passwordChanged => l10n.passwordChangedSignInAgain,
|
PasswordProblem.passwordChanged => l10n.passwordChangedSignInAgain,
|
||||||
|
PasswordProblem.signInAgain => l10n.passwordSignInAgain,
|
||||||
PasswordProblem.noReset => l10n.passwordNoReset,
|
PasswordProblem.noReset => l10n.passwordNoReset,
|
||||||
PasswordProblem.passwordExists => l10n.passwordExists,
|
PasswordProblem.passwordExists => l10n.passwordExists,
|
||||||
PasswordProblem.addressTaken => l10n.passwordAddressTaken,
|
PasswordProblem.addressTaken => l10n.passwordAddressTaken,
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,12 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
|
||||||
/// here.
|
/// here.
|
||||||
final gitHubPeople = <String>{};
|
final gitHubPeople = <String>{};
|
||||||
|
|
||||||
|
/// The people of [gitHubPeople] whose GitHub sign-in on this device is
|
||||||
|
/// too old for a first password (the fourth reading): refused
|
||||||
|
/// [PasswordProblem.signInAgain] until they are taken out, as signing in
|
||||||
|
/// with GitHub anew does in a test.
|
||||||
|
final staleGitHubSignIns = <String>{};
|
||||||
|
|
||||||
/// The codes mailed, in order.
|
/// The codes mailed, in order.
|
||||||
final mailed =
|
final mailed =
|
||||||
<
|
<
|
||||||
|
|
@ -396,12 +402,28 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
|
||||||
}) => _call('setPassword', () {
|
}) => _call('setPassword', () {
|
||||||
final person = _signedIn();
|
final person = _signedIn();
|
||||||
var account = _accountOfPerson(person);
|
var account = _accountOfPerson(person);
|
||||||
if (request != null && code != null) {
|
if (account != null) {
|
||||||
final address = _proven(request, code, MailCodePurpose.signUp);
|
// A session alone sets none: their own address proves them (the
|
||||||
if (accounts[address] case final other? when other.person != person) {
|
// fourth reading).
|
||||||
throw const PasswordFailed(PasswordProblem.addressTaken);
|
if (request == null || code == null) {
|
||||||
|
throw const PasswordFailed(PasswordProblem.wrongCredentials);
|
||||||
|
}
|
||||||
|
final address = _proven(request, code, MailCodePurpose.firstPassword);
|
||||||
|
if (address != account.address) {
|
||||||
|
throw const PasswordFailed(PasswordProblem.otherAddressLinked);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (gitHubPeople.contains(person) &&
|
||||||
|
staleGitHubSignIns.contains(person)) {
|
||||||
|
throw const PasswordFailed(PasswordProblem.signInAgain);
|
||||||
|
}
|
||||||
|
if (request != null && code != null) {
|
||||||
|
final address = _proven(request, code, MailCodePurpose.signUp);
|
||||||
|
if (accounts[address] case final other? when other.person != person) {
|
||||||
|
throw const PasswordFailed(PasswordProblem.addressTaken);
|
||||||
|
}
|
||||||
|
account = accounts[address] = FakePasswordAccount(person, address);
|
||||||
}
|
}
|
||||||
account ??= accounts[address] = FakePasswordAccount(person, address);
|
|
||||||
}
|
}
|
||||||
if (account == null) {
|
if (account == null) {
|
||||||
throw const PasswordFailed(PasswordProblem.noAddress);
|
throw const PasswordFailed(PasswordProblem.noAddress);
|
||||||
|
|
|
||||||
|
|
@ -111,14 +111,14 @@ void main() {
|
||||||
|
|
||||||
bloc.add(const SessionEventGitHubRequested());
|
bloc.add(const SessionEventGitHubRequested());
|
||||||
|
|
||||||
|
// A password is offered first (ADR 0017, D2), then the person's app.
|
||||||
|
const person = SignedIn(authUserId: 'github-person', isIdentified: true);
|
||||||
await expectLater(
|
await expectLater(
|
||||||
bloc.stream,
|
bloc.stream,
|
||||||
emitsThrough(
|
emitsThrough(const SessionStateGitHubSignedIn(person)),
|
||||||
const SessionStateActive(
|
|
||||||
SignedIn(authUserId: 'github-person', isIdentified: true),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
bloc.add(const SessionEventPasswordOfferDone());
|
||||||
|
await expectLater(bloc.stream, emits(const SessionStateActive(person)));
|
||||||
expect(gateway.signIns, 0);
|
expect(gateway.signIns, 0);
|
||||||
expect(mark.signedOut, isFalse);
|
expect(mark.signedOut, isFalse);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -119,10 +119,27 @@ void main() {
|
||||||
gateway.gitHubError = null;
|
gateway.gitHubError = null;
|
||||||
await tester.tap(find.text('Sign in with GitHub'));
|
await tester.tap(find.text('Sign in with GitHub'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
// A password is offered first (ADR 0017, D2), while the sign-in is
|
||||||
|
// recent enough to set one; put off, the person's app.
|
||||||
|
expect(find.text('Set up a password'), findsWidgets);
|
||||||
|
await tester.tap(find.text('Later'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('App of github-person'), findsOneWidget);
|
expect(find.text('App of github-person'), findsOneWidget);
|
||||||
expect(gateway.signIns, 0);
|
expect(gateway.signIns, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('after GitHub, offers no password to someone who has one, or '
|
||||||
|
'where the instance offers none', (tester) async {
|
||||||
|
dependencies.passwordGateway.offered = false;
|
||||||
|
await dependencies.signedOut.write(signedOut: true);
|
||||||
|
await start(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Sign in with GitHub'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Later'), findsNothing);
|
||||||
|
expect(find.text('App of github-person'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('offers only the ways the instance has', (tester) async {
|
testWidgets('offers only the ways the instance has', (tester) async {
|
||||||
gateway.offersGitHub = false;
|
gateway.offersGitHub = false;
|
||||||
dependencies.passwordGateway.offered = false;
|
dependencies.passwordGateway.offered = false;
|
||||||
|
|
|
||||||
|
|
@ -210,6 +210,69 @@ void main() {
|
||||||
expect((auth.status as SignedIn).authUserId, person);
|
expect((auth.status as SignedIn).authUserId, person);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('gives a signed-in person with an address their first password only '
|
||||||
|
'with a code mailed to that address for it, never with the session '
|
||||||
|
'alone (the fourth reading)', () async {
|
||||||
|
await auth.signInAnonymously();
|
||||||
|
final person = (auth.status as SignedIn).authUserId;
|
||||||
|
final first = setUpAs(AccountSetupMode.setPassword)
|
||||||
|
..addressEntered('anna@example.org');
|
||||||
|
await first.passwordEntered('correct horse', 'correct horse');
|
||||||
|
first.phraseWrittenDown();
|
||||||
|
await first.phraseChecked(wordsAsked(first.state));
|
||||||
|
await first.mailCodeEntered(gateway.codeFor('anna@example.org'));
|
||||||
|
// An address and no password, as codes by mail left accounts.
|
||||||
|
gateway.accountOf(person)
|
||||||
|
..password = null
|
||||||
|
..authenticators.clear();
|
||||||
|
|
||||||
|
final cubit = setUpAs(
|
||||||
|
AccountSetupMode.setPassword,
|
||||||
|
address: 'anna@example.org',
|
||||||
|
hasAddress: true,
|
||||||
|
);
|
||||||
|
expect(cubit.state, at(AccountSetupStep.password));
|
||||||
|
await cubit.passwordEntered('another horse', 'another horse');
|
||||||
|
cubit.phraseWrittenDown();
|
||||||
|
await cubit.phraseChecked(wordsAsked(cubit.state));
|
||||||
|
|
||||||
|
expect(cubit.state, at(AccountSetupStep.mailCode));
|
||||||
|
expect(gateway.mailed.last.purpose, MailCodePurpose.firstPassword);
|
||||||
|
await cubit.mailCodeEntered(gateway.codeFor('anna@example.org'));
|
||||||
|
expect(cubit.state, at(AccountSetupStep.authenticator));
|
||||||
|
expect(gateway.accountOf(person).password, 'another horse');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('asks for the address a signed-in person has where the device '
|
||||||
|
"doesn't know it", () {
|
||||||
|
final cubit = setUpAs(AccountSetupMode.setPassword, hasAddress: true);
|
||||||
|
expect(cubit.state, at(AccountSetupStep.address));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('asks GitHub people to sign in with GitHub again where theirs is '
|
||||||
|
'too old, keeping the code for after', () async {
|
||||||
|
await auth.switchToGitHub();
|
||||||
|
gateway
|
||||||
|
..gitHubPeople.add('github-person')
|
||||||
|
..staleGitHubSignIns.add('github-person');
|
||||||
|
final cubit = setUpAs(AccountSetupMode.setPassword)
|
||||||
|
..addressEntered('gina@example.org');
|
||||||
|
await cubit.passwordEntered('correct horse', 'correct horse');
|
||||||
|
cubit.phraseWrittenDown();
|
||||||
|
await cubit.phraseChecked(wordsAsked(cubit.state));
|
||||||
|
final code = gateway.codeFor('gina@example.org');
|
||||||
|
|
||||||
|
await cubit.mailCodeEntered(code);
|
||||||
|
expect(
|
||||||
|
cubit.state,
|
||||||
|
at(AccountSetupStep.mailCode, problem: PasswordProblem.signInAgain),
|
||||||
|
);
|
||||||
|
|
||||||
|
gateway.staleGitHubSignIns.clear();
|
||||||
|
await cubit.mailCodeEntered(code);
|
||||||
|
expect(cubit.state, at(AccountSetupStep.authenticator));
|
||||||
|
});
|
||||||
|
|
||||||
test("refuses another account's address to a signed-in person, back at "
|
test("refuses another account's address to a signed-in person, back at "
|
||||||
'the address', () async {
|
'the address', () async {
|
||||||
gateway.addAccount(
|
gateway.addAccount(
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,11 @@ enum EmailCodePurpose implements _isc.SerializableModel {
|
||||||
accountReset,
|
accountReset,
|
||||||
|
|
||||||
/// Deleting an account whose setup never finished, with the password.
|
/// Deleting an account whose setup never finished, with the password.
|
||||||
deletion;
|
deletion,
|
||||||
|
|
||||||
|
/// Proving, signed in, the account's own address to give it its first
|
||||||
|
/// password (the fourth reading: a session alone sets none).
|
||||||
|
firstPassword;
|
||||||
|
|
||||||
static EmailCodePurpose fromJson(String name) {
|
static EmailCodePurpose fromJson(String name) {
|
||||||
switch (name) {
|
switch (name) {
|
||||||
|
|
@ -46,6 +50,8 @@ enum EmailCodePurpose implements _isc.SerializableModel {
|
||||||
return EmailCodePurpose.accountReset;
|
return EmailCodePurpose.accountReset;
|
||||||
case 'deletion':
|
case 'deletion':
|
||||||
return EmailCodePurpose.deletion;
|
return EmailCodePurpose.deletion;
|
||||||
|
case 'firstPassword':
|
||||||
|
return EmailCodePurpose.firstPassword;
|
||||||
default:
|
default:
|
||||||
throw ArgumentError(
|
throw ArgumentError(
|
||||||
'Value "$name" cannot be converted to "EmailCodePurpose"',
|
'Value "$name" cannot be converted to "EmailCodePurpose"',
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue