feat(app): the warning that a session was ended for its proofs

A password sign-in whose account carries the warning (a session ended
for ten wrong proofs in a row) shows it before the sign-in is put in
place: when, what it means, and to change the password if it wasn't
the owner. Dismiss clears it on the instance through the held-back
session; Remind me later signs in and keeps it. The account section
shows it above the change of password, with Dismiss, while it stands.
Nothing identifies the session or its device.

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

View file

@ -221,5 +221,9 @@
"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",
"passwordAuthenticatorPasswordIntro": "Bestätige zum Hinzufügen mit deinem Passwort, dass du es bist."
"passwordAuthenticatorPasswordIntro": "Bestätige zum Hinzufügen mit deinem Passwort, dass du es bist.",
"passwordWarningTitle": "Jemand hat dein Passwort ausprobiert",
"passwordWarningBody": "Am {date} hat ein bei deinem Konto angemeldetes Gerät zehnmal hintereinander ein falsches Passwort oder einen falschen Code gesendet und wurde abgemeldet. Warst du das nicht, hat vielleicht jemand anderes dieses Gerät oder seine Sitzung: Ändere dein Passwort.",
"passwordWarningDismiss": "Ausblenden",
"passwordWarningLater": "Später erinnern"
}

View file

@ -468,5 +468,16 @@
"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",
"passwordAuthenticatorPasswordIntro": "To add it, confirm it is you with your password."
"passwordAuthenticatorPasswordIntro": "To add it, confirm it is you with your password.",
"passwordWarningTitle": "Someone tried your password",
"passwordWarningBody": "On {date}, a device signed in to your account sent a wrong password or code ten times in a row, and was signed out. If that wasn't you, someone else may have that device or its session: change your password.",
"@passwordWarningBody": {
"placeholders": {
"date": {
"type": "String"
}
}
},
"passwordWarningDismiss": "Dismiss",
"passwordWarningLater": "Remind me later"
}

View file

@ -1434,6 +1434,30 @@ abstract class HalkyonAppLocalizations {
/// In en, this message translates to:
/// **'To add it, confirm it is you with your password.'**
String get passwordAuthenticatorPasswordIntro;
/// No description provided for @passwordWarningTitle.
///
/// In en, this message translates to:
/// **'Someone tried your password'**
String get passwordWarningTitle;
/// No description provided for @passwordWarningBody.
///
/// In en, this message translates to:
/// **'On {date}, a device signed in to your account sent a wrong password or code ten times in a row, and was signed out. If that wasn\'t you, someone else may have that device or its session: change your password.'**
String passwordWarningBody(String date);
/// No description provided for @passwordWarningDismiss.
///
/// In en, this message translates to:
/// **'Dismiss'**
String get passwordWarningDismiss;
/// No description provided for @passwordWarningLater.
///
/// In en, this message translates to:
/// **'Remind me later'**
String get passwordWarningLater;
}
class _HalkyonAppLocalizationsDelegate

View file

@ -832,4 +832,18 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
@override
String get passwordAuthenticatorPasswordIntro =>
'Bestätige zum Hinzufügen mit deinem Passwort, dass du es bist.';
@override
String get passwordWarningTitle => 'Jemand hat dein Passwort ausprobiert';
@override
String passwordWarningBody(String date) {
return 'Am $date hat ein bei deinem Konto angemeldetes Gerät zehnmal hintereinander ein falsches Passwort oder einen falschen Code gesendet und wurde abgemeldet. Warst du das nicht, hat vielleicht jemand anderes dieses Gerät oder seine Sitzung: Ändere dein Passwort.';
}
@override
String get passwordWarningDismiss => 'Ausblenden';
@override
String get passwordWarningLater => 'Später erinnern';
}

View file

@ -815,6 +815,20 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
@override
String get passwordAuthenticatorPasswordIntro =>
'To add it, confirm it is you with your password.';
@override
String get passwordWarningTitle => 'Someone tried your password';
@override
String passwordWarningBody(String date) {
return 'On $date, a device signed in to your account sent a wrong password or code ten times in a row, and was signed out. If that wasn\'t you, someone else may have that device or its session: change your password.';
}
@override
String get passwordWarningDismiss => 'Dismiss';
@override
String get passwordWarningLater => 'Remind me later';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -35,6 +35,11 @@ enum PasswordSignInStep() {
/// The recovery codes the first authenticator brought, shown once.
recoveryCodes,
/// A session of the account was ended for ten wrong proofs in a row, at
/// [PasswordSignInState.sessionEndedAt]: read before the sign-in is put
/// in place, dismissed or kept for later.
sessionEndedWarning,
/// Signed in.
done,
}
@ -51,6 +56,9 @@ final class const PasswordSignInState({
final AuthenticatorSetup? authenticator,
final List<String> recoveryCodes = const [],
final DateTime? resetAt,
/// When a session of the account was ended for its wrong proofs.
final DateTime? sessionEndedAt,
}) with Equatable {
PasswordSignInState copyWith({
PasswordSignInStep? step,
@ -62,6 +70,7 @@ final class const PasswordSignInState({
AuthenticatorSetup? authenticator,
List<String>? recoveryCodes,
DateTime? resetAt,
DateTime? sessionEndedAt,
}) => PasswordSignInState(
step: step ?? this.step,
busy: busy ?? this.busy,
@ -71,6 +80,7 @@ final class const PasswordSignInState({
authenticator: authenticator ?? this.authenticator,
recoveryCodes: recoveryCodes ?? this.recoveryCodes,
resetAt: resetAt ?? this.resetAt,
sessionEndedAt: sessionEndedAt ?? this.sessionEndedAt,
);
@override
@ -83,6 +93,7 @@ final class const PasswordSignInState({
authenticator,
recoveryCodes,
resetAt,
sessionEndedAt,
];
}
@ -243,6 +254,17 @@ class PasswordSignInCubit(
/// The recovery codes are kept: signed in.
Future<void> recoveryCodesKept() => _run(_enter);
/// The warning that a session was ended for its wrong proofs is read and
/// dismissed: signed in.
Future<void> warningDismissed() => _run(() async {
await _passwords.dismissSessionEndedWarning(pending: _pending);
await _putInPlace();
});
/// The warning is kept for later (the account section shows it, and the
/// next sign-in again): signed in.
Future<void> warningKept() => _run(_putInPlace);
Future<void> _signIn({
String? totpCode,
String? recoveryCode,
@ -297,7 +319,23 @@ class PasswordSignInCubit(
/// What a new authenticator is called until its owner renames it.
static const _authenticatorName = 'Authenticator';
/// Signs in, once the warning of a session ended for its wrong proofs,
/// should the account have one, was read.
Future<void> _enter() async {
if (_pending!.sessionEndedAt case final ended?) {
emit(
state.copyWith(
step: PasswordSignInStep.sessionEndedWarning,
sessionEndedAt: ended,
clearProblem: true,
),
);
return;
}
await _putInPlace();
}
Future<void> _putInPlace() async {
await _passwords.enter(_pending!);
if (!isClosed) {
emit(state.copyWith(step: PasswordSignInStep.done, clearProblem: true));

View file

@ -21,6 +21,10 @@ typedef PasswordWays = ({
bool password,
bool authenticator,
bool secondFactorWorks,
/// When a session of the account was last ended for ten wrong proofs in
/// a row, until the warning is dismissed; null for none.
DateTime? sessionEndedForProofsAt,
});
/// What a signed-in owner proves to change how their account is secured
@ -120,6 +124,11 @@ abstract interface class PasswordGateway() {
/// How the signed-in person signs in.
Future<PasswordWays> ways();
/// Dismisses the signed-in person's warning that a session of theirs was
/// ended for its wrong proofs (as [as] where given: a sign-in held back
/// until the warning is read). Their session alone does.
Future<void> dismissSessionEndedWarning({SessionGrant? as});
/// A change of password's first step.
Future<protocol.PasswordChallenge> beginChange({SessionGrant? as});

View file

@ -44,6 +44,11 @@ final class const PendingSignIn({
/// Whether an authenticator must be set up before the password signs in
/// alone again.
required final bool needsAuthenticator,
/// When a session of the account was last ended for ten wrong proofs in
/// a row: the owner's warning, shown before the sign-in is put in place;
/// null for none.
final DateTime? sessionEndedAt,
});
/// A new recovery phrase made for the signed-in person's keys, shown before
@ -182,6 +187,7 @@ class PasswordRepository(
address: unlocked.address,
keys: keys,
needsAuthenticator: reply.needsAuthenticator,
sessionEndedAt: reply.sessionEndedForProofsAt,
);
}
@ -370,6 +376,7 @@ class PasswordRepository(
gitHubEnds: offers.gitHubEnds,
hasPassword: ways.password,
secondFactorWorks: ways.secondFactorWorks,
sessionEndedAt: ways.sessionEndedForProofsAt,
authenticators: ways.password
? await _gateway.authenticators()
: const <AccountAuthenticator>[],
@ -377,6 +384,14 @@ class PasswordRepository(
);
}
/// Dismisses the warning that a session of the account was ended for its
/// wrong proofs: the signed-in person's, or [pending]'s before it is put
/// in place. The session alone does: it hides a warning, nothing more.
Future<void> dismissSessionEndedWarning({PendingSignIn? pending}) async {
await _gateway.dismissSessionEndedWarning(as: pending?.grant);
_changed();
}
// Changes while signed in.
/// Changes the signed-in person's password from [oldPassword] to

View file

@ -205,9 +205,14 @@ class ServerpodPasswordGateway(
password: ways.password,
authenticator: ways.authenticator,
secondFactorWorks: ways.secondFactorWorks,
sessionEndedForProofsAt: ways.sessionEndedForProofsAt,
);
});
@override
Future<void> dismissSessionEndedWarning({SessionGrant? as}) =>
_as(as, (base) => base.password.dismissSessionEndedWarning());
@override
Future<protocol.PasswordChallenge> beginChange({SessionGrant? as}) =>
_as(as, (base) => base.password.beginChange());

View file

@ -24,6 +24,11 @@ final class const AccountSecurity({
/// does, deleting the account takes a code by mail.
final bool secondFactorWorks = false,
/// When a session of the account was last ended for ten wrong proofs in
/// a row: someone with a session tried the password or its codes. The
/// owner's warning until dismissed or a new password; null for none.
final DateTime? sessionEndedAt,
/// Their authenticators, the oldest first; empty without a password.
final List<AccountAuthenticator> authenticators = const [],
@ -42,6 +47,7 @@ final class const AccountSecurity({
gitHubEnds,
hasPassword,
secondFactorWorks,
sessionEndedAt,
authenticators,
keysHere,
];

View file

@ -62,6 +62,8 @@ class const _AccountSecurity() extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(l10n.passwordSectionTitle, style: theme.textTheme.titleSmall),
if (security.sessionEndedAt case final ended?)
_SessionEndedWarning(ended),
if (security.gitHub && security.gitHubEnds != null)
Padding(
padding: const EdgeInsets.only(top: 8),
@ -209,6 +211,104 @@ String? _problemOf(BuildContext context, SecurityStepState<Object?> state) =>
};
/// Changes the password: the current one, the new one twice, and a code.
/// The warning that a session of the account was ended at [_ended] for ten
/// wrong proofs in a row, with a way to dismiss it (the session alone does;
/// changing the password, right below, clears it too).
class const _SessionEndedWarning(final DateTime _ended) extends StatefulWidget {
@override
State<_SessionEndedWarning> createState() => _SessionEndedWarningState();
}
class _SessionEndedWarningState() extends State<_SessionEndedWarning> {
late final _step = SecurityStepCubit<bool>();
@override
void dispose() {
_step.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = HalkyonAppLocalizations.of(context);
final theme = Theme.of(context);
final colors = theme.colorScheme;
return BlocBuilder<SecurityStepCubit<bool>, SecurityStepState<bool>>(
bloc: _step,
builder: (context, state) => Padding(
padding: const EdgeInsets.only(top: 8),
child: DecoratedBox(
decoration: BoxDecoration(
color: colors.errorContainer,
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 8, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(
LucideIcons.shieldAlert,
color: colors.onErrorContainer,
),
const SizedBox(width: 12),
Expanded(
child: Text(
l10n.passwordWarningTitle,
style: theme.textTheme.titleSmall?.copyWith(
color: colors.onErrorContainer,
),
),
),
],
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.only(right: 8),
child: Text(
l10n.passwordWarningBody(
passwordMoment(context, widget._ended),
),
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onErrorContainer,
),
),
),
if (state.problem case final failed?)
Padding(
padding: const EdgeInsets.only(top: 8, right: 8),
child: Text(
passwordProblemText(context, failed),
style: theme.textTheme.bodySmall?.copyWith(
color: colors.error,
),
),
),
Align(
alignment: AlignmentDirectional.centerEnd,
child: TextButton(
onPressed: state.busy
? null
: () => _step.run(() async {
await AppScope.of(
context,
).passwords.dismissSessionEndedWarning();
return true;
}),
child: Text(l10n.passwordWarningDismiss),
),
),
],
),
),
),
),
);
}
}
class const _ChangePassword() extends StatefulWidget {
@override
State<_ChangePassword> createState() => _ChangePasswordState();

View file

@ -77,6 +77,8 @@ class _PasswordSignInState() extends State<_PasswordSignIn> {
_cubit.authenticatorCodeEntered(_code.text);
case PasswordSignInStep.recoveryCodes:
_cubit.recoveryCodesKept();
case PasswordSignInStep.sessionEndedWarning:
_cubit.warningDismissed();
case PasswordSignInStep.lostAuthenticator ||
PasswordSignInStep.resetRequested ||
PasswordSignInStep.done:
@ -107,6 +109,7 @@ class _PasswordSignInState() extends State<_PasswordSignIn> {
title: switch (state.step) {
PasswordSignInStep.authenticator => l10n.passwordAuthenticatorTitle,
PasswordSignInStep.recoveryCodes => l10n.passwordRecoveryCodesTitle,
PasswordSignInStep.sessionEndedWarning => l10n.passwordWarningTitle,
PasswordSignInStep.lostAuthenticator ||
PasswordSignInStep.resetMailCode ||
PasswordSignInStep.resetPhrase ||
@ -301,6 +304,17 @@ class _PasswordSignInState() extends State<_PasswordSignIn> {
if (problem case final problem?) SheetProblem(problem),
],
),
PasswordSignInStep.sessionEndedWarning => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.passwordWarningBody(
passwordMoment(context, state.sessionEndedAt!),
),
),
if (problem case final problem?) SheetProblem(problem),
],
),
PasswordSignInStep.done => const SheetWorking(),
};
}
@ -316,6 +330,7 @@ class _PasswordSignInState() extends State<_PasswordSignIn> {
PasswordSignInStep.authenticator => l10n.passwordContinue,
PasswordSignInStep.recoveryCodes => l10n.passwordCodesKept,
PasswordSignInStep.resetRequested => l10n.passwordDone,
PasswordSignInStep.sessionEndedWarning => l10n.passwordWarningDismiss,
PasswordSignInStep.lostAuthenticator => null,
PasswordSignInStep.done => null,
};
@ -327,9 +342,24 @@ class _PasswordSignInState() extends State<_PasswordSignIn> {
)
: null;
}
return FilledButton(
final submit = FilledButton(
onPressed: state.busy ? null : () => _submit(state),
child: state.busy ? const ButtonLoadingIndicator() : Text(label),
);
if (state.step != PasswordSignInStep.sessionEndedWarning) {
return submit;
}
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
submit,
const SizedBox(height: 8),
TextButton(
onPressed: state.busy ? null : _cubit.warningKept,
child: Text(l10n.passwordWarningLater),
),
],
);
}
}

View file

@ -50,6 +50,10 @@ final class FakePasswordAccount(
/// Whether the person signs in with GitHub.
bool gitHub = false;
/// When a session of the account was last ended for its wrong proofs:
/// the owner's warning, until dismissed or a new password.
DateTime? sessionEndedForProofsAt;
bool get finished => authenticators.any((each) => each.confirmed);
}
@ -350,6 +354,7 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
signingPublicKey: account.signingPublicKey,
passwordKeys: account.passwordKeys!,
needsAuthenticator: needsAuthenticator,
sessionEndedForProofsAt: account.sessionEndedForProofsAt,
authStrategy: 'session',
token: grant.session as String,
authUserId: protocol.UuidValue.fromString(account.person),
@ -566,9 +571,16 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
secondFactorWorks:
(account?.finished ?? false) &&
(account?.authenticatorReset?.isAfter(clock.now()) ?? true),
sessionEndedForProofsAt: account?.sessionEndedForProofsAt,
);
});
@override
Future<void> dismissSessionEndedWarning({SessionGrant? as}) =>
_call('dismissSessionEndedWarning', () {
_accountAs(as).sessionEndedForProofsAt = null;
});
@override
Future<protocol.PasswordChallenge> beginChange({SessionGrant? as}) =>
_call('beginChange', () {
@ -621,7 +633,8 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
_ownerProved(owner)
..password = utf8.decode(Uint8List.sublistView(password.verifier))
..passwordKeys = password.passwordKeys
..codesWaitUntil = null;
..codesWaitUntil = null
..sessionEndedForProofsAt = null;
});
/// The signed-in person's account, once the [owner]'s proof proved its
@ -744,7 +757,8 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
_secondFactor(account, totpCode: totpCode, recoveryCode: recoveryCode);
account
..password = utf8.decode(Uint8List.sublistView(password.verifier))
..passwordKeys = password.passwordKeys;
..passwordKeys = password.passwordKeys
..sessionEndedForProofsAt = null;
return _grant(account.person);
});
@ -787,7 +801,8 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
..accountReset = null
..authenticators.clear()
..recoveryCodes.clear()
..authenticatorReset = clock.now();
..authenticatorReset = clock.now()
..sessionEndedForProofsAt = null;
return _grant(account.person);
});
}

View file

@ -81,6 +81,23 @@ void main() {
expect(cubit.state.failed, isTrue);
expect(cubit.state.security, before);
});
test('carries the warning that a session was ended for its wrong '
'proofs, until it is dismissed', () async {
final ended = DateTime.utc(2026, 10, 19, 8, 30);
anna.sessionEndedForProofsAt = ended;
final cubit = AccountSecurityCubit(passwords);
addTearDown(cubit.close);
await cubit.load();
expect(cubit.state.security?.sessionEndedAt, ended);
await passwords.dismissSessionEndedWarning();
await pumpEventQueue();
expect(gateway.calls, contains('dismissSessionEndedWarning'));
expect(anna.sessionEndedForProofsAt, isNull);
expect(cubit.state.security?.sessionEndedAt, isNull);
});
});
group('a step of the account section', () {

View file

@ -82,6 +82,72 @@ void main() {
);
});
group('warns before signing in that a session was ended for its wrong '
'proofs', () {
late FakePasswordAccount account;
final ended = DateTime.utc(2026, 10, 19, 8, 30);
setUp(() async {
account = anna()..sessionEndedForProofsAt = ended;
await cubit.credentialsEntered('anna@example.org', 'correct horse');
});
test('with when, the sign-in held back until it is read', () async {
expect(await statesOf(cubit, () => cubit.codeEntered(fakeTotpCode)), [
at(PasswordSignInStep.secondFactor, busy: true),
at(PasswordSignInStep.sessionEndedWarning, busy: true),
at(PasswordSignInStep.sessionEndedWarning),
]);
expect(cubit.state.sessionEndedAt, ended);
expect(auth.status, const SignedOut());
});
test('dismissed: signed in, the warning gone', () async {
await cubit.codeEntered(fakeTotpCode);
expect(await statesOf(cubit, cubit.warningDismissed), [
at(PasswordSignInStep.sessionEndedWarning, busy: true),
at(PasswordSignInStep.done, busy: true),
at(PasswordSignInStep.done),
]);
expect(account.sessionEndedForProofsAt, isNull);
expect(
auth.status,
SignedIn(authUserId: account.person, isIdentified: true),
);
});
test('kept for later: signed in, the warning kept', () async {
await cubit.codeEntered(fakeTotpCode);
expect(await statesOf(cubit, cubit.warningKept), [
at(PasswordSignInStep.sessionEndedWarning, busy: true),
at(PasswordSignInStep.done, busy: true),
at(PasswordSignInStep.done),
]);
expect(account.sessionEndedForProofsAt, ended);
expect(
auth.status,
SignedIn(authUserId: account.person, isIdentified: true),
);
});
test('a dismissal the instance refuses stays on the warning, signing '
'nobody in', () async {
await cubit.codeEntered(fakeTotpCode);
gateway.failing = const PasswordFailed(PasswordProblem.failed);
expect(await statesOf(cubit, cubit.warningDismissed), [
at(PasswordSignInStep.sessionEndedWarning, busy: true),
at(
PasswordSignInStep.sessionEndedWarning,
problem: PasswordProblem.failed,
),
]);
expect(auth.status, const SignedOut());
});
});
test('refuses a wrong password and a wrong code alike, on the code, '
'signing nobody in', () async {
anna();

View file

@ -113,6 +113,52 @@ void main() {
expect(dependencies.addresses.addresses[anna.person], 'anna@example.org');
});
group('warns, before the app opens, that a session was ended for its '
'wrong proofs', () {
late FakePasswordAccount anna;
Future<void> signInAsAnna(WidgetTester tester) async {
anna = dependencies.passwordGateway.addAccount(
'anna@example.org',
password: 'correct horse',
crypto: dependencies.crypto,
)..sessionEndedForProofsAt = DateTime.utc(2026, 10, 19, 8, 30);
await start(tester);
await tester.tap(find.widgetWithText(FilledButton, 'Sign in'));
await tester.pumpAndSettle();
await tester.enterText(field('Email address'), 'anna@example.org');
await tester.enterText(field('Password'), 'correct horse');
await tapButton(tester, 'Continue');
await tester.enterText(
field('Code from your authenticator app'),
fakeTotpCode,
);
await tapButton(tester, 'Sign in');
}
testWidgets('saying when, then dismissed', (tester) async {
await signInAsAnna(tester);
expect(find.text('Someone tried your password'), findsOneWidget);
expect(find.textContaining('2026'), findsOneWidget);
expect(find.textContaining('change your password'), findsOneWidget);
expect(find.text('App of ${anna.person}'), findsNothing);
await tapButton(tester, 'Dismiss');
expect(find.text('App of ${anna.person}'), findsOneWidget);
expect(anna.sessionEndedForProofsAt, isNull);
});
testWidgets('or kept for later', (tester) async {
await signInAsAnna(tester);
await tester.tap(find.widgetWithText(TextButton, 'Remind me later'));
await tester.pumpAndSettle();
expect(find.text('App of ${anna.person}'), findsOneWidget);
expect(anna.sessionEndedForProofsAt, isNotNull);
});
});
testWidgets('an account never finished signs in with a code by mail, '
'then sets up its authenticator and keeps its recovery '
'codes', (tester) async {
@ -480,6 +526,35 @@ void main() {
expect(find.text('Change password'), findsNothing);
});
testWidgets('warns that a session was ended for its wrong proofs, until '
'dismissed', (tester) async {
final anna = dependencies.passwordGateway.addAccount(
'anna@example.org',
password: 'correct horse',
crypto: dependencies.crypto,
);
final passwords = dependencies.passwords;
await passwords.enter(
await passwords.signIn(
await passwords.unlock('anna@example.org', 'correct horse'),
totpCode: fakeTotpCode,
language: 'en',
),
);
anna.sessionEndedForProofsAt = DateTime.utc(2026, 10, 19, 8, 30);
await show(tester);
expect(find.text('Someone tried your password'), findsOneWidget);
expect(find.textContaining('change your password'), findsOneWidget);
// The change the warning suggests is right below it.
expect(find.text('Change password'), findsOneWidget);
await tester.tap(find.widgetWithText(TextButton, 'Dismiss'));
await tester.pumpAndSettle();
expect(find.text('Someone tried your password'), findsNothing);
expect(anna.sessionEndedForProofsAt, isNull);
});
testWidgets('once set up, changes the password and adds an authenticator '
'only with the password and a code from one before', (tester) async {
final anna = dependencies.passwordGateway.addAccount(