feat(app): signing in with SimpleLogin, its sheet and the start screen's offer (ADR 0020)
Where the instance offers SimpleLogin, the start screen offers it first, saying that the instance keeps only the SimpleLogin ID, and opens a sheet that waits for SimpleLogin's page in the browser (Cancel ends the wait), then: for someone new, the choice between creating an account (the suggested handle or another, checked with the instance, the password twice, the recovery phrase shown once and three of its words typed back, nothing reaching the instance before) and linking theirs again with its handle and recovery phrase; for an account, its password opening its keys; for an account of before, the keys made with the handle it lacks. SimpleLoginCubit holds the steps, each refusal shown where it is mended (a handle's on the handle, a sign-in to begin again on the browser's), and closing the sheet stops waiting on the loopback. The suggested handle is never checked: the instance suggests none that anybody has, and every answer about a handle counts against its limit (D5). The fake's handle rule is now the module's. Learn and Todo build it with SimpleLogin's browser (halkyon_oidc_flutter, now a direct dependency of both, with their names on the loopback's page), their schemes and their loopback ports (43823, 43824), as the instance's forwarding page has them. Their tests offer no SimpleLogin; halkyon_app's test the sheet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
e87b156676
commit
3a93dc4fd8
18 changed files with 2230 additions and 29 deletions
|
|
@ -128,6 +128,26 @@ Written as the apps are: Dart 3.13's primary constructors, Material from
|
|||
real cryptography to the register's own checks. Anonymous people get no
|
||||
keys until something is encrypted (step 4). Every build of an app needs a C
|
||||
compiler and make: sodium's build hook compiles libsodium.
|
||||
- **Signing in with SimpleLogin** (`lib/src/simplelogin/`, ADR 0020):
|
||||
`SimpleLoginRepository` on a `SimpleLoginGateway` (`ServerpodSimpleLoginGateway`,
|
||||
halkyon_base's `simpleLogin` endpoint) and halkyon_oidc_flutter's
|
||||
`OidcBrowser`: SimpleLogin's page in the system's browser sheet, back by
|
||||
the app's scheme, or on Linux and Windows in the browser, back to the
|
||||
app's port on the loopback, as the instance's forwarding page names them
|
||||
(`callbackScheme`, `loopbackPort`: Learn's 43823, Todo's 43824). Someone
|
||||
new creates an account (a handle, the one the instance suggests being
|
||||
free; the password twice, which makes the account's keys on the device;
|
||||
the recovery phrase shown once and three of its words typed back; nothing
|
||||
reaches the instance before) or links theirs again in place of a lost
|
||||
SimpleLogin account with its handle and recovery phrase, which open the
|
||||
keys on the device before any proof is sent; an account opens its keys
|
||||
with its password; an account of before gets its keys, and its handle
|
||||
where it has none. With `link`, SimpleLogin joins the account of the
|
||||
device's person instead, an anonymous person's device keys kept (no
|
||||
screen offers it yet). The screens: the start screen's Continue with
|
||||
SimpleLogin, first where the instance offers it, and
|
||||
`showSimpleLoginSheet` (`SimpleLoginCubit`), which waits for the browser
|
||||
with Cancel and stops waiting on the loopback once it closes.
|
||||
- **Files** (`FileSaver`, `PlatformFileSaver`): keeping a file the app made
|
||||
where the person chooses (a recovery phrase, recovery codes, an app's
|
||||
exports): on Linux and macOS the own user's alone (`writePrivately`). On
|
||||
|
|
|
|||
|
|
@ -92,11 +92,14 @@ export 'src/password/widget/password_widgets.dart';
|
|||
export 'src/password/widget/secret_screen.dart';
|
||||
export 'src/person/person_folders.dart';
|
||||
export 'src/privacy/content_cipher.dart';
|
||||
export 'src/simplelogin/bloc/simplelogin_cubit.dart';
|
||||
export 'src/simplelogin/data/serverpod_simplelogin_gateway.dart';
|
||||
export 'src/simplelogin/data/simplelogin_gateway.dart';
|
||||
export 'src/simplelogin/data/simplelogin_repository.dart';
|
||||
export 'src/simplelogin/model/simplelogin_arrival.dart';
|
||||
export 'src/simplelogin/model/simplelogin_failed.dart';
|
||||
export 'src/simplelogin/widget/simplelogin_sheet.dart';
|
||||
export 'src/simplelogin/widget/simplelogin_texts.dart';
|
||||
export 'src/sync/app_compatibility.dart';
|
||||
export 'src/sync/bloc/sync_status_bloc.dart';
|
||||
export 'src/sync/pending_changes_store.dart';
|
||||
|
|
|
|||
|
|
@ -241,5 +241,42 @@
|
|||
"passwordCallOffTitle": "Zurücksetzen absagen",
|
||||
"passwordCallOffIntro": "Dein Passwort und ein Code sagen jedes anstehende Zurücksetzen ab.",
|
||||
"passwordSectionGitHubEndsDeletes": "Die Anmeldung mit GitHub endet am {date}. Richte bis dahin ein Passwort ein: Ein Konto ohne wird 30 Tage später mit allem darin gelöscht.",
|
||||
"passwordSignedInBefore": "Nur ein seitdem angemeldetes Gerät kann diese Warnung ausblenden: Melde dich auf diesem neu an oder ändere dein Passwort."
|
||||
"passwordSignedInBefore": "Nur ein seitdem angemeldetes Gerät kann diese Warnung ausblenden: Melde dich auf diesem neu an oder ändere dein Passwort.",
|
||||
"simpleLoginContinue": "Weiter mit SimpleLogin",
|
||||
"simpleLoginNote": "SimpleLogin ist die Anmeldung von Proton, und ein Proton-Konto funktioniert dort auch. SimpleLogin erfährt, dass du {instance} nutzt, und {instance} speichert nur deine SimpleLogin-ID: Dein Name schlägt einmal ein Handle vor, sonst wird nichts gespeichert.",
|
||||
"simpleLoginTitle": "Mit SimpleLogin anmelden",
|
||||
"simpleLoginAddTitle": "SimpleLogin hinzufügen",
|
||||
"simpleLoginInBrowser": "Mach in deinem Browser weiter.",
|
||||
"simpleLoginNewcomerTitle": "Neu hier?",
|
||||
"simpleLoginNewcomerIntro": "Zu deinem SimpleLogin-Konto gibt es auf {instance} noch kein Konto.",
|
||||
"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",
|
||||
"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",
|
||||
"simpleLoginUnlockIntroAs": "Mit SimpleLogin als @{handle} angemeldet. Dein Passwort öffnet deine Schlüssel auf diesem Gerät.",
|
||||
"simpleLoginUnlockIntro": "Mit SimpleLogin angemeldet. Dein Passwort öffnet deine Schlüssel auf diesem Gerät.",
|
||||
"simpleLoginOpen": "Öffnen",
|
||||
"simpleLoginMendTitle": "Konto verknüpfen",
|
||||
"simpleLoginMendIntro": "Dein SimpleLogin-Konto ist nicht mehr das, das dein Konto kennt. Verknüpfe sie wieder mit deinem Handle und deiner Wiederherstellungsphrase: Deine anderen Geräte werden dann abgemeldet.",
|
||||
"simpleLoginMend": "Verknüpfen",
|
||||
"simpleLoginWrongPassword": "Das Passwort stimmt nicht.",
|
||||
"simpleLoginMendDoesNotFit": "Das Handle oder die Wiederherstellungsphrase stimmt nicht.",
|
||||
"simpleLoginUnavailable": "Diese Instanz bietet die Anmeldung mit SimpleLogin nicht an.",
|
||||
"simpleLoginDenied": "Die Anmeldung bei SimpleLogin wurde abgelehnt, also wurde nichts geteilt.",
|
||||
"simpleLoginNoBrowser": "Es gibt keinen Browser, der die Seite von SimpleLogin öffnen kann.",
|
||||
"simpleLoginStartAgain": "Die Anmeldung hat zu lange gedauert, oder eine andere ist an ihre Stelle getreten. Beginne sie noch einmal.",
|
||||
"simpleLoginRejected": "SimpleLogin hat die Anmeldung nicht bestätigt. Beginne sie noch einmal.",
|
||||
"simpleLoginProviderUnavailable": "Die Instanz erreicht SimpleLogin gerade nicht. Versuch es später noch einmal.",
|
||||
"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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -539,5 +539,70 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"passwordSignedInBefore": "Only a device signed in since then can dismiss this warning: sign in again on this one, or change your password."
|
||||
"passwordSignedInBefore": "Only a device signed in since then can dismiss this warning: sign in again on this one, or change your password.",
|
||||
"simpleLoginContinue": "Continue with SimpleLogin",
|
||||
"simpleLoginNote": "SimpleLogin is Proton's sign-in, and a Proton account works there too. It learns that you use {instance}, which keeps only your SimpleLogin ID: your name suggests a handle once, and nothing else is kept.",
|
||||
"@simpleLoginNote": {
|
||||
"placeholders": {
|
||||
"instance": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"simpleLoginTitle": "Sign in with SimpleLogin",
|
||||
"simpleLoginAddTitle": "Add SimpleLogin",
|
||||
"simpleLoginInBrowser": "Continue in your browser.",
|
||||
"simpleLoginNewcomerTitle": "New here?",
|
||||
"simpleLoginNewcomerIntro": "Your SimpleLogin account has no account on {instance} yet.",
|
||||
"@simpleLoginNewcomerIntro": {
|
||||
"placeholders": {
|
||||
"instance": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"placeholders": {
|
||||
"handle": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"simpleLoginUnlockIntroAs": "Signed in with SimpleLogin as @{handle}. Your password opens your keys on this device.",
|
||||
"@simpleLoginUnlockIntroAs": {
|
||||
"placeholders": {
|
||||
"handle": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"simpleLoginUnlockIntro": "Signed in with SimpleLogin. Your password opens your keys on this device.",
|
||||
"simpleLoginOpen": "Open",
|
||||
"simpleLoginMendTitle": "Link your account",
|
||||
"simpleLoginMendIntro": "Your SimpleLogin account isn't the one your account knows any more. Link them again with your handle and your recovery phrase: your other devices are then signed out.",
|
||||
"simpleLoginMend": "Link",
|
||||
"simpleLoginWrongPassword": "The password isn't right.",
|
||||
"simpleLoginMendDoesNotFit": "The handle or the recovery phrase isn't right.",
|
||||
"simpleLoginUnavailable": "This instance doesn't offer sign-in with SimpleLogin.",
|
||||
"simpleLoginDenied": "SimpleLogin's sign-in was declined, so nothing was shared.",
|
||||
"simpleLoginNoBrowser": "There's no browser to open SimpleLogin's page in.",
|
||||
"simpleLoginStartAgain": "The sign-in took too long, or another one took its place. Begin it again.",
|
||||
"simpleLoginRejected": "SimpleLogin didn't confirm the sign-in. Begin it again.",
|
||||
"simpleLoginProviderUnavailable": "The instance can't reach SimpleLogin right now. Try again later.",
|
||||
"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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1554,6 +1554,228 @@ abstract class HalkyonAppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'Only a device signed in since then can dismiss this warning: sign in again on this one, or change your password.'**
|
||||
String get passwordSignedInBefore;
|
||||
|
||||
/// No description provided for @simpleLoginContinue.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Continue with SimpleLogin'**
|
||||
String get simpleLoginContinue;
|
||||
|
||||
/// No description provided for @simpleLoginNote.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'SimpleLogin is Proton\'s sign-in, and a Proton account works there too. It learns that you use {instance}, which keeps only your SimpleLogin ID: your name suggests a handle once, and nothing else is kept.'**
|
||||
String simpleLoginNote(String instance);
|
||||
|
||||
/// No description provided for @simpleLoginTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sign in with SimpleLogin'**
|
||||
String get simpleLoginTitle;
|
||||
|
||||
/// No description provided for @simpleLoginAddTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add SimpleLogin'**
|
||||
String get simpleLoginAddTitle;
|
||||
|
||||
/// No description provided for @simpleLoginInBrowser.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Continue in your browser.'**
|
||||
String get simpleLoginInBrowser;
|
||||
|
||||
/// No description provided for @simpleLoginNewcomerTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'New here?'**
|
||||
String get simpleLoginNewcomerTitle;
|
||||
|
||||
/// No description provided for @simpleLoginNewcomerIntro.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Your SimpleLogin account has no account on {instance} yet.'**
|
||||
String simpleLoginNewcomerIntro(String instance);
|
||||
|
||||
/// No description provided for @simpleLoginCreateHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Choose a handle and a password; you get a recovery phrase.'**
|
||||
String get simpleLoginCreateHint;
|
||||
|
||||
/// No description provided for @simpleLoginHaveOne.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'I already have one'**
|
||||
String get simpleLoginHaveOne;
|
||||
|
||||
/// No description provided for @simpleLoginHaveOneHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Link it again with your handle and your recovery phrase.'**
|
||||
String get simpleLoginHaveOneHint;
|
||||
|
||||
/// No description provided for @simpleLoginHandleIntro.
|
||||
///
|
||||
/// 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;
|
||||
|
||||
/// No description provided for @simpleLoginHandle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Handle'**
|
||||
String get simpleLoginHandle;
|
||||
|
||||
/// No description provided for @simpleLoginHandleFree.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'@{handle} is free'**
|
||||
String simpleLoginHandleFree(String handle);
|
||||
|
||||
/// No description provided for @simpleLoginPasswordIntro.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'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.'**
|
||||
String get simpleLoginPasswordIntro;
|
||||
|
||||
/// No description provided for @simpleLoginCreate.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Create the account'**
|
||||
String get simpleLoginCreate;
|
||||
|
||||
/// No description provided for @simpleLoginUnlockTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Your password'**
|
||||
String get simpleLoginUnlockTitle;
|
||||
|
||||
/// No description provided for @simpleLoginUnlockIntroAs.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Signed in with SimpleLogin as @{handle}. Your password opens your keys on this device.'**
|
||||
String simpleLoginUnlockIntroAs(String handle);
|
||||
|
||||
/// No description provided for @simpleLoginUnlockIntro.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Signed in with SimpleLogin. Your password opens your keys on this device.'**
|
||||
String get simpleLoginUnlockIntro;
|
||||
|
||||
/// No description provided for @simpleLoginOpen.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Open'**
|
||||
String get simpleLoginOpen;
|
||||
|
||||
/// No description provided for @simpleLoginMendTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Link your account'**
|
||||
String get simpleLoginMendTitle;
|
||||
|
||||
/// No description provided for @simpleLoginMendIntro.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Your SimpleLogin account isn\'t the one your account knows any more. Link them again with your handle and your recovery phrase: your other devices are then signed out.'**
|
||||
String get simpleLoginMendIntro;
|
||||
|
||||
/// No description provided for @simpleLoginMend.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Link'**
|
||||
String get simpleLoginMend;
|
||||
|
||||
/// No description provided for @simpleLoginWrongPassword.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The password isn\'t right.'**
|
||||
String get simpleLoginWrongPassword;
|
||||
|
||||
/// No description provided for @simpleLoginMendDoesNotFit.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The handle or the recovery phrase isn\'t right.'**
|
||||
String get simpleLoginMendDoesNotFit;
|
||||
|
||||
/// No description provided for @simpleLoginUnavailable.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This instance doesn\'t offer sign-in with SimpleLogin.'**
|
||||
String get simpleLoginUnavailable;
|
||||
|
||||
/// No description provided for @simpleLoginDenied.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'SimpleLogin\'s sign-in was declined, so nothing was shared.'**
|
||||
String get simpleLoginDenied;
|
||||
|
||||
/// No description provided for @simpleLoginNoBrowser.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'There\'s no browser to open SimpleLogin\'s page in.'**
|
||||
String get simpleLoginNoBrowser;
|
||||
|
||||
/// No description provided for @simpleLoginStartAgain.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The sign-in took too long, or another one took its place. Begin it again.'**
|
||||
String get simpleLoginStartAgain;
|
||||
|
||||
/// No description provided for @simpleLoginRejected.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'SimpleLogin didn\'t confirm the sign-in. Begin it again.'**
|
||||
String get simpleLoginRejected;
|
||||
|
||||
/// No description provided for @simpleLoginProviderUnavailable.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The instance can\'t reach SimpleLogin right now. Try again later.'**
|
||||
String get simpleLoginProviderUnavailable;
|
||||
|
||||
/// No description provided for @simpleLoginTakenByAnotherPerson.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This SimpleLogin account belongs to another account here.'**
|
||||
String get simpleLoginTakenByAnotherPerson;
|
||||
|
||||
/// No description provided for @simpleLoginOtherIdentityLinked.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Your account has another SimpleLogin account already.'**
|
||||
String get simpleLoginOtherIdentityLinked;
|
||||
|
||||
/// No description provided for @simpleLoginTooManyAttempts.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Too many sign-ins began from this network. Try again in an hour.'**
|
||||
String get simpleLoginTooManyAttempts;
|
||||
|
||||
/// No description provided for @simpleLoginHandleInvalid.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'A handle is 1 to 30 letters (a to z), digits or underscores.'**
|
||||
String get simpleLoginHandleInvalid;
|
||||
|
||||
/// No description provided for @simpleLoginHandleReserved.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This handle is kept for the instance.'**
|
||||
String get simpleLoginHandleReserved;
|
||||
|
||||
/// No description provided for @simpleLoginHandleTaken.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Someone else has this handle.'**
|
||||
String get simpleLoginHandleTaken;
|
||||
|
||||
/// No description provided for @simpleLoginTooManyHandleChecks.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Too many handles were tried. Try again in an hour.'**
|
||||
String get simpleLoginTooManyHandleChecks;
|
||||
}
|
||||
|
||||
class _HalkyonAppLocalizationsDelegate
|
||||
|
|
|
|||
|
|
@ -912,4 +912,143 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
|
|||
@override
|
||||
String get passwordSignedInBefore =>
|
||||
'Nur ein seitdem angemeldetes Gerät kann diese Warnung ausblenden: Melde dich auf diesem neu an oder ändere dein Passwort.';
|
||||
|
||||
@override
|
||||
String get simpleLoginContinue => 'Weiter mit SimpleLogin';
|
||||
|
||||
@override
|
||||
String simpleLoginNote(String instance) {
|
||||
return 'SimpleLogin ist die Anmeldung von Proton, und ein Proton-Konto funktioniert dort auch. SimpleLogin erfährt, dass du $instance nutzt, und $instance speichert nur deine SimpleLogin-ID: Dein Name schlägt einmal ein Handle vor, sonst wird nichts gespeichert.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get simpleLoginTitle => 'Mit SimpleLogin anmelden';
|
||||
|
||||
@override
|
||||
String get simpleLoginAddTitle => 'SimpleLogin hinzufügen';
|
||||
|
||||
@override
|
||||
String get simpleLoginInBrowser => 'Mach in deinem Browser weiter.';
|
||||
|
||||
@override
|
||||
String get simpleLoginNewcomerTitle => 'Neu hier?';
|
||||
|
||||
@override
|
||||
String simpleLoginNewcomerIntro(String instance) {
|
||||
return 'Zu deinem SimpleLogin-Konto gibt es auf $instance noch kein Konto.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get simpleLoginCreateHint =>
|
||||
'Wähle ein Handle und ein Passwort; du bekommst eine Wiederherstellungsphrase.';
|
||||
|
||||
@override
|
||||
String get simpleLoginHaveOne => 'Ich habe schon eins';
|
||||
|
||||
@override
|
||||
String get simpleLoginHaveOneHint =>
|
||||
'Verknüpfe es wieder mit deinem Handle und deiner Wiederherstellungsphrase.';
|
||||
|
||||
@override
|
||||
String get 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.';
|
||||
|
||||
@override
|
||||
String get simpleLoginHandle => 'Handle';
|
||||
|
||||
@override
|
||||
String simpleLoginHandleFree(String handle) {
|
||||
return '@$handle ist frei';
|
||||
}
|
||||
|
||||
@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.';
|
||||
|
||||
@override
|
||||
String get simpleLoginCreate => 'Konto erstellen';
|
||||
|
||||
@override
|
||||
String get simpleLoginUnlockTitle => 'Dein Passwort';
|
||||
|
||||
@override
|
||||
String simpleLoginUnlockIntroAs(String handle) {
|
||||
return 'Mit SimpleLogin als @$handle angemeldet. Dein Passwort öffnet deine Schlüssel auf diesem Gerät.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get simpleLoginUnlockIntro =>
|
||||
'Mit SimpleLogin angemeldet. Dein Passwort öffnet deine Schlüssel auf diesem Gerät.';
|
||||
|
||||
@override
|
||||
String get simpleLoginOpen => 'Öffnen';
|
||||
|
||||
@override
|
||||
String get simpleLoginMendTitle => 'Konto verknüpfen';
|
||||
|
||||
@override
|
||||
String get simpleLoginMendIntro =>
|
||||
'Dein SimpleLogin-Konto ist nicht mehr das, das dein Konto kennt. Verknüpfe sie wieder mit deinem Handle und deiner Wiederherstellungsphrase: Deine anderen Geräte werden dann abgemeldet.';
|
||||
|
||||
@override
|
||||
String get simpleLoginMend => 'Verknüpfen';
|
||||
|
||||
@override
|
||||
String get simpleLoginWrongPassword => 'Das Passwort stimmt nicht.';
|
||||
|
||||
@override
|
||||
String get simpleLoginMendDoesNotFit =>
|
||||
'Das Handle oder die Wiederherstellungsphrase stimmt nicht.';
|
||||
|
||||
@override
|
||||
String get simpleLoginUnavailable =>
|
||||
'Diese Instanz bietet die Anmeldung mit SimpleLogin nicht an.';
|
||||
|
||||
@override
|
||||
String get simpleLoginDenied =>
|
||||
'Die Anmeldung bei SimpleLogin wurde abgelehnt, also wurde nichts geteilt.';
|
||||
|
||||
@override
|
||||
String get simpleLoginNoBrowser =>
|
||||
'Es gibt keinen Browser, der die Seite von SimpleLogin öffnen kann.';
|
||||
|
||||
@override
|
||||
String get simpleLoginStartAgain =>
|
||||
'Die Anmeldung hat zu lange gedauert, oder eine andere ist an ihre Stelle getreten. Beginne sie noch einmal.';
|
||||
|
||||
@override
|
||||
String get simpleLoginRejected =>
|
||||
'SimpleLogin hat die Anmeldung nicht bestätigt. Beginne sie noch einmal.';
|
||||
|
||||
@override
|
||||
String get simpleLoginProviderUnavailable =>
|
||||
'Die Instanz erreicht SimpleLogin gerade nicht. Versuch es später noch einmal.';
|
||||
|
||||
@override
|
||||
String get simpleLoginTakenByAnotherPerson =>
|
||||
'Dieses SimpleLogin-Konto gehört hier zu einem anderen Konto.';
|
||||
|
||||
@override
|
||||
String get simpleLoginOtherIdentityLinked =>
|
||||
'Dein Konto hat schon ein anderes SimpleLogin-Konto.';
|
||||
|
||||
@override
|
||||
String get simpleLoginTooManyAttempts =>
|
||||
'Von diesem Netz aus wurden zu viele Anmeldungen begonnen. Versuch es in einer Stunde noch einmal.';
|
||||
|
||||
@override
|
||||
String get simpleLoginHandleInvalid =>
|
||||
'Ein Handle besteht aus 1 bis 30 Buchstaben (a bis z), Ziffern oder Unterstrichen.';
|
||||
|
||||
@override
|
||||
String get simpleLoginHandleReserved =>
|
||||
'Dieses Handle ist der Instanz vorbehalten.';
|
||||
|
||||
@override
|
||||
String get simpleLoginHandleTaken =>
|
||||
'Dieses Handle hat schon jemand anderes.';
|
||||
|
||||
@override
|
||||
String get simpleLoginTooManyHandleChecks =>
|
||||
'Es wurden zu viele Handles ausprobiert. Versuch es in einer Stunde noch einmal.';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -894,6 +894,144 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
|
|||
@override
|
||||
String get passwordSignedInBefore =>
|
||||
'Only a device signed in since then can dismiss this warning: sign in again on this one, or change your password.';
|
||||
|
||||
@override
|
||||
String get simpleLoginContinue => 'Continue with SimpleLogin';
|
||||
|
||||
@override
|
||||
String simpleLoginNote(String instance) {
|
||||
return 'SimpleLogin is Proton\'s sign-in, and a Proton account works there too. It learns that you use $instance, which keeps only your SimpleLogin ID: your name suggests a handle once, and nothing else is kept.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get simpleLoginTitle => 'Sign in with SimpleLogin';
|
||||
|
||||
@override
|
||||
String get simpleLoginAddTitle => 'Add SimpleLogin';
|
||||
|
||||
@override
|
||||
String get simpleLoginInBrowser => 'Continue in your browser.';
|
||||
|
||||
@override
|
||||
String get simpleLoginNewcomerTitle => 'New here?';
|
||||
|
||||
@override
|
||||
String simpleLoginNewcomerIntro(String instance) {
|
||||
return 'Your SimpleLogin account has no account on $instance yet.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get simpleLoginCreateHint =>
|
||||
'Choose a handle and a password; you get a recovery phrase.';
|
||||
|
||||
@override
|
||||
String get simpleLoginHaveOne => 'I already have one';
|
||||
|
||||
@override
|
||||
String get simpleLoginHaveOneHint =>
|
||||
'Link it again with your handle and your recovery phrase.';
|
||||
|
||||
@override
|
||||
String get 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.';
|
||||
|
||||
@override
|
||||
String get simpleLoginHandle => 'Handle';
|
||||
|
||||
@override
|
||||
String simpleLoginHandleFree(String handle) {
|
||||
return '@$handle is free';
|
||||
}
|
||||
|
||||
@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.';
|
||||
|
||||
@override
|
||||
String get simpleLoginCreate => 'Create the account';
|
||||
|
||||
@override
|
||||
String get simpleLoginUnlockTitle => 'Your password';
|
||||
|
||||
@override
|
||||
String simpleLoginUnlockIntroAs(String handle) {
|
||||
return 'Signed in with SimpleLogin as @$handle. Your password opens your keys on this device.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get simpleLoginUnlockIntro =>
|
||||
'Signed in with SimpleLogin. Your password opens your keys on this device.';
|
||||
|
||||
@override
|
||||
String get simpleLoginOpen => 'Open';
|
||||
|
||||
@override
|
||||
String get simpleLoginMendTitle => 'Link your account';
|
||||
|
||||
@override
|
||||
String get simpleLoginMendIntro =>
|
||||
'Your SimpleLogin account isn\'t the one your account knows any more. Link them again with your handle and your recovery phrase: your other devices are then signed out.';
|
||||
|
||||
@override
|
||||
String get simpleLoginMend => 'Link';
|
||||
|
||||
@override
|
||||
String get simpleLoginWrongPassword => 'The password isn\'t right.';
|
||||
|
||||
@override
|
||||
String get simpleLoginMendDoesNotFit =>
|
||||
'The handle or the recovery phrase isn\'t right.';
|
||||
|
||||
@override
|
||||
String get simpleLoginUnavailable =>
|
||||
'This instance doesn\'t offer sign-in with SimpleLogin.';
|
||||
|
||||
@override
|
||||
String get simpleLoginDenied =>
|
||||
'SimpleLogin\'s sign-in was declined, so nothing was shared.';
|
||||
|
||||
@override
|
||||
String get simpleLoginNoBrowser =>
|
||||
'There\'s no browser to open SimpleLogin\'s page in.';
|
||||
|
||||
@override
|
||||
String get simpleLoginStartAgain =>
|
||||
'The sign-in took too long, or another one took its place. Begin it again.';
|
||||
|
||||
@override
|
||||
String get simpleLoginRejected =>
|
||||
'SimpleLogin didn\'t confirm the sign-in. Begin it again.';
|
||||
|
||||
@override
|
||||
String get simpleLoginProviderUnavailable =>
|
||||
'The instance can\'t reach SimpleLogin right now. Try again later.';
|
||||
|
||||
@override
|
||||
String get simpleLoginTakenByAnotherPerson =>
|
||||
'This SimpleLogin account belongs to another account here.';
|
||||
|
||||
@override
|
||||
String get simpleLoginOtherIdentityLinked =>
|
||||
'Your account has another SimpleLogin account already.';
|
||||
|
||||
@override
|
||||
String get simpleLoginTooManyAttempts =>
|
||||
'Too many sign-ins began from this network. Try again in an hour.';
|
||||
|
||||
@override
|
||||
String get simpleLoginHandleInvalid =>
|
||||
'A handle is 1 to 30 letters (a to z), digits or underscores.';
|
||||
|
||||
@override
|
||||
String get simpleLoginHandleReserved =>
|
||||
'This handle is kept for the instance.';
|
||||
|
||||
@override
|
||||
String get simpleLoginHandleTaken => 'Someone else has this handle.';
|
||||
|
||||
@override
|
||||
String get simpleLoginTooManyHandleChecks =>
|
||||
'Too many handles were tried. Try again in an hour.';
|
||||
}
|
||||
|
||||
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'package:halkyon_app/src/auth/data/signed_out_mark.dart';
|
|||
import 'package:halkyon_app/src/auth/model/auth_status.dart';
|
||||
import 'package:halkyon_app/src/files/file_saver.dart';
|
||||
import 'package:halkyon_app/src/password/data/password_repository.dart';
|
||||
import 'package:halkyon_app/src/simplelogin/data/simplelogin_repository.dart';
|
||||
|
||||
/// What can be closed once it is no longer needed: an app's dependencies on
|
||||
/// an instance, or a person's.
|
||||
|
|
@ -32,6 +33,10 @@ abstract interface class AppDependencies<P extends Disposable>()
|
|||
/// what keeps an account (ADR 0017).
|
||||
PasswordRepository get passwords;
|
||||
|
||||
/// Signing in with SimpleLogin, and the keys of the accounts that do
|
||||
/// (ADR 0020).
|
||||
SimpleLoginRepository get simpleLogin;
|
||||
|
||||
/// Keeps a file the app made where the person chooses (a recovery phrase,
|
||||
/// recovery codes, an app's exports).
|
||||
FileSaver get fileSaver;
|
||||
|
|
|
|||
|
|
@ -11,21 +11,28 @@ import 'package:halkyon_app/src/password/widget/account_recovery_sheet.dart';
|
|||
import 'package:halkyon_app/src/password/widget/account_setup_sheet.dart';
|
||||
import 'package:halkyon_app/src/password/widget/password_sign_in_sheet.dart';
|
||||
import 'package:halkyon_app/src/password/widget/password_texts.dart';
|
||||
import 'package:halkyon_app/src/simplelogin/model/simplelogin_failed.dart';
|
||||
import 'package:halkyon_app/src/simplelogin/widget/simplelogin_sheet.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// How to start once signed out: on the instance in use, signing in with a
|
||||
/// password or creating an account where it offers password sign-in (ADR
|
||||
/// 0017), with GitHub where it offers it (saying when it ends), or without an
|
||||
/// account; or on another instance. Nothing is made until the person
|
||||
/// chooses.
|
||||
/// How to start once signed out: on the instance in use, with SimpleLogin
|
||||
/// where it offers it (ADR 0020), signing in with a password or creating an
|
||||
/// account where it offers password sign-in (ADR 0017), with GitHub where it
|
||||
/// offers it (saying when it ends), or without an account; or on another
|
||||
/// instance. Nothing is made until the person chooses.
|
||||
class const StartScreen({required final SessionStateSignedOut state, super.key})
|
||||
extends StatefulWidget {
|
||||
@override
|
||||
State<StartScreen> createState() => _StartScreenState();
|
||||
}
|
||||
|
||||
typedef _Offers = ({bool gitHub, bool password, DateTime? gitHubEnds});
|
||||
typedef _Offers = ({
|
||||
bool simpleLogin,
|
||||
bool gitHub,
|
||||
bool password,
|
||||
DateTime? gitHubEnds,
|
||||
});
|
||||
|
||||
class _StartScreenState() extends State<StartScreen> {
|
||||
late final Future<_Offers> _offers = () async {
|
||||
|
|
@ -39,11 +46,21 @@ class _StartScreenState() extends State<StartScreen> {
|
|||
}
|
||||
}
|
||||
|
||||
final (gitHub, password) = await (
|
||||
Future<bool> simpleLogin() async {
|
||||
try {
|
||||
return await dependencies.simpleLogin.offered();
|
||||
} on SimpleLoginFailed {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
final (simpleLoginOffered, gitHub, password) = await (
|
||||
simpleLogin(),
|
||||
auth.canSignInWithGitHub(),
|
||||
passwords(),
|
||||
).wait;
|
||||
return (
|
||||
simpleLogin: simpleLoginOffered,
|
||||
gitHub: gitHub,
|
||||
password: password.offered,
|
||||
gitHubEnds: password.gitHubEnds,
|
||||
|
|
@ -91,9 +108,17 @@ class _StartScreenState() extends State<StartScreen> {
|
|||
child: FutureBuilder(
|
||||
future: _offers,
|
||||
builder: (context, offers) {
|
||||
final (:gitHub, :password, :gitHubEnds) =
|
||||
final (:simpleLogin, :gitHub, :password, :gitHubEnds) =
|
||||
offers.data ??
|
||||
(gitHub: false, password: false, gitHubEnds: null);
|
||||
(
|
||||
simpleLogin: false,
|
||||
gitHub: false,
|
||||
password: false,
|
||||
gitHubEnds: null,
|
||||
);
|
||||
final note = theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
|
|
@ -109,12 +134,36 @@ class _StartScreenState() extends State<StartScreen> {
|
|||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (password) ...[
|
||||
if (simpleLogin) ...[
|
||||
FilledButton(
|
||||
onPressed: busy ? null : () => _signIn(instance),
|
||||
child: Text(l10n.startSignIn),
|
||||
onPressed: busy
|
||||
? null
|
||||
: () => showSimpleLoginSheet(
|
||||
context,
|
||||
instance: instance,
|
||||
),
|
||||
child: Text(l10n.simpleLoginContinue),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.simpleLoginNote(instance),
|
||||
textAlign: TextAlign.center,
|
||||
style: note,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
if (password) ...[
|
||||
if (simpleLogin)
|
||||
OutlinedButton(
|
||||
onPressed: busy ? null : () => _signIn(instance),
|
||||
child: Text(l10n.startSignIn),
|
||||
)
|
||||
else
|
||||
FilledButton(
|
||||
onPressed: busy ? null : () => _signIn(instance),
|
||||
child: Text(l10n.startSignIn),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton(
|
||||
onPressed: busy
|
||||
? null
|
||||
|
|
@ -128,7 +177,7 @@ class _StartScreenState() extends State<StartScreen> {
|
|||
const SizedBox(height: 8),
|
||||
],
|
||||
if (gitHub) ...[
|
||||
if (password)
|
||||
if (password || simpleLogin)
|
||||
OutlinedButton(
|
||||
onPressed: busy
|
||||
? null
|
||||
|
|
@ -155,9 +204,7 @@ class _StartScreenState() extends State<StartScreen> {
|
|||
Text(
|
||||
l10n.startGitHubEnds(passwordMoment(context, ends)),
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
style: note,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
|
|
@ -174,9 +221,7 @@ class _StartScreenState() extends State<StartScreen> {
|
|||
Text(
|
||||
l10n.startAnonymous,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
style: note,
|
||||
),
|
||||
if (widget.state.problem case final problem?) ...[
|
||||
const SizedBox(height: 16),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,379 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.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/model/phrase_check.dart';
|
||||
import 'package:halkyon_app/src/simplelogin/data/simplelogin_repository.dart';
|
||||
import 'package:halkyon_app/src/simplelogin/model/simplelogin_arrival.dart';
|
||||
import 'package:halkyon_app/src/simplelogin/model/simplelogin_failed.dart';
|
||||
|
||||
/// Where signing in with SimpleLogin stands (ADR 0020).
|
||||
enum SimpleLoginStep() {
|
||||
/// SimpleLogin's page in the browser, until it comes back.
|
||||
browser,
|
||||
|
||||
/// Someone new here: an account to create, or theirs to link again.
|
||||
newcomer,
|
||||
|
||||
/// The handle the account takes.
|
||||
handle,
|
||||
|
||||
/// The password, twice, for the account's keys.
|
||||
password,
|
||||
|
||||
/// The recovery phrase, to write down.
|
||||
phrase,
|
||||
|
||||
/// Three of its words typed back.
|
||||
phraseCheck,
|
||||
|
||||
/// The password that opens the account's keys.
|
||||
unlock,
|
||||
|
||||
/// The handle and the recovery phrase, which link the account again.
|
||||
mend,
|
||||
|
||||
/// Signed in, or SimpleLogin added to the account.
|
||||
done,
|
||||
|
||||
/// The browser closed before SimpleLogin came back: nothing happened.
|
||||
closed,
|
||||
}
|
||||
|
||||
/// What [SimpleLoginCubit] shows.
|
||||
final class const SimpleLoginState({
|
||||
required final SimpleLoginStep step,
|
||||
final bool busy = false,
|
||||
|
||||
/// Why the last step failed: a `SimpleLoginFailed`, or the keys'
|
||||
/// `PasswordFailed`.
|
||||
final Exception? problem,
|
||||
final bool passwordsDiffer = false,
|
||||
final bool phraseWrong = false,
|
||||
|
||||
/// The handle, as suggested or typed.
|
||||
final String handle = '',
|
||||
|
||||
/// Whether the instance found [handle] free when last asked.
|
||||
final bool handleFree = false,
|
||||
|
||||
/// The account's handle, which the password opens the keys of.
|
||||
final String? signedInAs,
|
||||
|
||||
/// Whether the sign-in is someone new's, whose account is yet to be.
|
||||
final bool newcomer = false,
|
||||
final String phrase = '',
|
||||
final List<int> checked = const [],
|
||||
}) with Equatable {
|
||||
SimpleLoginState copyWith({
|
||||
SimpleLoginStep? step,
|
||||
bool? busy,
|
||||
Exception? problem,
|
||||
bool clearProblem = false,
|
||||
bool? passwordsDiffer,
|
||||
bool? phraseWrong,
|
||||
String? handle,
|
||||
bool? handleFree,
|
||||
String? signedInAs,
|
||||
bool? newcomer,
|
||||
String? phrase,
|
||||
List<int>? checked,
|
||||
}) => SimpleLoginState(
|
||||
step: step ?? this.step,
|
||||
busy: busy ?? this.busy,
|
||||
problem: clearProblem ? null : problem ?? this.problem,
|
||||
passwordsDiffer: passwordsDiffer ?? this.passwordsDiffer,
|
||||
phraseWrong: phraseWrong ?? this.phraseWrong,
|
||||
handle: handle ?? this.handle,
|
||||
handleFree: handleFree ?? this.handleFree,
|
||||
signedInAs: signedInAs ?? this.signedInAs,
|
||||
newcomer: newcomer ?? this.newcomer,
|
||||
phrase: phrase ?? this.phrase,
|
||||
checked: checked ?? this.checked,
|
||||
);
|
||||
|
||||
/// Never the phrase.
|
||||
@override
|
||||
String toString() => 'SimpleLoginState($step, busy: $busy, $problem)';
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
step,
|
||||
busy,
|
||||
problem,
|
||||
passwordsDiffer,
|
||||
phraseWrong,
|
||||
handle,
|
||||
handleFree,
|
||||
signedInAs,
|
||||
newcomer,
|
||||
phrase,
|
||||
checked,
|
||||
];
|
||||
}
|
||||
|
||||
/// Signing in with SimpleLogin (ADR 0020): SimpleLogin's page in the
|
||||
/// browser, then, for someone new, an account created (a handle, a password
|
||||
/// twice, the recovery phrase shown once with three of its words typed
|
||||
/// back) or theirs linked again with its handle and recovery phrase (D7);
|
||||
/// for an account, its password, which opens its keys, or, for one without
|
||||
/// keys, the steps making them. With [_link], SimpleLogin is added to the
|
||||
/// account of the device's person instead, who keeps everything. Nothing is
|
||||
/// the device's until the last step: no account exists whose phrase was
|
||||
/// never written down.
|
||||
class SimpleLoginCubit(
|
||||
final SimpleLoginRepository _simpleLogin, {
|
||||
final bool _link = false,
|
||||
}) extends Cubit<SimpleLoginState> {
|
||||
this : super(const SimpleLoginState(step: SimpleLoginStep.browser));
|
||||
|
||||
SimpleLoginArrival? _arrival;
|
||||
NewKeysMaterial? _made;
|
||||
|
||||
/// Whether the phrase of [_made] was typed back, so a handle refused at
|
||||
/// the end goes straight on once changed.
|
||||
var _phraseChecked = false;
|
||||
|
||||
/// Closing the sheet stops waiting for the browser on the loopback.
|
||||
@override
|
||||
Future<void> close() async {
|
||||
if (state case SimpleLoginState(
|
||||
step: SimpleLoginStep.browser,
|
||||
busy: true,
|
||||
)) {
|
||||
await _simpleLogin.cancel();
|
||||
}
|
||||
await super.close();
|
||||
}
|
||||
|
||||
/// Opens SimpleLogin's page in the browser and waits until it comes back,
|
||||
/// anew after a sign-in that has to begin again.
|
||||
Future<void> started() => _run(() async {
|
||||
_arrival = null;
|
||||
_made = null;
|
||||
_phraseChecked = false;
|
||||
final arrival = await _simpleLogin.signIn(link: _link);
|
||||
_arrival = arrival;
|
||||
if (isClosed) {
|
||||
return;
|
||||
}
|
||||
emit(switch (arrival) {
|
||||
null => state.copyWith(step: SimpleLoginStep.closed, newcomer: false),
|
||||
SimpleLoginNewcomer(:final suggestedHandle) => state.copyWith(
|
||||
step: SimpleLoginStep.newcomer,
|
||||
newcomer: true,
|
||||
handle: suggestedHandle ?? '',
|
||||
),
|
||||
// The device has the keys of its person already.
|
||||
SimpleLoginAccount(hasKeys: true) when _link => state.copyWith(
|
||||
step: SimpleLoginStep.done,
|
||||
newcomer: false,
|
||||
),
|
||||
SimpleLoginAccount(hasKeys: true, :final handle) => state.copyWith(
|
||||
step: SimpleLoginStep.unlock,
|
||||
newcomer: false,
|
||||
signedInAs: handle,
|
||||
),
|
||||
SimpleLoginAccount(handle: null) => state.copyWith(
|
||||
step: SimpleLoginStep.handle,
|
||||
newcomer: false,
|
||||
),
|
||||
SimpleLoginAccount() => state.copyWith(
|
||||
step: SimpleLoginStep.password,
|
||||
newcomer: false,
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
/// Stops waiting for the browser: the sign-in ends as closed.
|
||||
Future<void> cancelled() => _simpleLogin.cancel();
|
||||
|
||||
/// Someone new creates an account: its handle first, the one suggested
|
||||
/// free, as the instance suggests none that anybody has.
|
||||
void createChosen() => emit(
|
||||
state.copyWith(
|
||||
step: SimpleLoginStep.handle,
|
||||
handleFree: state.handle.isNotEmpty && state.handle == _suggested,
|
||||
clearProblem: true,
|
||||
),
|
||||
);
|
||||
|
||||
String? get _suggested => switch (_arrival) {
|
||||
SimpleLoginNewcomer(:final suggestedHandle) => suggestedHandle,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// Someone new links the account they have, in place of a lost
|
||||
/// SimpleLogin account.
|
||||
void linkChosen() => emit(
|
||||
state.copyWith(step: SimpleLoginStep.mend, clearProblem: true),
|
||||
);
|
||||
|
||||
/// Back to the choice between creating an account and linking one.
|
||||
void choiceShownAgain() => emit(
|
||||
state.copyWith(step: SimpleLoginStep.newcomer, clearProblem: true),
|
||||
);
|
||||
|
||||
/// The handle typed: checked for someone new, then the password; or,
|
||||
/// where everything else is done, the account made with it.
|
||||
Future<void> handleEntered(String handle) => _run(() async {
|
||||
final typed = _handleOf(handle);
|
||||
await _check(typed);
|
||||
if (_made != null && _phraseChecked) {
|
||||
await _finish();
|
||||
} else {
|
||||
emit(state.copyWith(step: SimpleLoginStep.password));
|
||||
}
|
||||
});
|
||||
|
||||
/// Whether someone new could take [handle], unless it is the one
|
||||
/// suggested: each answer counts against the instance's limit (ADR 0020,
|
||||
/// D5). An account of before learns it once its keys are set.
|
||||
Future<void> _check(String handle) async {
|
||||
emit(state.copyWith(handle: handle, handleFree: false));
|
||||
if (handle == _suggested) {
|
||||
emit(state.copyWith(handleFree: true));
|
||||
} else if (_arrival case final SimpleLoginNewcomer newcomer) {
|
||||
if (await _simpleLogin.checkHandle(newcomer, handle)
|
||||
case final problem?) {
|
||||
throw SimpleLoginFailed(problem);
|
||||
}
|
||||
emit(state.copyWith(handleFree: true));
|
||||
}
|
||||
}
|
||||
|
||||
static String _handleOf(String typed) {
|
||||
final handle = typed.trim();
|
||||
return handle.startsWith('@') ? handle.substring(1) : handle;
|
||||
}
|
||||
|
||||
/// The password, typed twice: made into the account's keys and a recovery
|
||||
/// phrase on the device.
|
||||
Future<void> passwordEntered(String password, String again) async {
|
||||
if (password != again) {
|
||||
emit(state.copyWith(passwordsDiffer: true, clearProblem: true));
|
||||
return;
|
||||
}
|
||||
emit(state.copyWith(passwordsDiffer: false));
|
||||
await _run(() async {
|
||||
final made = await _simpleLogin.makeKeys(
|
||||
password,
|
||||
into: switch (_arrival) {
|
||||
final SimpleLoginAccount account => account,
|
||||
_ => null,
|
||||
},
|
||||
);
|
||||
_made = made;
|
||||
_phraseChecked = false;
|
||||
emit(
|
||||
state.copyWith(
|
||||
step: SimpleLoginStep.phrase,
|
||||
phrase: made.phrase,
|
||||
clearProblem: true,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// The phrase is written down: three of its words to type back.
|
||||
void phraseWrittenDown() => emit(
|
||||
state.copyWith(
|
||||
step: SimpleLoginStep.phraseCheck,
|
||||
checked: wordsToCheck(wordsOf(state.phrase).length),
|
||||
phraseWrong: false,
|
||||
),
|
||||
);
|
||||
|
||||
/// Back to the phrase.
|
||||
void phraseShownAgain() => emit(
|
||||
state.copyWith(step: SimpleLoginStep.phrase, phraseWrong: false),
|
||||
);
|
||||
|
||||
/// The words typed back: the account is made with its keys.
|
||||
Future<void> phraseChecked(List<String> words) async {
|
||||
if (!checksPhrase(state.phrase, state.checked, words)) {
|
||||
emit(state.copyWith(phraseWrong: true));
|
||||
return;
|
||||
}
|
||||
emit(state.copyWith(phraseWrong: false));
|
||||
_phraseChecked = true;
|
||||
await _run(_finish);
|
||||
}
|
||||
|
||||
Future<void> _finish() async {
|
||||
final made = _made!;
|
||||
final pending = switch (_arrival!) {
|
||||
final SimpleLoginNewcomer newcomer => await _simpleLogin.signUp(
|
||||
newcomer,
|
||||
handle: state.handle,
|
||||
made: made,
|
||||
),
|
||||
final SimpleLoginAccount account => await _simpleLogin.setFirstKeys(
|
||||
account,
|
||||
made: made,
|
||||
handle: account.handle == null ? state.handle : null,
|
||||
),
|
||||
};
|
||||
await _enter(pending);
|
||||
}
|
||||
|
||||
/// The password: the account's keys opened with it.
|
||||
Future<void> unlockEntered(String password) => _run(() async {
|
||||
await _enter(
|
||||
await _simpleLogin.unlock(_arrival! as SimpleLoginAccount, password),
|
||||
);
|
||||
});
|
||||
|
||||
/// The handle and the recovery phrase: the account linked again.
|
||||
Future<void> mendEntered(String handle, String phrase) => _run(() async {
|
||||
final typed = _handleOf(handle);
|
||||
emit(state.copyWith(handle: typed));
|
||||
await _enter(
|
||||
await _simpleLogin.mend(
|
||||
_arrival! as SimpleLoginNewcomer,
|
||||
handle: typed,
|
||||
phrase: phrase,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
Future<void> _enter(PendingSimpleLogin pending) async {
|
||||
await _simpleLogin.enter(pending);
|
||||
if (!isClosed) {
|
||||
emit(state.copyWith(step: SimpleLoginStep.done));
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs [step], busy meanwhile; a refusal is shown on the step it came
|
||||
/// from, or where it is to be mended: a handle's on the handle, a
|
||||
/// sign-in to begin again on the browser's.
|
||||
Future<void> _run(Future<void> Function() step) async {
|
||||
emit(state.copyWith(busy: true, clearProblem: true));
|
||||
try {
|
||||
await step();
|
||||
if (!isClosed) {
|
||||
emit(state.copyWith(busy: false));
|
||||
}
|
||||
} on Object catch (error) {
|
||||
final Exception failed = error is SimpleLoginFailed
|
||||
? error
|
||||
: asPasswordFailed(error);
|
||||
if (isClosed) {
|
||||
return;
|
||||
}
|
||||
final mendAt = switch (failed) {
|
||||
SimpleLoginFailed(problem: SimpleLoginProblem.startAgain) =>
|
||||
SimpleLoginStep.browser,
|
||||
SimpleLoginFailed(
|
||||
problem: SimpleLoginProblem.handleInvalid ||
|
||||
SimpleLoginProblem.handleReserved ||
|
||||
SimpleLoginProblem.handleTaken ||
|
||||
SimpleLoginProblem.tooManyHandleChecks,
|
||||
) =>
|
||||
SimpleLoginStep.handle,
|
||||
_ => null,
|
||||
};
|
||||
emit(state.copyWith(busy: false, problem: failed, step: mendAt));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Why signing in with SimpleLogin can't go on (ADR 0020). What the
|
||||
/// account's keys refuse on the way (a wrong password, too many tries) is a
|
||||
/// `PasswordFailed`, as for a password's sign-in.
|
||||
|
|
@ -48,7 +50,11 @@ enum SimpleLoginProblem() {
|
|||
|
||||
/// Signing in with SimpleLogin failed, for [problem].
|
||||
final class const SimpleLoginFailed(final SimpleLoginProblem problem)
|
||||
with Equatable
|
||||
implements Exception {
|
||||
@override
|
||||
List<Object?> get props => [problem];
|
||||
|
||||
@override
|
||||
String toString() => 'SimpleLoginFailed(${problem.name})';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,389 @@
|
|||
import 'dart:async';
|
||||
|
||||
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/widget/password_widgets.dart';
|
||||
import 'package:halkyon_app/src/simplelogin/bloc/simplelogin_cubit.dart';
|
||||
import 'package:halkyon_app/src/simplelogin/widget/simplelogin_texts.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Signs in with SimpleLogin on [instance] in a sheet (ADR 0020): its page
|
||||
/// in the browser first, while the sheet waits, then what the account
|
||||
/// needs. With [link], adds SimpleLogin to the account of the device's
|
||||
/// person instead. True once signed in, or SimpleLogin added; the sheet
|
||||
/// can't be swiped away, as an account half made would otherwise be left
|
||||
/// behind unnoticed.
|
||||
Future<bool> showSimpleLoginSheet(
|
||||
BuildContext context, {
|
||||
required String instance,
|
||||
bool link = false,
|
||||
}) async {
|
||||
final simpleLogin = AppScope.of(context).simpleLogin;
|
||||
return await showAdaptiveSheet<bool>(
|
||||
context,
|
||||
swipeable: false,
|
||||
builder: (context) => BlocProvider(
|
||||
create: (context) {
|
||||
final cubit = SimpleLoginCubit(simpleLogin, link: link);
|
||||
unawaited(cubit.started());
|
||||
return cubit;
|
||||
},
|
||||
child: _SimpleLogin(instance: instance, link: link),
|
||||
),
|
||||
) ??
|
||||
false;
|
||||
}
|
||||
|
||||
class const _SimpleLogin({
|
||||
required final String instance,
|
||||
required final bool link,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<_SimpleLogin> createState() => _SimpleLoginState();
|
||||
}
|
||||
|
||||
class _SimpleLoginState() extends State<_SimpleLogin> {
|
||||
final _handle = TextEditingController();
|
||||
final _password = TextEditingController();
|
||||
final _again = TextEditingController();
|
||||
final _phrase = TextEditingController();
|
||||
final _words = [for (var i = 0; i < 3; i++) TextEditingController()];
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_handle.dispose();
|
||||
_password.dispose();
|
||||
_again.dispose();
|
||||
_phrase.dispose();
|
||||
for (final word in _words) {
|
||||
word.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
SimpleLoginCubit get _cubit => context.read<SimpleLoginCubit>();
|
||||
|
||||
void _submit(SimpleLoginState state) {
|
||||
switch (state.step) {
|
||||
case SimpleLoginStep.browser:
|
||||
_cubit.started();
|
||||
case SimpleLoginStep.handle:
|
||||
_cubit.handleEntered(_handle.text);
|
||||
case SimpleLoginStep.password:
|
||||
_cubit.passwordEntered(_password.text, _again.text);
|
||||
case SimpleLoginStep.phrase:
|
||||
_cubit.phraseWrittenDown();
|
||||
case SimpleLoginStep.phraseCheck:
|
||||
_cubit.phraseChecked([for (final word in _words) word.text]);
|
||||
case SimpleLoginStep.unlock:
|
||||
_cubit.unlockEntered(_password.text);
|
||||
case SimpleLoginStep.mend:
|
||||
_cubit.mendEntered(_handle.text, _phrase.text);
|
||||
case SimpleLoginStep.newcomer ||
|
||||
SimpleLoginStep.done ||
|
||||
SimpleLoginStep.closed:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _stepChanged(BuildContext context, SimpleLoginState state) {
|
||||
switch (state.step) {
|
||||
case SimpleLoginStep.done:
|
||||
Navigator.of(context).pop(true);
|
||||
case SimpleLoginStep.closed:
|
||||
Navigator.of(context).pop(false);
|
||||
case SimpleLoginStep.newcomer:
|
||||
_handle.text = state.handle;
|
||||
case SimpleLoginStep.password || SimpleLoginStep.unlock:
|
||||
_password.clear();
|
||||
_again.clear();
|
||||
case SimpleLoginStep.phraseCheck:
|
||||
for (final word in _words) {
|
||||
word.clear();
|
||||
}
|
||||
case SimpleLoginStep.mend:
|
||||
// Their own handle, not the one suggested to someone new.
|
||||
_handle.clear();
|
||||
_phrase.clear();
|
||||
case SimpleLoginStep.browser ||
|
||||
SimpleLoginStep.handle ||
|
||||
SimpleLoginStep.phrase:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = HalkyonAppLocalizations.of(context);
|
||||
return BlocConsumer<SimpleLoginCubit, SimpleLoginState>(
|
||||
listenWhen: (previous, current) => previous.step != current.step,
|
||||
listener: _stepChanged,
|
||||
builder: (context, state) {
|
||||
final problem = switch (state.problem) {
|
||||
final failed? => simpleLoginFailureText(context, failed),
|
||||
null => null,
|
||||
};
|
||||
return SheetScaffold(
|
||||
title: switch (state.step) {
|
||||
SimpleLoginStep.newcomer => l10n.simpleLoginNewcomerTitle,
|
||||
SimpleLoginStep.handle || SimpleLoginStep.password
|
||||
when state.newcomer =>
|
||||
l10n.passwordCreateTitle,
|
||||
SimpleLoginStep.handle || SimpleLoginStep.password
|
||||
when widget.link =>
|
||||
l10n.simpleLoginAddTitle,
|
||||
SimpleLoginStep.handle ||
|
||||
SimpleLoginStep.password => l10n.passwordSetUpTitle,
|
||||
SimpleLoginStep.phrase => l10n.passwordPhraseTitle,
|
||||
SimpleLoginStep.phraseCheck => l10n.passwordPhraseCheckTitle,
|
||||
SimpleLoginStep.unlock => l10n.simpleLoginUnlockTitle,
|
||||
SimpleLoginStep.mend => l10n.simpleLoginMendTitle,
|
||||
_ when widget.link => l10n.simpleLoginAddTitle,
|
||||
_ => l10n.simpleLoginTitle,
|
||||
},
|
||||
busy: state.busy,
|
||||
body: AutofillGroup(child: _body(context, state, problem)),
|
||||
footer: _footer(context, state),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget? _footer(BuildContext context, SimpleLoginState state) {
|
||||
final l10n = HalkyonAppLocalizations.of(context);
|
||||
return switch (state.step) {
|
||||
// Waiting for the browser, which the loopback can stop waiting for.
|
||||
SimpleLoginStep.browser when state.problem == null => OutlinedButton(
|
||||
onPressed: _cubit.cancelled,
|
||||
child: Text(HalkyonUiLocalizations.of(context).cancel),
|
||||
),
|
||||
SimpleLoginStep.newcomer ||
|
||||
SimpleLoginStep.done ||
|
||||
SimpleLoginStep.closed => null,
|
||||
_ => FilledButton(
|
||||
onPressed: state.busy ? null : () => _submit(state),
|
||||
child: state.busy
|
||||
? const ButtonLoadingIndicator()
|
||||
: Text(switch (state.step) {
|
||||
SimpleLoginStep.browser => l10n.tryAgain,
|
||||
SimpleLoginStep.phrase => l10n.passwordPhraseWrittenDown,
|
||||
SimpleLoginStep.phraseCheck when state.newcomer =>
|
||||
l10n.simpleLoginCreate,
|
||||
SimpleLoginStep.unlock => l10n.simpleLoginOpen,
|
||||
SimpleLoginStep.mend => l10n.simpleLoginMend,
|
||||
_ => l10n.passwordContinue,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
Widget _body(
|
||||
BuildContext context,
|
||||
SimpleLoginState state,
|
||||
String? problem,
|
||||
) {
|
||||
final l10n = HalkyonAppLocalizations.of(context);
|
||||
final enabled = !state.busy;
|
||||
return switch (state.step) {
|
||||
SimpleLoginStep.browser => switch (problem) {
|
||||
final problem? => SheetProblem(problem),
|
||||
null => SheetWorking(message: l10n.simpleLoginInBrowser),
|
||||
},
|
||||
SimpleLoginStep.newcomer => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SheetIntro(l10n.simpleLoginNewcomerIntro(widget.instance)),
|
||||
_Choice(
|
||||
icon: LucideIcons.userPlus,
|
||||
title: l10n.passwordCreateTitle,
|
||||
hint: l10n.simpleLoginCreateHint,
|
||||
onTap: _cubit.createChosen,
|
||||
),
|
||||
_Choice(
|
||||
icon: LucideIcons.link,
|
||||
title: l10n.simpleLoginHaveOne,
|
||||
hint: l10n.simpleLoginHaveOneHint,
|
||||
onTap: _cubit.linkChosen,
|
||||
),
|
||||
],
|
||||
),
|
||||
SimpleLoginStep.handle => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SheetIntro(l10n.simpleLoginHandleIntro),
|
||||
_HandleField(
|
||||
controller: _handle,
|
||||
free: state.handleFree ? state.handle : null,
|
||||
enabled: enabled,
|
||||
autofocus: true,
|
||||
errorText: problem,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _submit(state),
|
||||
),
|
||||
],
|
||||
),
|
||||
SimpleLoginStep.password =>
|
||||
state.busy && problem == null
|
||||
? SheetWorking(message: l10n.passwordMakingKeys)
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SheetIntro(l10n.simpleLoginPasswordIntro),
|
||||
PasswordField(
|
||||
controller: _password,
|
||||
label: l10n.passwordNewPassword,
|
||||
showsMinimum: true,
|
||||
isNew: true,
|
||||
enabled: enabled,
|
||||
autofocus: true,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
PasswordField(
|
||||
controller: _again,
|
||||
label: l10n.passwordRepeat,
|
||||
isNew: true,
|
||||
enabled: enabled,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _submit(state),
|
||||
errorText: state.passwordsDiffer
|
||||
? l10n.passwordsDiffer
|
||||
: problem,
|
||||
),
|
||||
],
|
||||
),
|
||||
SimpleLoginStep.phrase => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SheetIntro(l10n.passwordPhraseIntro),
|
||||
RecoveryPhraseView(phrase: state.phrase),
|
||||
],
|
||||
),
|
||||
SimpleLoginStep.phraseCheck => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SheetIntro(l10n.passwordPhraseCheckIntro),
|
||||
PhraseCheckFields(
|
||||
positions: state.checked,
|
||||
controllers: _words,
|
||||
enabled: enabled,
|
||||
errorText: state.phraseWrong ? l10n.passwordPhraseWrong : problem,
|
||||
onDone: () => _submit(state),
|
||||
),
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: TextButton(
|
||||
onPressed: enabled ? _cubit.phraseShownAgain : null,
|
||||
child: Text(l10n.passwordPhraseShowAgain),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SimpleLoginStep.unlock => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SheetIntro(switch (state.signedInAs) {
|
||||
final handle? => l10n.simpleLoginUnlockIntroAs(handle),
|
||||
null => l10n.simpleLoginUnlockIntro,
|
||||
}),
|
||||
PasswordField(
|
||||
controller: _password,
|
||||
label: l10n.passwordPassword,
|
||||
enabled: enabled,
|
||||
autofocus: true,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _submit(state),
|
||||
errorText: problem,
|
||||
),
|
||||
],
|
||||
),
|
||||
SimpleLoginStep.mend => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SheetIntro(l10n.simpleLoginMendIntro),
|
||||
_HandleField(controller: _handle, enabled: enabled, autofocus: true),
|
||||
PhraseField(
|
||||
controller: _phrase,
|
||||
enabled: enabled,
|
||||
errorText: problem,
|
||||
),
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: TextButton(
|
||||
onPressed: enabled ? _cubit.choiceShownAgain : null,
|
||||
child: Text(l10n.passwordBack),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SimpleLoginStep.done || SimpleLoginStep.closed => const SheetWorking(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// One of the ways on for someone new.
|
||||
class const _Choice({
|
||||
required final IconData icon,
|
||||
required final String title,
|
||||
required final String hint,
|
||||
required final VoidCallback onTap,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(icon),
|
||||
title: Text(title),
|
||||
subtitle: Text(hint),
|
||||
trailing: const Icon(LucideIcons.chevronRight),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
|
||||
/// The handle, after an `@`; [free] says that the handle it names was
|
||||
/// found free, while it is still the one typed.
|
||||
class const _HandleField({
|
||||
required final TextEditingController controller,
|
||||
final String? free,
|
||||
final bool enabled = true,
|
||||
final bool autofocus = false,
|
||||
final String? errorText,
|
||||
final TextInputAction textInputAction = TextInputAction.next,
|
||||
final ValueChanged<String>? onSubmitted,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = HalkyonAppLocalizations.of(context);
|
||||
return ListenableBuilder(
|
||||
listenable: controller,
|
||||
builder: (context, _) {
|
||||
final typed = controller.text.trim();
|
||||
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.simpleLoginHandle,
|
||||
prefixText: '@',
|
||||
helperText: switch (free) {
|
||||
final free? when free == typed || '@$free' == typed =>
|
||||
l10n.simpleLoginHandleFree(free),
|
||||
_ => null,
|
||||
},
|
||||
errorText: errorText,
|
||||
),
|
||||
missing: typed.isEmpty,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import 'package:halkyon_app/l10n/halkyon_app_localizations.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/simplelogin/model/simplelogin_failed.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// [failed] in the app's words: SimpleLogin's own refusals, and the keys'
|
||||
/// as signing in with SimpleLogin meets them, where a password or a handle
|
||||
/// takes an address's place.
|
||||
String simpleLoginFailureText(BuildContext context, Exception failed) {
|
||||
final l10n = HalkyonAppLocalizations.of(context);
|
||||
return switch (failed) {
|
||||
SimpleLoginFailed(:final problem) => switch (problem) {
|
||||
SimpleLoginProblem.unavailable => l10n.simpleLoginUnavailable,
|
||||
SimpleLoginProblem.denied => l10n.simpleLoginDenied,
|
||||
SimpleLoginProblem.noBrowser => l10n.simpleLoginNoBrowser,
|
||||
SimpleLoginProblem.startAgain => l10n.simpleLoginStartAgain,
|
||||
SimpleLoginProblem.rejected => l10n.simpleLoginRejected,
|
||||
SimpleLoginProblem.providerUnavailable =>
|
||||
l10n.simpleLoginProviderUnavailable,
|
||||
SimpleLoginProblem.takenByAnotherPerson =>
|
||||
l10n.simpleLoginTakenByAnotherPerson,
|
||||
SimpleLoginProblem.otherIdentityLinked =>
|
||||
l10n.simpleLoginOtherIdentityLinked,
|
||||
SimpleLoginProblem.tooManyAttempts => l10n.simpleLoginTooManyAttempts,
|
||||
SimpleLoginProblem.handleInvalid => l10n.simpleLoginHandleInvalid,
|
||||
SimpleLoginProblem.handleReserved => l10n.simpleLoginHandleReserved,
|
||||
SimpleLoginProblem.handleTaken => l10n.simpleLoginHandleTaken,
|
||||
SimpleLoginProblem.tooManyHandleChecks =>
|
||||
l10n.simpleLoginTooManyHandleChecks,
|
||||
SimpleLoginProblem.failed => l10n.passwordFailed,
|
||||
},
|
||||
PasswordFailed(problem: PasswordProblem.wrongCredentials) =>
|
||||
l10n.simpleLoginWrongPassword,
|
||||
PasswordFailed(problem: PasswordProblem.phraseDoesNotFit) =>
|
||||
l10n.simpleLoginMendDoesNotFit,
|
||||
final PasswordFailed failed => passwordProblemText(context, failed),
|
||||
_ => l10n.passwordFailed,
|
||||
};
|
||||
}
|
||||
|
|
@ -191,7 +191,8 @@ class FakeSimpleLoginGateway(final AuthGateway _auth)
|
|||
}
|
||||
|
||||
SimpleLoginProblem? _handleProblem(String handle) {
|
||||
if (!RegExp(r'^[a-z0-9_]{3,30}$').hasMatch(handle)) {
|
||||
// The module's own rule (`isHandle`).
|
||||
if (!RegExp(r'^[A-Za-z0-9_]{1,30}$').hasMatch(handle)) {
|
||||
return SimpleLoginProblem.handleInvalid;
|
||||
}
|
||||
if (handle == 'admin') {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:halkyon_app/l10n/halkyon_app_localizations.dart';
|
||||
import 'package:halkyon_app/src/app/app_frame.dart';
|
||||
import 'package:halkyon_app/src/app/app_scope.dart';
|
||||
|
|
@ -7,9 +6,12 @@ import 'package:halkyon_app/src/auth/data/auth_gateway.dart';
|
|||
import 'package:halkyon_app/src/auth/data/auth_repository.dart';
|
||||
import 'package:halkyon_app/src/auth/model/auth_status.dart';
|
||||
import 'package:halkyon_app/src/password/data/password_repository.dart';
|
||||
import 'package:halkyon_app/src/simplelogin/data/simplelogin_repository.dart';
|
||||
import 'package:halkyon_app/src/testing/fake_account_crypto.dart';
|
||||
import 'package:halkyon_app/src/testing/fake_auth_gateway.dart';
|
||||
import 'package:halkyon_app/src/testing/fake_oidc_browser.dart';
|
||||
import 'package:halkyon_app/src/testing/fake_password_gateway.dart';
|
||||
import 'package:halkyon_app/src/testing/fake_simplelogin_gateway.dart';
|
||||
import 'package:halkyon_app/src/testing/memory_account_key_store.dart';
|
||||
import 'package:halkyon_app/src/testing/memory_file_saver.dart';
|
||||
import 'package:halkyon_app/src/testing/memory_sign_in_addresses.dart';
|
||||
|
|
@ -125,6 +127,27 @@ class TestAppDependencies({
|
|||
addresses,
|
||||
);
|
||||
|
||||
/// The instance's sign-in with SimpleLogin, without a server: offered
|
||||
/// only where a test offers it (`isOffered`).
|
||||
late final FakeSimpleLoginGateway simpleLoginGateway = FakeSimpleLoginGateway(
|
||||
authGateway,
|
||||
)..isOffered = false;
|
||||
|
||||
/// SimpleLogin's page, without a browser.
|
||||
final browser = FakeOidcBrowser();
|
||||
|
||||
@override
|
||||
late final SimpleLoginRepository simpleLogin = SimpleLoginRepository(
|
||||
simpleLoginGateway,
|
||||
browser,
|
||||
crypto,
|
||||
keys,
|
||||
authGateway,
|
||||
callbackScheme: 'app.halkyon.test',
|
||||
loopbackPort: 43823,
|
||||
platform: TargetPlatform.android,
|
||||
);
|
||||
|
||||
@override
|
||||
final MemoryFileSaver fileSaver = MemoryFileSaver();
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,382 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
import 'package:halkyon_oidc_flutter/halkyon_oidc_flutter.dart';
|
||||
|
||||
/// A state at [step], [busy] or not, with [problem] or none.
|
||||
Matcher at(SimpleLoginStep step, {bool busy = false, Exception? problem}) =>
|
||||
isA<SimpleLoginState>()
|
||||
.having((state) => state.step, 'step', step)
|
||||
.having((state) => state.busy, 'busy', busy)
|
||||
.having((state) => state.problem, 'problem', problem);
|
||||
|
||||
const password = 'correct horse battery';
|
||||
|
||||
void main() {
|
||||
late FakeAuthGateway auth;
|
||||
late FakeSimpleLoginGateway gateway;
|
||||
late FakeOidcBrowser browser;
|
||||
late FakeAccountCrypto crypto;
|
||||
late MemoryAccountKeyStore keys;
|
||||
late SimpleLoginRepository simpleLogin;
|
||||
|
||||
setUp(() {
|
||||
auth = FakeAuthGateway();
|
||||
gateway = FakeSimpleLoginGateway(auth);
|
||||
browser = FakeOidcBrowser();
|
||||
crypto = FakeAccountCrypto();
|
||||
keys = MemoryAccountKeyStore();
|
||||
simpleLogin = SimpleLoginRepository(
|
||||
gateway,
|
||||
browser,
|
||||
crypto,
|
||||
keys,
|
||||
auth,
|
||||
callbackScheme: 'app.halkyon.test',
|
||||
loopbackPort: 43823,
|
||||
platform: TargetPlatform.android,
|
||||
);
|
||||
});
|
||||
|
||||
SimpleLoginCubit cubitFor({bool link = false}) {
|
||||
final cubit = SimpleLoginCubit(simpleLogin, link: link);
|
||||
addTearDown(cubit.close);
|
||||
return cubit;
|
||||
}
|
||||
|
||||
/// The words the check asks for, typed back right.
|
||||
List<String> wordsAsked(SimpleLoginState state) => [
|
||||
for (final position in state.checked) wordsOf(state.phrase)[position],
|
||||
];
|
||||
|
||||
/// Makes the keys with [password] and types the phrase back.
|
||||
Future<void> makeKeys(SimpleLoginCubit cubit) async {
|
||||
await cubit.passwordEntered(password, password);
|
||||
expect(cubit.state, at(SimpleLoginStep.phrase));
|
||||
expect(cubit.state.phrase, fakePhrase);
|
||||
cubit.phraseWrittenDown();
|
||||
expect(cubit.state, at(SimpleLoginStep.phraseCheck));
|
||||
await cubit.phraseChecked(wordsAsked(cubit.state));
|
||||
}
|
||||
|
||||
group('someone new', () {
|
||||
test('creates an account: the suggested handle checked, the password '
|
||||
'twice, the phrase shown and three words typed back, signed in '
|
||||
'only at the end', () async {
|
||||
final cubit = cubitFor();
|
||||
expect(cubit.state, at(SimpleLoginStep.browser));
|
||||
|
||||
await cubit.started();
|
||||
expect(cubit.state, at(SimpleLoginStep.newcomer));
|
||||
expect(cubit.state.handle, 'anna_mueller');
|
||||
|
||||
cubit.createChosen();
|
||||
expect(cubit.state, at(SimpleLoginStep.handle));
|
||||
expect(cubit.state.handleFree, isTrue);
|
||||
|
||||
await cubit.handleEntered(' @anna_mueller ');
|
||||
expect(cubit.state, at(SimpleLoginStep.password));
|
||||
expect(cubit.state.handle, 'anna_mueller');
|
||||
expect(
|
||||
gateway.calls,
|
||||
isNot(contains('checkHandle')),
|
||||
reason: 'the instance suggests no handle anybody has',
|
||||
);
|
||||
|
||||
await cubit.passwordEntered(password, 'correct horse batery');
|
||||
expect(cubit.state.passwordsDiffer, isTrue);
|
||||
await cubit.passwordEntered('too short', 'too short');
|
||||
expect(
|
||||
cubit.state,
|
||||
at(
|
||||
SimpleLoginStep.password,
|
||||
problem: const PasswordFailed(PasswordProblem.passwordTooShort),
|
||||
),
|
||||
);
|
||||
|
||||
await cubit.passwordEntered(password, password);
|
||||
cubit.phraseWrittenDown();
|
||||
await cubit.phraseChecked(['wrong', 'words', 'typed']);
|
||||
expect(cubit.state.phraseWrong, isTrue);
|
||||
cubit.phraseShownAgain();
|
||||
expect(cubit.state, at(SimpleLoginStep.phrase));
|
||||
expect(gateway.calls, isNot(contains('signUp')));
|
||||
expect(auth.status, const SignedOut());
|
||||
|
||||
cubit.phraseWrittenDown();
|
||||
await cubit.phraseChecked(wordsAsked(cubit.state));
|
||||
|
||||
expect(cubit.state, at(SimpleLoginStep.done));
|
||||
expect(
|
||||
auth.status,
|
||||
const SignedIn(authUserId: 'person-1', isIdentified: true),
|
||||
);
|
||||
expect(gateway.accounts['person-1']!.handle, 'anna_mueller');
|
||||
expect(keys.kept.keys.toList(), ['person-1']);
|
||||
});
|
||||
|
||||
test('learns on the handle that one typed is taken, and goes on with '
|
||||
'one free', () async {
|
||||
gateway.addAccount('person-9', subject: 'other', handle: 'anna');
|
||||
final cubit = cubitFor();
|
||||
await cubit.started();
|
||||
cubit.createChosen();
|
||||
|
||||
await cubit.handleEntered('anna');
|
||||
expect(
|
||||
cubit.state,
|
||||
at(
|
||||
SimpleLoginStep.handle,
|
||||
problem: const SimpleLoginFailed(SimpleLoginProblem.handleTaken),
|
||||
),
|
||||
);
|
||||
expect(cubit.state.handleFree, isFalse);
|
||||
|
||||
await cubit.handleEntered('admin');
|
||||
expect(
|
||||
cubit.state.problem,
|
||||
const SimpleLoginFailed(SimpleLoginProblem.handleReserved),
|
||||
);
|
||||
|
||||
await cubit.handleEntered('anna_m');
|
||||
expect(cubit.state, at(SimpleLoginStep.password));
|
||||
expect(cubit.state.handleFree, isTrue);
|
||||
});
|
||||
|
||||
test('goes back to the handle when it was taken meanwhile, then on with '
|
||||
'the keys made, once changed', () async {
|
||||
final cubit = cubitFor();
|
||||
await cubit.started();
|
||||
cubit.createChosen();
|
||||
await cubit.handleEntered('anna_mueller');
|
||||
gateway.addAccount('person-9', subject: 'other', handle: 'anna_mueller');
|
||||
|
||||
await makeKeys(cubit);
|
||||
expect(
|
||||
cubit.state,
|
||||
at(
|
||||
SimpleLoginStep.handle,
|
||||
problem: const SimpleLoginFailed(SimpleLoginProblem.handleTaken),
|
||||
),
|
||||
);
|
||||
|
||||
await cubit.handleEntered('anna_m');
|
||||
|
||||
expect(cubit.state, at(SimpleLoginStep.done));
|
||||
final person = auth.usedSessions.single.authUserId;
|
||||
expect(gateway.accounts[person]!.handle, 'anna_m');
|
||||
});
|
||||
|
||||
test('links the account they have again, by its handle and recovery '
|
||||
'phrase, refusing a phrase that does not fit', () async {
|
||||
final material = await crypto.newKeys(password);
|
||||
gateway.addAccount(
|
||||
'person-9',
|
||||
subject: 'simplelogin-lost',
|
||||
handle: 'anna',
|
||||
keys: material.setup,
|
||||
);
|
||||
final cubit = cubitFor();
|
||||
await cubit.started();
|
||||
|
||||
cubit.linkChosen();
|
||||
expect(cubit.state, at(SimpleLoginStep.mend));
|
||||
await cubit.mendEntered('anna', otherFakePhrase);
|
||||
expect(
|
||||
cubit.state,
|
||||
at(
|
||||
SimpleLoginStep.mend,
|
||||
problem: const PasswordFailed(PasswordProblem.phraseDoesNotFit),
|
||||
),
|
||||
);
|
||||
cubit.choiceShownAgain();
|
||||
expect(cubit.state, at(SimpleLoginStep.newcomer));
|
||||
cubit.linkChosen();
|
||||
|
||||
await cubit.mendEntered('@anna', fakePhrase);
|
||||
|
||||
expect(cubit.state, at(SimpleLoginStep.done));
|
||||
expect(gateway.linked, {'simplelogin-anna': 'person-9'});
|
||||
expect(keys.kept, {'person-9': material.keys});
|
||||
});
|
||||
|
||||
test('begins again once their sign-in ran out', () async {
|
||||
final cubit = cubitFor();
|
||||
await cubit.started();
|
||||
cubit.linkChosen();
|
||||
gateway.failing = const SimpleLoginFailed(SimpleLoginProblem.startAgain);
|
||||
|
||||
await cubit.mendEntered('anna', fakePhrase);
|
||||
expect(
|
||||
cubit.state,
|
||||
at(
|
||||
SimpleLoginStep.browser,
|
||||
problem: const SimpleLoginFailed(SimpleLoginProblem.startAgain),
|
||||
),
|
||||
);
|
||||
|
||||
gateway.failing = null;
|
||||
await cubit.started();
|
||||
expect(cubit.state, at(SimpleLoginStep.newcomer));
|
||||
});
|
||||
});
|
||||
|
||||
group('an account', () {
|
||||
test('opens its keys with the password, refusing a wrong one', () async {
|
||||
final material = await crypto.newKeys(password);
|
||||
gateway.addAccount(
|
||||
'person-9',
|
||||
subject: 'simplelogin-anna',
|
||||
handle: 'anna',
|
||||
keys: material.setup,
|
||||
);
|
||||
final cubit = cubitFor();
|
||||
await cubit.started();
|
||||
expect(cubit.state, at(SimpleLoginStep.unlock));
|
||||
expect(cubit.state.signedInAs, 'anna');
|
||||
|
||||
await cubit.unlockEntered('wrong horse battery');
|
||||
expect(
|
||||
cubit.state,
|
||||
at(
|
||||
SimpleLoginStep.unlock,
|
||||
problem: const PasswordFailed(PasswordProblem.wrongCredentials),
|
||||
),
|
||||
);
|
||||
expect(auth.status, const SignedOut());
|
||||
|
||||
await cubit.unlockEntered(password);
|
||||
|
||||
expect(cubit.state, at(SimpleLoginStep.done));
|
||||
expect(
|
||||
auth.status,
|
||||
const SignedIn(authUserId: 'person-9', isIdentified: true),
|
||||
);
|
||||
expect(keys.kept, {'person-9': material.keys});
|
||||
});
|
||||
|
||||
test('of before, without keys, makes them with the handle it '
|
||||
'lacks', () async {
|
||||
gateway.addAccount('person-9', subject: 'simplelogin-anna');
|
||||
final cubit = cubitFor();
|
||||
await cubit.started();
|
||||
expect(cubit.state, at(SimpleLoginStep.handle));
|
||||
|
||||
await cubit.handleEntered('anna');
|
||||
expect(cubit.state, at(SimpleLoginStep.password));
|
||||
expect(gateway.calls, isNot(contains('checkHandle')));
|
||||
await makeKeys(cubit);
|
||||
|
||||
expect(cubit.state, at(SimpleLoginStep.done));
|
||||
expect(gateway.accounts['person-9']!.handle, 'anna');
|
||||
expect(gateway.accounts['person-9']!.keys, isNotNull);
|
||||
expect(auth.usedSessions.single.authUserId, 'person-9');
|
||||
});
|
||||
});
|
||||
|
||||
group("SimpleLogin added to the account of the device's person", () {
|
||||
test('an anonymous person keeps their device keys, and is identified '
|
||||
'once the keys are set', () async {
|
||||
await auth.signInAnonymously();
|
||||
final deviceKeys = crypto.keysNumbered(1);
|
||||
keys.kept['anonymous-1'] = deviceKeys;
|
||||
final cubit = cubitFor(link: true);
|
||||
|
||||
await cubit.started();
|
||||
expect(cubit.state, at(SimpleLoginStep.handle));
|
||||
await cubit.handleEntered('anna');
|
||||
await makeKeys(cubit);
|
||||
|
||||
expect(cubit.state, at(SimpleLoginStep.done));
|
||||
expect(
|
||||
auth.status,
|
||||
const SignedIn(authUserId: 'anonymous-1', isIdentified: true),
|
||||
);
|
||||
expect(keys.kept, {'anonymous-1': deviceKeys});
|
||||
expect(auth.usedSessions, isEmpty);
|
||||
});
|
||||
|
||||
test('an account with keys is done at once', () async {
|
||||
final material = await crypto.newKeys(password);
|
||||
auth = FakeAuthGateway(
|
||||
status: const SignedIn(authUserId: 'person-9', isIdentified: true),
|
||||
);
|
||||
gateway = FakeSimpleLoginGateway(auth);
|
||||
gateway.accounts['person-9'] = FakeSimpleLoginAccount(
|
||||
handle: 'anna',
|
||||
keys: material.setup,
|
||||
);
|
||||
simpleLogin = SimpleLoginRepository(
|
||||
gateway,
|
||||
browser,
|
||||
crypto,
|
||||
keys,
|
||||
auth,
|
||||
callbackScheme: 'app.halkyon.test',
|
||||
loopbackPort: 43823,
|
||||
platform: TargetPlatform.android,
|
||||
);
|
||||
final cubit = cubitFor(link: true);
|
||||
|
||||
await cubit.started();
|
||||
|
||||
expect(cubit.state, at(SimpleLoginStep.done));
|
||||
expect(gateway.linked, {'simplelogin-anna': 'person-9'});
|
||||
});
|
||||
});
|
||||
|
||||
group('the browser', () {
|
||||
test('closed, the sign-in ends with nothing done', () async {
|
||||
browser.comesBack = () => null;
|
||||
final cubit = cubitFor();
|
||||
|
||||
await cubit.started();
|
||||
|
||||
expect(cubit.state, at(SimpleLoginStep.closed));
|
||||
});
|
||||
|
||||
test("refused on SimpleLogin's page, says so and can begin "
|
||||
'again', () async {
|
||||
browser.comesBack = () => const OidcDenied('access_denied');
|
||||
final cubit = cubitFor();
|
||||
|
||||
await cubit.started();
|
||||
expect(
|
||||
cubit.state,
|
||||
at(
|
||||
SimpleLoginStep.browser,
|
||||
problem: const SimpleLoginFailed(SimpleLoginProblem.denied),
|
||||
),
|
||||
);
|
||||
|
||||
browser.comesBack = null;
|
||||
await cubit.started();
|
||||
expect(cubit.state, at(SimpleLoginStep.newcomer));
|
||||
});
|
||||
|
||||
test('is no longer waited for once the sheet closes', () async {
|
||||
final waiting = Completer<OidcCallback?>();
|
||||
browser.comesBack = () => waiting.future;
|
||||
final cubit = SimpleLoginCubit(simpleLogin);
|
||||
|
||||
unawaited(cubit.started());
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(cubit.state, at(SimpleLoginStep.browser, busy: true));
|
||||
await cubit.close();
|
||||
|
||||
expect(browser.cancelled, 1);
|
||||
waiting.complete(null);
|
||||
});
|
||||
|
||||
test('is no longer waited for once cancelled', () async {
|
||||
final cubit = cubitFor();
|
||||
|
||||
await cubit.cancelled();
|
||||
|
||||
expect(browser.cancelled, 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -216,7 +216,7 @@ void main() {
|
|||
SimpleLoginProblem.handleReserved,
|
||||
);
|
||||
expect(
|
||||
await simpleLogin.checkHandle(newcomer, 'A'),
|
||||
await simpleLogin.checkHandle(newcomer, 'not a handle'),
|
||||
SimpleLoginProblem.handleInvalid,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,236 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
import 'package:halkyon_oidc_flutter/halkyon_oidc_flutter.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// A signed-in person's app as a test makes one: whose it is.
|
||||
class const _PersonApp(final SignedIn person) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => MaterialApp(
|
||||
home: Scaffold(body: Text('App of ${person.authUserId}')),
|
||||
);
|
||||
}
|
||||
|
||||
const password = 'correct horse battery';
|
||||
|
||||
void main() {
|
||||
late FakeAuthGateway gateway;
|
||||
late TestAppDependencies dependencies;
|
||||
|
||||
setUp(() {
|
||||
gateway = FakeAuthGateway();
|
||||
dependencies = TestAppDependencies(gateway: gateway);
|
||||
dependencies.simpleLoginGateway.isOffered = true;
|
||||
});
|
||||
|
||||
/// Starts signed out, on a phone tall enough for every sheet.
|
||||
Future<void> start(WidgetTester tester) async {
|
||||
tester.view.physicalSize = const Size(412, 1800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await dependencies.signedOut.write(signedOut: true);
|
||||
await tester.pumpWidget(
|
||||
AppFrameScope(
|
||||
frame: testFrame(),
|
||||
child: AppScope(
|
||||
dependencies: dependencies,
|
||||
child: SessionGate(
|
||||
look: (context) => (themeMode: ThemeMode.light, locale: null),
|
||||
app: _PersonApp.new,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Finder field(String label) => find.widgetWithText(TextField, label);
|
||||
|
||||
Future<void> tapButton(WidgetTester tester, String label) async {
|
||||
final button = find.widgetWithText(FilledButton, label).last;
|
||||
await tester.ensureVisible(button);
|
||||
await tester.tap(button);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// Types the recovery phrase's words the check asks for.
|
||||
Future<void> typeCheckedWords(WidgetTester tester, String phrase) async {
|
||||
final words = wordsOf(phrase);
|
||||
for (final element in find.byType(TextField).evaluate()) {
|
||||
if ((element.widget as TextField).decoration?.labelText case final label?
|
||||
when label.startsWith('Word ')) {
|
||||
await tester.enterText(
|
||||
field(label),
|
||||
words[int.parse(label.substring('Word '.length)) - 1],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
testWidgets('the start screen offers SimpleLogin first, saying what it '
|
||||
'learns and what the instance keeps', (tester) async {
|
||||
await start(tester);
|
||||
|
||||
expect(
|
||||
find.widgetWithText(FilledButton, 'Continue with SimpleLogin'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.textContaining('keeps only your SimpleLogin ID'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.textContaining('test.example.org'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('offers no SimpleLogin where the instance has none', (
|
||||
tester,
|
||||
) async {
|
||||
dependencies.simpleLoginGateway.isOffered = false;
|
||||
await start(tester);
|
||||
|
||||
expect(find.text('Continue with SimpleLogin'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('someone new creates an account: the handle suggested, the '
|
||||
'password twice, the phrase written down, then signed in', (
|
||||
tester,
|
||||
) async {
|
||||
await start(tester);
|
||||
|
||||
await tapButton(tester, 'Continue with SimpleLogin');
|
||||
expect(find.text('New here?'), findsOneWidget);
|
||||
await tester.tap(find.widgetWithText(ListTile, 'Create an account'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('anna_mueller'), findsOneWidget);
|
||||
expect(find.text('@anna_mueller is free'), findsOneWidget);
|
||||
await tester.enterText(field('Handle'), 'anna_m');
|
||||
await tester.pump();
|
||||
expect(find.text('@anna_mueller is free'), findsNothing);
|
||||
await tester.enterText(field('Handle'), 'anna_mueller');
|
||||
await tapButton(tester, 'Continue');
|
||||
|
||||
await tester.enterText(field('New password'), password);
|
||||
await tester.enterText(field('New password again'), password);
|
||||
await tapButton(tester, 'Continue');
|
||||
expect(find.text('Your recovery phrase'), findsOneWidget);
|
||||
await tapButton(tester, "I've written it down");
|
||||
|
||||
await typeCheckedWords(tester, fakePhrase);
|
||||
await tapButton(tester, 'Create the account');
|
||||
|
||||
expect(find.text('App of person-1'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('an account opens its keys with its password, a wrong one '
|
||||
'refused', (tester) async {
|
||||
final material = await dependencies.crypto.newKeys(password);
|
||||
dependencies.simpleLoginGateway.addAccount(
|
||||
'person-9',
|
||||
subject: 'simplelogin-anna',
|
||||
handle: 'anna',
|
||||
keys: material.setup,
|
||||
);
|
||||
await start(tester);
|
||||
|
||||
await tapButton(tester, 'Continue with SimpleLogin');
|
||||
expect(find.textContaining('as @anna'), findsOneWidget);
|
||||
await tester.enterText(field('Password'), 'wrong horse battery');
|
||||
await tapButton(tester, 'Open');
|
||||
expect(find.text("The password isn't right."), findsOneWidget);
|
||||
|
||||
await tester.enterText(field('Password'), password);
|
||||
await tapButton(tester, 'Open');
|
||||
|
||||
expect(find.text('App of person-9'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('someone whose SimpleLogin account was lost links theirs '
|
||||
'again with its handle and recovery phrase', (tester) async {
|
||||
final material = await dependencies.crypto.newKeys(password);
|
||||
dependencies.simpleLoginGateway.addAccount(
|
||||
'person-9',
|
||||
subject: 'simplelogin-lost',
|
||||
handle: 'anna',
|
||||
keys: material.setup,
|
||||
);
|
||||
await start(tester);
|
||||
|
||||
await tapButton(tester, 'Continue with SimpleLogin');
|
||||
await tester.tap(find.widgetWithText(ListTile, 'I already have one'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Link your account'), findsOneWidget);
|
||||
expect(find.text('anna_mueller'), findsNothing);
|
||||
|
||||
await tester.enterText(field('Handle'), 'anna');
|
||||
await tester.enterText(find.byType(TextField).last, otherFakePhrase);
|
||||
await tapButton(tester, 'Link');
|
||||
expect(
|
||||
find.text("The handle or the recovery phrase isn't right."),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.enterText(find.byType(TextField).last, fakePhrase);
|
||||
await tapButton(tester, 'Link');
|
||||
|
||||
expect(find.text('App of person-9'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('closing the browser leaves the start screen as it was', (
|
||||
tester,
|
||||
) async {
|
||||
dependencies.browser.comesBack = () => null;
|
||||
await start(tester);
|
||||
|
||||
await tapButton(tester, 'Continue with SimpleLogin');
|
||||
|
||||
expect(find.text('New here?'), findsNothing);
|
||||
expect(
|
||||
find.widgetWithText(FilledButton, 'Continue with SimpleLogin'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets("a refusal on SimpleLogin's page shows, and it begins again", (
|
||||
tester,
|
||||
) async {
|
||||
dependencies.browser.comesBack = () => const OidcDenied('access_denied');
|
||||
await start(tester);
|
||||
|
||||
await tapButton(tester, 'Continue with SimpleLogin');
|
||||
expect(find.textContaining('nothing was shared'), findsOneWidget);
|
||||
|
||||
dependencies.browser.comesBack = null;
|
||||
await tapButton(tester, 'Try again');
|
||||
|
||||
expect(find.text('New here?'), findsOneWidget);
|
||||
});
|
||||
|
||||
for (final locale in HalkyonAppLocalizations.supportedLocales) {
|
||||
testWidgets('every refusal has its words in $locale', (tester) async {
|
||||
late BuildContext context;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: locale,
|
||||
localizationsDelegates: testLocalizationsDelegates,
|
||||
supportedLocales: HalkyonAppLocalizations.supportedLocales,
|
||||
home: Builder(
|
||||
builder: (built) {
|
||||
context = built;
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
for (final problem in SimpleLoginProblem.values) {
|
||||
expect(
|
||||
simpleLoginFailureText(context, SimpleLoginFailed(problem)),
|
||||
isNotEmpty,
|
||||
reason: '$problem',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue