feat(app)!: no anonymous accounts on the device (ADR 0020, D4, D25)

Every account has a way in, so the apps make no anonymous account any
more. The start screen shows whenever nobody is signed in, a first
start included, and offers SimpleLogin, a password and GitHub for those
who have it, with no start without an account. An instance out of reach
is said so, with Try again, and one offering no way to create an account
too. An anonymous account's session left from before is deleted as the
app starts (D25), falling back to signing out on the device.

What only anonymous accounts needed goes: signing in anonymously, the
signed-out mark, adding GitHub to an account and switching to a GitHub
account from the Profile tab (with its Cancel, the only one), an
anonymous device's own keys, the warning that signing out deletes the
account, and the texts about keeping progress. Settings says on which
instance the account is signed in. Learn's publishing, reports and
suggestions and its learner count no longer ask whether the person has
an identity: everyone signed in has one.

The server still offers anonymous sign-in until the next commit, so
older apps keep working until v0.20.0 raises the minimum build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-24 14:50:55 +02:00
commit 5b9df73756
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
62 changed files with 981 additions and 3339 deletions

View file

@ -37,9 +37,14 @@ Written as the apps are: Dart 3.13's primary constructors, Material from
`SubdivisionNames`).
- **Gates** (`lib/src/app/`): `InstanceGate` opens the app's dependencies on
the instance in use (`AppDependencies`, handed down by `AppScope`), anew on
a switch, the previous ones closed first; `SessionGate` keeps a person
signed in, signing in anonymously at a first start and asking how to start
once signed out (`StartScreen`); `PersonScope` opens each person's own
a switch, the previous ones closed first; `SessionGate` shows the app of
whoever is signed in and asks how to start while nobody is
(`StartScreen`: SimpleLogin, a password, GitHub for those who have it,
another instance; an instance out of reach is said so, with Try again,
and one offering no way to create an account too), at a first start as
after signing out; an anonymous account's session, left from before
there were none, is deleted as the app starts (ADR 0020, D25), falling
back to signing out on the device; `PersonScope` opens each person's own
dependencies and closes them when the person goes. `resolveAppLocale`
picks the app's language as every Halkyon app does: the first preferred
language it speaks, never by country alone, British English otherwise.
@ -48,9 +53,9 @@ Written as the apps are: Dart 3.13's primary constructors, Material from
instance runs is (`InstanceSourceCode`, from `AppDependencies.sourceCodeUrl`;
both licences require offering it), the app's own parts (its credits), and
the licence list.
- **The account** (`AccountTiles`): whose account on which instance,
switching to the account on another, signing out (which of an anonymous
account deletes it, so asks first) and deleting it (after asking), for an
- **The account** (`AccountTiles`): on which instance the account is signed
in, switching to the account on another, signing out and deleting it
(after asking), for an
app's settings, in the app's words where only it can say them. While one
runs, the person's app stays and its tile shows a spinner; one that fails
keeps them signed in and says why once, in a snack bar (in a sheet, give
@ -58,26 +63,20 @@ Written as the apps are: Dart 3.13's primary constructors, Material from
the same (Serverpod's `signOutDevice`), so only the device's own storage
fails a sign-out.
- **Signing in** (`lib/src/auth/`): `AuthRepository` on an `AuthGateway`;
`ServerpodAuthGateway` signs in anonymously and with GitHub (the system
browser and a loopback address on Linux and Windows, halkyon_oidc_flutter's
`LoopbackWebAuth`),
`ServerpodAuthGateway` signs in with GitHub those who have it, as it is
added to nobody any more (ADR 0020, D25; the system browser and a loopback
address on Linux and Windows, halkyon_oidc_flutter's `LoopbackWebAuth`),
through the instance's `halkyon_base`, and puts a session granted elsewhere
in place (`useSession`); `SessionBloc`, `GitHubSignInBloc` and
`SignInWaysCubit`; `SignedOutMark`, `SignInAddresses`, `PersonFolders`.
Where the instance offers passwords, GitHub is added to nobody (ADR 0017):
`gitHubOffer` says so from its basics, `GitHubSignInStateReady.linking`
hands it to the screens, which then offer no Add, and the start screen
offers an account to create in place of the app's sentence on adding
GitHub; `gitHubProblemText` says why a GitHub sign-in was refused.
in place (`useSession`); `SessionBloc` and `SignInWaysCubit`;
`SignInAddresses`, `PersonFolders`. The start screen says why a GitHub
sign-in was refused, and for a GitHub account nobody here has, the way to
create an account in its place.
- **Content sealed with data keys** (`lib/src/privacy/`, ADR 0019):
`ContentCipher` (`SodiumContentCipher`, on the isolate it is used on, as an
app seals and opens rows one by one; `FakeContentCipher` in tests) makes a
store's data keys, sealed to the account and signed by it, opens them, and
seals and opens a row's content for the row it is stored in (its table, id
and column), the payload holding the signature before the ciphertext. An
anonymous person's device makes key pairs of its own
(`AccountCrypto.newDeviceKeys`), which `makePassword` keeps as the account's
once they set a password.
and column), the payload holding the signature before the ciphertext.
- **A password and TOTP** (`lib/src/password/`, ADR 0017): `PasswordRepository`
on a `PasswordGateway` (`ServerpodPasswordGateway`), an `AccountCrypto`
(`SodiumAccountCrypto`: halkyon_keys and SRP's client half, each step on an
@ -125,9 +124,8 @@ Written as the apps are: Dart 3.13's primary constructors, Material from
code or opened; and anything a step didn't expect (bytes of the wrong
length from an instance, libsodium's or the keyring's own errors) fails it
rather than leaving its sheet busy. `register_interop_test.dart` holds the
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.
real cryptography to the register's own checks. 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
@ -143,18 +141,17 @@ Written as the apps are: Dart 3.13's primary constructors, Material from
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. The
device's person instead. 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. Adding it to an account (ADR 0020, D21, D23): where the
once it closes. Adding it to an account (ADR 0020, D21): where the
person stands (`SimpleLoginRepository.standing`, read anew after every
change, `SimpleLoginStandingCubit`: nothing to show, linked, or
missing), shown by `SimpleLoginSection` (each app places it above
`AccountSecuritySection`: a heading and Add SimpleLogin; once added a row
among the ways in), which reads it once where a
`SimpleLoginStandingScope` is above it. Where the instance offers
SimpleLogin, GitHub is added to nobody (`GitHubOffer.linking`).
`SimpleLoginStandingScope` is above it.
- **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

View file

@ -11,10 +11,12 @@
/// - **The account and About**: `AccountTiles` for an app's settings,
/// `showAppAbout` with the source of what the instance runs, and
/// `resolveAppLocale`, the language every Halkyon app picks.
/// - **Signing in**: anonymously, with GitHub (`AuthRepository` on
/// `ServerpodAuthGateway`), and with a password and TOTP (ADR 0017:
/// `PasswordRepository`, its sheets and `AccountSecuritySection`), the
/// account's keys on the device.
/// - **Signing in**: with SimpleLogin (ADR 0020: `SimpleLoginRepository`,
/// its sheet and `SimpleLoginSection`), with a password and TOTP (ADR
/// 0017: `PasswordRepository`, its sheets and `AccountSecuritySection`),
/// and with GitHub while it lasts (`AuthRepository` on
/// `ServerpodAuthGateway`), the account's keys on the device. Every
/// account has a way in: there are no anonymous ones (ADR 0020, D4).
/// - **Sync**: `SyncService` on triggers, the changes still waiting, and
/// where it stands (`SyncStatusBloc`, `SyncStatusCard`, `AppMismatchBanner`,
/// `SyncTroubleBadge`).
@ -30,21 +32,17 @@ export 'src/app/app_locale.dart';
export 'src/app/app_scope.dart';
export 'src/app/instance_gate.dart';
export 'src/app/person_scope.dart';
export 'src/auth/bloc/github_sign_in_bloc.dart';
export 'src/auth/bloc/session_bloc.dart';
export 'src/auth/bloc/sign_in_ways_cubit.dart';
export 'src/auth/data/auth_gateway.dart';
export 'src/auth/data/auth_repository.dart';
export 'src/auth/data/serverpod_auth_gateway.dart';
export 'src/auth/data/sign_in_addresses.dart';
export 'src/auth/data/signed_out_mark.dart';
export 'src/auth/model/auth_status.dart';
export 'src/auth/model/github_link_outcome.dart';
export 'src/auth/model/github_sign_in.dart';
export 'src/auth/model/session_grant.dart';
export 'src/auth/model/sign_in_ways.dart';
export 'src/auth/widget/account_tiles.dart';
export 'src/auth/widget/github_texts.dart';
export 'src/auth/widget/session_gate.dart';
export 'src/auth/widget/start_screen.dart';
export 'src/base/base_client.dart';

View file

@ -27,16 +27,13 @@
"accountAdd": "Konto hinzufügen",
"accountAddSubtitle": "Wähle die Instanz für das Konto, das du hinzufügst.",
"accountSignedIn": "Angemeldet",
"accountAnonymous": "Anonym",
"accountRemoveTooltip": "Konto entfernen",
"accountRemoveTitle": "Konto entfernen?",
"accountRemoveMessage": "Entfernt das Konto auf {name} von diesem Gerät, mit allem, was das Gerät davon hat. Was es synchronisiert hat, bleibt auf {name}: Melde dich dort wieder an, um es wiederzuhaben. In ein anonymes Konto kann man sich nicht wieder anmelden.",
"accountRemoveMessage": "Entfernt das Konto auf {name} von diesem Gerät, mit allem, was das Gerät davon hat. Was es synchronisiert hat, bleibt auf {name}: Melde dich dort wieder an, um es wiederzuhaben.",
"accountRemoveConfirm": "Entfernen",
"accountSignedInOn": "Angemeldet auf {instance}",
"accountAnonymousOn": "Anonym auf {instance}",
"accountSwitch": "Konto wechseln",
"accountSignOut": "Abmelden",
"accountSignOutTitle": "Abmelden?",
"accountDelete": "Konto löschen",
"accountDeleteTitle": "Dein Konto löschen?",
"accountSignOutFailed": "Abmelden auf diesem Gerät hat nicht geklappt. Versuch es noch einmal.",
@ -48,16 +45,14 @@
"startSignedOut": "Abgemeldet",
"startOn": "Starte auf {instance}:",
"startWithGitHub": "Mit GitHub anmelden",
"startWithoutAccount": "Ohne Konto starten",
"startAnonymous": "Dein Fortschritt wird auf diesem Gerät gespeichert. Melde dich an, um ihn zu behalten.",
"startGitHubFailed": "Anmeldung mit GitHub fehlgeschlagen.",
"startNoGitHubAccount": "Noch kein Konto hier meldet sich mit diesem GitHub-Konto an.",
"startNoGitHubAccount": "Kein Konto hier meldet sich mit diesem GitHub-Konto an.",
"gitHubCreateAccountInstead": "Erstelle stattdessen ein Konto mit deiner E-Mail-Adresse und einem Passwort.",
"gitHubNoAccountSetUpPassword": "Noch kein Konto hier meldet sich mit diesem GitHub-Konto an. Richte stattdessen ein Passwort ein, um deinen Fortschritt zu behalten.",
"gitHubSimpleLoginInstead": "Mit SimpleLogin kannst du stattdessen eins erstellen.",
"gitHubUsePassword": "Dieses Konto meldet sich jetzt mit seiner E-Mail-Adresse und seinem Passwort an, nicht mehr mit GitHub.",
"gitHubDeprecated": "GitHub lässt sich nicht mehr als Anmeldeweg hinzufügen: Deine E-Mail-Adresse und dein Passwort sind es.",
"gitHubEnded": "Die Anmeldung mit GitHub ist auf dieser Instanz beendet.",
"startOtherInstance": "Andere Instanz verwenden",
"startNoWay": "{instance} bietet keinen Weg, ein Konto zu erstellen.",
"syncTitle": "Synchronisierung",
"syncNow": "Jetzt synchronisieren",
"syncStateSyncing": "Wird synchronisiert…",

View file

@ -116,10 +116,9 @@
"accountAdd": "Add account",
"accountAddSubtitle": "Choose the instance for the account to add.",
"accountSignedIn": "Signed in",
"accountAnonymous": "Anonymous",
"accountRemoveTooltip": "Remove account",
"accountRemoveTitle": "Remove account?",
"accountRemoveMessage": "Removes the account on {name} from this device, with all the device holds of it. What it synced stays on {name}: sign in there again to have it. An anonymous account can't be signed into again.",
"accountRemoveMessage": "Removes the account on {name} from this device, with all the device holds of it. What it synced stays on {name}: sign in there again to have it.",
"@accountRemoveMessage": {
"placeholders": {
"name": {
@ -136,17 +135,8 @@
}
}
},
"accountAnonymousOn": "Anonymous on {instance}",
"@accountAnonymousOn": {
"placeholders": {
"instance": {
"type": "String"
}
}
},
"accountSwitch": "Switch account",
"accountSignOut": "Sign out",
"accountSignOutTitle": "Sign out?",
"accountDelete": "Delete account",
"accountDeleteTitle": "Delete your account?",
"accountSignOutFailed": "Couldn't sign out on this device. Try again.",
@ -165,22 +155,28 @@
}
},
"startWithGitHub": "Sign in with GitHub",
"startWithoutAccount": "Start without an account",
"startAnonymous": "Progress is saved on this device. Sign in to keep it.",
"startGitHubFailed": "Couldn't sign in with GitHub.",
"startNoGitHubAccount": "No account here signs in with this GitHub account yet.",
"startNoGitHubAccount": "No account here signs in with this GitHub account.",
"gitHubCreateAccountInstead": "Create an account with your email address and a password instead.",
"@gitHubCreateAccountInstead": {
"description": "Said after a GitHub account nobody on the instance signs in with, where people sign in with a password and GitHub is added to nobody (ADR 0017)."
},
"gitHubNoAccountSetUpPassword": "No account here signs in with this GitHub account yet. To keep your progress, set up a password instead.",
"@gitHubNoAccountSetUpPassword": {
"description": "An anonymous person signed in with a GitHub account nobody here has, where GitHub is added to nobody: the password set up below keeps their account."
"gitHubSimpleLoginInstead": "Continue with SimpleLogin to create one instead.",
"@gitHubSimpleLoginInstead": {
"description": "Said after a GitHub account nobody on the instance signs in with, where the instance offers SimpleLogin (ADR 0020): GitHub makes no account."
},
"gitHubUsePassword": "This account signs in with its email address and password now, not with GitHub.",
"gitHubDeprecated": "GitHub can't be added as a way to sign in any more: your email address and password are.",
"gitHubEnded": "Sign-in with GitHub has ended on this instance.",
"startOtherInstance": "Use another instance",
"startNoWay": "{instance} offers no way to create an account.",
"@startNoWay": {
"description": "On the start screen, where the instance offers neither SimpleLogin nor a password (ADR 0020, D4): nobody can make an account there.",
"placeholders": {
"instance": {
"type": "String"
}
}
},
"syncTitle": "Sync",
"syncNow": "Sync now",
"syncStateSyncing": "Syncing…",

View file

@ -265,12 +265,6 @@ abstract class HalkyonAppLocalizations {
/// **'Signed in'**
String get accountSignedIn;
/// No description provided for @accountAnonymous.
///
/// In en, this message translates to:
/// **'Anonymous'**
String get accountAnonymous;
/// No description provided for @accountRemoveTooltip.
///
/// In en, this message translates to:
@ -286,7 +280,7 @@ abstract class HalkyonAppLocalizations {
/// No description provided for @accountRemoveMessage.
///
/// In en, this message translates to:
/// **'Removes the account on {name} from this device, with all the device holds of it. What it synced stays on {name}: sign in there again to have it. An anonymous account can\'t be signed into again.'**
/// **'Removes the account on {name} from this device, with all the device holds of it. What it synced stays on {name}: sign in there again to have it.'**
String accountRemoveMessage(String name);
/// No description provided for @accountRemoveConfirm.
@ -301,12 +295,6 @@ abstract class HalkyonAppLocalizations {
/// **'Signed in on {instance}'**
String accountSignedInOn(String instance);
/// No description provided for @accountAnonymousOn.
///
/// In en, this message translates to:
/// **'Anonymous on {instance}'**
String accountAnonymousOn(String instance);
/// No description provided for @accountSwitch.
///
/// In en, this message translates to:
@ -319,12 +307,6 @@ abstract class HalkyonAppLocalizations {
/// **'Sign out'**
String get accountSignOut;
/// No description provided for @accountSignOutTitle.
///
/// In en, this message translates to:
/// **'Sign out?'**
String get accountSignOutTitle;
/// No description provided for @accountDelete.
///
/// In en, this message translates to:
@ -391,18 +373,6 @@ abstract class HalkyonAppLocalizations {
/// **'Sign in with GitHub'**
String get startWithGitHub;
/// No description provided for @startWithoutAccount.
///
/// In en, this message translates to:
/// **'Start without an account'**
String get startWithoutAccount;
/// No description provided for @startAnonymous.
///
/// In en, this message translates to:
/// **'Progress is saved on this device. Sign in to keep it.'**
String get startAnonymous;
/// No description provided for @startGitHubFailed.
///
/// In en, this message translates to:
@ -412,7 +382,7 @@ abstract class HalkyonAppLocalizations {
/// No description provided for @startNoGitHubAccount.
///
/// In en, this message translates to:
/// **'No account here signs in with this GitHub account yet.'**
/// **'No account here signs in with this GitHub account.'**
String get startNoGitHubAccount;
/// Said after a GitHub account nobody on the instance signs in with, where people sign in with a password and GitHub is added to nobody (ADR 0017).
@ -421,11 +391,11 @@ abstract class HalkyonAppLocalizations {
/// **'Create an account with your email address and a password instead.'**
String get gitHubCreateAccountInstead;
/// An anonymous person signed in with a GitHub account nobody here has, where GitHub is added to nobody: the password set up below keeps their account.
/// Said after a GitHub account nobody on the instance signs in with, where the instance offers SimpleLogin (ADR 0020): GitHub makes no account.
///
/// In en, this message translates to:
/// **'No account here signs in with this GitHub account yet. To keep your progress, set up a password instead.'**
String get gitHubNoAccountSetUpPassword;
/// **'Continue with SimpleLogin to create one instead.'**
String get gitHubSimpleLoginInstead;
/// No description provided for @gitHubUsePassword.
///
@ -433,12 +403,6 @@ abstract class HalkyonAppLocalizations {
/// **'This account signs in with its email address and password now, not with GitHub.'**
String get gitHubUsePassword;
/// No description provided for @gitHubDeprecated.
///
/// In en, this message translates to:
/// **'GitHub can\'t be added as a way to sign in any more: your email address and password are.'**
String get gitHubDeprecated;
/// No description provided for @gitHubEnded.
///
/// In en, this message translates to:
@ -451,6 +415,12 @@ abstract class HalkyonAppLocalizations {
/// **'Use another instance'**
String get startOtherInstance;
/// On the start screen, where the instance offers neither SimpleLogin nor a password (ADR 0020, D4): nobody can make an account there.
///
/// In en, this message translates to:
/// **'{instance} offers no way to create an account.'**
String startNoWay(String instance);
/// No description provided for @syncTitle.
///
/// In en, this message translates to:

View file

@ -113,9 +113,6 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
@override
String get accountSignedIn => 'Angemeldet';
@override
String get accountAnonymous => 'Anonym';
@override
String get accountRemoveTooltip => 'Konto entfernen';
@ -124,7 +121,7 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
@override
String accountRemoveMessage(String name) {
return 'Entfernt das Konto auf $name von diesem Gerät, mit allem, was das Gerät davon hat. Was es synchronisiert hat, bleibt auf $name: Melde dich dort wieder an, um es wiederzuhaben. In ein anonymes Konto kann man sich nicht wieder anmelden.';
return 'Entfernt das Konto auf $name von diesem Gerät, mit allem, was das Gerät davon hat. Was es synchronisiert hat, bleibt auf $name: Melde dich dort wieder an, um es wiederzuhaben.';
}
@override
@ -135,20 +132,12 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
return 'Angemeldet auf $instance';
}
@override
String accountAnonymousOn(String instance) {
return 'Anonym auf $instance';
}
@override
String get accountSwitch => 'Konto wechseln';
@override
String get accountSignOut => 'Abmelden';
@override
String get accountSignOutTitle => 'Abmelden?';
@override
String get accountDelete => 'Konto löschen';
@ -189,36 +178,25 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
@override
String get startWithGitHub => 'Mit GitHub anmelden';
@override
String get startWithoutAccount => 'Ohne Konto starten';
@override
String get startAnonymous =>
'Dein Fortschritt wird auf diesem Gerät gespeichert. Melde dich an, um ihn zu behalten.';
@override
String get startGitHubFailed => 'Anmeldung mit GitHub fehlgeschlagen.';
@override
String get startNoGitHubAccount =>
'Noch kein Konto hier meldet sich mit diesem GitHub-Konto an.';
'Kein Konto hier meldet sich mit diesem GitHub-Konto an.';
@override
String get gitHubCreateAccountInstead =>
'Erstelle stattdessen ein Konto mit deiner E-Mail-Adresse und einem Passwort.';
@override
String get gitHubNoAccountSetUpPassword =>
'Noch kein Konto hier meldet sich mit diesem GitHub-Konto an. Richte stattdessen ein Passwort ein, um deinen Fortschritt zu behalten.';
String get gitHubSimpleLoginInstead =>
'Mit SimpleLogin kannst du stattdessen eins erstellen.';
@override
String get gitHubUsePassword =>
'Dieses Konto meldet sich jetzt mit seiner E-Mail-Adresse und seinem Passwort an, nicht mehr mit GitHub.';
@override
String get gitHubDeprecated =>
'GitHub lässt sich nicht mehr als Anmeldeweg hinzufügen: Deine E-Mail-Adresse und dein Passwort sind es.';
@override
String get gitHubEnded =>
'Die Anmeldung mit GitHub ist auf dieser Instanz beendet.';
@ -226,6 +204,11 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
@override
String get startOtherInstance => 'Andere Instanz verwenden';
@override
String startNoWay(String instance) {
return '$instance bietet keinen Weg, ein Konto zu erstellen.';
}
@override
String get syncTitle => 'Synchronisierung';

View file

@ -113,9 +113,6 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
@override
String get accountSignedIn => 'Signed in';
@override
String get accountAnonymous => 'Anonymous';
@override
String get accountRemoveTooltip => 'Remove account';
@ -124,7 +121,7 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
@override
String accountRemoveMessage(String name) {
return 'Removes the account on $name from this device, with all the device holds of it. What it synced stays on $name: sign in there again to have it. An anonymous account can\'t be signed into again.';
return 'Removes the account on $name from this device, with all the device holds of it. What it synced stays on $name: sign in there again to have it.';
}
@override
@ -135,20 +132,12 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
return 'Signed in on $instance';
}
@override
String accountAnonymousOn(String instance) {
return 'Anonymous on $instance';
}
@override
String get accountSwitch => 'Switch account';
@override
String get accountSignOut => 'Sign out';
@override
String get accountSignOutTitle => 'Sign out?';
@override
String get accountDelete => 'Delete account';
@ -187,42 +176,36 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
@override
String get startWithGitHub => 'Sign in with GitHub';
@override
String get startWithoutAccount => 'Start without an account';
@override
String get startAnonymous =>
'Progress is saved on this device. Sign in to keep it.';
@override
String get startGitHubFailed => 'Couldn\'t sign in with GitHub.';
@override
String get startNoGitHubAccount =>
'No account here signs in with this GitHub account yet.';
'No account here signs in with this GitHub account.';
@override
String get gitHubCreateAccountInstead =>
'Create an account with your email address and a password instead.';
@override
String get gitHubNoAccountSetUpPassword =>
'No account here signs in with this GitHub account yet. To keep your progress, set up a password instead.';
String get gitHubSimpleLoginInstead =>
'Continue with SimpleLogin to create one instead.';
@override
String get gitHubUsePassword =>
'This account signs in with its email address and password now, not with GitHub.';
@override
String get gitHubDeprecated =>
'GitHub can\'t be added as a way to sign in any more: your email address and password are.';
@override
String get gitHubEnded => 'Sign-in with GitHub has ended on this instance.';
@override
String get startOtherInstance => 'Use another instance';
@override
String startNoWay(String instance) {
return '$instance offers no way to create an account.';
}
@override
String get syncTitle => 'Sync';

View file

@ -30,18 +30,6 @@ final class const AppFrame({
/// on.").
required final String Function(BuildContext context) firstStartSubtitle,
/// What switching this device to another account leaves behind, as the
/// email sign-in says before it switches ("The studysets on this device
/// can't be combined with it yet: if you switch, they are deleted.").
required final String Function(BuildContext context) switchLeavesBehind,
/// What the start screen says after a GitHub account nobody here signs in
/// with: how to have it anyway, naming where the app adds a way to sign in
/// ("Start without an account, then add GitHub in Profile."). A sentence of
/// its own, as the place takes the grammar of the app's language ("im
/// Profil").
required final String Function(BuildContext context) thenAddGitHub,
/// The app's theme in a [Brightness].
required final ThemeData Function(Brightness brightness) theme,

View file

@ -1,6 +1,5 @@
import 'package:flutter/widgets.dart';
import 'package:halkyon_app/src/auth/data/auth_repository.dart';
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';
@ -26,9 +25,6 @@ abstract interface class AppDependencies<P extends Disposable>()
/// Who is signed in, and signing in and out.
AuthRepository get auth;
/// Whether the person here signed out, so the app asks how to start.
SignedOutMark get signedOut;
/// Signing in with a password and TOTP, signing up, getting back in, and
/// what keeps an account (ADR 0017).
PasswordRepository get passwords;

View file

@ -1,210 +0,0 @@
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/github_link_outcome.dart';
import 'package:halkyon_app/src/auth/model/github_sign_in.dart';
/// Signs an anonymous person in with GitHub, keeping everything they have,
/// or adds GitHub as a way in for someone who signs in with an email
/// address.
///
/// If the GitHub account already belongs to another person (typically their
/// account from another device), this device switches to that person: right
/// away when it holds nothing (`deviceHoldsContent`), otherwise only once
/// the person confirms that this device's content stays behind. Someone
/// identified is never switched, as their own account would be left: they
/// are told the GitHub account is another's.
///
/// Where people sign in with a password, GitHub is added to nobody (ADR
/// 0017, D2): [GitHubSignInStateReady.linking] says so, and an anonymous
/// person reaches only the GitHub account someone has already, by the same
/// switch.
class GitHubSignInBloc(
final AuthRepository _auth, {
/// Whether this device holds content of the person signed in that a
/// switch would leave behind, as the app counts it (Halkyon Learn: its
/// studysets, those waiting in Recently deleted too).
required final Future<bool> Function() _deviceHoldsContent,
}) extends Bloc<GitHubSignInEvent, GitHubSignInState> {
this : super(const GitHubSignInStateChecking()) {
on<GitHubSignInEventStarted>(_onStarted);
on<GitHubSignInEventRequested>(_onRequested);
on<GitHubSignInEventSwitchConfirmed>(_onSwitchConfirmed);
on<GitHubSignInEventSwitchDeclined>(
(event, emit) => emit(GitHubSignInStateReady(linking: _linking)),
);
// The sign-in in progress then ends as cancelled.
on<GitHubSignInEventCancelled>((event, emit) => _auth.cancelGitHub());
}
/// Closing the account dialog cancels a sign-in still waiting for GitHub's
/// page: nothing finishes without the dialog to show it, such as the
/// question whether to switch.
@override
Future<void> close() async {
if (state is GitHubSignInStateBusy) {
await _auth.cancelGitHub();
}
await super.close();
}
/// Whether this instance adds GitHub to people as a way in, as it said
/// when the dialog started.
bool _linking = true;
Future<void> _onStarted(
GitHubSignInEventStarted event,
Emitter<GitHubSignInState> emit,
) async {
try {
final offer = await _auth.gitHubOffer();
_linking = offer.linking;
emit(
offer.signIn
? GitHubSignInStateReady(linking: _linking)
: const GitHubSignInStateUnavailable(),
);
} on Exception catch (error, st) {
// Without the instance, signing in couldn't work either.
addError(error, st);
emit(const GitHubSignInStateUnavailable());
}
}
Future<void> _onRequested(
GitHubSignInEventRequested event,
Emitter<GitHubSignInState> emit,
) async {
emit(GitHubSignInStateBusy(cancellable: _auth.canCancelGitHub));
try {
switch (await _auth.linkGitHub()) {
case GitHubLinkOutcome.linked:
emit(const GitHubSignInStateLinked());
case GitHubLinkOutcome.cancelled:
emit(GitHubSignInStateReady(linking: _linking));
case GitHubLinkOutcome.takenByAnotherPerson when _auth.isIdentified:
emit(const GitHubSignInStateTaken());
case GitHubLinkOutcome.takenByAnotherPerson:
if (await _deviceHoldsContent()) {
emit(const GitHubSignInStateConfirmSwitch());
} else {
await _switch(emit);
}
}
} on Exception catch (error, st) {
_failed(emit, error, st);
}
}
Future<void> _onSwitchConfirmed(
GitHubSignInEventSwitchConfirmed event,
Emitter<GitHubSignInState> emit,
) async {
emit(GitHubSignInStateBusy(cancellable: _auth.canCancelGitHub));
try {
await _switch(emit);
} on Exception catch (error, st) {
_failed(emit, error, st);
}
}
/// Ready again after [error]: why, where the instance said so.
void _failed(
Emitter<GitHubSignInState> emit,
Exception error,
StackTrace st,
) {
addError(error, st);
emit(
GitHubSignInStateReady(
linking: _linking,
failed: true,
problem: switch (error) {
GitHubSignInFailed(:final problem) => problem,
_ => null,
},
),
);
}
// After a switch, the previous person's whole subtree goes away, this bloc
// with it, so nothing is emitted then.
Future<void> _switch(Emitter<GitHubSignInState> emit) async {
if (!await _auth.switchToGitHub()) {
emit(GitHubSignInStateReady(linking: _linking));
}
}
}
/// What happens to signing in with GitHub.
sealed class const GitHubSignInEvent() with Equatable {
@override
List<Object?> get props => const [];
}
/// The account dialog opens: does this instance offer GitHub sign-in?
final class const GitHubSignInEventStarted() extends GitHubSignInEvent;
/// Sign in with GitHub.
final class const GitHubSignInEventRequested() extends GitHubSignInEvent;
/// Switch to the GitHub account's person; this device's content stays
/// behind.
final class const GitHubSignInEventSwitchConfirmed() extends GitHubSignInEvent;
/// Stay anonymous.
final class const GitHubSignInEventSwitchDeclined() extends GitHubSignInEvent;
/// Stop waiting for GitHub's page in the browser.
final class const GitHubSignInEventCancelled() extends GitHubSignInEvent;
/// Signing in with GitHub, as the account dialog shows it.
sealed class const GitHubSignInState() with Equatable {
@override
List<Object?> get props => const [];
}
/// Asking the instance whether it offers GitHub sign-in.
final class const GitHubSignInStateChecking() extends GitHubSignInState;
/// This instance has no GitHub sign-in, or can't be reached.
final class const GitHubSignInStateUnavailable() extends GitHubSignInState;
/// Ready to sign in with GitHub.
final class const GitHubSignInStateReady({
/// Whether GitHub is added to people as a way in here: not where they
/// sign in with a password (ADR 0017, D2), where someone identified is
/// offered no Add.
final bool linking = true,
/// Whether the last attempt failed.
final bool failed = false,
/// Why it failed, where the instance said; null otherwise.
final GitHubSignInProblem? problem,
}) extends GitHubSignInState {
@override
List<Object?> get props => [linking, failed, problem];
}
/// Signing in: GitHub's page is open, or the server is being asked.
final class const GitHubSignInStateBusy({
/// Whether the app offers to cancel, where no sign-in screen of the system
/// does. A cancel only stops the wait for GitHub's page.
final bool cancellable = false,
}) extends GitHubSignInState {
@override
List<Object?> get props => [cancellable];
}
/// The GitHub account belongs to another person and this device holds
/// content: switch and leave it behind, or stay anonymous?
final class const GitHubSignInStateConfirmSwitch() extends GitHubSignInState;
/// Linked: the person now signs in with GitHub.
final class const GitHubSignInStateLinked() extends GitHubSignInState;
/// The GitHub account is another account's, and the person signed in is
/// identified: nothing changed.
final class const GitHubSignInStateTaken() extends GitHubSignInState;

View file

@ -3,21 +3,19 @@ import 'dart:async';
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/data/signed_out_mark.dart';
import 'package:halkyon_app/src/auth/model/auth_status.dart';
import 'package:halkyon_app/src/auth/model/github_sign_in.dart';
/// Keeps a person signed in: follows who is signed in, signs in anonymously
/// at a first start, and signs out on request. Once signed out (by the
/// person, by deleting the account, or by the server ending the session),
/// it asks how to start rather than making an account by itself, also at
/// every start of the app until someone starts.
class SessionBloc(final AuthRepository _auth, final SignedOutMark _mark)
/// Keeps a person signed in: follows who is signed in, and signs out on
/// request. Nobody signed in (at a first start, after signing out or
/// 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
/// person chooses (ADR 0020, D4). An anonymous account's session left from
/// before they went ends as the app starts, the account deleted (D25).
class SessionBloc(final AuthRepository _auth)
extends Bloc<SessionEvent, SessionState> {
this : super(const SessionStateLoading()) {
on<SessionEventStarted>(_onStarted);
on<SessionEventSignInRequested>(_onSignInRequested);
on<SessionEventStartedAnonymously>(_onStartedAnonymously);
on<SessionEventGitHubRequested>(_onGitHubRequested);
on<SessionEventPasswordOfferDone>(
(event, emit) => emit(_stateOf(_auth.status)),
@ -33,55 +31,20 @@ class SessionBloc(final AuthRepository _auth, final SignedOutMark _mark)
SessionEventStarted event,
Emitter<SessionState> emit,
) async {
if (_auth.status is SignedOut && await _mark.read()) {
emit(const SessionStateSignedOut());
} else {
add(const SessionEventSignInRequested());
if (_auth.isAnonymous) {
try {
await _auth.endAnonymousSession();
} on Object catch (error, st) {
// Only the device's own storage fails it: the session stays, but
// nothing works in it any more ([_stateOf]).
addError(error, st);
}
}
await emit.forEach<AuthStatus>(
_auth.statusChanges,
onData: (status) {
if (status is SignedOut) {
// Signed out, here or by the server (a revoked session): ask how
// to start, now and at the next starts.
unawaited(_mark.write(signedOut: true));
return const SessionStateSignedOut();
}
unawaited(_mark.write(signedOut: false));
return _stateOf(status);
},
);
emit(_stateOf(_auth.status));
await emit.forEach<AuthStatus>(_auth.statusChanges, onData: _stateOf);
}
Future<void> _onSignInRequested(
SessionEventSignInRequested event,
Emitter<SessionState> emit,
) async {
// A session stored on the device wins: never a second anonymous account.
if (_auth.status case final SignedIn person) {
emit(SessionStateActive(person));
return;
}
emit(const SessionStateLoading());
try {
await _auth.ensureSignedIn();
emit(_stateOf(_auth.status));
} on Object catch (error, st) {
addError(error, st);
emit(const SessionStateFailed());
}
}
Future<void> _onStartedAnonymously(
SessionEventStartedAnonymously event,
Emitter<SessionState> emit,
) async {
await _mark.write(signedOut: false);
add(const SessionEventSignInRequested());
}
/// Signing in with GitHub needs no account here first: the GitHub
/// account's person, if there is one.
/// Signing in with GitHub: the GitHub account's person, if there is one.
Future<void> _onGitHubRequested(
SessionEventGitHubRequested event,
Emitter<SessionState> emit,
@ -173,6 +136,9 @@ class SessionBloc(final AuthRepository _auth, final SignedOutMark _mark)
}
SessionState _stateOf(AuthStatus status) => switch (status) {
// Nothing works in an anonymous account any more (D25): only one that
// began before the app started could be signed in, and it has ended.
SignedIn(isIdentified: false) => const SessionStateSignedOut(),
SignedIn() when _offerPassword => _offered(status),
SignedIn() => SessionStateActive(status),
SignedOut() => const SessionStateSignedOut(),
@ -190,16 +156,10 @@ sealed class const SessionEvent() with Equatable {
List<Object?> get props => const [];
}
/// The app starts: follow who is signed in, and sign in anonymously if nobody
/// is.
/// The app starts: follow who is signed in, and end an anonymous account's
/// session left from before (ADR 0020, D25).
final class const SessionEventStarted() extends SessionEvent;
/// Sign in (again): after a failed attempt, or once nobody is signed in.
final class const SessionEventSignInRequested() extends SessionEvent;
/// Start without an account, once signed out: a new anonymous one.
final class const SessionEventStartedAnonymously() extends SessionEvent;
/// Sign in with GitHub, once signed out.
final class const SessionEventGitHubRequested() extends SessionEvent;
@ -224,19 +184,12 @@ final class const SessionEventDeleteRequested() extends SessionEvent;
/// Whether the app has a signed-in person to work for.
sealed class const SessionState() with Equatable;
/// Signing in, or finding out who is signed in.
/// Finding out who is signed in.
final class const SessionStateLoading() extends SessionState {
@override
List<Object?> get props => const [];
}
/// Signing in failed, for example without a connection; the app offers to try
/// again.
final class const SessionStateFailed() extends SessionState {
@override
List<Object?> get props => const [];
}
/// A person is signed in.
final class const SessionStateActive(
/// Who is signed in.
@ -287,7 +240,7 @@ final class const SessionStateGitHubSignedIn(
List<Object?> get props => [person];
}
/// Nobody is signed in since signing out: the app asks how to start.
/// Nobody is signed in: the app asks how to start.
final class const SessionStateSignedOut({
/// Whether a way to start is under way.
final bool busy = false,
@ -301,8 +254,8 @@ final class const SessionStateSignedOut({
/// Why a way to start failed.
enum SignedOutProblem() {
/// No account has the GitHub account yet: start without one, then add
/// GitHub, or, where passwords are offered, create an account.
/// No account has the GitHub account: GitHub makes none (ADR 0020, D25),
/// so the person creates one where the instance offers a way.
noGitHubAccount,
/// The GitHub account's person signs in with their address and password

View file

@ -13,9 +13,7 @@ class SignInWaysCubit(final AuthRepository _auth)
/// Reads it all anew.
Future<void> load() async {
try {
final ways = _auth.isIdentified
? await _auth.signInWays()
: SignInWays.none;
final ways = await _auth.signInWays();
final address = await _auth.emailAddress();
if (!isClosed) {
emit(

View file

@ -1,6 +1,4 @@
import 'package:halkyon_app/src/auth/model/auth_status.dart';
import 'package:halkyon_app/src/auth/model/github_link_outcome.dart';
import 'package:halkyon_app/src/auth/model/github_sign_in.dart';
import 'package:halkyon_app/src/auth/model/session_grant.dart';
import 'package:halkyon_app/src/auth/model/sign_in_ways.dart';
@ -13,9 +11,6 @@ abstract interface class AuthGateway() {
/// Every later change of [status].
Stream<AuthStatus> get statusChanges;
/// Creates an anonymous account on the server and signs in with it.
Future<void> signInAnonymously();
/// Signs out on this device.
Future<void> signOut();
@ -23,38 +18,19 @@ abstract interface class AuthGateway() {
/// and so on all their devices, and forgets the session here.
Future<void> deleteAccount();
/// What this instance offers of GitHub: signing in, and adding it to
/// people as a way in.
Future<GitHubOffer> gitHubOffer();
/// Whether this instance signs people in with GitHub: those who have it
/// already, as GitHub is added to nobody any more (ADR 0020, D25).
Future<bool> gitHubSignInOffered();
/// Authorizes with GitHub in the browser and links the GitHub account to the
/// signed-in person: the lossless upgrade from an anonymous account.
Future<GitHubLinkOutcome> linkGitHub();
/// Authorizes with GitHub again and switches this device to the person who
/// has that GitHub account; false when the person cancelled on GitHub.
/// Authorizes with GitHub in the browser and signs this device in as the
/// person who has that GitHub account; false when the person cancelled
/// on GitHub.
Future<bool> switchToGitHub();
/// Whether the app itself waits for GitHub's page in the browser, so that
/// only the app can cancel it (Linux, Windows). Elsewhere the system's
/// sign-in screen has its own Cancel.
bool get canCancelGitHub;
/// Stops waiting for GitHub's page in the browser: the [linkGitHub] or
/// [switchToGitHub] in progress ends as cancelled. Does nothing once
/// GitHub has answered.
Future<void> cancelGitHub();
/// How the signed-in person signs in.
Future<SignInWays> signInWays();
/// Puts [grant] in place of this device's session: its person is signed
/// in. An anonymous account left behind is deleted, as nobody could sign
/// into it again (as switching to GitHub does).
/// in.
Future<void> useSession(SessionGrant grant);
/// The signed-in person was made identified on the instance (a password
/// and address set, ADR 0017): this device's copy of their session
/// follows.
Future<void> markIdentified();
}

View file

@ -3,8 +3,6 @@ import 'dart:developer';
import 'package:halkyon_app/src/auth/data/auth_gateway.dart';
import 'package:halkyon_app/src/auth/data/sign_in_addresses.dart';
import 'package:halkyon_app/src/auth/model/auth_status.dart';
import 'package:halkyon_app/src/auth/model/github_link_outcome.dart';
import 'package:halkyon_app/src/auth/model/github_sign_in.dart';
import 'package:halkyon_app/src/auth/model/sign_in_ways.dart';
/// Who is signed in, and signing in and out, as the features use it.
@ -18,40 +16,32 @@ class AuthRepository(
/// Every later change of [status].
Stream<AuthStatus> get statusChanges => _gateway.statusChanges;
/// Whether the signed-in person has an identity, as GitHub or an email
/// address.
bool get isIdentified => switch (_gateway.status) {
SignedIn(:final isIdentified) => isIdentified,
/// Whether a session left from before anonymous accounts went is signed
/// in (ADR 0020, D25): one without an identity.
bool get isAnonymous => switch (_gateway.status) {
SignedIn(:final isIdentified) => !isIdentified,
SignedOut() => false,
};
/// Signs in anonymously unless someone is signed in already, so the app
/// works from the first launch without any sign-up.
Future<void> ensureSignedIn() async {
if (_gateway.status is SignedOut) {
await _gateway.signInAnonymously();
}
}
/// Signs out on this device.
Future<void> signOut() => _gateway.signOut();
/// Signs out on this device. An anonymous account can never be signed into
/// again, so signing out of one deletes it with its progress (decided
/// 2026-09-30); the UI warns first. Should deleting it fail (on a member
/// while the instance's register is out of reach, which deletes nobody
/// then: the short reading's L2), the device signs out all the same, as a
/// switch leaves one behind: the account is left to the instance's
/// pruning of anonymous accounts unused.
Future<void> signOut() async {
if (_gateway.status case SignedIn(isIdentified: false)) {
try {
await _gateway.deleteAccount();
return;
} on Exception catch (error, st) {
log(
'Deleting the anonymous account failed: ${error.runtimeType}',
name: 'Auth',
stackTrace: st,
);
}
/// Ends an anonymous account's session left from before they went (ADR
/// 0020, D25), as the app starts: deletes the account with its progress,
/// as nobody can sign into it again, and signs out. Should deleting it
/// fail (the instance out of reach, or on a member the instance's
/// register), the device signs out all the same, the account left to the
/// instance's pruning, which deletes every anonymous person left.
Future<void> endAnonymousSession() async {
try {
await _gateway.deleteAccount();
return;
} on Exception catch (error, st) {
log(
'Deleting the anonymous account failed: ${error.runtimeType}',
name: 'Auth',
stackTrace: st,
);
}
await _gateway.signOut();
}
@ -60,29 +50,12 @@ class AuthRepository(
/// and all their devices; the UI asks first.
Future<void> deleteAccount() => _gateway.deleteAccount();
/// Whether this instance offers signing in with GitHub.
Future<bool> canSignInWithGitHub() async =>
(await _gateway.gitHubOffer()).signIn;
/// Whether this instance signs in with GitHub those who have it.
Future<bool> canSignInWithGitHub() => _gateway.gitHubSignInOffered();
/// What this instance offers of GitHub: signing in, and adding it to
/// people as a way in (not where they sign in with a password).
Future<GitHubOffer> gitHubOffer() => _gateway.gitHubOffer();
/// Links a GitHub account to the signed-in person; the person, and all
/// their data, stay the same.
Future<GitHubLinkOutcome> linkGitHub() => _gateway.linkGitHub();
/// Switches this device to the person who has the GitHub account. The
/// anonymous account this device leaves behind can't be signed in again.
/// Signs this device in as the person who has the GitHub account.
Future<bool> switchToGitHub() => _gateway.switchToGitHub();
/// Whether the app offers to cancel a GitHub sign-in in progress, where no
/// sign-in screen of the system does.
bool get canCancelGitHub => _gateway.canCancelGitHub;
/// Cancels the GitHub sign-in in progress while GitHub's page is open.
Future<void> cancelGitHub() => _gateway.cancelGitHub();
/// How the signed-in person signs in.
Future<SignInWays> signInWays() => _gateway.signInWays();

View file

@ -1,11 +1,9 @@
import 'dart:async';
import 'dart:developer';
import 'dart:io';
import 'package:flutter_web_auth_2_platform_interface/flutter_web_auth_2_platform_interface.dart';
import 'package:halkyon_app/src/auth/data/auth_gateway.dart';
import 'package:halkyon_app/src/auth/model/auth_status.dart';
import 'package:halkyon_app/src/auth/model/github_link_outcome.dart';
import 'package:halkyon_app/src/auth/model/github_sign_in.dart';
import 'package:halkyon_app/src/auth/model/session_grant.dart';
import 'package:halkyon_app/src/auth/model/sign_in_ways.dart';
@ -40,8 +38,7 @@ class ServerpodAuthGateway(
required final String appName,
/// What the clients it makes for itself send through (to sign in as
/// another person, and to delete an account left behind); tests give a
/// fake one.
/// another person); tests give a fake one.
final http.Client Function()? _httpClient,
}) implements AuthGateway {
this {
@ -50,7 +47,8 @@ class ServerpodAuthGateway(
final _changes = StreamController<AuthStatus>.broadcast();
/// The scope a person gets once they have an identity.
/// The scope every account has (ADR 0020, D4): a session without it is
/// an anonymous account's, left from before they went.
static const _identifiedScope = 'identified';
/// Where GitHub sends the browser back after authorizing: the app's own
@ -75,9 +73,6 @@ class ServerpodAuthGateway(
? LoopbackWebAuth(appName: appName)
: null;
/// Whether the person cancelled the GitHub sign-in in progress.
var _gitHubCancelled = false;
@override
AuthStatus get status => switch (_client.auth.authInfo) {
null => const SignedOut(),
@ -90,12 +85,6 @@ class ServerpodAuthGateway(
@override
Stream<AuthStatus> get statusChanges => _changes.stream;
@override
Future<void> signInAnonymously() async {
final authSuccess = await _base.anonymousIdp.login();
await _client.auth.updateSignedInUser(authSuccess);
}
@override
Future<void> signOut() async {
await _client.auth.signOutDevice();
@ -110,54 +99,14 @@ class ServerpodAuthGateway(
}
@override
Future<GitHubOffer> gitHubOffer() async {
final basics = await _base.instance.basics();
final signIn = basics.gitHubClientId != null;
return GitHubOffer(
signIn: signIn,
linking: signIn && !basics.passwordSignIn && !basics.simpleLogin,
);
}
Future<bool> gitHubSignInOffered() async =>
(await _base.instance.basics()).gitHubClientId != null;
/// The client ID of this instance's GitHub OAuth app; null when GitHub
/// sign-in is off on this instance.
Future<String?> _gitHubClientId() async =>
(await _base.instance.basics()).gitHubClientId;
@override
Future<GitHubLinkOutcome> linkGitHub() async {
final authorization = await _authorizeWithGitHub();
if (authorization == null) {
return GitHubLinkOutcome.cancelled;
}
try {
await _base.gitHubUpgrade.upgrade(
code: authorization.code,
codeVerifier: authorization.codeVerifier,
redirectUri: authorization.redirectUri,
);
} on GitHubSignInRefusedException catch (error) {
if (error.reason != GitHubSignInRefusal.takenByAnotherPerson) {
throw _gitHubFailure(error);
}
return GitHubLinkOutcome.takenByAnotherPerson;
}
await _becomeIdentified();
return GitHubLinkOutcome.linked;
}
/// The server gave the person's sessions the identified scope; the copy of
/// this device's session follows.
Future<void> _becomeIdentified() async {
if (_client.auth.authInfo case final authInfo?) {
await _client.auth.updateSignedInUser(
authInfo.copyWith(
scopeNames: {...authInfo.scopeNames, _identifiedScope},
),
);
}
}
@override
Future<bool> switchToGitHub() async {
final authorization = await _authorizeWithGitHub();
@ -179,36 +128,13 @@ class ServerpodAuthGateway(
} finally {
signedOut.close();
}
await _switchTo(person);
return true;
}
/// Switches this device to [person]. Swapping directly means the app never
/// passes through "signed out", which would start yet another anonymous
/// account. An anonymous account left behind is deleted, as nobody could
/// sign into it again; an identified one stays, to be signed into again.
Future<void> _switchTo(AuthSuccess person) async {
final leftBehind = _client.auth.authInfo;
await _client.auth.updateSignedInUser(person);
if (leftBehind != null &&
!leftBehind.scopeNames.contains(_identifiedScope)) {
await _deleteLeftBehind(leftBehind);
}
}
@override
bool get canCancelGitHub => _loopback != null;
@override
Future<void> cancelGitHub() async {
_gitHubCancelled = true;
await _loopback?.cancel();
return true;
}
/// GitHub's authorization in the browser, or null when the person
/// cancelled it.
Future<GitHubSignInResult?> _authorizeWithGitHub() async {
_gitHubCancelled = false;
final clientId = await _gitHubClientId();
if (clientId == null) {
throw StateError('This instance offers no GitHub sign-in.');
@ -226,21 +152,10 @@ class ServerpodAuthGateway(
// Here, after the package has put its own in place at startup.
FlutterWebAuth2Platform.instance = loopback;
}
if (_gitHubCancelled) {
return null;
}
try {
return await GitHubSignInService.instance.signIn();
} on OAuth2PkceUserCancelledException {
return null;
} on OAuth2PkceException {
// Serverpod (checked with v4.0.3) reports a cancelled browser round
// trip as an unknown error, so a cancel is known by having asked for
// it.
if (_gitHubCancelled) {
return null;
}
rethrow;
}
}
@ -252,38 +167,12 @@ class ServerpodAuthGateway(
@override
Future<void> useSession(SessionGrant grant) =>
_switchTo(grant.session as AuthSuccess);
@override
Future<void> markIdentified() => _becomeIdentified();
_client.auth.updateSignedInUser(grant.session as AuthSuccess);
/// The instance's refusal of a GitHub sign-in as [GitHubSignInFailed].
static GitHubSignInFailed _gitHubFailure(GitHubSignInRefusedException e) =>
GitHubSignInFailed(gitHubSignInProblemOf(e.reason));
/// Deletes the anonymous account this device leaves behind when switching:
/// nobody could ever sign into it again. It signs in as that account from
/// a client of its own; a failure only leaves the account to the pruning.
Future<void> _deleteLeftBehind(AuthSuccess anonymous) async {
final sessionManager = FlutterAuthSessionManager(
storage: _MemoryAuthStorage(),
);
final client = _clientOfItsOwn()..authSessionManager = sessionManager;
try {
await sessionManager.updateSignedInUser(anonymous);
await client.modules.account.delete();
} on Exception catch (error, st) {
log(
'Deleting the anonymous account left behind failed',
name: 'Auth',
error: error,
stackTrace: st,
);
} finally {
client.close();
}
}
/// A client of the instance's halkyon_base, holding no session.
BaseClient _clientOfItsOwn() =>
BaseClient(_client.host, httpClientOverride: _httpClient?.call());
@ -297,19 +186,6 @@ class ServerpodAuthGateway(
void _onAuthInfoChanged() => _changes.add(status);
}
/// Keeps one session in memory, for a client used once.
final class _MemoryAuthStorage() implements ClientAuthSuccessStorage {
AuthSuccess? _session;
@override
Future<AuthSuccess?> get() async => _session;
@override
Future<void> set(AuthSuccess? data) async {
_session = data;
}
}
/// The instance's refusal [reason] of a GitHub sign-in as the app's problem,
/// which the screens put into words.
GitHubSignInProblem gitHubSignInProblemOf(GitHubSignInRefusal reason) =>

View file

@ -1,33 +0,0 @@
import 'dart:io';
import 'package:path/path.dart' as p;
/// Whether the person on this device signed out of the instance, and
/// nobody started since: the app then asks how to start rather than making
/// an anonymous account by itself, at once and at every start after.
abstract interface class SignedOutMark() {
/// Whether the device is marked signed out.
Future<bool> read();
/// Marks the device signed out, or not.
Future<void> write({required bool signedOut});
}
/// The mark as a file in the folder of the instance's people, which
/// outlives every person's own.
class FileSignedOutMark(final Directory _people) implements SignedOutMark {
File get _file => File(p.join(_people.path, 'signed_out'));
@override
Future<bool> read() async => _file.existsSync();
@override
Future<void> write({required bool signedOut}) async {
if (signedOut) {
await _people.create(recursive: true);
await _file.writeAsString('');
} else if (_file.existsSync()) {
await _file.delete();
}
}
}

View file

@ -14,8 +14,9 @@ final class const SignedIn({
/// The person, i.e. the server's `AuthUser` id.
required final String authUserId,
/// Whether the person has an identity, such as GitHub. Anonymous people have
/// none: their progress lives on this device, and signing out loses it.
/// Whether the person has an identity, a way in. Every account has one
/// (ADR 0020, D4): a session without it is an anonymous account's, left
/// from before they went, which the app ends at its start (D25).
required final bool isIdentified,
}) extends AuthStatus {
@override

View file

@ -1,11 +0,0 @@
/// How linking a GitHub account to the signed-in person ended.
enum GitHubLinkOutcome() {
/// Linked: the person now signs in with GitHub and counts as identified.
linked,
/// The GitHub account belongs to another person; nothing changed.
takenByAnotherPerson,
/// The person closed GitHub's page without authorizing; nothing changed.
cancelled,
}

View file

@ -1,9 +1,7 @@
import 'package:equatable/equatable.dart';
/// Why signing in with GitHub didn't go through, beyond a cancel.
enum GitHubSignInProblem() {
/// No person has this GitHub account yet; only adding it to one makes it
/// a way in.
/// No person has this GitHub account: GitHub makes no account (ADR 0020,
/// D25).
unknownAccount,
/// The account signs in with its address and password now, not with
@ -28,17 +26,3 @@ final class const GitHubSignInFailed(final GitHubSignInProblem problem)
@override
String toString() => 'GitHubSignInFailed(${problem.name})';
}
/// What an instance offers of GitHub, from one look at its basics.
final class const GitHubOffer({
/// Whether people sign in with GitHub here.
required final bool signIn,
/// Whether GitHub is added to people as a way in: not where they sign in
/// with a password (ADR 0017, D2) or with SimpleLogin (ADR 0020, D23),
/// where only those who have it already sign in with it.
required final bool linking,
}) with Equatable {
@override
List<Object?> get props => [signIn, linking];
}

View file

@ -1,12 +1,13 @@
import 'package:equatable/equatable.dart';
/// How a person signs in, besides the sessions of their devices: with
/// GitHub, with an email address, both, or neither (anonymous).
/// GitHub, with an email address, both, or neither (with SimpleLogin alone,
/// which `SimpleLoginStandingCubit` tells).
final class const SignInWays({
required final bool gitHub,
required final bool email,
}) with Equatable {
/// An anonymous person's: neither.
/// Neither, as long as nothing was read.
static const none = SignInWays(gitHub: false, email: false);
@override

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