feat(app): a wait screen for accounts that wait for the operator (ADR 0021, step 1)
An account signed in or up to on an instance whose operator approves new accounts, or one suspended, gets no session but a wait: the device keeps its pass, words and handle in the keyring per instance (SecureAccountWaitStore, InstanceStorage.waitOf), the auth status is Waiting, and SessionGate shows WaitScreen in its own app, so the start screen's sheets go with it. Every sign-in flow hands over to it: the password's sign-in, sign-up, reset and account reset, SimpleLogin's sign-in, sign-up and mending; their cubits show no problem then. The wait screen shows the handle, the four words the operator reads out for the person to match, and that Halkyon never asks anyone to read them out (D33); it asks where the wait stands as it opens, every 30 seconds, as the app comes back and when asked. A new account can be deleted with its pass, a suspended one can't (ADR 0017). Approved, it leads to the usual sign-in, once more as before (D31); a pass the instance knows no more ends the wait. The start screen says where new accounts wait for approval, and offers no Create an account where the operator takes none; both refusals (closed, too many waiting) have their words, en and de. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
a060fbc21d
commit
7ac715cd97
50 changed files with 2284 additions and 103 deletions
|
|
@ -228,7 +228,11 @@ class Dependencies({
|
||||||
final authGateway = ServerpodAuthGateway(
|
final authGateway = ServerpodAuthGateway(
|
||||||
client,
|
client,
|
||||||
base: client.modules.base,
|
base: client.modules.base,
|
||||||
|
waits: SecureAccountWaitStore(device.storage.waitOf(instance)),
|
||||||
);
|
);
|
||||||
|
// As the session above: an account waiting for the operator shows its
|
||||||
|
// wait, not the start screen.
|
||||||
|
await authGateway.initialize();
|
||||||
final addresses = FileSignInAddresses(device.storage.peopleOf(instance));
|
final addresses = FileSignInAddresses(device.storage.peopleOf(instance));
|
||||||
final accountCrypto = SodiumAccountCrypto();
|
final accountCrypto = SodiumAccountCrypto();
|
||||||
final accountKeys = SecureAccountKeyStore(device.storage.keysOf(instance));
|
final accountKeys = SecureAccountKeyStore(device.storage.keysOf(instance));
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,11 @@ class TodoDependencies({
|
||||||
final authGateway = ServerpodAuthGateway(
|
final authGateway = ServerpodAuthGateway(
|
||||||
client,
|
client,
|
||||||
base: client.modules.base,
|
base: client.modules.base,
|
||||||
|
waits: SecureAccountWaitStore(device.storage.waitOf(instance)),
|
||||||
);
|
);
|
||||||
|
// As the session above: an account waiting for the operator shows its
|
||||||
|
// wait, not the start screen.
|
||||||
|
await authGateway.initialize();
|
||||||
final people = device.storage.peopleOf(instance);
|
final people = device.storage.peopleOf(instance);
|
||||||
final folders = PersonFolders(people);
|
final folders = PersonFolders(people);
|
||||||
final addresses = FileSignInAddresses(people);
|
final addresses = FileSignInAddresses(people);
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,9 @@
|
||||||
/// - **Gates**: `InstanceGate` opens the app's dependencies on the instance
|
/// - **Gates**: `InstanceGate` opens the app's dependencies on the instance
|
||||||
/// in use (`AppDependencies`, `AppScope`); `SessionGate` keeps a person
|
/// in use (`AppDependencies`, `AppScope`); `SessionGate` keeps a person
|
||||||
/// signed in and asks how to start once signed out (`StartScreen`);
|
/// signed in and asks how to start once signed out (`StartScreen`);
|
||||||
/// `PersonScope` opens each person's own dependencies.
|
/// `PersonScope` opens each person's own dependencies. While an account
|
||||||
|
/// waits for the instance's operator, `WaitScreen` shows where its wait
|
||||||
|
/// stands (Halkyon Learn's ADR 0021, D31).
|
||||||
/// - **The account and About**: `AccountTiles` for an app's settings,
|
/// - **The account and About**: `AccountTiles` for an app's settings,
|
||||||
/// `showAppAbout` with the source of what the instance runs, and
|
/// `showAppAbout` with the source of what the instance runs, and
|
||||||
/// `resolveAppLocale`, the language every Halkyon app picks.
|
/// `resolveAppLocale`, the language every Halkyon app picks.
|
||||||
|
|
@ -34,16 +36,21 @@ export 'src/app/instance_gate.dart';
|
||||||
export 'src/app/person_scope.dart';
|
export 'src/app/person_scope.dart';
|
||||||
export 'src/auth/bloc/session_bloc.dart';
|
export 'src/auth/bloc/session_bloc.dart';
|
||||||
export 'src/auth/bloc/sign_in_ways_cubit.dart';
|
export 'src/auth/bloc/sign_in_ways_cubit.dart';
|
||||||
|
export 'src/auth/bloc/wait_cubit.dart';
|
||||||
|
export 'src/auth/data/account_wait_store.dart';
|
||||||
export 'src/auth/data/auth_gateway.dart';
|
export 'src/auth/data/auth_gateway.dart';
|
||||||
export 'src/auth/data/auth_repository.dart';
|
export 'src/auth/data/auth_repository.dart';
|
||||||
export 'src/auth/data/serverpod_auth_gateway.dart';
|
export 'src/auth/data/serverpod_auth_gateway.dart';
|
||||||
export 'src/auth/data/sign_in_addresses.dart';
|
export 'src/auth/data/sign_in_addresses.dart';
|
||||||
|
export 'src/auth/model/account_wait.dart';
|
||||||
export 'src/auth/model/auth_status.dart';
|
export 'src/auth/model/auth_status.dart';
|
||||||
export 'src/auth/model/session_grant.dart';
|
export 'src/auth/model/session_grant.dart';
|
||||||
export 'src/auth/model/sign_in_ways.dart';
|
export 'src/auth/model/sign_in_ways.dart';
|
||||||
|
export 'src/auth/model/sign_ups.dart';
|
||||||
export 'src/auth/widget/account_tiles.dart';
|
export 'src/auth/widget/account_tiles.dart';
|
||||||
export 'src/auth/widget/session_gate.dart';
|
export 'src/auth/widget/session_gate.dart';
|
||||||
export 'src/auth/widget/start_screen.dart';
|
export 'src/auth/widget/start_screen.dart';
|
||||||
|
export 'src/auth/widget/wait_screen.dart';
|
||||||
export 'src/base/base_client.dart';
|
export 'src/base/base_client.dart';
|
||||||
export 'src/files/file_saver.dart';
|
export 'src/files/file_saver.dart';
|
||||||
export 'src/handle/bloc/handle_check_cubit.dart';
|
export 'src/handle/bloc/handle_check_cubit.dart';
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,8 @@
|
||||||
"startOn": "Starte auf {instance}:",
|
"startOn": "Starte auf {instance}:",
|
||||||
"startOtherInstance": "Andere Instanz verwenden",
|
"startOtherInstance": "Andere Instanz verwenden",
|
||||||
"startNoWay": "{instance} bietet keinen Weg, ein Konto zu erstellen.",
|
"startNoWay": "{instance} bietet keinen Weg, ein Konto zu erstellen.",
|
||||||
|
"startSignUpsApproved": "Neue Konten auf {instance} warten, bis sie freigegeben werden.",
|
||||||
|
"startSignUpsClosed": "{instance} nimmt keine neuen Konten an: Melde dich mit deinem an.",
|
||||||
"syncTitle": "Synchronisierung",
|
"syncTitle": "Synchronisierung",
|
||||||
"syncNow": "Jetzt synchronisieren",
|
"syncNow": "Jetzt synchronisieren",
|
||||||
"syncStateSyncing": "Wird synchronisiert…",
|
"syncStateSyncing": "Wird synchronisiert…",
|
||||||
|
|
@ -63,6 +65,19 @@
|
||||||
"syncMismatchMore": "Mehr",
|
"syncMismatchMore": "Mehr",
|
||||||
"startSignIn": "Anmelden",
|
"startSignIn": "Anmelden",
|
||||||
"startCreateAccount": "Konto erstellen",
|
"startCreateAccount": "Konto erstellen",
|
||||||
|
"waitWaiting": "Dein Konto wartet, bis {instance} es freigibt.",
|
||||||
|
"waitSuspended": "{instance} hat dein Konto gesperrt. Nur wer {instance} betreibt, kann die Sperre aufheben.",
|
||||||
|
"waitApproved": "{instance} hat dein Konto freigegeben. Melde dich noch einmal an, so wie vorhin.",
|
||||||
|
"waitEnded": "Diese Registrierung ist beendet: {instance} hat das Konto abgelehnt, oder es hat zu lange gewartet.",
|
||||||
|
"waitBack": "Zurück zum Start",
|
||||||
|
"waitMatchingCode": "Kontrollwörter",
|
||||||
|
"waitMatchingNote": "Wenn dir jemand von {instance} vier Wörter vorliest, prüf, ob es diese sind. Lies sie selbst nie vor: Halkyon bittet niemanden darum.",
|
||||||
|
"waitCheckNow": "Jetzt nachsehen",
|
||||||
|
"waitUnreachable": "{instance} war nicht erreichbar. Alle 30 Sekunden wird erneut nachgefragt.",
|
||||||
|
"waitDelete": "Dieses Konto löschen",
|
||||||
|
"waitDeleteTitle": "Dieses Konto löschen?",
|
||||||
|
"waitDeleteMessage": "Es wird mit allem gelöscht, was es hat, und niemand kann sich mehr damit anmelden.",
|
||||||
|
"waitDeleteFailed": "Das Konto konnte nicht gelöscht werden. Versuch es noch einmal.",
|
||||||
"passwordAddress": "E-Mail-Adresse",
|
"passwordAddress": "E-Mail-Adresse",
|
||||||
"passwordPassword": "Passwort",
|
"passwordPassword": "Passwort",
|
||||||
"passwordNewPassword": "Neues Passwort",
|
"passwordNewPassword": "Neues Passwort",
|
||||||
|
|
@ -200,6 +215,8 @@
|
||||||
"passwordUnusable": "Dieses Passwort geht nicht: Es ist leer oder enthält Zeichen, die kein Text sind.",
|
"passwordUnusable": "Dieses Passwort geht nicht: Es ist leer oder enthält Zeichen, die kein Text sind.",
|
||||||
"passwordInstanceUnproven": "Die Instanz konnte nicht beweisen, dass sie dein Passwort kennt, daher vertraut dieses Gerät ihr nicht. Versuch es noch einmal; passiert es wieder, ist die Instanz nicht die, die sie zu sein behauptet.",
|
"passwordInstanceUnproven": "Die Instanz konnte nicht beweisen, dass sie dein Passwort kennt, daher vertraut dieses Gerät ihr nicht. Versuch es noch einmal; passiert es wieder, ist die Instanz nicht die, die sie zu sein behauptet.",
|
||||||
"passwordKeysMissing": "Die Schlüssel deines Kontos sind nicht auf diesem Gerät. Melde dich hier ab und mit deinem Passwort wieder an.",
|
"passwordKeysMissing": "Die Schlüssel deines Kontos sind nicht auf diesem Gerät. Melde dich hier ab und mit deinem Passwort wieder an.",
|
||||||
|
"signUpsClosed": "Diese Instanz nimmt keine neuen Konten an.",
|
||||||
|
"signUpsTooManyWaiting": "Es warten schon so viele neue Konten auf ihre Freigabe, wie dürfen. Versuch es später noch einmal.",
|
||||||
"passwordUnsafeCosts": "Die Instanz wollte dein Passwort auf eine Weise prüfen lassen, die kein Konto nutzt, daher wurde nichts gesendet. Passiert es wieder, ist die Instanz nicht die, die sie zu sein behauptet.",
|
"passwordUnsafeCosts": "Die Instanz wollte dein Passwort auf eine Weise prüfen lassen, die kein Konto nutzt, daher wurde nichts gesendet. Passiert es wieder, ist die Instanz nicht die, die sie zu sein behauptet.",
|
||||||
"passwordFailed": "Die Instanz war nicht erreichbar. Prüf die Verbindung und versuch es noch einmal.",
|
"passwordFailed": "Die Instanz war nicht erreichbar. Prüf die Verbindung und versuch es noch einmal.",
|
||||||
"passwordMinLength": "Mindestens {count} Zeichen",
|
"passwordMinLength": "Mindestens {count} Zeichen",
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,24 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"startSignUpsApproved": "New accounts on {instance} wait until its operator approves them.",
|
||||||
|
"@startSignUpsApproved": {
|
||||||
|
"description": "On the start screen, where new accounts wait for the operator's approval (Halkyon Learn's ADR 0021, D19).",
|
||||||
|
"placeholders": {
|
||||||
|
"instance": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"startSignUpsClosed": "{instance} takes no new accounts: sign in with yours.",
|
||||||
|
"@startSignUpsClosed": {
|
||||||
|
"description": "On the start screen, where the operator takes no new accounts (D19); Create an account is gone.",
|
||||||
|
"placeholders": {
|
||||||
|
"instance": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"syncTitle": "Sync",
|
"syncTitle": "Sync",
|
||||||
"syncNow": "Sync now",
|
"syncNow": "Sync now",
|
||||||
"syncStateSyncing": "Syncing…",
|
"syncStateSyncing": "Syncing…",
|
||||||
|
|
@ -216,6 +234,88 @@
|
||||||
"syncMismatchMore": "More",
|
"syncMismatchMore": "More",
|
||||||
"startSignIn": "Sign in",
|
"startSignIn": "Sign in",
|
||||||
"startCreateAccount": "Create an account",
|
"startCreateAccount": "Create an account",
|
||||||
|
"waitWaiting": "Your account waits until the operator of {instance} approves it.",
|
||||||
|
"@waitWaiting": {
|
||||||
|
"description": "On the wait screen of a new account (D31), under its handle.",
|
||||||
|
"placeholders": {
|
||||||
|
"instance": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"waitSuspended": "The operator of {instance} has suspended your account. Only they can lift it.",
|
||||||
|
"@waitSuspended": {
|
||||||
|
"description": "On the wait screen of a suspended account (D31).",
|
||||||
|
"placeholders": {
|
||||||
|
"instance": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"waitApproved": "The operator of {instance} has approved your account. Sign in once more, as you did before.",
|
||||||
|
"@waitApproved": {
|
||||||
|
"description": "On the wait screen once the account is approved (D31), above Sign in.",
|
||||||
|
"placeholders": {
|
||||||
|
"instance": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"waitEnded": "This sign-up has ended: the operator of {instance} refused it, or it waited too long.",
|
||||||
|
"@waitEnded": {
|
||||||
|
"description": "On the wait screen once the instance knows the wait no more: refused, past its 14 days, or its pass past its 30 (D31).",
|
||||||
|
"placeholders": {
|
||||||
|
"instance": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"waitBack": "Back to the start",
|
||||||
|
"@waitBack": {
|
||||||
|
"description": "The button of an ended wait: the start screen."
|
||||||
|
},
|
||||||
|
"waitMatchingCode": "Matching code",
|
||||||
|
"@waitMatchingCode": {
|
||||||
|
"description": "Above the four words the operator reads out for the person to match (D33)."
|
||||||
|
},
|
||||||
|
"waitMatchingNote": "When the operator of {instance} reads you four words, check that they are these. Never read them out yourself: Halkyon never asks anyone to.",
|
||||||
|
"@waitMatchingNote": {
|
||||||
|
"description": "Under the matching code (D33): the operator reads, the person confirms, so nobody posing as the operator learns the words.",
|
||||||
|
"placeholders": {
|
||||||
|
"instance": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"waitCheckNow": "Check now",
|
||||||
|
"@waitCheckNow": {
|
||||||
|
"description": "Asks the instance at once where the wait stands; it asks every 30 seconds by itself."
|
||||||
|
},
|
||||||
|
"waitUnreachable": "{instance} couldn't be reached. It's asked again every 30 seconds.",
|
||||||
|
"@waitUnreachable": {
|
||||||
|
"description": "On the wait screen, when asking where the wait stands failed.",
|
||||||
|
"placeholders": {
|
||||||
|
"instance": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"waitDelete": "Delete this account",
|
||||||
|
"@waitDelete": {
|
||||||
|
"description": "On the wait screen of a new account: deletes it with its pass (D31)."
|
||||||
|
},
|
||||||
|
"waitDeleteTitle": "Delete this account?",
|
||||||
|
"@waitDeleteTitle": {
|
||||||
|
"description": "The question before a waiting account is deleted."
|
||||||
|
},
|
||||||
|
"waitDeleteMessage": "It goes with everything it has, and nobody can sign in to it again.",
|
||||||
|
"@waitDeleteMessage": {
|
||||||
|
"description": "Under that question."
|
||||||
|
},
|
||||||
|
"waitDeleteFailed": "Couldn't delete the account. Try again.",
|
||||||
|
"@waitDeleteFailed": {
|
||||||
|
"description": "On the wait screen, when deleting the account failed."
|
||||||
|
},
|
||||||
"passwordAddress": "Email address",
|
"passwordAddress": "Email address",
|
||||||
"passwordPassword": "Password",
|
"passwordPassword": "Password",
|
||||||
"passwordNewPassword": "New password",
|
"passwordNewPassword": "New password",
|
||||||
|
|
@ -437,6 +537,14 @@
|
||||||
"passwordUnusable": "This password can't be used: it's empty, or holds characters that aren't text.",
|
"passwordUnusable": "This password can't be used: it's empty, or holds characters that aren't text.",
|
||||||
"passwordInstanceUnproven": "The instance couldn't prove it knows your password, so this device trusts nothing it says. Try again; if it happens again, the instance isn't the one it claims to be.",
|
"passwordInstanceUnproven": "The instance couldn't prove it knows your password, so this device trusts nothing it says. Try again; if it happens again, the instance isn't the one it claims to be.",
|
||||||
"passwordKeysMissing": "Your account's keys aren't on this device. Sign out and in again with your password here.",
|
"passwordKeysMissing": "Your account's keys aren't on this device. Sign out and in again with your password here.",
|
||||||
|
"signUpsClosed": "This instance takes no new accounts.",
|
||||||
|
"@signUpsClosed": {
|
||||||
|
"description": "A sign-up refused: the operator takes no new accounts (D19)."
|
||||||
|
},
|
||||||
|
"signUpsTooManyWaiting": "As many new accounts wait for approval as may. Try again later.",
|
||||||
|
"@signUpsTooManyWaiting": {
|
||||||
|
"description": "A sign-up refused: ten new accounts wait for the operator's approval already (D19)."
|
||||||
|
},
|
||||||
"passwordUnsafeCosts": "The instance asked for your password to be checked in a way no account uses, so nothing was sent. If it happens again, the instance isn't the one it claims to be.",
|
"passwordUnsafeCosts": "The instance asked for your password to be checked in a way no account uses, so nothing was sent. If it happens again, the instance isn't the one it claims to be.",
|
||||||
"passwordFailed": "Couldn't reach the instance. Check the connection and try again.",
|
"passwordFailed": "Couldn't reach the instance. Check the connection and try again.",
|
||||||
"passwordMinLength": "At least {count} characters",
|
"passwordMinLength": "At least {count} characters",
|
||||||
|
|
|
||||||
|
|
@ -379,6 +379,18 @@ abstract class HalkyonAppLocalizations {
|
||||||
/// **'{instance} offers no way to create an account.'**
|
/// **'{instance} offers no way to create an account.'**
|
||||||
String startNoWay(String instance);
|
String startNoWay(String instance);
|
||||||
|
|
||||||
|
/// On the start screen, where new accounts wait for the operator's approval (Halkyon Learn's ADR 0021, D19).
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'New accounts on {instance} wait until its operator approves them.'**
|
||||||
|
String startSignUpsApproved(String instance);
|
||||||
|
|
||||||
|
/// On the start screen, where the operator takes no new accounts (D19); Create an account is gone.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{instance} takes no new accounts: sign in with yours.'**
|
||||||
|
String startSignUpsClosed(String instance);
|
||||||
|
|
||||||
/// No description provided for @syncTitle.
|
/// No description provided for @syncTitle.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -481,6 +493,84 @@ abstract class HalkyonAppLocalizations {
|
||||||
/// **'Create an account'**
|
/// **'Create an account'**
|
||||||
String get startCreateAccount;
|
String get startCreateAccount;
|
||||||
|
|
||||||
|
/// On the wait screen of a new account (D31), under its handle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Your account waits until the operator of {instance} approves it.'**
|
||||||
|
String waitWaiting(String instance);
|
||||||
|
|
||||||
|
/// On the wait screen of a suspended account (D31).
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The operator of {instance} has suspended your account. Only they can lift it.'**
|
||||||
|
String waitSuspended(String instance);
|
||||||
|
|
||||||
|
/// On the wait screen once the account is approved (D31), above Sign in.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The operator of {instance} has approved your account. Sign in once more, as you did before.'**
|
||||||
|
String waitApproved(String instance);
|
||||||
|
|
||||||
|
/// On the wait screen once the instance knows the wait no more: refused, past its 14 days, or its pass past its 30 (D31).
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This sign-up has ended: the operator of {instance} refused it, or it waited too long.'**
|
||||||
|
String waitEnded(String instance);
|
||||||
|
|
||||||
|
/// The button of an ended wait: the start screen.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Back to the start'**
|
||||||
|
String get waitBack;
|
||||||
|
|
||||||
|
/// Above the four words the operator reads out for the person to match (D33).
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Matching code'**
|
||||||
|
String get waitMatchingCode;
|
||||||
|
|
||||||
|
/// Under the matching code (D33): the operator reads, the person confirms, so nobody posing as the operator learns the words.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'When the operator of {instance} reads you four words, check that they are these. Never read them out yourself: Halkyon never asks anyone to.'**
|
||||||
|
String waitMatchingNote(String instance);
|
||||||
|
|
||||||
|
/// Asks the instance at once where the wait stands; it asks every 30 seconds by itself.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Check now'**
|
||||||
|
String get waitCheckNow;
|
||||||
|
|
||||||
|
/// On the wait screen, when asking where the wait stands failed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{instance} couldn\'t be reached. It\'s asked again every 30 seconds.'**
|
||||||
|
String waitUnreachable(String instance);
|
||||||
|
|
||||||
|
/// On the wait screen of a new account: deletes it with its pass (D31).
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Delete this account'**
|
||||||
|
String get waitDelete;
|
||||||
|
|
||||||
|
/// The question before a waiting account is deleted.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Delete this account?'**
|
||||||
|
String get waitDeleteTitle;
|
||||||
|
|
||||||
|
/// Under that question.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'It goes with everything it has, and nobody can sign in to it again.'**
|
||||||
|
String get waitDeleteMessage;
|
||||||
|
|
||||||
|
/// On the wait screen, when deleting the account failed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Couldn\'t delete the account. Try again.'**
|
||||||
|
String get waitDeleteFailed;
|
||||||
|
|
||||||
/// No description provided for @passwordAddress.
|
/// No description provided for @passwordAddress.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -1303,6 +1393,18 @@ abstract class HalkyonAppLocalizations {
|
||||||
/// **'Your account\'s keys aren\'t on this device. Sign out and in again with your password here.'**
|
/// **'Your account\'s keys aren\'t on this device. Sign out and in again with your password here.'**
|
||||||
String get passwordKeysMissing;
|
String get passwordKeysMissing;
|
||||||
|
|
||||||
|
/// A sign-up refused: the operator takes no new accounts (D19).
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This instance takes no new accounts.'**
|
||||||
|
String get signUpsClosed;
|
||||||
|
|
||||||
|
/// A sign-up refused: ten new accounts wait for the operator's approval already (D19).
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'As many new accounts wait for approval as may. Try again later.'**
|
||||||
|
String get signUpsTooManyWaiting;
|
||||||
|
|
||||||
/// No description provided for @passwordUnsafeCosts.
|
/// No description provided for @passwordUnsafeCosts.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -183,6 +183,16 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
|
||||||
return '$instance bietet keinen Weg, ein Konto zu erstellen.';
|
return '$instance bietet keinen Weg, ein Konto zu erstellen.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String startSignUpsApproved(String instance) {
|
||||||
|
return 'Neue Konten auf $instance warten, bis sie freigegeben werden.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String startSignUpsClosed(String instance) {
|
||||||
|
return '$instance nimmt keine neuen Konten an: Melde dich mit deinem an.';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get syncTitle => 'Synchronisierung';
|
String get syncTitle => 'Synchronisierung';
|
||||||
|
|
||||||
|
|
@ -252,6 +262,59 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get startCreateAccount => 'Konto erstellen';
|
String get startCreateAccount => 'Konto erstellen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String waitWaiting(String instance) {
|
||||||
|
return 'Dein Konto wartet, bis $instance es freigibt.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String waitSuspended(String instance) {
|
||||||
|
return '$instance hat dein Konto gesperrt. Nur wer $instance betreibt, kann die Sperre aufheben.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String waitApproved(String instance) {
|
||||||
|
return '$instance hat dein Konto freigegeben. Melde dich noch einmal an, so wie vorhin.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String waitEnded(String instance) {
|
||||||
|
return 'Diese Registrierung ist beendet: $instance hat das Konto abgelehnt, oder es hat zu lange gewartet.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get waitBack => 'Zurück zum Start';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get waitMatchingCode => 'Kontrollwörter';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String waitMatchingNote(String instance) {
|
||||||
|
return 'Wenn dir jemand von $instance vier Wörter vorliest, prüf, ob es diese sind. Lies sie selbst nie vor: Halkyon bittet niemanden darum.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get waitCheckNow => 'Jetzt nachsehen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String waitUnreachable(String instance) {
|
||||||
|
return '$instance war nicht erreichbar. Alle 30 Sekunden wird erneut nachgefragt.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get waitDelete => 'Dieses Konto löschen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get waitDeleteTitle => 'Dieses Konto löschen?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get waitDeleteMessage =>
|
||||||
|
'Es wird mit allem gelöscht, was es hat, und niemand kann sich mehr damit anmelden.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get waitDeleteFailed =>
|
||||||
|
'Das Konto konnte nicht gelöscht werden. Versuch es noch einmal.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordAddress => 'E-Mail-Adresse';
|
String get passwordAddress => 'E-Mail-Adresse';
|
||||||
|
|
||||||
|
|
@ -753,6 +816,13 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
|
||||||
String get passwordKeysMissing =>
|
String get passwordKeysMissing =>
|
||||||
'Die Schlüssel deines Kontos sind nicht auf diesem Gerät. Melde dich hier ab und mit deinem Passwort wieder an.';
|
'Die Schlüssel deines Kontos sind nicht auf diesem Gerät. Melde dich hier ab und mit deinem Passwort wieder an.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get signUpsClosed => 'Diese Instanz nimmt keine neuen Konten an.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get signUpsTooManyWaiting =>
|
||||||
|
'Es warten schon so viele neue Konten auf ihre Freigabe, wie dürfen. Versuch es später noch einmal.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordUnsafeCosts =>
|
String get passwordUnsafeCosts =>
|
||||||
'Die Instanz wollte dein Passwort auf eine Weise prüfen lassen, die kein Konto nutzt, daher wurde nichts gesendet. Passiert es wieder, ist die Instanz nicht die, die sie zu sein behauptet.';
|
'Die Instanz wollte dein Passwort auf eine Weise prüfen lassen, die kein Konto nutzt, daher wurde nichts gesendet. Passiert es wieder, ist die Instanz nicht die, die sie zu sein behauptet.';
|
||||||
|
|
|
||||||
|
|
@ -181,6 +181,16 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
|
||||||
return '$instance offers no way to create an account.';
|
return '$instance offers no way to create an account.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String startSignUpsApproved(String instance) {
|
||||||
|
return 'New accounts on $instance wait until its operator approves them.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String startSignUpsClosed(String instance) {
|
||||||
|
return '$instance takes no new accounts: sign in with yours.';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get syncTitle => 'Sync';
|
String get syncTitle => 'Sync';
|
||||||
|
|
||||||
|
|
@ -249,6 +259,58 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get startCreateAccount => 'Create an account';
|
String get startCreateAccount => 'Create an account';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String waitWaiting(String instance) {
|
||||||
|
return 'Your account waits until the operator of $instance approves it.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String waitSuspended(String instance) {
|
||||||
|
return 'The operator of $instance has suspended your account. Only they can lift it.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String waitApproved(String instance) {
|
||||||
|
return 'The operator of $instance has approved your account. Sign in once more, as you did before.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String waitEnded(String instance) {
|
||||||
|
return 'This sign-up has ended: the operator of $instance refused it, or it waited too long.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get waitBack => 'Back to the start';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get waitMatchingCode => 'Matching code';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String waitMatchingNote(String instance) {
|
||||||
|
return 'When the operator of $instance reads you four words, check that they are these. Never read them out yourself: Halkyon never asks anyone to.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get waitCheckNow => 'Check now';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String waitUnreachable(String instance) {
|
||||||
|
return '$instance couldn\'t be reached. It\'s asked again every 30 seconds.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get waitDelete => 'Delete this account';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get waitDeleteTitle => 'Delete this account?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get waitDeleteMessage =>
|
||||||
|
'It goes with everything it has, and nobody can sign in to it again.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get waitDeleteFailed => 'Couldn\'t delete the account. Try again.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordAddress => 'Email address';
|
String get passwordAddress => 'Email address';
|
||||||
|
|
||||||
|
|
@ -736,6 +798,13 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
|
||||||
String get passwordKeysMissing =>
|
String get passwordKeysMissing =>
|
||||||
'Your account\'s keys aren\'t on this device. Sign out and in again with your password here.';
|
'Your account\'s keys aren\'t on this device. Sign out and in again with your password here.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get signUpsClosed => 'This instance takes no new accounts.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get signUpsTooManyWaiting =>
|
||||||
|
'As many new accounts wait for approval as may. Try again later.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordUnsafeCosts =>
|
String get passwordUnsafeCosts =>
|
||||||
'The instance asked for your password to be checked in a way no account uses, so nothing was sent. If it happens again, the instance isn\'t the one it claims to be.';
|
'The instance asked for your password to be checked in a way no account uses, so nothing was sent. If it happens again, the instance isn\'t the one it claims to be.';
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'dart:async';
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:halkyon_app/src/auth/data/auth_repository.dart';
|
import 'package:halkyon_app/src/auth/data/auth_repository.dart';
|
||||||
|
import 'package:halkyon_app/src/auth/model/account_wait.dart';
|
||||||
import 'package:halkyon_app/src/auth/model/auth_status.dart';
|
import 'package:halkyon_app/src/auth/model/auth_status.dart';
|
||||||
|
|
||||||
/// Keeps a person signed in: follows who is signed in, and signs out on
|
/// Keeps a person signed in: follows who is signed in, and signs out on
|
||||||
|
|
@ -10,7 +11,9 @@ import 'package:halkyon_app/src/auth/model/auth_status.dart';
|
||||||
/// deleting the account, or once the server ended the session), it asks
|
/// deleting the account, or once the server ended the session), it asks
|
||||||
/// how to start: every account has a way in, and nothing is made until the
|
/// how to start: every account has a way in, and nothing is made until the
|
||||||
/// person chooses (ADR 0020, D4). An anonymous account's session left from
|
/// person chooses (ADR 0020, D4). An anonymous account's session left from
|
||||||
/// before they went ends as the app starts, the account deleted (D25).
|
/// before they went ends as the app starts, the account deleted (D25). An
|
||||||
|
/// account that waits for the instance's operator has no session but its
|
||||||
|
/// wait (Halkyon Learn's ADR 0021, D31).
|
||||||
class SessionBloc(final AuthRepository _auth)
|
class SessionBloc(final AuthRepository _auth)
|
||||||
extends Bloc<SessionEvent, SessionState> {
|
extends Bloc<SessionEvent, SessionState> {
|
||||||
this : super(const SessionStateLoading()) {
|
this : super(const SessionStateLoading()) {
|
||||||
|
|
@ -80,7 +83,7 @@ class SessionBloc(final AuthRepository _auth)
|
||||||
addError(error, st);
|
addError(error, st);
|
||||||
emit(switch (_auth.status) {
|
emit(switch (_auth.status) {
|
||||||
final SignedIn person => SessionStateActive(person, problem: problem),
|
final SignedIn person => SessionStateActive(person, problem: problem),
|
||||||
SignedOut() => const SessionStateSignedOut(),
|
final status => _stateOf(status),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -91,6 +94,7 @@ class SessionBloc(final AuthRepository _auth)
|
||||||
SignedIn(isIdentified: false) => const SessionStateSignedOut(),
|
SignedIn(isIdentified: false) => const SessionStateSignedOut(),
|
||||||
SignedIn() => SessionStateActive(status),
|
SignedIn() => SessionStateActive(status),
|
||||||
SignedOut() => const SessionStateSignedOut(),
|
SignedOut() => const SessionStateSignedOut(),
|
||||||
|
Waiting(:final wait) => SessionStateWaiting(wait),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -164,3 +168,12 @@ final class const SessionStateSignedOut() extends SessionState {
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => const [];
|
List<Object?> get props => const [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Nobody is signed in: an account signed in or up to here waits for the
|
||||||
|
/// instance's operator (Halkyon Learn's ADR 0021, D31), and the app shows
|
||||||
|
/// where its wait stands.
|
||||||
|
final class const SessionStateWaiting(final AccountWait wait)
|
||||||
|
extends SessionState {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [wait];
|
||||||
|
}
|
||||||
|
|
|
||||||
122
packages/halkyon_app/lib/src/auth/bloc/wait_cubit.dart
Normal file
122
packages/halkyon_app/lib/src/auth/bloc/wait_cubit.dart
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:developer';
|
||||||
|
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:halkyon_app/src/auth/data/auth_repository.dart';
|
||||||
|
import 'package:halkyon_app/src/auth/model/account_wait.dart';
|
||||||
|
import 'package:halkyon_app/src/auth/model/auth_status.dart';
|
||||||
|
|
||||||
|
/// Why the wait screen's last step failed.
|
||||||
|
enum WaitProblem() {
|
||||||
|
/// The instance wasn't reached: asked again in a while.
|
||||||
|
unreachable,
|
||||||
|
|
||||||
|
/// Deleting the account failed: nothing deleted.
|
||||||
|
deleteFailed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the wait screen shows besides the wait itself, which the session
|
||||||
|
/// holds (`SessionStateWaiting`).
|
||||||
|
final class const WaitState({
|
||||||
|
/// Asking the instance, as the person asked to.
|
||||||
|
final bool checking = false,
|
||||||
|
|
||||||
|
/// Deleting the account.
|
||||||
|
final bool deleting = false,
|
||||||
|
final WaitProblem? problem,
|
||||||
|
}) with Equatable {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [checking, deleting, problem];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Follows an account's wait for the instance's operator (Halkyon Learn's
|
||||||
|
/// ADR 0021, D31): asks where it stands as it opens, every [every], and
|
||||||
|
/// whenever the app comes back ([check]) or the person asks ([checkNow]),
|
||||||
|
/// until the account is approved or the wait ended; deletes a new account
|
||||||
|
/// with its pass, and stops waiting on this device.
|
||||||
|
class WaitCubit(
|
||||||
|
final AuthRepository _auth, {
|
||||||
|
final Duration every = const Duration(seconds: 30),
|
||||||
|
}) extends Cubit<WaitState> {
|
||||||
|
this : super(const WaitState()) {
|
||||||
|
_timer = Timer.periodic(every, (_) => unawaited(check()));
|
||||||
|
unawaited(check());
|
||||||
|
}
|
||||||
|
|
||||||
|
late final Timer _timer;
|
||||||
|
var _asking = false;
|
||||||
|
|
||||||
|
/// Asks the instance where the wait stands, quietly.
|
||||||
|
Future<void> check() => _look(shown: false);
|
||||||
|
|
||||||
|
/// Asks the instance where the wait stands, as the person asked to.
|
||||||
|
Future<void> checkNow() => _look(shown: true);
|
||||||
|
|
||||||
|
Future<void> _look({required bool shown}) async {
|
||||||
|
if (_asking || state.deleting || !_stillAsks) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_asking = true;
|
||||||
|
if (shown) {
|
||||||
|
emit(const WaitState(checking: true));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await _auth.lookWhileWaiting();
|
||||||
|
if (!isClosed) {
|
||||||
|
emit(const WaitState());
|
||||||
|
}
|
||||||
|
} on Object catch (error) {
|
||||||
|
log('Asking for the wait failed: ${error.runtimeType}', name: 'Auth');
|
||||||
|
if (!isClosed) {
|
||||||
|
emit(const WaitState(problem: WaitProblem.unreachable));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_asking = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether there is anything left to ask: approved or ended, the
|
||||||
|
/// instance knows the pass no more.
|
||||||
|
bool get _stillAsks => switch (_auth.status) {
|
||||||
|
Waiting(
|
||||||
|
wait: AccountWait(
|
||||||
|
state: AccountWaitState.waiting || AccountWaitState.suspended,
|
||||||
|
),
|
||||||
|
) =>
|
||||||
|
true,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Deletes the new account with all it has; the person has confirmed
|
||||||
|
/// it. Deleted, the start screen follows; one the operator approved,
|
||||||
|
/// refused or suspended meanwhile stays, and the wait is asked anew.
|
||||||
|
Future<void> delete() async {
|
||||||
|
emit(const WaitState(deleting: true));
|
||||||
|
try {
|
||||||
|
final deleted = await _auth.deleteWaitingAccount();
|
||||||
|
if (isClosed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit(const WaitState());
|
||||||
|
if (!deleted) {
|
||||||
|
await check();
|
||||||
|
}
|
||||||
|
} on Object catch (error) {
|
||||||
|
log('Deleting the account failed: ${error.runtimeType}', name: 'Auth');
|
||||||
|
if (!isClosed) {
|
||||||
|
emit(const WaitState(problem: WaitProblem.deleteFailed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops waiting on this device: back to the start, where the person
|
||||||
|
/// signs in (once approved, as before), or waits anew once they do.
|
||||||
|
Future<void> leave() => _auth.stopWaiting();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> close() {
|
||||||
|
_timer.cancel();
|
||||||
|
return super.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:developer';
|
||||||
|
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
import 'package:halkyon_app/src/auth/model/account_wait.dart';
|
||||||
|
import 'package:halkyon_app/src/password/data/account_key_store.dart';
|
||||||
|
|
||||||
|
/// Where the device keeps the wait of an account on one instance (Halkyon
|
||||||
|
/// Learn's ADR 0021, D31), across the app's starts. Tests keep it in memory.
|
||||||
|
abstract interface class AccountWaitStore() {
|
||||||
|
/// The wait kept; null where none is, or what is kept can't be read as
|
||||||
|
/// one.
|
||||||
|
Future<AccountWait?> read();
|
||||||
|
|
||||||
|
/// Keeps [wait] in place of the one kept; null forgets it.
|
||||||
|
Future<void> keep(AccountWait? wait);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [AccountWaitStore] in the system's keyring, as the account's keys are
|
||||||
|
/// (`accountKeyStorage`): the pass asks for the account's state and
|
||||||
|
/// deletes a new one, so it stays on this device alone. Under [_key], the
|
||||||
|
/// instance's own (`InstanceStorage.waitOf`).
|
||||||
|
class SecureAccountWaitStore(
|
||||||
|
final String _key, {
|
||||||
|
final FlutterSecureStorage _storage = accountKeyStorage,
|
||||||
|
}) implements AccountWaitStore {
|
||||||
|
@override
|
||||||
|
Future<AccountWait?> read() async {
|
||||||
|
final kept = await _storage.read(key: _key);
|
||||||
|
if (kept == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return switch (jsonDecode(kept)) {
|
||||||
|
{
|
||||||
|
'pass': final String pass,
|
||||||
|
'words': final List<Object?> words,
|
||||||
|
'handle': final String? handle,
|
||||||
|
'state': final String state,
|
||||||
|
} =>
|
||||||
|
AccountWait(
|
||||||
|
pass: pass,
|
||||||
|
words: words.cast<String>(),
|
||||||
|
handle: handle,
|
||||||
|
state: AccountWaitState.values.byName(state),
|
||||||
|
),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
} on Object catch (error) {
|
||||||
|
// Its type only: its message may hold the pass.
|
||||||
|
log('Unreadable wait: ${error.runtimeType}', name: 'Auth');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> keep(AccountWait? wait) async {
|
||||||
|
if (wait == null) {
|
||||||
|
await _storage.delete(key: _key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _storage.write(
|
||||||
|
key: _key,
|
||||||
|
value: jsonEncode({
|
||||||
|
'pass': wait.pass,
|
||||||
|
'words': wait.words,
|
||||||
|
'handle': wait.handle,
|
||||||
|
'state': wait.state.name,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
packages/halkyon_app/lib/src/auth/data/account_waits_of.dart
Normal file
16
packages/halkyon_app/lib/src/auth/data/account_waits_of.dart
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import 'package:halkyon_app/src/auth/model/account_wait.dart';
|
||||||
|
import 'package:halkyon_base_client/halkyon_base_client.dart' as protocol;
|
||||||
|
|
||||||
|
/// The wait the instance hands this device in place of a session (Halkyon
|
||||||
|
/// Learn's ADR 0021, D31), as the app keeps it.
|
||||||
|
AccountWaits accountWaitsOf(protocol.AccountWaitingException waiting) =>
|
||||||
|
AccountWaits(
|
||||||
|
AccountWait(
|
||||||
|
pass: waiting.pass,
|
||||||
|
words: List.unmodifiable(waiting.words),
|
||||||
|
handle: waiting.handle,
|
||||||
|
state: waiting.suspended
|
||||||
|
? AccountWaitState.suspended
|
||||||
|
: AccountWaitState.waiting,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
|
import 'package:halkyon_app/src/auth/model/account_wait.dart';
|
||||||
import 'package:halkyon_app/src/auth/model/auth_status.dart';
|
import 'package:halkyon_app/src/auth/model/auth_status.dart';
|
||||||
import 'package:halkyon_app/src/auth/model/session_grant.dart';
|
import 'package:halkyon_app/src/auth/model/session_grant.dart';
|
||||||
import 'package:halkyon_app/src/auth/model/sign_in_ways.dart';
|
import 'package:halkyon_app/src/auth/model/sign_in_ways.dart';
|
||||||
|
import 'package:halkyon_app/src/auth/model/sign_ups.dart';
|
||||||
|
|
||||||
/// The boundary to the server's authentication: who is signed in, and signing
|
/// The boundary to the server's authentication: who is signed in, and signing
|
||||||
/// in and out. Tests replace it with a fake.
|
/// in and out, or who waits for the instance's operator (Halkyon Learn's ADR
|
||||||
|
/// 0021, D31). Tests replace it with a fake.
|
||||||
abstract interface class AuthGateway() {
|
abstract interface class AuthGateway() {
|
||||||
/// Who is signed in right now.
|
/// Who is signed in right now.
|
||||||
AuthStatus get status;
|
AuthStatus get status;
|
||||||
|
|
@ -22,6 +25,28 @@ abstract interface class AuthGateway() {
|
||||||
Future<SignInWays> signInWays();
|
Future<SignInWays> signInWays();
|
||||||
|
|
||||||
/// Puts [grant] in place of this device's session: its person is signed
|
/// Puts [grant] in place of this device's session: its person is signed
|
||||||
/// in.
|
/// in, and a wait kept is gone.
|
||||||
Future<void> useSession(SessionGrant grant);
|
Future<void> useSession(SessionGrant grant);
|
||||||
|
|
||||||
|
/// Who may make an account on the instance (D19).
|
||||||
|
Future<SignUps> signUps();
|
||||||
|
|
||||||
|
/// Keeps [wait] as this device's (D31): [status] is [Waiting] until a
|
||||||
|
/// session takes its place ([useSession]) or the device stops waiting
|
||||||
|
/// ([stopWaiting]).
|
||||||
|
Future<void> wait(AccountWait wait);
|
||||||
|
|
||||||
|
/// Asks the instance where this device's wait stands, and keeps what it
|
||||||
|
/// says; approved, the instance forgets the pass with that answer.
|
||||||
|
Future<AccountWaitState> lookWhileWaiting();
|
||||||
|
|
||||||
|
/// Deletes the new account this device waits for, with all it has, and
|
||||||
|
/// stops waiting: whether the instance did. Not a suspended account,
|
||||||
|
/// whose deletion takes its owner's password and a code (ADR 0017), nor
|
||||||
|
/// one approved or refused meanwhile.
|
||||||
|
Future<bool> deleteWaitingAccount();
|
||||||
|
|
||||||
|
/// Stops waiting on this device: the pass forgotten on the instance, as
|
||||||
|
/// far as it is reached, and here; [status] is [SignedOut].
|
||||||
|
Future<void> stopWaiting();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,14 @@ import 'dart:developer';
|
||||||
|
|
||||||
import 'package:halkyon_app/src/auth/data/auth_gateway.dart';
|
import 'package:halkyon_app/src/auth/data/auth_gateway.dart';
|
||||||
import 'package:halkyon_app/src/auth/data/sign_in_addresses.dart';
|
import 'package:halkyon_app/src/auth/data/sign_in_addresses.dart';
|
||||||
|
import 'package:halkyon_app/src/auth/model/account_wait.dart';
|
||||||
import 'package:halkyon_app/src/auth/model/auth_status.dart';
|
import 'package:halkyon_app/src/auth/model/auth_status.dart';
|
||||||
import 'package:halkyon_app/src/auth/model/sign_in_ways.dart';
|
import 'package:halkyon_app/src/auth/model/sign_in_ways.dart';
|
||||||
|
import 'package:halkyon_app/src/auth/model/sign_ups.dart';
|
||||||
|
|
||||||
/// Who is signed in, and signing in and out, as the features use it.
|
/// Who is signed in, and signing in and out, as the features use it; and
|
||||||
|
/// an account's wait for the instance's operator (Halkyon Learn's ADR 0021,
|
||||||
|
/// D31).
|
||||||
class AuthRepository(
|
class AuthRepository(
|
||||||
final AuthGateway _gateway,
|
final AuthGateway _gateway,
|
||||||
final SignInAddresses _addresses,
|
final SignInAddresses _addresses,
|
||||||
|
|
@ -20,7 +24,7 @@ class AuthRepository(
|
||||||
/// in (ADR 0020, D25): one without an identity.
|
/// in (ADR 0020, D25): one without an identity.
|
||||||
bool get isAnonymous => switch (_gateway.status) {
|
bool get isAnonymous => switch (_gateway.status) {
|
||||||
SignedIn(:final isIdentified) => !isIdentified,
|
SignedIn(:final isIdentified) => !isIdentified,
|
||||||
SignedOut() => false,
|
SignedOut() || Waiting() => false,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Signs out on this device.
|
/// Signs out on this device.
|
||||||
|
|
@ -57,6 +61,22 @@ class AuthRepository(
|
||||||
/// null when they didn't, or did elsewhere.
|
/// null when they didn't, or did elsewhere.
|
||||||
Future<String?> emailAddress() async => switch (_gateway.status) {
|
Future<String?> emailAddress() async => switch (_gateway.status) {
|
||||||
SignedIn(:final authUserId) => await _addresses.of(authUserId),
|
SignedIn(:final authUserId) => await _addresses.of(authUserId),
|
||||||
SignedOut() => null,
|
SignedOut() || Waiting() => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Who may make an account on the instance (D19).
|
||||||
|
Future<SignUps> signUps() => _gateway.signUps();
|
||||||
|
|
||||||
|
/// Asks the instance where this device's wait stands, and keeps what it
|
||||||
|
/// says.
|
||||||
|
Future<AccountWaitState> lookWhileWaiting() => _gateway.lookWhileWaiting();
|
||||||
|
|
||||||
|
/// Deletes the new account this device waits for, with all it has:
|
||||||
|
/// whether the instance did (not for one suspended, approved or refused
|
||||||
|
/// meanwhile).
|
||||||
|
Future<bool> deleteWaitingAccount() => _gateway.deleteWaitingAccount();
|
||||||
|
|
||||||
|
/// Stops waiting on this device: the person signs in again (approved), or
|
||||||
|
/// waits anew once they do.
|
||||||
|
Future<void> stopWaiting() => _gateway.stopWaiting();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:developer';
|
||||||
|
|
||||||
|
import 'package:halkyon_app/src/auth/data/account_wait_store.dart';
|
||||||
import 'package:halkyon_app/src/auth/data/auth_gateway.dart';
|
import 'package:halkyon_app/src/auth/data/auth_gateway.dart';
|
||||||
|
import 'package:halkyon_app/src/auth/model/account_wait.dart';
|
||||||
import 'package:halkyon_app/src/auth/model/auth_status.dart';
|
import 'package:halkyon_app/src/auth/model/auth_status.dart';
|
||||||
import 'package:halkyon_app/src/auth/model/session_grant.dart';
|
import 'package:halkyon_app/src/auth/model/session_grant.dart';
|
||||||
import 'package:halkyon_app/src/auth/model/sign_in_ways.dart';
|
import 'package:halkyon_app/src/auth/model/sign_in_ways.dart';
|
||||||
|
import 'package:halkyon_app/src/auth/model/sign_ups.dart';
|
||||||
import 'package:halkyon_base_client/halkyon_base_client.dart' as base_client;
|
import 'package:halkyon_base_client/halkyon_base_client.dart' as base_client;
|
||||||
import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart';
|
import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart';
|
||||||
|
|
||||||
|
|
@ -11,10 +15,12 @@ import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart';
|
||||||
/// manager, which keeps the session on the device (secure storage). Signing
|
/// manager, which keeps the session on the device (secure storage). Signing
|
||||||
/// in and the account are the instance's halkyon_base's, called through
|
/// in and the account are the instance's halkyon_base's, called through
|
||||||
/// [_base], the module's endpoints on that client
|
/// [_base], the module's endpoints on that client
|
||||||
/// (`client.modules.base`).
|
/// (`client.modules.base`). An account's wait for the instance's operator
|
||||||
|
/// is kept in [_waits] (Halkyon Learn's ADR 0021, D31).
|
||||||
class ServerpodAuthGateway(
|
class ServerpodAuthGateway(
|
||||||
final ServerpodClientShared _client, {
|
final ServerpodClientShared _client, {
|
||||||
required final base_client.Caller _base,
|
required final base_client.Caller _base,
|
||||||
|
required final AccountWaitStore _waits,
|
||||||
}) implements AuthGateway {
|
}) implements AuthGateway {
|
||||||
this {
|
this {
|
||||||
_client.auth.authInfoListenable.addListener(_onAuthInfoChanged);
|
_client.auth.authInfoListenable.addListener(_onAuthInfoChanged);
|
||||||
|
|
@ -22,13 +28,32 @@ class ServerpodAuthGateway(
|
||||||
|
|
||||||
final _changes = StreamController<AuthStatus>.broadcast();
|
final _changes = StreamController<AuthStatus>.broadcast();
|
||||||
|
|
||||||
|
/// The wait this device keeps; null for none.
|
||||||
|
AccountWait? _wait;
|
||||||
|
|
||||||
/// The scope every account has (ADR 0020, D4): a session without it is
|
/// The scope every account has (ADR 0020, D4): a session without it is
|
||||||
/// an anonymous account's, left from before they went.
|
/// an anonymous account's, left from before they went.
|
||||||
static const _identifiedScope = 'identified';
|
static const _identifiedScope = 'identified';
|
||||||
|
|
||||||
|
/// Reads the wait the device keeps, before anything asks who is signed in
|
||||||
|
/// (as `client.auth.initialize()` restores the session), so the app
|
||||||
|
/// doesn't ask how to start while someone waits. A keyring that can't be
|
||||||
|
/// read leaves none: the person signs in again, and waits anew.
|
||||||
|
Future<void> initialize() async {
|
||||||
|
try {
|
||||||
|
_wait = await _waits.read();
|
||||||
|
} on Object catch (error) {
|
||||||
|
// Its type only: never what the keyring held.
|
||||||
|
log('Reading the wait failed: ${error.runtimeType}', name: 'Auth');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
AuthStatus get status => switch (_client.auth.authInfo) {
|
AuthStatus get status => switch (_client.auth.authInfo) {
|
||||||
|
null => switch (_wait) {
|
||||||
|
final wait? => Waiting(wait),
|
||||||
null => const SignedOut(),
|
null => const SignedOut(),
|
||||||
|
},
|
||||||
final authInfo => SignedIn(
|
final authInfo => SignedIn(
|
||||||
authUserId: authInfo.authUserId.toString(),
|
authUserId: authInfo.authUserId.toString(),
|
||||||
isIdentified: authInfo.scopeNames.contains(_identifiedScope),
|
isIdentified: authInfo.scopeNames.contains(_identifiedScope),
|
||||||
|
|
@ -58,8 +83,89 @@ class ServerpodAuthGateway(
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> useSession(SessionGrant grant) =>
|
Future<void> useSession(SessionGrant grant) async {
|
||||||
_client.auth.updateSignedInUser(grant.session as AuthSuccess);
|
if (_wait != null) {
|
||||||
|
_wait = null;
|
||||||
|
await _keep(null);
|
||||||
|
}
|
||||||
|
await _client.auth.updateSignedInUser(grant.session as AuthSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SignUps> signUps() async =>
|
||||||
|
switch ((await _base.instance.basics()).signUp) {
|
||||||
|
base_client.SignUpMode.open => SignUps.open,
|
||||||
|
base_client.SignUpMode.approval => SignUps.approval,
|
||||||
|
base_client.SignUpMode.closed => SignUps.closed,
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> wait(AccountWait wait) => _setWait(wait);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AccountWaitState> lookWhileWaiting() async {
|
||||||
|
final wait = _waiting();
|
||||||
|
final state = switch (await _base.waiting.look(pass: wait.pass)) {
|
||||||
|
base_client.WaitingState.waiting => AccountWaitState.waiting,
|
||||||
|
base_client.WaitingState.suspended => AccountWaitState.suspended,
|
||||||
|
base_client.WaitingState.approved => AccountWaitState.approved,
|
||||||
|
base_client.WaitingState.unknown => AccountWaitState.ended,
|
||||||
|
};
|
||||||
|
// Unless the device stopped waiting meanwhile.
|
||||||
|
if (_wait == wait && state != wait.state) {
|
||||||
|
await _setWait(wait.standing(state));
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> deleteWaitingAccount() async {
|
||||||
|
final wait = _waiting();
|
||||||
|
final deleted = await _base.waiting.deleteAccount(pass: wait.pass);
|
||||||
|
if (deleted && _wait == wait) {
|
||||||
|
await _setWait(null);
|
||||||
|
}
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> stopWaiting() async {
|
||||||
|
final wait = _wait;
|
||||||
|
if (wait == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Approved or ended, the instance knows the pass no more.
|
||||||
|
if (wait.state
|
||||||
|
case AccountWaitState.waiting || AccountWaitState.suspended) {
|
||||||
|
try {
|
||||||
|
await _base.waiting.forget(pass: wait.pass);
|
||||||
|
} on Exception catch (error) {
|
||||||
|
// Out of reach: the pass runs out within 30 days all the same.
|
||||||
|
log('Forgetting the pass failed: ${error.runtimeType}', name: 'Auth');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await _setWait(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
AccountWait _waiting() => _wait ?? (throw StateError('Nothing waits here.'));
|
||||||
|
|
||||||
|
/// Puts [wait] in place of the device's, kept even where the keyring
|
||||||
|
/// can't take it: the wait screen shows it all the same, until the app
|
||||||
|
/// starts again.
|
||||||
|
Future<void> _setWait(AccountWait? wait) async {
|
||||||
|
_wait = wait;
|
||||||
|
_changes.add(status);
|
||||||
|
await _keep(wait);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _keep(AccountWait? wait) async {
|
||||||
|
try {
|
||||||
|
await _waits.keep(wait);
|
||||||
|
} on Object catch (error) {
|
||||||
|
// Its type only: never what the keyring was to hold.
|
||||||
|
log('Keeping the wait failed: ${error.runtimeType}', name: 'Auth');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Stops listening; the owner calls it when the app shuts down.
|
/// Stops listening; the owner calls it when the app shuts down.
|
||||||
Future<void> dispose() async {
|
Future<void> dispose() async {
|
||||||
|
|
|
||||||
55
packages/halkyon_app/lib/src/auth/model/account_wait.dart
Normal file
55
packages/halkyon_app/lib/src/auth/model/account_wait.dart
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
/// Where an account waiting for the instance's operator stands (Halkyon
|
||||||
|
/// Learn's ADR 0021, D19 and D31).
|
||||||
|
enum AccountWaitState() {
|
||||||
|
/// New, until the operator approves or refuses it.
|
||||||
|
waiting,
|
||||||
|
|
||||||
|
/// Suspended by the operator, until they lift it.
|
||||||
|
suspended,
|
||||||
|
|
||||||
|
/// Approved: the person signs in once more, as they did before (D31).
|
||||||
|
approved,
|
||||||
|
|
||||||
|
/// The instance knows the pass no more: the operator refused the
|
||||||
|
/// account, it waited longer than it may, or the pass ran out.
|
||||||
|
ended,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An account signed in or up to on this device that waits for the
|
||||||
|
/// instance's operator (D31): no session, only a pass for this device, which
|
||||||
|
/// asks where the wait stands, and the four words the operator reads out
|
||||||
|
/// for the person to match (D33).
|
||||||
|
final class const AccountWait({
|
||||||
|
/// What asks the instance for this account (`waiting.look`); a secret,
|
||||||
|
/// never shown or logged.
|
||||||
|
required final String pass,
|
||||||
|
|
||||||
|
/// The matching code, four words of BIP-39's English list.
|
||||||
|
required final List<String> words,
|
||||||
|
|
||||||
|
/// The account's handle; null for an account without one yet.
|
||||||
|
final String? handle,
|
||||||
|
final AccountWaitState state = AccountWaitState.waiting,
|
||||||
|
}) with Equatable {
|
||||||
|
/// This wait, standing as [state] says.
|
||||||
|
AccountWait standing(AccountWaitState state) =>
|
||||||
|
AccountWait(pass: pass, words: words, handle: handle, state: state);
|
||||||
|
|
||||||
|
/// Never the pass.
|
||||||
|
@override
|
||||||
|
String toString() => 'AccountWait(${handle ?? '-'}, ${state.name})';
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [pass, words, handle, state];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The instance gave no session but a wait ([wait]): the device keeps it
|
||||||
|
/// (`AuthGateway.wait`), and the wait screen takes the place of the sheet
|
||||||
|
/// that met it.
|
||||||
|
final class const AccountWaits(final AccountWait wait) implements Exception {
|
||||||
|
/// Never the pass.
|
||||||
|
@override
|
||||||
|
String toString() => 'AccountWaits($wait)';
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:halkyon_app/src/auth/model/account_wait.dart';
|
||||||
|
|
||||||
/// Who is signed in on this device.
|
/// Who is signed in on this device, or waits to be.
|
||||||
sealed class const AuthStatus() with Equatable;
|
sealed class const AuthStatus() with Equatable;
|
||||||
|
|
||||||
/// Nobody is signed in.
|
/// Nobody is signed in.
|
||||||
|
|
@ -22,3 +23,11 @@ final class const SignedIn({
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [authUserId, isIdentified];
|
List<Object?> get props => [authUserId, isIdentified];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Nobody is signed in: an account signed in or up to here waits for the
|
||||||
|
/// instance's operator (Halkyon Learn's ADR 0021, D31), with a pass in
|
||||||
|
/// place of a session.
|
||||||
|
final class const Waiting(final AccountWait wait) extends AuthStatus {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [wait];
|
||||||
|
}
|
||||||
|
|
|
||||||
12
packages/halkyon_app/lib/src/auth/model/sign_ups.dart
Normal file
12
packages/halkyon_app/lib/src/auth/model/sign_ups.dart
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
/// Who may make an account on an instance, as its operator says (Halkyon
|
||||||
|
/// Learn's ADR 0021, D19).
|
||||||
|
enum SignUps() {
|
||||||
|
/// Anyone.
|
||||||
|
open,
|
||||||
|
|
||||||
|
/// Anyone, each new account waiting for the operator's approval.
|
||||||
|
approval,
|
||||||
|
|
||||||
|
/// Nobody: only the accounts there are sign in.
|
||||||
|
closed,
|
||||||
|
}
|
||||||
|
|
@ -4,10 +4,12 @@ import 'package:halkyon_app/src/app/app_scope.dart';
|
||||||
import 'package:halkyon_app/src/auth/bloc/session_bloc.dart';
|
import 'package:halkyon_app/src/auth/bloc/session_bloc.dart';
|
||||||
import 'package:halkyon_app/src/auth/model/auth_status.dart';
|
import 'package:halkyon_app/src/auth/model/auth_status.dart';
|
||||||
import 'package:halkyon_app/src/auth/widget/start_screen.dart';
|
import 'package:halkyon_app/src/auth/widget/start_screen.dart';
|
||||||
|
import 'package:halkyon_app/src/auth/widget/wait_screen.dart';
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
/// Shows the app of whoever is signed in, and asks how to start while
|
/// Shows the app of whoever is signed in, and asks how to start while
|
||||||
/// nobody is ([StartScreen]).
|
/// nobody is ([StartScreen]), or where the wait stands while an account
|
||||||
|
/// waits for the instance's operator ([WaitScreen]).
|
||||||
///
|
///
|
||||||
/// [app] is built anew for every person (keyed by the person), so nothing of
|
/// [app] is built anew for every person (keyed by the person), so nothing of
|
||||||
/// one person's app, such as screens, blocs, streams or caches, reaches
|
/// one person's app, such as screens, blocs, streams or caches, reaches
|
||||||
|
|
@ -46,6 +48,12 @@ class const SessionGate({
|
||||||
look: look(context),
|
look: look(context),
|
||||||
home: const StartScreen(),
|
home: const StartScreen(),
|
||||||
),
|
),
|
||||||
|
// An app of its own, so the start screen's sheets go with it.
|
||||||
|
SessionStateWaiting() => PlainApp(
|
||||||
|
key: const ValueKey(SessionStateWaiting),
|
||||||
|
look: look(context),
|
||||||
|
home: const WaitScreen(),
|
||||||
|
),
|
||||||
SessionStateActive(:final person) => KeyedSubtree(
|
SessionStateActive(:final person) => KeyedSubtree(
|
||||||
key: ValueKey(person.authUserId),
|
key: ValueKey(person.authUserId),
|
||||||
child: app(person),
|
child: app(person),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:halkyon_app/l10n/halkyon_app_localizations.dart';
|
import 'package:halkyon_app/l10n/halkyon_app_localizations.dart';
|
||||||
import 'package:halkyon_app/src/app/app_frame.dart';
|
import 'package:halkyon_app/src/app/app_frame.dart';
|
||||||
import 'package:halkyon_app/src/app/app_scope.dart';
|
import 'package:halkyon_app/src/app/app_scope.dart';
|
||||||
|
import 'package:halkyon_app/src/auth/model/sign_ups.dart';
|
||||||
import 'package:halkyon_app/src/instance/instance_scope.dart';
|
import 'package:halkyon_app/src/instance/instance_scope.dart';
|
||||||
import 'package:halkyon_app/src/instance/widget/account_switcher_sheet.dart';
|
import 'package:halkyon_app/src/instance/widget/account_switcher_sheet.dart';
|
||||||
import 'package:halkyon_app/src/password/bloc/account_setup_cubit.dart';
|
import 'package:halkyon_app/src/password/bloc/account_setup_cubit.dart';
|
||||||
|
|
@ -18,13 +19,20 @@ import 'package:material_ui/material_ui.dart';
|
||||||
/// creating an account where it offers password sign-in (ADR 0017); or on
|
/// creating an account where it offers password sign-in (ADR 0017); or on
|
||||||
/// another instance. There is no starting without an account (ADR 0020,
|
/// another instance. There is no starting without an account (ADR 0020,
|
||||||
/// D4): an instance offering no way to create one says so, and one out of
|
/// D4): an instance offering no way to create one says so, and one out of
|
||||||
/// reach is asked again. Nothing is made until the person chooses.
|
/// reach is asked again. Nothing is made until the person chooses. Where
|
||||||
|
/// new accounts wait for the operator's approval, or none are made
|
||||||
|
/// (Halkyon Learn's ADR 0021, D19), it says so first.
|
||||||
class const StartScreen({super.key}) extends StatefulWidget {
|
class const StartScreen({super.key}) extends StatefulWidget {
|
||||||
@override
|
@override
|
||||||
State<StartScreen> createState() => _StartScreenState();
|
State<StartScreen> createState() => _StartScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
typedef _Offers = ({bool reached, bool simpleLogin, bool password});
|
typedef _Offers = ({
|
||||||
|
bool reached,
|
||||||
|
bool simpleLogin,
|
||||||
|
bool password,
|
||||||
|
SignUps signUps,
|
||||||
|
});
|
||||||
|
|
||||||
class _StartScreenState() extends State<StartScreen> {
|
class _StartScreenState() extends State<StartScreen> {
|
||||||
late Future<_Offers> _offers = _ask();
|
late Future<_Offers> _offers = _ask();
|
||||||
|
|
@ -49,14 +57,26 @@ class _StartScreenState() extends State<StartScreen> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final (simpleLoginOffered, passwordOffered) = await (
|
// Unknown, as from an instance out of reach: the instance refuses what
|
||||||
|
// it doesn't take all the same.
|
||||||
|
Future<SignUps> signUps() async {
|
||||||
|
try {
|
||||||
|
return await dependencies.auth.signUps();
|
||||||
|
} on Exception {
|
||||||
|
return SignUps.open;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final (simpleLoginOffered, passwordOffered, signUpsTaken) = await (
|
||||||
simpleLogin(),
|
simpleLogin(),
|
||||||
passwords(),
|
passwords(),
|
||||||
|
signUps(),
|
||||||
).wait;
|
).wait;
|
||||||
return (
|
return (
|
||||||
reached: simpleLoginOffered != null || passwordOffered != null,
|
reached: simpleLoginOffered != null || passwordOffered != null,
|
||||||
simpleLogin: simpleLoginOffered ?? false,
|
simpleLogin: simpleLoginOffered ?? false,
|
||||||
password: passwordOffered ?? false,
|
password: passwordOffered ?? false,
|
||||||
|
signUps: signUpsTaken,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -131,7 +151,7 @@ class _StartScreenState() extends State<StartScreen> {
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final (:reached, :simpleLogin, :password) = asked;
|
final (:reached, :simpleLogin, :password, :signUps) = asked;
|
||||||
if (!reached) {
|
if (!reached) {
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
|
@ -164,6 +184,16 @@ class _StartScreenState() extends State<StartScreen> {
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
] else if (switch (signUps) {
|
||||||
|
SignUps.approval => l10n.startSignUpsApproved(
|
||||||
|
instance,
|
||||||
|
),
|
||||||
|
SignUps.closed => l10n.startSignUpsClosed(instance),
|
||||||
|
SignUps.open => null,
|
||||||
|
}
|
||||||
|
case final signUpsNote?) ...[
|
||||||
|
Text(signUpsNote, textAlign: TextAlign.center),
|
||||||
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
if (simpleLogin) ...[
|
if (simpleLogin) ...[
|
||||||
FilledButton(
|
FilledButton(
|
||||||
|
|
@ -191,6 +221,7 @@ class _StartScreenState() extends State<StartScreen> {
|
||||||
child: Text(l10n.startSignIn),
|
child: Text(l10n.startSignIn),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
if (signUps != SignUps.closed) ...[
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onPressed: () => showAccountSetupSheet(
|
onPressed: () => showAccountSetupSheet(
|
||||||
context,
|
context,
|
||||||
|
|
@ -201,6 +232,7 @@ class _StartScreenState() extends State<StartScreen> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
|
],
|
||||||
...otherInstance,
|
...otherInstance,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue