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
18 changed files with 762 additions and 66 deletions
|
|
@ -215,5 +215,10 @@
|
|||
"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.",
|
||||
"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.",
|
||||
"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",
|
||||
"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:
|
||||
/// **'Your password was changed, but this service couldn\'t sign you in just yet. Sign in again with the new password.'**
|
||||
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
|
||||
|
|
|
|||
|
|
@ -811,4 +811,21 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
|
|||
@override
|
||||
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.';
|
||||
|
||||
@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
|
||||
String get passwordChangedSignInAgain =>
|
||||
'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`).
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ class SessionBloc(final AuthRepository _auth, final SignedOutMark _mark)
|
|||
on<SessionEventSignInRequested>(_onSignInRequested);
|
||||
on<SessionEventStartedAnonymously>(_onStartedAnonymously);
|
||||
on<SessionEventGitHubRequested>(_onGitHubRequested);
|
||||
on<SessionEventPasswordOfferDone>(
|
||||
(event, emit) => emit(_stateOf(_auth.status)),
|
||||
);
|
||||
on<SessionEventSignedOut>(
|
||||
(event, emit) => emit(SessionStateSignedOut(problem: event.problem)),
|
||||
);
|
||||
|
|
@ -84,12 +87,19 @@ class SessionBloc(final AuthRepository _auth, final SignedOutMark _mark)
|
|||
Emitter<SessionState> emit,
|
||||
) async {
|
||||
emit(const SessionStateSignedOut(busy: true));
|
||||
// The person signed in with GitHub is offered a password first (ADR
|
||||
// 0017, D2: "at their next sign-in"), while that sign-in is recent
|
||||
// enough for the instance to take one (the fourth reading).
|
||||
_offerPassword = true;
|
||||
try {
|
||||
if (!await _auth.switchToGitHub()) {
|
||||
_offerPassword = false;
|
||||
emit(const SessionStateSignedOut());
|
||||
}
|
||||
// Signed in: the status change shows the person's app.
|
||||
// Signed in: the status change shows the offer, then the person's
|
||||
// app.
|
||||
} on GitHubSignInFailed catch (e) {
|
||||
_offerPassword = false;
|
||||
emit(
|
||||
SessionStateSignedOut(
|
||||
problem: e.problem == GitHubSignInProblem.unknownAccount
|
||||
|
|
@ -98,11 +108,16 @@ class SessionBloc(final AuthRepository _auth, final SignedOutMark _mark)
|
|||
),
|
||||
);
|
||||
} on Object catch (error, st) {
|
||||
_offerPassword = false;
|
||||
addError(error, st);
|
||||
emit(const SessionStateSignedOut(problem: SignedOutProblem.failed));
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the next person signed in comes from a GitHub sign-in, and is
|
||||
/// offered a password before their app ([SessionStateGitHubSignedIn]).
|
||||
bool _offerPassword = false;
|
||||
|
||||
Future<void> _onSignOutRequested(
|
||||
SessionEventSignOutRequested event,
|
||||
Emitter<SessionState> emit,
|
||||
|
|
@ -132,9 +147,15 @@ class SessionBloc(final AuthRepository _auth, final SignedOutMark _mark)
|
|||
}
|
||||
|
||||
SessionState _stateOf(AuthStatus status) => switch (status) {
|
||||
SignedIn() when _offerPassword => _offered(status),
|
||||
SignedIn() => SessionStateActive(status),
|
||||
SignedOut() => const SessionStateSignedOut(),
|
||||
};
|
||||
|
||||
SessionState _offered(SignedIn person) {
|
||||
_offerPassword = false;
|
||||
return SessionStateGitHubSignedIn(person);
|
||||
}
|
||||
}
|
||||
|
||||
/// What happens to the session.
|
||||
|
|
@ -163,6 +184,10 @@ final class const SessionEventSignedOut({final SignedOutProblem? 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.
|
||||
final class const SessionEventSignOutRequested() extends SessionEvent;
|
||||
|
||||
|
|
@ -195,6 +220,17 @@ final class const SessionStateActive(
|
|||
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.
|
||||
final class const SessionStateSignedOut({
|
||||
/// 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/model/auth_status.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';
|
||||
|
||||
/// Shows the app of whoever is signed in, signing in anonymously at a
|
||||
|
|
@ -46,6 +52,10 @@ class const SessionGate({
|
|||
look: look(context),
|
||||
home: StartScreen(state: signedOut),
|
||||
),
|
||||
SessionStateGitHubSignedIn() => PlainApp(
|
||||
look: look(context),
|
||||
home: const _PasswordOffer(),
|
||||
),
|
||||
SessionStateActive(:final person) => KeyedSubtree(
|
||||
key: ValueKey(person.authUserId),
|
||||
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,
|
||||
|
||||
/// 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,
|
||||
|
||||
/// 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.
|
||||
final String? address,
|
||||
|
||||
/// Whether the signed-in person has an address already, so no code is
|
||||
/// needed ([AccountSetupMode.setPassword]).
|
||||
/// Whether the signed-in person has an address already, which the code
|
||||
/// then goes to as proof that they are its owner
|
||||
/// ([AccountSetupMode.setPassword], [MailCodePurpose.firstPassword]).
|
||||
final bool _hasAddress = false,
|
||||
final String? _phrase,
|
||||
}) extends Cubit<AccountSetupState> {
|
||||
this
|
||||
: super(
|
||||
AccountSetupState(
|
||||
step: address == null && !_hasAddress
|
||||
step: address == null
|
||||
? AccountSetupStep.address
|
||||
: AccountSetupStep.password,
|
||||
address: address ?? '',
|
||||
|
|
@ -221,13 +224,9 @@ class AccountSetupCubit(
|
|||
await _run(_toCode);
|
||||
}
|
||||
|
||||
/// The code mailed to the address, where one is needed; else the setup
|
||||
/// goes to the instance at once.
|
||||
/// The code mailed to the address: nothing goes to the instance without
|
||||
/// it.
|
||||
Future<void> _toCode() async {
|
||||
if (_mode == AccountSetupMode.setPassword && _hasAddress) {
|
||||
await _submit();
|
||||
return;
|
||||
}
|
||||
_request = await _passwords.sendCode(
|
||||
state.address,
|
||||
language: _language,
|
||||
|
|
@ -237,9 +236,12 @@ class AccountSetupCubit(
|
|||
}
|
||||
|
||||
/// What the codes mailed here are for.
|
||||
MailCodePurpose get _codePurpose => _mode == AccountSetupMode.finishReset
|
||||
? MailCodePurpose.accountReset
|
||||
: MailCodePurpose.signUp;
|
||||
MailCodePurpose get _codePurpose => switch (_mode) {
|
||||
AccountSetupMode.finishReset => MailCodePurpose.accountReset,
|
||||
AccountSetupMode.setPassword when _hasAddress =>
|
||||
MailCodePurpose.firstPassword,
|
||||
_ => MailCodePurpose.signUp,
|
||||
};
|
||||
|
||||
/// Mails a new code.
|
||||
Future<void> mailCodeResent() => _run(() async {
|
||||
|
|
@ -273,7 +275,7 @@ class AccountSetupCubit(
|
|||
case AccountSetupMode.setPassword:
|
||||
await _passwords.setPassword(
|
||||
material,
|
||||
address: _hasAddress ? null : state.address,
|
||||
address: state.address,
|
||||
request: _request,
|
||||
code: code,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -55,6 +55,8 @@ class ServerpodPasswordGateway(
|
|||
protocol.EmailCodePurpose.authenticatorReset,
|
||||
MailCodePurpose.accountReset => protocol.EmailCodePurpose.accountReset,
|
||||
MailCodePurpose.deletion => protocol.EmailCodePurpose.deletion,
|
||||
MailCodePurpose.firstPassword =>
|
||||
protocol.EmailCodePurpose.firstPassword,
|
||||
},
|
||||
)).uuid,
|
||||
);
|
||||
|
|
@ -449,6 +451,7 @@ PasswordProblem _problemOf(protocol.PasswordRefusal reason) => switch (reason) {
|
|||
protocol.PasswordRefusal.codesWait => PasswordProblem.codesWait,
|
||||
protocol.PasswordRefusal.sessionEnded => PasswordProblem.sessionEnded,
|
||||
protocol.PasswordRefusal.passwordChanged => PasswordProblem.passwordChanged,
|
||||
protocol.PasswordRefusal.signInAgain => PasswordProblem.signInAgain,
|
||||
};
|
||||
|
||||
/// Keeps one session in memory, for a client used once.
|
||||
|
|
|
|||
|
|
@ -17,4 +17,8 @@ enum MailCodePurpose() {
|
|||
|
||||
/// Deleting an account whose setup never finished.
|
||||
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.
|
||||
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.
|
||||
noReset,
|
||||
|
||||
|
|
|
|||
|
|
@ -152,10 +152,10 @@ class const _AccountSecurity() extends StatelessWidget {
|
|||
final instance =
|
||||
InstanceScope.maybeOf(context)?.choice.current?.name ??
|
||||
AppScope.of(context).serverName;
|
||||
// The address this device knows the person by, to name the account in
|
||||
// their authenticator app.
|
||||
// The address this device knows the person by, which the code goes to;
|
||||
// the setup asks for it where the device doesn't know it.
|
||||
final address = security.hasAddress
|
||||
? await AppScope.of(context).auth.emailAddress() ?? ''
|
||||
? await AppScope.of(context).auth.emailAddress()
|
||||
: null;
|
||||
if (!context.mounted) {
|
||||
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/src/app/app_scope.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_widgets.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
|
||||
/// new account on [instance] from a device signed out, a password for the
|
||||
/// signed-in person ([hasAddress]: whether they have an address, which
|
||||
/// then needs no code), or the reset of the account at [address] once due
|
||||
/// signed-in person ([hasAddress]: whether they have an address, which the
|
||||
/// 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).
|
||||
/// True once done; the sheet can't be swiped away, as an account half set
|
||||
/// up would otherwise be left behind unnoticed.
|
||||
|
|
@ -76,6 +78,15 @@ class _AccountSetupState() extends State<_AccountSetup> {
|
|||
|
||||
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) {
|
||||
switch (state.step) {
|
||||
case AccountSetupStep.address:
|
||||
|
|
@ -259,6 +270,14 @@ class _AccountSetupState() extends State<_AccountSetup> {
|
|||
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(
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ String passwordProblemText(BuildContext context, PasswordFailed failed) {
|
|||
},
|
||||
PasswordProblem.sessionEnded => l10n.passwordSessionEnded,
|
||||
PasswordProblem.passwordChanged => l10n.passwordChangedSignInAgain,
|
||||
PasswordProblem.signInAgain => l10n.passwordSignInAgain,
|
||||
PasswordProblem.noReset => l10n.passwordNoReset,
|
||||
PasswordProblem.passwordExists => l10n.passwordExists,
|
||||
PasswordProblem.addressTaken => l10n.passwordAddressTaken,
|
||||
|
|
|
|||
|
|
@ -76,6 +76,12 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
|
|||
/// here.
|
||||
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.
|
||||
final mailed =
|
||||
<
|
||||
|
|
@ -396,12 +402,28 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
|
|||
}) => _call('setPassword', () {
|
||||
final person = _signedIn();
|
||||
var account = _accountOfPerson(person);
|
||||
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);
|
||||
if (account != null) {
|
||||
// A session alone sets none: their own address proves them (the
|
||||
// fourth reading).
|
||||
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) {
|
||||
throw const PasswordFailed(PasswordProblem.noAddress);
|
||||
|
|
|
|||
|
|
@ -111,14 +111,14 @@ void main() {
|
|||
|
||||
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(
|
||||
bloc.stream,
|
||||
emitsThrough(
|
||||
const SessionStateActive(
|
||||
SignedIn(authUserId: 'github-person', isIdentified: true),
|
||||
),
|
||||
),
|
||||
emitsThrough(const SessionStateGitHubSignedIn(person)),
|
||||
);
|
||||
bloc.add(const SessionEventPasswordOfferDone());
|
||||
await expectLater(bloc.stream, emits(const SessionStateActive(person)));
|
||||
expect(gateway.signIns, 0);
|
||||
expect(mark.signedOut, isFalse);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -119,10 +119,27 @@ void main() {
|
|||
gateway.gitHubError = null;
|
||||
await tester.tap(find.text('Sign in with GitHub'));
|
||||
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(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 {
|
||||
gateway.offersGitHub = false;
|
||||
dependencies.passwordGateway.offered = false;
|
||||
|
|
|
|||
|
|
@ -210,6 +210,69 @@ void main() {
|
|||
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 "
|
||||
'the address', () async {
|
||||
gateway.addAccount(
|
||||
|
|
|
|||
Loading…
Reference in a new issue