feat(base)!: a handle at password sign-up (ADR 0020, D26, D28–D30)

Every new account gets a handle, and an account without one gets its
first as it sets a password. The handle is private until its person
goes public (D5).

- `passwordSignIn.signUp` takes a handle. `passwordSignIn.checkHandle`
  says whether a sign-up could take one, only while that sign-up's
  mailed code waits (`codeExpired` otherwise), counted per code request
  (30 an hour) and per network (60) (D29). Members ask the register.
- `password.set` takes the first handle of an account without one
  (D28); `SignInWays.handle` tells the app whether there is one.
- Each checks the handle before the code is used, so a refusal leaves
  the code for another try. An existing account at the address keeps
  its own handle where it has one, and the typed handle is still
  checked, so the answer says nothing about the address.
- `first_handle.dart` holds what SimpleLogin, the keys and passwords
  share: `refusalOfNewHandle`, `insertFirstProfile`, `hasHandle`.
- halkyon_app: one `HandleField` with its `HandleCheckCubit` (asked
  300 ms after typing pauses, only about the handle typed last; the
  instance's suggestion is free without asking) (D30). The password
  setup asks for the handle between the phrase check and the code, and
  a refusal at the code sends back to it. SimpleLogin's sign-up and
  keys steps use the same field and now check as it is typed.
- The module's README and ADR 0020 (D29, D30, the built state) amended.

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-24 18:09:04 +02:00
commit 630965d920
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
67 changed files with 2305 additions and 395 deletions

View file

@ -48,7 +48,7 @@ void main() {
}
testWidgets('an account without a password sets one up: the address, the '
'phrase checked, the code, the authenticator', (
'phrase checked, the handle it lacks, the code, the authenticator', (
tester,
) async {
await openProfile(tester);
@ -75,6 +75,11 @@ void main() {
);
}
await next(tester, 'Continue');
await tester.enterText(field('Handle'), 'anna');
await tester.pump(HandleCheckCubit.pause);
await tester.pumpAndSettle();
expect(find.text('@anna is free'), findsOneWidget);
await next(tester, 'Continue');
await tester.enterText(
field('Code from the email'),
passwords.codeFor('anna@example.org'),
@ -91,6 +96,7 @@ void main() {
expect(find.text('Your progress is kept with your account.'), findsOne);
expect(keys.kept.keys, [FakeAuthGateway.person.authUserId]);
expect(passwords.accounts['anna@example.org']!.finished, isTrue);
expect(passwords.handles[FakeAuthGateway.person.authUserId], 'anna');
await tester.dragUntilVisible(
find.text('Change password'),
find.byType(ListView).last,

View file

@ -1,6 +1,6 @@
# ADR 0020: Signing in with SimpleLogin only
- **Status:** decided 23 Oct 2026, by the developer (D1–D20 below; D21–D28 on 24
- **Status:** decided 23 Oct 2026, by the developer (D1–D20 below; D21–D30 on 24
Oct), the *Proposal* with it (D15), as D18 to D20 change it and D24 drops the
cut-off's machinery (D8's code, D10, D22, 10's `ANONYMOUS_SESSIONS_END`, 11, step 5).
- **Built (23 Oct 2026), step 1:** the forwarding page (3),
@ -91,6 +91,19 @@
(`FILE_LIMIT_ANONYMOUS_MB` went), and `minimumAppBuild` is 23 (v0.20.0), as builds up
to 22 start anonymously. Learn's sync tests make their people and sessions by SQL,
as Serverpod's server-side sessions make them, in place of an anonymous sign-in.
Then the handle at password sign-up (D26, D28–D30): `passwordSignIn.signUp` takes
one and `password.set` takes one from an account without (`SignInWays.handle` tells
the app), each checked before the code is used, so a refusal (`ProfileRefusedException`
as the Profile's check gives it) leaves the code for another try; a new account's
profile is private (D5). `passwordSignIn.checkHandle` answers while the code travels,
only for a sign-up's code request still waiting (`codeExpired` otherwise), counted per
request (30 an hour) and network (60), as the register's members ask the register.
An existing account given the address's code keeps its own handle where it has one,
the typed one still checked, so the answer tells nothing of the address. In the apps,
one `HandleField` with its `HandleCheckCubit` (asked 300 ms after typing pauses, only
for the handle typed last, the instance's suggestion free without asking) serves the
password setup's handle step, between the phrase check and the code, and
SimpleLogin's sign-up and keys steps.
- **Builds on:** ADR 0010 (email sign-in), ADR 0011 (profile pages), ADR 0016 (the
register), ADR 0017 (a password, TOTP and the keys).
- **Changes:** ADR 0017's way in on an instance that offers SimpleLogin (its keys, their
@ -271,6 +284,15 @@ against app.simplelogin.io on 23 Oct:
- **D28 No prompt for an account without a handle** (24 Oct, night, recommended, over a
step after signing in): the developer sets theirs in Profile; a handle is asked where
one is required and the account has none (a password added, the keys set).
- **D29 The handle is chosen while the code travels** (24 Oct, recommended, over a step
before the address, which would let anyone ask about handles without mailing a code,
and over one after the code, which would use the code up on a taken handle): the step
comes after the phrase check has mailed the code and before it is typed, and its free
check is tied to that code request.
- **D30 One handle field everywhere** (24 Oct, recommended, over keeping SimpleLogin's
own): the same field, checking as it is typed, for password sign-up, setting a
password, SimpleLogin's sign-up and its keys step; the handle the instance suggested
counts as free without asking.
- **Also (23 Oct):** the developer's own account moves by adding SimpleLogin to it while
signed in with GitHub; the instance's SimpleLogin app goes up for approval once
halkyon.app/learn exists, before learn.halkyon.eu switches.

View file

@ -47,6 +47,9 @@ export 'src/auth/widget/session_gate.dart';
export 'src/auth/widget/start_screen.dart';
export 'src/base/base_client.dart';
export 'src/files/file_saver.dart';
export 'src/handle/bloc/handle_check_cubit.dart';
export 'src/handle/model/handle_problem.dart';
export 'src/handle/widget/handle_field.dart';
export 'src/instance/cldr_license.dart';
export 'src/instance/country_names.dart';
export 'src/instance/european_union.dart';

View file

@ -86,6 +86,7 @@
"passwordQrCode": "QR-Code für deine Authenticator-App",
"passwordMailCode": "Code aus der E-Mail",
"passwordMailCodeSent": "Wir haben dir einen Code an {address} geschickt. Er gilt 15 Minuten.",
"passwordHandleIntro": "Ein Code ist unterwegs an {address}. Wähle inzwischen dein Handle: Darüber finden dich andere. Es bleibt privat, bis du etwas veröffentlichst oder dein Profil ausfüllst, und lässt sich bis zu deiner ersten Veröffentlichung ändern.",
"passwordResendCode": "Neuen Code senden",
"passwordTotpCode": "Code aus deiner Authenticator-App",
"passwordRecoveryCode": "Wiederherstellungscode",
@ -247,9 +248,10 @@
"simpleLoginCreateHint": "Wähle ein Handle und ein Passwort; du bekommst eine Wiederherstellungsphrase.",
"simpleLoginHaveOne": "Ich habe schon eins",
"simpleLoginHaveOneHint": "Verknüpfe es wieder mit deinem Handle und deiner Wiederherstellungsphrase.",
"simpleLoginHandleIntro": "Andere finden dich über dein Handle. Es bleibt privat, bis du etwas veröffentlichst oder dein Profil ausfüllst, und lässt sich bis zu deiner ersten Veröffentlichung ändern.",
"simpleLoginHandle": "Handle",
"simpleLoginHandleFree": "@{handle} ist frei",
"handleIntro": "Andere finden dich über dein Handle. Es bleibt privat, bis du etwas veröffentlichst oder dein Profil ausfüllst, und lässt sich bis zu deiner ersten Veröffentlichung ändern.",
"handleLabel": "Handle",
"handleFree": "@{handle} ist frei",
"handleChecking": "Wird geprüft …",
"simpleLoginPasswordIntro": "Wähle ein Passwort: Du gibst es ein, wenn du dich auf einem anderen Gerät anmeldest. Es verlässt dieses Gerät nie: Die Instanz kann es nicht lesen und nicht zurücksetzen, ohne dass verloren geht, was es schützt.",
"simpleLoginCreate": "Konto erstellen",
"simpleLoginUnlockTitle": "Dein Passwort",
@ -270,10 +272,10 @@
"simpleLoginTakenByAnotherPerson": "Dieses SimpleLogin-Konto gehört hier zu einem anderen Konto.",
"simpleLoginOtherIdentityLinked": "Dein Konto hat schon ein anderes SimpleLogin-Konto.",
"simpleLoginTooManyAttempts": "Von diesem Netz aus wurden zu viele Anmeldungen begonnen. Versuch es in einer Stunde noch einmal.",
"simpleLoginHandleInvalid": "Ein Handle besteht aus 1 bis 30 Buchstaben (a bis z), Ziffern oder Unterstrichen.",
"simpleLoginHandleReserved": "Dieses Handle ist der Instanz vorbehalten.",
"simpleLoginHandleTaken": "Dieses Handle hat schon jemand anderes.",
"simpleLoginTooManyHandleChecks": "Es wurden zu viele Handles ausprobiert. Versuch es in einer Stunde noch einmal.",
"handleInvalid": "Ein Handle besteht aus 1 bis 30 Buchstaben (a bis z), Ziffern oder Unterstrichen.",
"handleReserved": "Dieses Handle ist der Instanz vorbehalten.",
"handleTaken": "Dieses Handle hat schon jemand anderes.",
"handleTooManyChecks": "Es wurden zu viele Handles ausprobiert. Versuch es in einer Stunde noch einmal.",
"simpleLoginSectionTitle": "SimpleLogin",
"simpleLoginSignsYouIn": "Meldet dich an",
"simpleLoginAddHint": "Melde dich mit SimpleLogin an, oder mit deinem Proton-Konto.",

View file

@ -259,6 +259,14 @@
}
}
},
"passwordHandleIntro": "A code is on its way to {address}. Meanwhile, choose your handle: others find you by it. It stays private until you publish something or fill in your profile, and can change until your first publish.",
"@passwordHandleIntro": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"passwordResendCode": "Send a new code",
"passwordTotpCode": "Code from your authenticator app",
"passwordRecoveryCode": "Recovery code",
@ -560,16 +568,17 @@
"simpleLoginCreateHint": "Choose a handle and a password; you get a recovery phrase.",
"simpleLoginHaveOne": "I already have one",
"simpleLoginHaveOneHint": "Link it again with your handle and your recovery phrase.",
"simpleLoginHandleIntro": "Others find you by your handle. It stays private until you publish something or fill in your profile, and can change until your first publish.",
"simpleLoginHandle": "Handle",
"simpleLoginHandleFree": "@{handle} is free",
"@simpleLoginHandleFree": {
"handleIntro": "Others find you by your handle. It stays private until you publish something or fill in your profile, and can change until your first publish.",
"handleLabel": "Handle",
"handleFree": "@{handle} is free",
"@handleFree": {
"placeholders": {
"handle": {
"type": "String"
}
}
},
"handleChecking": "Checking…",
"simpleLoginPasswordIntro": "Choose a password: you type it when you sign in on another device. It never leaves this device: the instance can't read it, and can't reset it without what it protects being lost.",
"simpleLoginCreate": "Create the account",
"simpleLoginUnlockTitle": "Your password",
@ -597,10 +606,10 @@
"simpleLoginTakenByAnotherPerson": "This SimpleLogin account belongs to another account here.",
"simpleLoginOtherIdentityLinked": "Your account has another SimpleLogin account already.",
"simpleLoginTooManyAttempts": "Too many sign-ins began from this network. Try again in an hour.",
"simpleLoginHandleInvalid": "A handle is 1 to 30 letters (a to z), digits or underscores.",
"simpleLoginHandleReserved": "This handle is kept for the instance.",
"simpleLoginHandleTaken": "Someone else has this handle.",
"simpleLoginTooManyHandleChecks": "Too many handles were tried. Try again in an hour.",
"handleInvalid": "A handle is 1 to 30 letters (a to z), digits or underscores.",
"handleReserved": "This handle is kept for the instance.",
"handleTaken": "Someone else has this handle.",
"handleTooManyChecks": "Too many handles were tried. Try again in an hour.",
"simpleLoginSectionTitle": "SimpleLogin",
"simpleLoginSignsYouIn": "Signs you in",
"simpleLoginAddHint": "Sign in with SimpleLogin, or with your Proton account.",

View file

@ -619,6 +619,12 @@ abstract class HalkyonAppLocalizations {
/// **'We emailed a code to {address}. It\'s valid for 15 minutes.'**
String passwordMailCodeSent(String address);
/// No description provided for @passwordHandleIntro.
///
/// In en, this message translates to:
/// **'A code is on its way to {address}. Meanwhile, choose your handle: others find you by it. It stays private until you publish something or fill in your profile, and can change until your first publish.'**
String passwordHandleIntro(String address);
/// No description provided for @passwordResendCode.
///
/// In en, this message translates to:
@ -1585,23 +1591,29 @@ abstract class HalkyonAppLocalizations {
/// **'Link it again with your handle and your recovery phrase.'**
String get simpleLoginHaveOneHint;
/// No description provided for @simpleLoginHandleIntro.
/// No description provided for @handleIntro.
///
/// In en, this message translates to:
/// **'Others find you by your handle. It stays private until you publish something or fill in your profile, and can change until your first publish.'**
String get simpleLoginHandleIntro;
String get handleIntro;
/// No description provided for @simpleLoginHandle.
/// No description provided for @handleLabel.
///
/// In en, this message translates to:
/// **'Handle'**
String get simpleLoginHandle;
String get handleLabel;
/// No description provided for @simpleLoginHandleFree.
/// No description provided for @handleFree.
///
/// In en, this message translates to:
/// **'@{handle} is free'**
String simpleLoginHandleFree(String handle);
String handleFree(String handle);
/// No description provided for @handleChecking.
///
/// In en, this message translates to:
/// **'Checking…'**
String get handleChecking;
/// No description provided for @simpleLoginPasswordIntro.
///
@ -1723,29 +1735,29 @@ abstract class HalkyonAppLocalizations {
/// **'Too many sign-ins began from this network. Try again in an hour.'**
String get simpleLoginTooManyAttempts;
/// No description provided for @simpleLoginHandleInvalid.
/// No description provided for @handleInvalid.
///
/// In en, this message translates to:
/// **'A handle is 1 to 30 letters (a to z), digits or underscores.'**
String get simpleLoginHandleInvalid;
String get handleInvalid;
/// No description provided for @simpleLoginHandleReserved.
/// No description provided for @handleReserved.
///
/// In en, this message translates to:
/// **'This handle is kept for the instance.'**
String get simpleLoginHandleReserved;
String get handleReserved;
/// No description provided for @simpleLoginHandleTaken.
/// No description provided for @handleTaken.
///
/// In en, this message translates to:
/// **'Someone else has this handle.'**
String get simpleLoginHandleTaken;
String get handleTaken;
/// No description provided for @simpleLoginTooManyHandleChecks.
/// No description provided for @handleTooManyChecks.
///
/// In en, this message translates to:
/// **'Too many handles were tried. Try again in an hour.'**
String get simpleLoginTooManyHandleChecks;
String get handleTooManyChecks;
/// No description provided for @simpleLoginSectionTitle.
///

View file

@ -330,6 +330,11 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
return 'Wir haben dir einen Code an $address geschickt. Er gilt 15 Minuten.';
}
@override
String passwordHandleIntro(String address) {
return 'Ein Code ist unterwegs an $address. Wähle inzwischen dein Handle: Darüber finden dich andere. Es bleibt privat, bis du etwas veröffentlichst oder dein Profil ausfüllst, und lässt sich bis zu deiner ersten Veröffentlichung ändern.';
}
@override
String get passwordResendCode => 'Neuen Code senden';
@ -933,17 +938,20 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
'Verknüpfe es wieder mit deinem Handle und deiner Wiederherstellungsphrase.';
@override
String get simpleLoginHandleIntro =>
String get handleIntro =>
'Andere finden dich über dein Handle. Es bleibt privat, bis du etwas veröffentlichst oder dein Profil ausfüllst, und lässt sich bis zu deiner ersten Veröffentlichung ändern.';
@override
String get simpleLoginHandle => 'Handle';
String get handleLabel => 'Handle';
@override
String simpleLoginHandleFree(String handle) {
String handleFree(String handle) {
return '@$handle ist frei';
}
@override
String get handleChecking => 'Wird geprüft …';
@override
String get simpleLoginPasswordIntro =>
'Wähle ein Passwort: Du gibst es ein, wenn du dich auf einem anderen Gerät anmeldest. Es verlässt dieses Gerät nie: Die Instanz kann es nicht lesen und nicht zurücksetzen, ohne dass verloren geht, was es schützt.';
@ -1020,19 +1028,17 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
'Von diesem Netz aus wurden zu viele Anmeldungen begonnen. Versuch es in einer Stunde noch einmal.';
@override
String get simpleLoginHandleInvalid =>
String get handleInvalid =>
'Ein Handle besteht aus 1 bis 30 Buchstaben (a bis z), Ziffern oder Unterstrichen.';
@override
String get simpleLoginHandleReserved =>
'Dieses Handle ist der Instanz vorbehalten.';
String get handleReserved => 'Dieses Handle ist der Instanz vorbehalten.';
@override
String get simpleLoginHandleTaken =>
'Dieses Handle hat schon jemand anderes.';
String get handleTaken => 'Dieses Handle hat schon jemand anderes.';
@override
String get simpleLoginTooManyHandleChecks =>
String get handleTooManyChecks =>
'Es wurden zu viele Handles ausprobiert. Versuch es in einer Stunde noch einmal.';
@override

View file

@ -326,6 +326,11 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
return 'We emailed a code to $address. It\'s valid for 15 minutes.';
}
@override
String passwordHandleIntro(String address) {
return 'A code is on its way to $address. Meanwhile, choose your handle: others find you by it. It stays private until you publish something or fill in your profile, and can change until your first publish.';
}
@override
String get passwordResendCode => 'Send a new code';
@ -915,17 +920,20 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
'Link it again with your handle and your recovery phrase.';
@override
String get simpleLoginHandleIntro =>
String get handleIntro =>
'Others find you by your handle. It stays private until you publish something or fill in your profile, and can change until your first publish.';
@override
String get simpleLoginHandle => 'Handle';
String get handleLabel => 'Handle';
@override
String simpleLoginHandleFree(String handle) {
String handleFree(String handle) {
return '@$handle is free';
}
@override
String get handleChecking => 'Checking…';
@override
String get simpleLoginPasswordIntro =>
'Choose a password: you type it when you sign in on another device. It never leaves this device: the instance can\'t read it, and can\'t reset it without what it protects being lost.';
@ -1002,18 +1010,17 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
'Too many sign-ins began from this network. Try again in an hour.';
@override
String get simpleLoginHandleInvalid =>
String get handleInvalid =>
'A handle is 1 to 30 letters (a to z), digits or underscores.';
@override
String get simpleLoginHandleReserved =>
'This handle is kept for the instance.';
String get handleReserved => 'This handle is kept for the instance.';
@override
String get simpleLoginHandleTaken => 'Someone else has this handle.';
String get handleTaken => 'Someone else has this handle.';
@override
String get simpleLoginTooManyHandleChecks =>
String get handleTooManyChecks =>
'Too many handles were tried. Try again in an hour.';
@override

View file

@ -101,6 +101,7 @@ class _PasswordOfferState() extends State<_PasswordOffer> {
InstanceScope.maybeOf(context)?.choice.current?.name ??
AppScope.of(context).serverName,
hasAddress: security.hasAddress,
hasHandle: security.hasHandle,
address: address,
);
if (mounted) {

View file

@ -0,0 +1,98 @@
import 'dart:async';
import 'dart:developer';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/src/handle/model/handle_problem.dart';
/// Whether the handle being typed could be the person's.
enum HandleCheck() {
/// Nothing typed: nothing to say.
none,
/// Being asked of the instance.
checking,
/// Free.
free,
/// Refused: [HandleCheckState.problem] says why.
refused,
/// The instance couldn't be asked; going on will say.
unknown,
}
/// The handle typed, and what the instance says of it.
final class const HandleCheckState({
final String handle = '',
final HandleCheck check = HandleCheck.none,
final HandleProblem? problem,
}) with Equatable {
/// Whether the instance refused the handle [typed] names.
bool refuses(String typed) =>
check == HandleCheck.refused && handle == handleOf(typed);
@override
List<Object?> get props => [handle, check, problem];
}
/// Asks the instance whether a handle could be the person's while it is
/// typed (ADR 0020, D26 and D30), through [_check] once typing pauses, and
/// says only what it says of the handle typed last. [_free], the handle the
/// instance suggested, is free without asking.
class HandleCheckCubit(
final Future<HandleProblem?> Function(String handle) _check, {
final String? _free,
}) extends Cubit<HandleCheckState> {
this : super(const HandleCheckState());
/// How long typing must pause before the instance is asked.
static const pause = Duration(milliseconds: 300);
Timer? _pause;
/// The field holds [typed] now, typed or filled in.
void typed(String typed) {
final handle = handleOf(typed);
if (handle == state.handle) {
return;
}
_pause?.cancel();
if (handle.isEmpty) {
emit(const HandleCheckState());
} else if (handle == _free) {
emit(HandleCheckState(handle: handle, check: HandleCheck.free));
} else {
emit(HandleCheckState(handle: handle, check: HandleCheck.checking));
_pause = Timer(pause, () => unawaited(_ask(handle)));
}
}
Future<void> _ask(String handle) async {
HandleCheckState answer;
try {
answer = switch (await _check(handle)) {
null => HandleCheckState(handle: handle, check: HandleCheck.free),
final problem => HandleCheckState(
handle: handle,
check: HandleCheck.refused,
problem: problem,
),
};
} on Object catch (error) {
log('Checking a handle failed', name: 'Handle', error: error);
answer = HandleCheckState(handle: handle, check: HandleCheck.unknown);
}
// Only what is still typed.
if (!isClosed && state.handle == handle) {
emit(answer);
}
}
@override
Future<void> close() {
_pause?.cancel();
return super.close();
}
}

View file

@ -0,0 +1,14 @@
import 'package:halkyon_app/src/handle/model/handle_problem.dart';
import 'package:halkyon_base_client/halkyon_base_client.dart' as protocol;
/// What the instance's [reason] for refusing a handle means; null for none
/// a handle's check meets (a profile's other fields).
HandleProblem? handleProblemOf(protocol.ProfileRefusal reason) =>
switch (reason) {
protocol.ProfileRefusal.handleInvalid => HandleProblem.invalid,
protocol.ProfileRefusal.handleReserved => HandleProblem.reserved,
protocol.ProfileRefusal.handleTaken => HandleProblem.taken,
protocol.ProfileRefusal.tooManyHandleChecks =>
HandleProblem.tooManyChecks,
_ => null,
};

View file

@ -0,0 +1,22 @@
/// Why a handle can't be someone's (ADR 0020, D5), as the instance says
/// while it is typed (D30).
enum HandleProblem() {
/// None a handle can be: its letters, its length.
invalid,
/// Kept back, as a word of the instance's own.
reserved,
/// Someone has it.
taken,
/// Too many handles were tried within an hour.
tooManyChecks,
}
/// The handle [typed] names: without the spaces around it, nor an `@`
/// before it.
String handleOf(String typed) {
final handle = typed.trim();
return handle.startsWith('@') ? handle.substring(1) : handle;
}

View file

@ -0,0 +1,86 @@
import 'package:halkyon_app/l10n/halkyon_app_localizations.dart';
import 'package:halkyon_app/src/handle/bloc/handle_check_cubit.dart';
import 'package:halkyon_app/src/handle/model/handle_problem.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
/// A handle, after an `@` (ADR 0020, D5), and what the instance says of it
/// as it is typed ([checked], D30): that it is being checked, that it is
/// free, or why not. [errorText], a refusal of [errorFor] on going on, comes
/// first while that handle is typed (any, without [errorFor]). Without
/// [checked], the field alone: a handle someone has, to link their account
/// again.
class const HandleField({
required final TextEditingController controller,
final HandleCheckState? checked,
final bool enabled = true,
final bool autofocus = false,
final String? errorText,
final String? errorFor,
final TextInputAction textInputAction = TextInputAction.next,
final ValueChanged<String>? onSubmitted,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = HalkyonAppLocalizations.of(context);
return ListenableBuilder(
listenable: controller,
builder: (context, _) {
final typed = handleOf(controller.text);
final refused = switch (checked) {
HandleCheckState(
check: HandleCheck.refused,
:final handle,
:final problem?,
)
when handle == typed =>
handleProblemText(l10n, problem),
_ => null,
};
return TextField(
controller: controller,
enabled: enabled,
autofocus: autofocus,
autocorrect: false,
enableSuggestions: false,
textInputAction: textInputAction,
autofillHints: const [AutofillHints.username],
onSubmitted: onSubmitted,
decoration: requiredDecoration(
context,
InputDecoration(
labelText: l10n.handleLabel,
prefixText: '@',
helperText: switch (checked) {
HandleCheckState(check: HandleCheck.checking) =>
l10n.handleChecking,
HandleCheckState(check: HandleCheck.free, :final handle)
when handle == typed =>
l10n.handleFree(handle),
_ => null,
},
errorText: switch (errorText) {
final error? when errorFor == null || errorFor == typed =>
error,
_ => refused,
},
),
missing: typed.isEmpty,
),
);
},
);
}
}
/// [problem] in the app's words.
String handleProblemText(
HalkyonAppLocalizations l10n,
HandleProblem problem,
) => switch (problem) {
HandleProblem.invalid => l10n.handleInvalid,
HandleProblem.reserved => l10n.handleReserved,
HandleProblem.taken => l10n.handleTaken,
HandleProblem.tooManyChecks => l10n.handleTooManyChecks,
};

View file

@ -1,5 +1,6 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/src/handle/model/handle_problem.dart';
import 'package:halkyon_app/src/password/bloc/unexpected_failure.dart';
import 'package:halkyon_app/src/password/data/account_crypto.dart';
import 'package:halkyon_app/src/password/data/password_repository.dart';
@ -39,6 +40,10 @@ enum AccountSetupStep() {
/// Three of its words typed back.
phraseCheck,
/// The handle, while the code mailed travels (ADR 0020, D29): a new
/// account's, or the first of a signed-in person without one (D28).
handle,
/// The code mailed to the address.
mailCode,
@ -60,6 +65,9 @@ final class const AccountSetupState({
final bool passwordsDiffer = false,
final bool phraseWrong = false,
final String address = '',
/// The handle typed last, as it was given.
final String handle = '',
final String phrase = '',
final List<int> checked = const [],
final AuthenticatorSetup? authenticator,
@ -73,6 +81,7 @@ final class const AccountSetupState({
bool? passwordsDiffer,
bool? phraseWrong,
String? address,
String? handle,
String? phrase,
List<int>? checked,
AuthenticatorSetup? authenticator,
@ -84,6 +93,7 @@ final class const AccountSetupState({
passwordsDiffer: passwordsDiffer ?? this.passwordsDiffer,
phraseWrong: phraseWrong ?? this.phraseWrong,
address: address ?? this.address,
handle: handle ?? this.handle,
phrase: phrase ?? this.phrase,
checked: checked ?? this.checked,
authenticator: authenticator ?? this.authenticator,
@ -102,6 +112,7 @@ final class const AccountSetupState({
passwordsDiffer,
phraseWrong,
address,
handle,
phrase,
checked,
authenticator,
@ -111,12 +122,13 @@ final class const AccountSetupState({
/// Setting up an account's password (ADR 0017): the address (where it is
/// needed), the password twice, the recovery phrase shown once with three
/// of its words typed back, the code mailed to the address, then the
/// authenticator and the recovery codes it brings. Nothing reaches the
/// instance before the code: the phrase is checked first, so no account
/// exists whose phrase was never written down. [_mode] says what for; a
/// reset that keeps the keys takes the recovery phrase [_phrase], and shows
/// none.
/// of its words typed back, the code mailed to the address (with the
/// handle, where the account takes one, chosen while it travels: ADR 0020,
/// D26, D28 and D29), then the authenticator and the recovery codes it
/// brings. Nothing reaches the instance before the code: the phrase is
/// checked first, so no account exists whose phrase was never written
/// down. [_mode] says what for; a reset that keeps the keys takes the
/// recovery phrase [_phrase], and shows none.
class AccountSetupCubit(
final PasswordRepository _passwords, {
required final AccountSetupMode _mode,
@ -130,6 +142,11 @@ class AccountSetupCubit(
/// then goes to as proof that they are its owner
/// ([AccountSetupMode.setPassword], [MailCodePurpose.firstPassword]).
final bool _hasAddress = false,
/// Whether the signed-in person has a handle already
/// ([AccountSetupMode.setPassword]); one without takes one with their
/// first password (ADR 0020, D28).
final bool _hasHandle = false,
final String? _phrase,
}) extends Cubit<AccountSetupState> {
this
@ -153,6 +170,13 @@ class AccountSetupCubit(
/// Whether the steps end in a session to put in place.
bool get _entersSession => _mode != AccountSetupMode.setPassword;
/// Whether the setup gives the account its handle.
bool get _givesHandle => switch (_mode) {
AccountSetupMode.signUp => true,
AccountSetupMode.setPassword => !_hasHandle,
AccountSetupMode.finishReset => false,
};
/// The address to set up the account with.
void addressEntered(String address) {
final typed = address.trim();
@ -231,14 +255,49 @@ class AccountSetupCubit(
}
/// The code mailed to the address: nothing goes to the instance without
/// it.
/// it. The handle is chosen while it travels.
Future<void> _toCode() async {
_request = await _passwords.sendCode(
state.address,
language: _language,
purpose: _codePurpose,
);
emit(state.copyWith(step: AccountSetupStep.mailCode, clearProblem: true));
emit(
state.copyWith(
step: _givesHandle
? AccountSetupStep.handle
: AccountSetupStep.mailCode,
clearProblem: true,
),
);
}
/// Whether the instance would give the account [handle], asked while it
/// is typed: a new account's, with the code mailed for it (D29); the
/// signed-in person's first, as theirs.
Future<HandleProblem?> checkHandle(String handle) => switch (_mode) {
AccountSetupMode.signUp => _passwords.checkSignUpHandle(
request: _request!,
handle: handle,
),
_ => _passwords.checkHandle(handle),
};
/// The handle typed: the code mailed next.
void handleEntered(String typed) {
final handle = handleOf(typed);
emit(
handle.isEmpty
? state.copyWith(
handle: handle,
problem: const PasswordFailed(PasswordProblem.handleInvalid),
)
: state.copyWith(
step: AccountSetupStep.mailCode,
handle: handle,
clearProblem: true,
),
);
}
/// What the codes mailed here are for.
@ -270,6 +329,7 @@ class AccountSetupCubit(
address: state.address,
request: _request!,
code: code!,
handle: state.handle,
);
case AccountSetupMode.finishReset:
_pending = await _passwords.completeAccountReset(
@ -284,6 +344,7 @@ class AccountSetupCubit(
address: state.address,
request: _request,
code: code,
handle: _givesHandle ? state.handle : null,
);
}
final setup = await _passwords.enrol(
@ -331,7 +392,8 @@ class AccountSetupCubit(
});
/// Runs [step], busy meanwhile; a refusal is shown on the step it came
/// from, or, for the address's own refusals, on the address.
/// from, or, for the address's own refusals, on the address, and for the
/// handle's, on the handle (the code still unused).
Future<void> _run(Future<void> Function() step) async {
emit(state.copyWith(busy: true, clearProblem: true));
try {
@ -344,21 +406,20 @@ class AccountSetupCubit(
if (isClosed) {
return;
}
final aboutAddress = switch (failed.problem) {
final mendAt = switch (failed.problem) {
PasswordProblem.notAnAddress ||
PasswordProblem.addressTaken ||
PasswordProblem.passwordExists ||
PasswordProblem.otherAddressLinked =>
_mode != AccountSetupMode.finishReset,
_ => false,
PasswordProblem.otherAddressLinked
when _mode != AccountSetupMode.finishReset =>
AccountSetupStep.address,
PasswordProblem.handleInvalid ||
PasswordProblem.handleReserved ||
PasswordProblem.handleTaken ||
PasswordProblem.tooManyHandleChecks => AccountSetupStep.handle,
_ => null,
};
emit(
state.copyWith(
busy: false,
problem: failed,
step: aboutAddress ? AccountSetupStep.address : null,
),
);
emit(state.copyWith(busy: false, problem: failed, step: mendAt));
}
}
}

View file

@ -1,6 +1,7 @@
import 'dart:typed_data';
import 'package:halkyon_app/src/auth/model/session_grant.dart';
import 'package:halkyon_app/src/handle/model/handle_problem.dart';
import 'package:halkyon_app/src/password/data/account_crypto.dart';
import 'package:halkyon_app/src/password/model/account_authenticator.dart';
import 'package:halkyon_app/src/password/model/mail_code_purpose.dart';
@ -19,6 +20,10 @@ typedef PasswordWays = ({
bool gitHub,
bool email,
bool password,
/// Whether the account has a handle (ADR 0020, D5): a first password
/// takes one where it has none (D28).
bool handle,
bool authenticator,
bool secondFactorWorks,
@ -86,19 +91,35 @@ abstract interface class PasswordGateway() {
required String language,
});
/// Signs up with the address a [code] mailed for [request] proves.
/// Whether someone signing up with the code mailed for [request] could
/// take [handle] (ADR 0020, D29): null where they could, else why not
/// (too many checks among them).
Future<HandleProblem?> checkSignUpHandle({
required String request,
required String handle,
});
/// Whether the signed-in person could take [handle] as their first (D28):
/// null where they could, else why not.
Future<HandleProblem?> checkHandle(String handle);
/// Signs up with the address a [code] mailed for [request] proves, and
/// [handle].
Future<SessionGrant> signUp(
protocol.PasswordSetup setup, {
required String request,
required String code,
required String handle,
});
/// Gives the signed-in person the password [setup] describes, with the
/// address a [code] mailed for [request] proves unless they have one.
/// address a [code] mailed for [request] proves unless they have one,
/// and [handle] where they have none.
Future<void> setPassword(
protocol.PasswordSetup setup, {
String? request,
String? code,
String? handle,
});
/// A new authenticator called [name], naming the account [label].

View file

@ -7,6 +7,7 @@ import 'package:halkyon_app/src/auth/data/auth_gateway.dart';
import 'package:halkyon_app/src/auth/data/sign_in_addresses.dart';
import 'package:halkyon_app/src/auth/model/auth_status.dart';
import 'package:halkyon_app/src/auth/model/session_grant.dart';
import 'package:halkyon_app/src/handle/model/handle_problem.dart';
import 'package:halkyon_app/src/password/data/account_crypto.dart';
import 'package:halkyon_app/src/password/data/account_key_store.dart';
import 'package:halkyon_app/src/password/data/password_gateway.dart';
@ -244,16 +245,36 @@ class PasswordRepository(
Future<NewAccountMaterial> makePassword(String password) =>
_crypto.newAccount(password);
/// Signs up at [address] with [material], a [code] mailed for [request]
/// proving it: a new account, or the account with that address and no
/// password. Pending until an authenticator is set up and [enter].
/// Whether someone signing up with the code mailed for [request] could
/// take [handle], while the code is on its way (ADR 0020, D29): null where
/// they could, else why not.
Future<HandleProblem?> checkSignUpHandle({
required String request,
required String handle,
}) => _gateway.checkSignUpHandle(request: request, handle: handle);
/// Whether the signed-in person could take [handle] as their first, with
/// their first password (D28): null where they could, else why not.
Future<HandleProblem?> checkHandle(String handle) =>
_gateway.checkHandle(handle);
/// Signs up at [address] with [material] and [handle], a [code] mailed for
/// [request] proving the address: a new account, or the account with that
/// address and no password (which keeps a handle it has). Pending until
/// an authenticator is set up and [enter].
Future<PendingSignIn> signUp(
NewAccountMaterial material, {
required String address,
required String request,
required String code,
required String handle,
}) async => PendingSignIn(
grant: await _gateway.signUp(material.setup, request: request, code: code),
grant: await _gateway.signUp(
material.setup,
request: request,
code: code,
handle: handle,
),
address: address.trim(),
keys: material.keys,
needsAuthenticator: true,
@ -261,16 +282,22 @@ class PasswordRepository(
/// Gives the signed-in person [material]'s password, with [address]
/// proved by a [code] mailed for [request] where they have none (one who
/// signs in with GitHub, ADR 0017, D2), keeping everything. Their keys go
/// into the keyring.
/// signs in with GitHub, ADR 0017, D2), and [handle] where they have none
/// (ADR 0020, D28), keeping everything. Their keys go into the keyring.
Future<void> setPassword(
NewAccountMaterial material, {
String? address,
String? request,
String? code,
String? handle,
}) async {
final person = _signedIn();
await _gateway.setPassword(material.setup, request: request, code: code);
await _gateway.setPassword(
material.setup,
request: request,
code: code,
handle: handle,
);
await _keys.keep(person, material.keys);
if (address != null) {
await _addresses.remember(person, address.trim());
@ -438,6 +465,7 @@ class PasswordRepository(
return AccountSecurity(
offered: offers.offered,
hasAddress: ways.email,
hasHandle: ways.handle,
gitHub: ways.gitHub,
gitHubEnds: offers.gitHubEnds,
hasPassword: ways.password,

View file

@ -4,6 +4,8 @@ import 'dart:typed_data';
import 'package:halkyon_app/src/auth/model/session_grant.dart';
import 'package:halkyon_app/src/base/base_calls.dart';
import 'package:halkyon_app/src/base/base_client.dart';
import 'package:halkyon_app/src/handle/data/handle_refusals.dart';
import 'package:halkyon_app/src/handle/model/handle_problem.dart';
import 'package:halkyon_app/src/password/data/account_crypto.dart';
import 'package:halkyon_app/src/password/data/password_gateway.dart';
import 'package:halkyon_app/src/password/model/account_authenticator.dart';
@ -105,17 +107,36 @@ class ServerpodPasswordGateway(
);
});
@override
Future<HandleProblem?> checkSignUpHandle({
required String request,
required String handle,
}) => _handleChecked(
() => _calls.signedOut(
(base) => base.modules.passwordSignIn.checkHandle(
request: _idOf(request)!,
handle: handle,
),
),
);
@override
Future<HandleProblem?> checkHandle(String handle) =>
_handleChecked(() => _base.profile.checkHandle(handle));
@override
Future<SessionGrant> signUp(
protocol.PasswordSetup setup, {
required String request,
required String code,
required String handle,
}) => _signedOut(
(base) async => _grantOf(
await base.modules.passwordSignIn.signUp(
request: _idOf(request)!,
code: code,
setup: setup,
handle: handle,
),
),
);
@ -125,8 +146,14 @@ class ServerpodPasswordGateway(
protocol.PasswordSetup setup, {
String? request,
String? code,
String? handle,
}) => _failing(
() => _base.password.set(setup: setup, request: _idOf(request), code: code),
() => _base.password.set(
setup: setup,
request: _idOf(request),
code: code,
handle: handle,
),
);
@override
@ -210,6 +237,7 @@ class ServerpodPasswordGateway(
gitHub: ways.gitHub,
email: ways.email,
password: ways.password,
handle: ways.handle,
authenticator: ways.authenticator,
secondFactorWorks: ways.secondFactorWorks,
sessionEndedForProofsAt: ways.sessionEndedForProofsAt,
@ -404,12 +432,39 @@ Future<T> _failing<T>(Future<T> Function() call) async {
protocol.EmailSignInRefusal.mailFailed => PasswordProblem.mailFailed,
protocol.EmailSignInRefusal.usePassword => PasswordProblem.passwordExists,
});
} on protocol.ProfileRefusedException catch (refusal) {
throw PasswordFailed(switch (handleProblemOf(refusal.reason)) {
HandleProblem.reserved => PasswordProblem.handleReserved,
HandleProblem.taken => PasswordProblem.handleTaken,
HandleProblem.tooManyChecks => PasswordProblem.tooManyHandleChecks,
// None but a handle's a password's setup meets.
HandleProblem.invalid || null => PasswordProblem.handleInvalid,
});
} on Exception {
// No connection, the instance's own error, a timeout.
throw const PasswordFailed(PasswordProblem.failed);
}
}
/// What the instance says of a handle [call] checks: null for free, else
/// why not, too many checks among them (an answer to show at the handle, as
/// its others); its other refusals as [_failing]'s.
Future<HandleProblem?> _handleChecked(
Future<protocol.ProfileRefusal?> Function() call,
) async {
try {
return switch (await _failing(call)) {
final reason? => handleProblemOf(reason) ?? HandleProblem.invalid,
null => null,
};
} on PasswordFailed catch (failed) {
if (failed.problem == PasswordProblem.tooManyHandleChecks) {
return HandleProblem.tooManyChecks;
}
rethrow;
}
}
/// What the instance's [reason] means for the person, wherever a password's
/// keys refuse (`PasswordRefusedException`).
PasswordProblem passwordProblemOf(

View file

@ -10,6 +10,10 @@ final class const AccountSecurity({
/// Whether the person has an email address.
required final bool hasAddress,
/// Whether the person has a handle (ADR 0020, D5): a first password takes
/// one where they have none (D28).
required final bool hasHandle,
/// Whether the person signs in with GitHub.
required final bool gitHub,
@ -49,6 +53,7 @@ final class const AccountSecurity({
List<Object?> get props => [
offered,
hasAddress,
hasHandle,
gitHub,
gitHubEnds,
hasPassword,

View file

@ -95,6 +95,19 @@ enum PasswordProblem() {
/// The person has another address already.
otherAddressLinked,
/// The handle is none a handle can be (its letters, its length), or
/// missing (ADR 0020, D26 and D28).
handleInvalid,
/// The handle is kept back, as a word of the instance's own.
handleReserved,
/// Someone has the handle.
handleTaken,
/// Too many handles were tried within an hour.
tooManyHandleChecks,
/// The words aren't a recovery phrase (a word unknown, or the wrong
/// number of them, or its checksum off).
notAPhrase,

View file

@ -179,6 +179,7 @@ class const _AccountSecurity() extends StatelessWidget {
mode: AccountSetupMode.setPassword,
instance: instance,
hasAddress: security.hasAddress,
hasHandle: security.hasHandle,
address: address,
);
await cubit.load();

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