feat(auth): sign in with an email address, keeping only a keyed hash of it

People without GitHub can now keep an anonymous account, and sign in on
another device, with an email address and a code mailed to it each time;
no password. The instance keeps only HMAC-SHA256 of the address, keyed
with its emailSignInPepper (ADR 0010): from what someone types it finds
the person and tells whether an address is free, it mails only an address
someone has just typed, and it can't write to anybody otherwise or show
anybody their address. The device remembers the address it signed in
with, in the person's folder, to show it.

It works as GitHub does. A code for a new address makes it the signed-in
person's, who becomes identified and keeps everything. A code for another
person's address gives a switch token, which signs in as them without a
session; a device holding studysets asks first. Asking for a code answers
alike for every address. Codes are eight Crockford characters, valid 15
minutes and three tries (Serverpod's code challenges); three codes an
hour per address, ten per network. In development the code goes to the
server's log; elsewhere there is no email sign-in until a mail service
is set up (instance.info().emailSignIn).

Someone identified may add the other way in: the Profile tab lists how
they sign in and adds GitHub or an address. They are never switched to
another account: an address or GitHub account that is another's is said
to be so. Switching now deletes the account left behind only when it was
anonymous; it deleted any, which only anonymous people could reach so far.

The migration adds email_identity and email_code_request with their
indexes and foreign keys to Serverpod's challenges; nothing else changes.

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-15 19:42:21 +02:00
commit 1cfd391dbc
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
76 changed files with 21720 additions and 103 deletions

View file

@ -129,6 +129,7 @@ jobs:
run: | run: |
for name in SERVERPOD_DATABASE_PASSWORD \ for name in SERVERPOD_DATABASE_PASSWORD \
SERVERPOD_PASSWORD_serverSideSessionKeyHashPepper \ SERVERPOD_PASSWORD_serverSideSessionKeyHashPepper \
SERVERPOD_PASSWORD_emailSignInPepper \
SERVERPOD_PASSWORD_serviceSecret; do SERVERPOD_PASSWORD_serviceSecret; do
echo "$name=$(openssl rand -hex 32)" >> "$FORGEJO_ENV" echo "$name=$(openssl rand -hex 32)" >> "$FORGEJO_ENV"
done done

View file

@ -104,7 +104,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Statistics** (`feature/statistics/`, decided 2026-10-12, as Vaia has them): the Profile tab's second tab, for anonymous people too. The time studied in all, per day (every day from the first studied to today counted) and the most in one day, and by days, weeks or months (ten, ten, twelve bars) the study time and the ratings, as bars the app draws itself (`BarChart`, no chart package); a tap on a bar says what it holds. Computed on the device from every review (`studyStatistics`, `StatisticsCubit`), every rating counted, crammed ones too, a fresh start not, and **a review counts as at most a minute of study** (`Review.maxStudyTime`, as Anki does by default: a card left on the screen over lunch isn't an hour of study; stored durations stay as they were), here and in a studyset's Progress. What waits in Recently deleted counts nowhere, here or on Home. - **Statistics** (`feature/statistics/`, decided 2026-10-12, as Vaia has them): the Profile tab's second tab, for anonymous people too. The time studied in all, per day (every day from the first studied to today counted) and the most in one day, and by days, weeks or months (ten, ten, twelve bars) the study time and the ratings, as bars the app draws itself (`BarChart`, no chart package); a tap on a bar says what it holds. Computed on the device from every review (`studyStatistics`, `StatisticsCubit`), every rating counted, crammed ones too, a fresh start not, and **a review counts as at most a minute of study** (`Review.maxStudyTime`, as Anki does by default: a card left on the screen over lunch isn't an hour of study; stored durations stay as they were), here and in a studyset's Progress. What waits in Recently deleted counts nowhere, here or on Home.
- **Lists that can grow long are built as they scroll into view**: a studyset's parts are `CustomScrollView`s holding each list's slivers (`SliverList`, `SliverReorderableList`), and what a list says in place of its rows sits in a `SliverCentered`. Never a `shrinkWrap` list inside another scrolling view: that builds every row at once, and 1,000 cards then froze the window for seconds, long enough for the desktop to offer to kill the app. Rows are put in another order in a Reorder mode (`ReorderToggle` beside the list; a `ReorderGrip` in place of each row's options; positions from `core/sync/fractional_position.dart`), never with Flutter's default drag handles, which on the desktop lie over a row's trailing end, its … button. Flashcards have no Reorder (decided 2026-10-08): they keep the order they were created or imported in, which new cards and cramming follow, and sub-studysets put lessons in order; no one drags a card among a thousand. **The card browser** (`feature/card/widget/card_browser_screen.dart`, decided 2026-10-09: the older design, gaps filled) opens from Browse beside the flashcards' tab, over the tabs (`/studysets/:id/flashcards`, `?subtree=1` with everything inside, which a line below the title says; the title has how many cards beside it): grid or detail (one card beside the list, which follows it; on a phone above a strip), as last left per studyset on the device (`DeviceSettings.detailedBrowsers`); search over front, back, reading, meanings, hints, explanation and tag names (katakana as hiragana, romaji finding kana, accents, tone marks and umlauts left out, so strasse finds Straße and xue xi xué xí; `card_browser.dart`); sort (own order, newest, oldest, A–Z by reading, by bare letters, due first); a filter sheet (kind, one tag, state: new, learning, due, buried, reported); and choosing cards (a toggle; shift-click or long-press for a range) to move (`showCardMoveSheet`), tag, untag, start afresh or delete with Undo. It reads every card of the studysets, as lists do (schedules from the review repository's cache). - **Lists that can grow long are built as they scroll into view**: a studyset's parts are `CustomScrollView`s holding each list's slivers (`SliverList`, `SliverReorderableList`), and what a list says in place of its rows sits in a `SliverCentered`. Never a `shrinkWrap` list inside another scrolling view: that builds every row at once, and 1,000 cards then froze the window for seconds, long enough for the desktop to offer to kill the app. Rows are put in another order in a Reorder mode (`ReorderToggle` beside the list; a `ReorderGrip` in place of each row's options; positions from `core/sync/fractional_position.dart`), never with Flutter's default drag handles, which on the desktop lie over a row's trailing end, its … button. Flashcards have no Reorder (decided 2026-10-08): they keep the order they were created or imported in, which new cards and cramming follow, and sub-studysets put lessons in order; no one drags a card among a thousand. **The card browser** (`feature/card/widget/card_browser_screen.dart`, decided 2026-10-09: the older design, gaps filled) opens from Browse beside the flashcards' tab, over the tabs (`/studysets/:id/flashcards`, `?subtree=1` with everything inside, which a line below the title says; the title has how many cards beside it): grid or detail (one card beside the list, which follows it; on a phone above a strip), as last left per studyset on the device (`DeviceSettings.detailedBrowsers`); search over front, back, reading, meanings, hints, explanation and tag names (katakana as hiragana, romaji finding kana, accents, tone marks and umlauts left out, so strasse finds Straße and xue xi xué xí; `card_browser.dart`); sort (own order, newest, oldest, A–Z by reading, by bare letters, due first); a filter sheet (kind, one tag, state: new, learning, due, buried, reported); and choosing cards (a toggle; shift-click or long-press for a range) to move (`showCardMoveSheet`), tag, untag, start afresh or delete with Undo. It reads every card of the studysets, as lists do (schedules from the review repository's cache).
- **Forms and pickers open as sheets**: `showAdaptiveSheet` with a `SheetScaffold` (title and close button on top, the body scrolling with a scrollbar, always shown on the desktop, and Material 3's lines under the title and above the footer while some of the body lies beyond them, the action pinned below) shows a bottom sheet below expanded windows and a dialog from there on. A sheet that asks before discarding changes (a `PopScope`) passes `swipeable: false`: a bottom sheet swiped away closes without asking its `PopScope` (Flutter issue 152120, open with v3.47). A sheet offering an Undo passes `hasSnackBars: true`, so its snack bars show on it rather than behind its barrier. A sheet running something that mustn't be cut off (an import) is `busy` meanwhile: its close button is greyed and back does nothing; it is shown with `swipeable: false`, since a swipe closes a bottom sheet regardless. A screen whose blocs import (a studyset's PDFs and paths) greys its back button and path likewise, as leaving would close them; an import of paths shows the study plan at once, how far it has come above the paths (`PathImportProgress`). A picker that changes something outside it reports every change as it happens (`onChanged`) rather than as its result, since a sheet swiped away has none. What needs the whole window to be written or picked (a studyset's description, a step's feedback, a map question's region: `editRichText`, `pickRegion`, decided 2026-10-12) is a field in the form (`ChevronField`: the value, a chevron) opening a full-window route with Save in its app bar, where nothing scrolls around a map, so the wheel alone zooms it; a country's own map (Japan's prefectures) is offered there only once the country, or a region within it, is picked. - **Forms and pickers open as sheets**: `showAdaptiveSheet` with a `SheetScaffold` (title and close button on top, the body scrolling with a scrollbar, always shown on the desktop, and Material 3's lines under the title and above the footer while some of the body lies beyond them, the action pinned below) shows a bottom sheet below expanded windows and a dialog from there on. A sheet that asks before discarding changes (a `PopScope`) passes `swipeable: false`: a bottom sheet swiped away closes without asking its `PopScope` (Flutter issue 152120, open with v3.47). A sheet offering an Undo passes `hasSnackBars: true`, so its snack bars show on it rather than behind its barrier. A sheet running something that mustn't be cut off (an import) is `busy` meanwhile: its close button is greyed and back does nothing; it is shown with `swipeable: false`, since a swipe closes a bottom sheet regardless. A screen whose blocs import (a studyset's PDFs and paths) greys its back button and path likewise, as leaving would close them; an import of paths shows the study plan at once, how far it has come above the paths (`PathImportProgress`). A picker that changes something outside it reports every change as it happens (`onChanged`) rather than as its result, since a sheet swiped away has none. What needs the whole window to be written or picked (a studyset's description, a step's feedback, a map question's region: `editRichText`, `pickRegion`, decided 2026-10-12) is a field in the form (`ChevronField`: the value, a chevron) opening a full-window route with Save in its app bar, where nothing scrolls around a map, so the wheel alone zooms it; a country's own map (Japan's prefectures) is offered there only once the country, or a region within it, is picked.
- **Every text field has a limit** (decided 2026-10-11): `TextLimits` (`core/widget/text_limits.dart`) by what it holds (a name 40, a secondary title 60, a title 120, an item 200, a search 200, a citation's part 500, a message or an address 2000, a passage 5000), or the instance's own where the server checks it (`PublicProfile.max…`, `maxSlugLength`), with `buildCounter: nearLimitCounter`. Text can still be longer where it came from elsewhere, so every place showing a name cuts it (`maxLines` and an ellipsis, in a `Flexible` within a row); `test/app/long_names_test.dart` shows names beyond every limit on the main screens of a phone and a desktop, and fails on any overflow: add a screen there when it shows names. - **Every text field has a limit** (decided 2026-10-11): `TextLimits` (`core/widget/text_limits.dart`) by what it holds (a name 40, a secondary title 60, a title 120, an item 200, a search 200, a citation's part 500, an email address 254, a message or a web address 2000, a passage 5000), or the instance's own where the server checks it (`PublicProfile.max…`, `maxSlugLength`), with `buildCounter: nearLimitCounter`. Text can still be longer where it came from elsewhere, so every place showing a name cuts it (`maxLines` and an ellipsis, in a `Flexible` within a row); `test/app/long_names_test.dart` shows names beyond every limit on the main screens of a phone and a desktop, and fails on any overflow: add a screen there when it shows names.
- **Text comes from `AppLocalizations`** (Flutter's gen-l10n, `lib/l10n/`): no string a person reads is written into a widget. The app speaks British English, American English and German (informal "du"; the terms of `app_de.arb`, such as Lernset and Karteikarte, stay consistent). `app_en.arb` is the template and holds British English, also the fallback for any language the app doesn't speak; `app_en_US.arb` overrides only what American English spells differently, and `app_en_GB.arb` is empty, there so that Material and dates format the British way. A new message goes into `app_en.arb` and `app_de.arb` together (`test/l10n/arb_files_test.dart` fails otherwise, and checks the placeholders), and into `app_en_US.arb` if its spelling differs. `resolveAppLocale` (`core/l10n/`) picks the language, never by country alone, and both `MaterialApp`s take `appLocalizationsDelegates`, not gen-l10n's list: that brings flutter_localizations' Material texts, which serve only the SDK's Material, so material_ui's widgets would stay English. Errors found in data go up as values the widgets put into words (`PathImportProblem`, `PathImportTexts`), never as English sentences. The editor's own placeholders are replaced by ours (`buildersWithUnknown`), since the editor's texts follow a process-wide locale of their own. The generated `lib/l10n/app_localizations*.dart` are committed and never edited, analysed or formatted by hand. `flutter pub get` writes them anew, so CI fails when they don't match the `.arb` files. - **Text comes from `AppLocalizations`** (Flutter's gen-l10n, `lib/l10n/`): no string a person reads is written into a widget. The app speaks British English, American English and German (informal "du"; the terms of `app_de.arb`, such as Lernset and Karteikarte, stay consistent). `app_en.arb` is the template and holds British English, also the fallback for any language the app doesn't speak; `app_en_US.arb` overrides only what American English spells differently, and `app_en_GB.arb` is empty, there so that Material and dates format the British way. A new message goes into `app_en.arb` and `app_de.arb` together (`test/l10n/arb_files_test.dart` fails otherwise, and checks the placeholders), and into `app_en_US.arb` if its spelling differs. `resolveAppLocale` (`core/l10n/`) picks the language, never by country alone, and both `MaterialApp`s take `appLocalizationsDelegates`, not gen-l10n's list: that brings flutter_localizations' Material texts, which serve only the SDK's Material, so material_ui's widgets would stay English. Errors found in data go up as values the widgets put into words (`PathImportProblem`, `PathImportTexts`), never as English sentences. The editor's own placeholders are replaced by ours (`buildersWithUnknown`), since the editor's texts follow a process-wide locale of their own. The generated `lib/l10n/app_localizations*.dart` are committed and never edited, analysed or formatted by hand. `flutter pub get` writes them anew, so CI fails when they don't match the `.arb` files.
## Testing ## Testing
@ -133,10 +133,11 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
## Rules the code relies on ## Rules the code relies on
- **Authentication**: server-side sessions (`ServerSideSessionsConfigFromPasswords`), not JWT, for immediate revocation. Scopes are copied onto the session row, so changing a person's scopes means updating their session rows too. - **Authentication**: server-side sessions (`ServerSideSessionsConfigFromPasswords`), not JWT, for immediate revocation. Scopes are copied onto the session row, so changing a person's scopes means updating their session rows too.
- **Identity**: `AuthUser` is the person; identity providers (anonymous, GitHub) attach to it. Content references `AuthUser`, never a provider account. - **Identity**: `AuthUser` is the person; identity providers (anonymous, GitHub, an email address) attach to it. Content references `AuthUser`, never a provider account. A person may sign in with GitHub and with an address, one of each (`account.signInWays`); the Profile tab lists their ways in and adds the missing one.
- **GitHub keeps only the numeric GitHub ID**: our own slim login (`lib/src/auth/github.dart`) on Serverpod's GitHub helpers; Serverpod's stock GitHub login endpoint, which stores name, email and avatar, is never exposed. GitHub sign-in is on only where the passwords hold `githubClientId` and `githubClientSecret` (in production `GITHUB_CLIENT_ID`/`GITHUB_CLIENT_SECRET` in `deploy/.env`); the app asks `instance.info()`. - **GitHub keeps only the numeric GitHub ID**: our own slim login (`lib/src/auth/github.dart`) on Serverpod's GitHub helpers; Serverpod's stock GitHub login endpoint, which stores name, email and avatar, is never exposed. GitHub sign-in is on only where the passwords hold `githubClientId` and `githubClientSecret` (in production `GITHUB_CLIENT_ID`/`GITHUB_CLIENT_SECRET` in `deploy/.env`); the app asks `instance.info()`.
- **Linking keeps the person** (`GitHubUpgradeEndpoint`, `requireLogin`): the GitHub ID joins the signed-in anonymous person, who becomes identified and loses the anonymous identity, in one transaction. **Signing in** (`GitHubSignInEndpoint`) only reaches people who have a GitHub account already, and only without a session: Serverpod refuses to issue another person's session inside a signed-in call. Refusals are `GitHubSignInRefusedException` with a reason. - **Linking keeps the person** (`GitHubUpgradeEndpoint`, `requireLogin`): the GitHub ID joins the signed-in anonymous person, who becomes identified and loses the anonymous identity, in one transaction. **Signing in** (`GitHubSignInEndpoint`) only reaches people who have a GitHub account already, and only without a session: Serverpod refuses to issue another person's session inside a signed-in call. Refusals are `GitHubSignInRefusedException` with a reason.
- **Switching devices' person** (the app's `switchToGitHub`): swap the session directly, never through "signed out" (which would start another anonymous account), then end the anonymous session left behind. - **Switching devices' person** (the app's `switchToGitHub`, `switchToEmail`): swap the session directly, never through "signed out" (which would start another anonymous account), then delete the account left behind if it was anonymous (nobody could sign into it again), never an identified one. Someone identified is never offered a switch: an address or GitHub account that is another account's is said to be so.
- **Email keeps only a keyed hash of the address** (ADR 0010, decided 2026-10-15): our own slim login (`lib/src/auth/email.dart`) on Serverpod's code challenges and rate limiter, as Serverpod's email sign-in keeps the address in plain text and passwords. `email_identity` holds HMAC-SHA256 of the address in lower case, keyed with `emailSignInPepper`; the instance mails only an address someone has just typed, and the device remembers the address it signed in with (`SignInAddresses`, `sign_in.json` in the person's folder). A code by mail each time, no password: eight Crockford characters (`common/short_code.dart`, shared with connect codes), 15 minutes, 3 tries, a newer one replacing the older; at most 3 codes an hour per address and 10 per network. `emailCode.sendCode` answers alike for every address; `emailCode.verifyCode` (`requireLogin`) makes a new address the caller's (as linking GitHub does) or, for another person's, returns a switch token that `emailSignIn.signIn` (without a session) turns into a session for them. Refusals are `EmailSignInRefusedException` with a reason. Codes go to the server's log in development; elsewhere there is no email sign-in (`instance.info().emailSignIn`) until a mail service is set up.
- **On Linux and Windows the browser half is the app's own** (`LoopbackWebAuth`, `feature/auth/data/`): the system browser and a loopback address (RFC 8252), put in place of flutter_web_auth_2's own implementation (checked with v5.1.0) before each sign-in. The package's front ends every pending sign-in when the app is resumed, and on the desktop the window regaining focus resumes it, so its loopback server closed before GitHub answered. Ours waits for GitHub, the profile's Cancel (shown only there), choosing another tab, a change of person, or 5 minutes; the regression test drives the package's front through a focus change. Remove it once the package stops ending sign-ins on focus there. Serverpod (v4.0.3) reports every cancelled browser round trip as an unknown error, so the gateway knows a cancel only by having asked for it; a cancel on GitHub's own page, or leaving the browser on Android, still shows "Couldn't sign in with GitHub." - **On Linux and Windows the browser half is the app's own** (`LoopbackWebAuth`, `feature/auth/data/`): the system browser and a loopback address (RFC 8252), put in place of flutter_web_auth_2's own implementation (checked with v5.1.0) before each sign-in. The package's front ends every pending sign-in when the app is resumed, and on the desktop the window regaining focus resumes it, so its loopback server closed before GitHub answered. Ours waits for GitHub, the profile's Cancel (shown only there), choosing another tab, a change of person, or 5 minutes; the regression test drives the package's front through a focus change. Remove it once the package stops ending sign-ins on focus there. Serverpod (v4.0.3) reports every cancelled browser round trip as an unknown error, so the gateway knows a cancel only by having asked for it; a cancel on GitHub's own page, or leaving the browser on Android, still shows "Couldn't sign in with GitHub."
- **Authorization lives in endpoint code**; Serverpod has no row-level security. Load content filtered by the session's `authUserId`, never by an owner ID the client sends. A row another person owns must be indistinguishable from a missing one. Features involving other people require the `identified` scope, declared via `requiredScopes`, never checked ad hoc. - **Authorization lives in endpoint code**; Serverpod has no row-level security. Load content filtered by the session's `authUserId`, never by an owner ID the client sends. A row another person owns must be indistinguishable from a missing one. Features involving other people require the `identified` scope, declared via `requiredScopes`, never checked ad hoc.
- **The reference is `DevicesEndpoint`** (`halkyon_learn_server/lib/src/auth/`) with its tests: the owner check sits in the same query (`WHERE "id" = $1 AND "authUserId" = $2`), zero rows means `NotFoundException` (shared, `lib/src/common/`), and every rule has a test that asserts the refusal. Each query carries its SQL as a comment. - **The reference is `DevicesEndpoint`** (`halkyon_learn_server/lib/src/auth/`) with its tests: the owner check sits in the same query (`WHERE "id" = $1 AND "authUserId" = $2`), zero rows means `NotFoundException` (shared, `lib/src/common/`), and every rule has a test that asserts the refusal. Each query carries its SQL as a comment.
@ -171,11 +172,11 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Archives** (decided 2026-10-11; `feature/archive/`): a studyset with everything in it and its progress, as one `.zip`, imported from the Library's Import as the person's own (backups, moves between instances, a library brought from elsewhere). `archive.json` says the format (`{"format": 1}`; a newer one is refused whole); the studyset is laid out as a release (studyset.json, cards/, notes/, paths/, pdfs/, a directory per studyset inside, files.json and files/<sha256>), so the release copier writes it (`ReleaseCopier.write`: unlinked, no `published`, its cards marked `archive` as imported ones are, returning each item's new id). A card may name a dictionary entry in place of its payload (`"entry": {"source", "language", "id"}`): the import makes it from the instance's dictionary as adding from it does (`DictionaryEndpoint.entries`, at most 1,000 ids at once), with the tags that brings; an entry the instance lacks leaves the card out, counted. `reviews.json` lists reviews of its cards by their ids in it (`card`, `rating` 1–4, `reviewedAt`, optional `durationMs` and `mode`), appended to the person's review log once the cards are in, so schedules go on from them. Reading runs on an isolate of its own (`ArchiveReader`; tests read inline). **Exporting** (decided 2026-10-15) is a studyset's Export content, a sheet offering Archive first, with the person's progress or without (a checkbox, on), then Markdown: `ArchiveExporter` writes it as the instance writes a release (keys sorted, indented, a directory per studyset by its id) but unlinked (`published` left out), dictionary cards whole, and refuses (`ArchiveFilesMissing`) when a file its content names can't be had now rather than writing one that looks whole; with the progress also its reviews and what a release leaves out as only its author's (the exam, a card's report, a PDF's deadline). v0.17.0's import refuses a file it doesn't know, so what it doesn't read rides in the files it does, as keys it skips, and the format stays 1: `archive.json`'s `tags` (colours, Jellyfin's marked), a `studyset.json`'s `archived` and `starred`, a step's `answers`, a PDF's `readings` (`ImportArchive` lists them); stars, the archive and tag colours come along without the progress too. `test/feature/archive/archive_export_test.dart` exports a library and imports it as someone else (whose fakes have made items first, so no id matches by chance), and holds the archive to what v0.17.0 reads. - **Archives** (decided 2026-10-11; `feature/archive/`): a studyset with everything in it and its progress, as one `.zip`, imported from the Library's Import as the person's own (backups, moves between instances, a library brought from elsewhere). `archive.json` says the format (`{"format": 1}`; a newer one is refused whole); the studyset is laid out as a release (studyset.json, cards/, notes/, paths/, pdfs/, a directory per studyset inside, files.json and files/<sha256>), so the release copier writes it (`ReleaseCopier.write`: unlinked, no `published`, its cards marked `archive` as imported ones are, returning each item's new id). A card may name a dictionary entry in place of its payload (`"entry": {"source", "language", "id"}`): the import makes it from the instance's dictionary as adding from it does (`DictionaryEndpoint.entries`, at most 1,000 ids at once), with the tags that brings; an entry the instance lacks leaves the card out, counted. `reviews.json` lists reviews of its cards by their ids in it (`card`, `rating` 1–4, `reviewedAt`, optional `durationMs` and `mode`), appended to the person's review log once the cards are in, so schedules go on from them. Reading runs on an isolate of its own (`ArchiveReader`; tests read inline). **Exporting** (decided 2026-10-15) is a studyset's Export content, a sheet offering Archive first, with the person's progress or without (a checkbox, on), then Markdown: `ArchiveExporter` writes it as the instance writes a release (keys sorted, indented, a directory per studyset by its id) but unlinked (`published` left out), dictionary cards whole, and refuses (`ArchiveFilesMissing`) when a file its content names can't be had now rather than writing one that looks whole; with the progress also its reviews and what a release leaves out as only its author's (the exam, a card's report, a PDF's deadline). v0.17.0's import refuses a file it doesn't know, so what it doesn't read rides in the files it does, as keys it skips, and the format stays 1: `archive.json`'s `tags` (colours, Jellyfin's marked), a `studyset.json`'s `archived` and `starred`, a step's `answers`, a PDF's `readings` (`ImportArchive` lists them); stars, the archive and tag colours come along without the progress too. `test/feature/archive/archive_export_test.dart` exports a library and imports it as someone else (whose fakes have made items first, so no id matches by chance), and holds the archive to what v0.17.0 reads.
- **Reports and suggestions** (ADR 0005, stage 4, rule 11): `feedback_thread` (a report or suggestion: `publicationId` and `kind` columns, everything else in its payload: what it is about by release item id and version, a report's category, the text, a suggestion's proposed payload and the files it names), `feedback_message` (replies) and `feedback_decision` (an author's closed, reopened, hidden, shown, accepted, declined; the latest of each pair stands) are synced tables each person writes in their own space; a thread or reply names its thread by id only, as it may be another person's. `gatherFeedback` (`lib/src/feedback/`) assembles them for `FeedbackEndpoint.threadsOf` (`requireLogin`: public) and `InboxEndpoint` (`identified`: the inbox, unseen counts, `markSeen`, `block`, `file`) and decides what counts as it reads (see the ADR), since a modified app could write anything. `feedback_block` and `feedback_seen` are server-only; `deletePerson` deletes both. A release's commit credits the writers of suggestions accepted since the last (`Co-authored-by`). In the app (`feature/feedback/`): the card's Report sheet offers "Send to the author too" on a card from a release; a copy's ⋮ menu reports the studyset and lists its threads, as Explore's summary does; the editors of cards (⋮), notes and steps (app bar) suggest to the author (refused under "no derivatives"); the Inbox is in the Profile tab, with a count on the tab (`InboxCountBloc`, refreshed at start and whenever the inbox is read or acted on; no timer); a thread's sheet shows a suggestion beside the author's own item, its replies, and the author's decisions. Accepting writes the proposed content as the author's own revision (`FeedbackRepository.accept`), the writer's files fetched by hash. - **Reports and suggestions** (ADR 0005, stage 4, rule 11): `feedback_thread` (a report or suggestion: `publicationId` and `kind` columns, everything else in its payload: what it is about by release item id and version, a report's category, the text, a suggestion's proposed payload and the files it names), `feedback_message` (replies) and `feedback_decision` (an author's closed, reopened, hidden, shown, accepted, declined; the latest of each pair stands) are synced tables each person writes in their own space; a thread or reply names its thread by id only, as it may be another person's. `gatherFeedback` (`lib/src/feedback/`) assembles them for `FeedbackEndpoint.threadsOf` (`requireLogin`: public) and `InboxEndpoint` (`identified`: the inbox, unseen counts, `markSeen`, `block`, `file`) and decides what counts as it reads (see the ADR), since a modified app could write anything. `feedback_block` and `feedback_seen` are server-only; `deletePerson` deletes both. A release's commit credits the writers of suggestions accepted since the last (`Co-authored-by`). In the app (`feature/feedback/`): the card's Report sheet offers "Send to the author too" on a card from a release; a copy's ⋮ menu reports the studyset and lists its threads, as Explore's summary does; the editors of cards (⋮), notes and steps (app bar) suggest to the author (refused under "no derivatives"); the Inbox is in the Profile tab, with a count on the tab (`InboxCountBloc`, refreshed at start and whenever the inbox is read or acted on; no timer); a thread's sheet shows a suggestion beside the author's own item, its replies, and the author's decisions. Accepting writes the proposed content as the author's own revision (`FeedbackRepository.accept`), the writer's files fetched by hash.
- **Deleting a person** goes through `deletePerson` (`lib/src/auth/account.dart`), in one transaction and in ADR 0001 rule 6's order: their sessions end first (a device with a valid session would re-upload everything), then their space goes with all its content through the foreign keys, then the person with their identities. `AccountEndpoint.delete` deletes the caller; the app also calls it when an anonymous account is given up (signed out of, or switched away from to GitHub), since nobody could ever sign into it again. - **Deleting a person** goes through `deletePerson` (`lib/src/auth/account.dart`), in one transaction and in ADR 0001 rule 6's order: their sessions end first (a device with a valid session would re-upload everything), then their space goes with all its content through the foreign keys, then the person with their identities. `AccountEndpoint.delete` deletes the caller; the app also calls it when an anonymous account is given up (signed out of, or switched away from to GitHub), since nobody could ever sign into it again.
- **Pruning**: `PruningFutureCall` deletes anonymous people unused for 90 days, daily, scheduled when the server starts; and hourly the notes Serverpod's rate limit makes of anonymous sign-ups (`serverpod_auth_idp_rate_limited_request_attempt`, each with the IP address it came from) once the limit's hour is past (`forgetOldSignUpAttempts`): Serverpod deletes them only for email sign-in, so they would otherwise stay for good, and the privacy page says the instance keeps no IP addresses beyond that. Scheduling a future call always adds an entry, so the server cancels the previous one by its identifier first. - **Pruning**: `PruningFutureCall` deletes anonymous people unused for 90 days, daily, scheduled when the server starts; and hourly the notes Serverpod's rate limit makes of anonymous sign-ups (`serverpod_auth_idp_rate_limited_request_attempt`, each with the IP address it came from) once the limit's hour is past (`forgetOldSignUpAttempts`; email sign-in's notes too, and its codes past their time, `forgetExpiredEmailCodes`): Serverpod deletes them only for its own email sign-in, which this instance doesn't use, so they would otherwise stay for good, and the privacy page says the instance keeps no IP addresses beyond that. Scheduling a future call always adds an entry, so the server cancels the previous one by its identifier first.
- **Coming back with a field focused brings its keyboard back** (`KeyboardBackOnResume` around the app): Flutter (v3.47.5) gives the window back the keyboard's height after the app was left while the keyboard moved, with no keyboard showing, which left screens pushed up ([flutter#191156](https://github.com/flutter/flutter/issues/191156), open). Remove it once that is fixed. - **Coming back with a field focused brings its keyboard back** (`KeyboardBackOnResume` around the app): Flutter (v3.47.5) gives the window back the keyboard's height after the app was left while the keyboard moved, with no keyboard showing, which left screens pushed up ([flutter#191156](https://github.com/flutter/flutter/issues/191156), open). Remove it once that is fixed.
- **One person's data per device**: `PersonFolders` wipes every other person's folder when a person's data opens: their sessions ended on this device. Opening waits for the previous person's database to close. - **One person's data per device**: `PersonFolders` wipes every other person's folder when a person's data opens: their sessions ended on this device. Opening waits for the previous person's database to close.
- **Session logs are not persisted** (`persistentEnabled: false` in every run mode) for privacy. Don't re-enable them. - **Session logs are not persisted** (`persistentEnabled: false` in every run mode) for privacy. Don't re-enable them.
- **Secrets** live in `halkyon_learn_server/config/passwords.yaml` (git-ignored). Never change `serverSideSessionKeyHashPepper`: it would sign everyone out, and anonymous accounts can't sign back in. - **Secrets** live in `halkyon_learn_server/config/passwords.yaml` (git-ignored). Never change `serverSideSessionKeyHashPepper`: it would sign everyone out, and anonymous accounts can't sign back in. Never change `emailSignInPepper` either: every address's hash would be lost, and with it those people's way in.
- **Checks on a device** live in `halkyon_learn_flutter/integration_test/` and run by hand, never in `tool/check.sh` or CI: `flutter test integration_test/<file> -d <device>`. `pdf_engine_test.dart` checks the real PDF engine (on the Linux desktop too), `youtube_player_test.dart` the embedded YouTube player (Android), `jellyfin_player_test.dart` the video player streaming from a Jellyfin server (`--dart-define`s for the server and the person, in the file). Never on the emulator from an agent session: it takes the editor down with it. - **Checks on a device** live in `halkyon_learn_flutter/integration_test/` and run by hand, never in `tool/check.sh` or CI: `flutter test integration_test/<file> -d <device>`. `pdf_engine_test.dart` checks the real PDF engine (on the Linux desktop too), `youtube_player_test.dart` the embedded YouTube player (Android), `jellyfin_player_test.dart` the video player streaming from a Jellyfin server (`--dart-define`s for the server and the person, in the file). Never on the emulator from an agent session: it takes the editor down with it.
- **Windows builds need Developer Mode**: building makes symbolic links (Flutter links each plugin's native code into the build, and pdfrx's build does too), which Windows lets ordinary users make only in Developer Mode. PDFium itself comes with the build as a Dart native asset. - **Windows builds need Developer Mode**: building makes symbolic links (Flutter links each plugin's native code into the build, and pdfrx's build does too), which Windows lets ordinary users make only in Developer Mode. PDFium itself comes with the build as a Dart native asset.
- **Linux builds need** `libwebkit2gtk-4.1-dev`, `libsecret-1-dev`, for sounds (`audioplayers`) `libgstreamer1.0-dev` and `libgstreamer-plugins-base1.0-dev`, and for videos (`media_kit_video`) `libmpv-dev`: the video player links libmpv, so the app doesn't start where `libmpv.so.2` is missing (the snap brings Ubuntu's `libmpv2` with ffmpeg's libraries, and its `cleanup` part removes every staged file the GNOME runtime or core24 holds at the same path, since older copies of cairo, pango or librsvg would hide the runtime's from its GTK; staging never runs packages' install scripts, so the snap makes the `update-alternatives` links for BLAS and LAPACK itself); that build also downloads mimalloc's source from GitHub (checked against its MD5) and links it in. If a Linux build tries to install to `/usr/local`, the CMake cache is stale: delete `build/linux`. - **Linux builds need** `libwebkit2gtk-4.1-dev`, `libsecret-1-dev`, for sounds (`audioplayers`) `libgstreamer1.0-dev` and `libgstreamer-plugins-base1.0-dev`, and for videos (`media_kit_video`) `libmpv-dev`: the video player links libmpv, so the app doesn't start where `libmpv.so.2` is missing (the snap brings Ubuntu's `libmpv2` with ffmpeg's libraries, and its `cleanup` part removes every staged file the GNOME runtime or core24 holds at the same path, since older copies of cairo, pango or librsvg would hide the runtime's from its GTK; staging never runs packages' install scripts, so the snap makes the `update-alternatives` links for BLAS and LAPACK itself); that build also downloads mimalloc's source from GitHub (checked against its MD5) and links it in. If a Linux build tries to install to `/usr/local`, the CMake cache is stale: delete `build/linux`.

View file

@ -0,0 +1,80 @@
# ADR 0010: Signing in with an email address, keeping only a keyed hash of it
- **Status:** accepted
- **Date:** 2026-10-15
## Context
People keep an anonymous account by linking an identity, which makes them `identified`:
they can sign in on another device, publish, report and suggest. Until now the only
identity was GitHub, which many learners don't have. The developer asked for email
sign-up first among the features after the hackathon (11 Oct).
GitHub sign-in keeps only the numeric GitHub ID (decided 2026-09-30): the instance holds
no personal data of anybody beyond what they write. Serverpod's email sign-in (checked
with v4.0.4) doesn't fit that. It keeps the address in plain text in three tables (the
account, the pending registration, and a user profile it creates), works with passwords
(Argon2id) and resets by mailed codes, and finishing a registration always creates a new
person, so an anonymous account can't be upgraded in place with it. Its building blocks
are public, though: code challenges kept as Argon2id hashes, with expiry, a limit on
tries and completion tokens (`SecretChallengeUtil`), and a rate limiter
(`DatabaseRateLimiter`).
## Decision
1. **The instance keeps a keyed hash of the address, never the address.** An address is
trimmed and put in lower case; the instance keeps HMAC-SHA256 of it, keyed with its
own `emailSignInPepper` (in `passwords.yaml`, `SERVERPOD_PASSWORD_emailSignInPepper`
in production), in hex (`addressHash` in `lib/src/auth/email.dart`). `email_identity`
holds the person and the hash, one address per person and each address one
person's. From what someone types, the instance can still find the person and tell
whether an address is free; it mails only an address someone has just typed, to send
a code; it can't write to anybody otherwise, nor show anybody their address. The
device that signed in remembers the address, to show it (`sign_in.json` in the
person's folder, which goes with the rest of their data).
2. **A code by mail each time, no password.** A code is eight characters of Crockford's
base 32 (`K7QX-M2PD`, as the assistant's connect codes), valid 15 minutes and 3
tries, kept as Serverpod's `SecretChallenge` (Argon2id with the same pepper); a newer
code for an address replaces the older one. No password is stored, reset or leaked.
3. **The same answer for every address.** Mailing a code answers alike whether anybody
has the address. Only once the code proves the mailbox does the instance say whose
it is.
4. **As GitHub works.** A code for an address nobody has makes it the signed-in
person's, in one transaction: they become `identified`, on their sessions too, and
lose their anonymous identity, keeping everything. A code for another person's
address changes nothing and gives a switch token (Serverpod's completion token),
which signs in as that person without a session (`emailSignIn.signIn`, as Serverpod
issues no session for another person inside a signed-in call), once, within the 15
minutes; the device then deletes the anonymous account it leaves behind, as nobody
could sign into it again. Someone identified is never switched, by GitHub either:
the app tells them the address or GitHub account is another account's, as their own
account would otherwise be left behind (and the app's switch deleted only what it
left behind, whoever that was; now only an anonymous account).
5. **Two ways in.** A person may sign in with GitHub and with an address, one of each
(`account.signInWays`); the Profile tab lists them and adds the one missing.
6. **Limits.** At most 3 codes an hour for one address (counted by its hash) and 10 from
one network (IP address); the rate limiter's notes, which hold the IP address, go
hourly with the anonymous sign-ups' (`forgetOldSignUpAttempts`), and codes past their
time hourly too (`forgetExpiredEmailCodes`).
7. **Mail.** In development, codes go to the server's log (`LogCodeMailer`). Elsewhere
the instance offers no email sign-in (`instance.info().emailSignIn`) until its
operator sets up a mail service: chosen before the first deploy that brings this
(Proton SMTP and Scaleway TEM were the candidates, 11 Oct).
8. **The pepper never changes** once anybody signs in with an address: every hash would
be lost, and with it those people's way in. Rotating it would need a list of earlier
peppers tried in turn, which isn't built.
## Consequences
- The instance can send no notices: no "your sign-in changed", no newsletter. A breach
would be communicated publicly (GDPR Article 34(3)(c)), as the instance holds no way
to reach people.
- Whoever has both the database and the pepper can check whether a guessed address
signs in here, but can't list the addresses. Without the pepper, the hashes tell
nothing.
- Someone who loses their mailbox loses that way in, as someone who loses their GitHub
account loses that one; a second way in is the remedy.
- Every sign-in on a new device needs a mail. Sessions last until 90 days unused, so
that is rare.
- Deleting a person deletes their hash (`deletePerson`); a restore from backup brings
it back with the rest.

View file

@ -52,7 +52,7 @@ abstract class Device
/// The session's id; signing the device out takes it. /// The session's id; signing the device out takes it.
_isc.UuidValue sessionId; _isc.UuidValue sessionId;
/// How the session was started, such as "anonymous" or "github". /// How the session was started: "anonymous", "github" or "email".
String method; String method;
DateTime createdAt; DateTime createdAt;

View file

@ -0,0 +1,75 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// A code accepted: the address is now the signed-in person's (or already
/// was), or it is another person's and [switchToken] signs in as them.
abstract class EmailCodeAccepted
implements _isc.SerializableModel, _isc.ProtocolSerialization {
EmailCodeAccepted._({this.switchToken});
factory EmailCodeAccepted({String? switchToken}) = _EmailCodeAcceptedImpl;
factory EmailCodeAccepted.fromJson(Map<String, dynamic> jsonSerialization) {
return EmailCodeAccepted(
switchToken: jsonSerialization['switchToken'] as String?,
);
}
/// Null when the address is the signed-in person's; else what signs in as
/// the person who has it, once, within the code's 15 minutes
/// (`EmailSignInEndpoint.signIn`).
String? switchToken;
/// Returns a shallow copy of this [EmailCodeAccepted]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
EmailCodeAccepted copyWith({String? switchToken});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'EmailCodeAccepted',
if (switchToken != null) 'switchToken': switchToken,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'EmailCodeAccepted',
if (switchToken != null) 'switchToken': switchToken,
};
}
@override
String toString() {
return _isc.SerializationManager.encode(this);
}
}
class _Undefined {}
class _EmailCodeAcceptedImpl extends EmailCodeAccepted {
_EmailCodeAcceptedImpl({String? switchToken})
: super._(switchToken: switchToken);
/// Returns a shallow copy of this [EmailCodeAccepted]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
EmailCodeAccepted copyWith({Object? switchToken = _Undefined}) {
return EmailCodeAccepted(
switchToken: switchToken is String? ? switchToken : this.switchToken,
);
}
}

View file

@ -0,0 +1,70 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// Why mailing a code, accepting it, or signing in with an email address
/// was refused.
enum EmailSignInRefusal implements _isc.SerializableModel {
/// This instance has no email sign-in (no emailSignInPepper in its
/// passwords, or no way to send mail).
unavailable,
/// What was typed isn't an email address.
notAnAddress,
/// Too many codes asked for, for this address or from this network, in
/// the last hour.
tooManyCodes,
/// The code is wrong.
wrongCode,
/// The code was typed wrong too often; a new one is needed.
tooManyTries,
/// The code is no longer valid: past its time, used, or replaced by a
/// newer one.
codeExpired,
/// The person already signs in with another address.
otherAddressLinked;
static EmailSignInRefusal fromJson(String name) {
switch (name) {
case 'unavailable':
return EmailSignInRefusal.unavailable;
case 'notAnAddress':
return EmailSignInRefusal.notAnAddress;
case 'tooManyCodes':
return EmailSignInRefusal.tooManyCodes;
case 'wrongCode':
return EmailSignInRefusal.wrongCode;
case 'tooManyTries':
return EmailSignInRefusal.tooManyTries;
case 'codeExpired':
return EmailSignInRefusal.codeExpired;
case 'otherAddressLinked':
return EmailSignInRefusal.otherAddressLinked;
default:
throw ArgumentError(
'Value "$name" cannot be converted to "EmailSignInRefusal"',
);
}
}
@override
String toJson() => name;
@override
String toString() => name;
}

View file

@ -0,0 +1,80 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod_client/serverpod_client.dart' as _isc;
import '../auth/email_sign_in_refusal.dart' as _in9x16lz;
/// Mailing a code, accepting it, or signing in with an email address was
/// refused; nothing changed.
abstract class EmailSignInRefusedException
implements
_isc.SerializableException,
_isc.SerializableModel,
_isc.ProtocolSerialization {
EmailSignInRefusedException._({required this.reason});
factory EmailSignInRefusedException({
required _in9x16lz.EmailSignInRefusal reason,
}) = _EmailSignInRefusedExceptionImpl;
factory EmailSignInRefusedException.fromJson(
Map<String, dynamic> jsonSerialization,
) {
return EmailSignInRefusedException(
reason: _in9x16lz.EmailSignInRefusal.fromJson(
(jsonSerialization['reason'] as String),
),
);
}
/// Why it was refused.
_in9x16lz.EmailSignInRefusal reason;
/// Returns a shallow copy of this [EmailSignInRefusedException]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
EmailSignInRefusedException copyWith({_in9x16lz.EmailSignInRefusal? reason});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'EmailSignInRefusedException',
'reason': reason.toJson(),
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'EmailSignInRefusedException',
'reason': reason.toJson(),
};
}
@override
String toString() {
return 'EmailSignInRefusedException(reason: $reason)';
}
}
class _EmailSignInRefusedExceptionImpl extends EmailSignInRefusedException {
_EmailSignInRefusedExceptionImpl({
required _in9x16lz.EmailSignInRefusal reason,
}) : super._(reason: reason);
/// Returns a shallow copy of this [EmailSignInRefusedException]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
EmailSignInRefusedException copyWith({_in9x16lz.EmailSignInRefusal? reason}) {
return EmailSignInRefusedException(reason: reason ?? this.reason);
}
}

View file

@ -0,0 +1,92 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// The ways a person signs in, besides an anonymous account's session.
abstract class SignInWays
implements _isc.SerializableModel, _isc.ProtocolSerialization {
SignInWays._({
required this.gitHub,
required this.email,
});
factory SignInWays({
required bool gitHub,
required bool email,
}) = _SignInWaysImpl;
factory SignInWays.fromJson(Map<String, dynamic> jsonSerialization) {
return SignInWays(
gitHub: _isc.BoolJsonExtension.fromJson(jsonSerialization['gitHub']),
email: _isc.BoolJsonExtension.fromJson(jsonSerialization['email']),
);
}
bool gitHub;
bool email;
/// Returns a shallow copy of this [SignInWays]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
SignInWays copyWith({
bool? gitHub,
bool? email,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'SignInWays',
'gitHub': gitHub,
'email': email,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'SignInWays',
'gitHub': gitHub,
'email': email,
};
}
@override
String toString() {
return _isc.SerializationManager.encode(this);
}
}
class _SignInWaysImpl extends SignInWays {
_SignInWaysImpl({
required bool gitHub,
required bool email,
}) : super._(
gitHub: gitHub,
email: email,
);
/// Returns a shallow copy of this [SignInWays]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
SignInWays copyWith({
bool? gitHub,
bool? email,
}) {
return SignInWays(
gitHub: gitHub ?? this.gitHub,
email: email ?? this.email,
);
}
}

View file

@ -20,6 +20,10 @@ import 'package:halkyon_learn_client/src/protocol/assistant/assistant_connection
as _iplrdti2; as _iplrdti2;
import 'package:halkyon_learn_client/src/protocol/auth/device.dart' import 'package:halkyon_learn_client/src/protocol/auth/device.dart'
as _ieqh4yr9; as _ieqh4yr9;
import 'package:halkyon_learn_client/src/protocol/auth/email_code_accepted.dart'
as _ivlnmdt4;
import 'package:halkyon_learn_client/src/protocol/auth/sign_in_ways.dart'
as _ixy30i05;
import 'package:halkyon_learn_client/src/protocol/dictionary/dictionary_hit.dart' import 'package:halkyon_learn_client/src/protocol/dictionary/dictionary_hit.dart'
as _iqbqiphu; as _iqbqiphu;
import 'package:halkyon_learn_client/src/protocol/dictionary/dictionary_key_hit.dart' import 'package:halkyon_learn_client/src/protocol/dictionary/dictionary_key_hit.dart'
@ -171,6 +175,15 @@ class EndpointAccount extends _isc.EndpointRef {
'delete', 'delete',
{}, {},
); );
/// How the caller signs in besides their sessions: with GitHub, with an
/// email address, both, or neither (anonymous).
_ida.Future<_ixy30i05.SignInWays> signInWays() =>
caller.callServerEndpoint<_ixy30i05.SignInWays>(
'account',
'signInWays',
{},
);
} }
/// Signs people in anonymously; the app calls it on first launch. Creating an /// Signs people in anonymously; the app calls it on first launch. Creating an
@ -227,6 +240,67 @@ class EndpointDevices extends _isc.EndpointRef {
); );
} }
/// Signing in with an email address, by a code mailed to it (ADR 0010):
/// the way to keep an anonymous account, as linking GitHub is, and a second
/// way in for someone who signs in with GitHub. Signed in only, as every
/// device is from its first start.
/// {@category Endpoint}
class EndpointEmailCode extends _isc.EndpointRef {
EndpointEmailCode(_isc.EndpointCaller caller) : super(caller);
@override
String get name => 'emailCode';
/// Mails a code to [address], worded in [language] (`en`, `de`), and
/// returns the request it answers; refusals as in [sendSignInCode].
_ida.Future<_isc.UuidValue> sendCode({
required String address,
required String language,
}) => caller.callServerEndpoint<_isc.UuidValue>(
'emailCode',
'sendCode',
{
'address': address,
'language': language,
},
);
/// Accepts [code] for [request]: the address becomes the caller's unless
/// it is another person's, whose token then signs in as them
/// (`EmailSignInEndpoint.signIn`); refusals as in [acceptSignInCode].
_ida.Future<_ivlnmdt4.EmailCodeAccepted> verifyCode({
required _isc.UuidValue request,
required String code,
}) => caller.callServerEndpoint<_ivlnmdt4.EmailCodeAccepted>(
'emailCode',
'verifyCode',
{
'request': request,
'code': code,
},
);
}
/// Signs in the person whose address a mailed code proved, when the device
/// switches to them. Called without a session: Serverpod issues no session
/// for another person inside a signed-in call.
/// {@category Endpoint}
class EndpointEmailSignIn extends _isc.EndpointRef {
EndpointEmailSignIn(_isc.EndpointCaller caller) : super(caller);
@override
String get name => 'emailSignIn';
/// A session for the person [switchToken] names; refusals as in
/// [signInWithEmail].
_ida.Future<_iacc.AuthSuccess> signIn({required String switchToken}) =>
caller.callServerEndpoint<_iacc.AuthSuccess>(
'emailSignIn',
'signIn',
{'switchToken': switchToken},
);
}
/// Signs in the person who already has a GitHub account, e.g. when they switch /// Signs in the person who already has a GitHub account, e.g. when they switch
/// to it on another device. Called without a session: Serverpod issues no /// to it on another device. Called without a session: Serverpod issues no
/// session for another person inside a signed-in call. /// session for another person inside a signed-in call.
@ -596,7 +670,7 @@ class EndpointInstance extends _isc.EndpointRef {
String get name => 'instance'; String get name => 'instance';
/// This instance's offer: GitHub sign-in only with the operator's OAuth /// This instance's offer: GitHub sign-in only with the operator's OAuth
/// app, where its source is, the oldest app build it works with, the /// app, email sign-in only where it can mail codes, where its source is, the oldest app build it works with, the
/// domain its people's handles are on, where assistants connect, and the /// domain its people's handles are on, where assistants connect, and the
/// Jellyfin server it suggests. /// Jellyfin server it suggests.
_ida.Future<_i26u8vl4.InstanceInfo> info() => _ida.Future<_i26u8vl4.InstanceInfo> info() =>
@ -829,6 +903,8 @@ class Client extends _isc.ServerpodClientShared {
account = EndpointAccount(this); account = EndpointAccount(this);
anonymousIdp = EndpointAnonymousIdp(this); anonymousIdp = EndpointAnonymousIdp(this);
devices = EndpointDevices(this); devices = EndpointDevices(this);
emailCode = EndpointEmailCode(this);
emailSignIn = EndpointEmailSignIn(this);
gitHubSignIn = EndpointGitHubSignIn(this); gitHubSignIn = EndpointGitHubSignIn(this);
gitHubUpgrade = EndpointGitHubUpgrade(this); gitHubUpgrade = EndpointGitHubUpgrade(this);
dictionary = EndpointDictionary(this); dictionary = EndpointDictionary(this);
@ -852,6 +928,10 @@ class Client extends _isc.ServerpodClientShared {
late final EndpointDevices devices; late final EndpointDevices devices;
late final EndpointEmailCode emailCode;
late final EndpointEmailSignIn emailSignIn;
late final EndpointGitHubSignIn gitHubSignIn; late final EndpointGitHubSignIn gitHubSignIn;
late final EndpointGitHubUpgrade gitHubUpgrade; late final EndpointGitHubUpgrade gitHubUpgrade;
@ -881,6 +961,8 @@ class Client extends _isc.ServerpodClientShared {
'account': account, 'account': account,
'anonymousIdp': anonymousIdp, 'anonymousIdp': anonymousIdp,
'devices': devices, 'devices': devices,
'emailCode': emailCode,
'emailSignIn': emailSignIn,
'gitHubSignIn': gitHubSignIn, 'gitHubSignIn': gitHubSignIn,
'gitHubUpgrade': gitHubUpgrade, 'gitHubUpgrade': gitHubUpgrade,
'dictionary': dictionary, 'dictionary': dictionary,

View file

@ -23,6 +23,7 @@ abstract class InstanceInfo
this.accountDomain, this.accountDomain,
this.assistantConnectorUrl, this.assistantConnectorUrl,
this.jellyfinUrl, this.jellyfinUrl,
this.emailSignIn,
}); });
factory InstanceInfo({ factory InstanceInfo({
@ -32,6 +33,7 @@ abstract class InstanceInfo
String? accountDomain, String? accountDomain,
String? assistantConnectorUrl, String? assistantConnectorUrl,
String? jellyfinUrl, String? jellyfinUrl,
bool? emailSignIn,
}) = _InstanceInfoImpl; }) = _InstanceInfoImpl;
factory InstanceInfo.fromJson(Map<String, dynamic> jsonSerialization) { factory InstanceInfo.fromJson(Map<String, dynamic> jsonSerialization) {
@ -43,6 +45,9 @@ abstract class InstanceInfo
assistantConnectorUrl: assistantConnectorUrl:
jsonSerialization['assistantConnectorUrl'] as String?, jsonSerialization['assistantConnectorUrl'] as String?,
jellyfinUrl: jsonSerialization['jellyfinUrl'] as String?, jellyfinUrl: jsonSerialization['jellyfinUrl'] as String?,
emailSignIn: jsonSerialization['emailSignIn'] == null
? null
: _isc.BoolJsonExtension.fromJson(jsonSerialization['emailSignIn']),
); );
} }
@ -76,6 +81,10 @@ abstract class InstanceInfo
/// none, and from servers that predate it. /// none, and from servers that predate it.
String? jellyfinUrl; String? jellyfinUrl;
/// Whether people can sign in with an email address here (ADR 0010): a
/// code mailed to it. Null from servers that predate it: no.
bool? emailSignIn;
/// Returns a shallow copy of this [InstanceInfo] /// Returns a shallow copy of this [InstanceInfo]
/// with some or all fields replaced by the given arguments. /// with some or all fields replaced by the given arguments.
@_isc.useResult @_isc.useResult
@ -86,6 +95,7 @@ abstract class InstanceInfo
String? accountDomain, String? accountDomain,
String? assistantConnectorUrl, String? assistantConnectorUrl,
String? jellyfinUrl, String? jellyfinUrl,
bool? emailSignIn,
}); });
@override @override
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
@ -98,6 +108,7 @@ abstract class InstanceInfo
if (assistantConnectorUrl != null) if (assistantConnectorUrl != null)
'assistantConnectorUrl': assistantConnectorUrl, 'assistantConnectorUrl': assistantConnectorUrl,
if (jellyfinUrl != null) 'jellyfinUrl': jellyfinUrl, if (jellyfinUrl != null) 'jellyfinUrl': jellyfinUrl,
if (emailSignIn != null) 'emailSignIn': emailSignIn,
}; };
} }
@ -112,6 +123,7 @@ abstract class InstanceInfo
if (assistantConnectorUrl != null) if (assistantConnectorUrl != null)
'assistantConnectorUrl': assistantConnectorUrl, 'assistantConnectorUrl': assistantConnectorUrl,
if (jellyfinUrl != null) 'jellyfinUrl': jellyfinUrl, if (jellyfinUrl != null) 'jellyfinUrl': jellyfinUrl,
if (emailSignIn != null) 'emailSignIn': emailSignIn,
}; };
} }
@ -131,6 +143,7 @@ class _InstanceInfoImpl extends InstanceInfo {
String? accountDomain, String? accountDomain,
String? assistantConnectorUrl, String? assistantConnectorUrl,
String? jellyfinUrl, String? jellyfinUrl,
bool? emailSignIn,
}) : super._( }) : super._(
gitHubClientId: gitHubClientId, gitHubClientId: gitHubClientId,
sourceCodeUrl: sourceCodeUrl, sourceCodeUrl: sourceCodeUrl,
@ -138,6 +151,7 @@ class _InstanceInfoImpl extends InstanceInfo {
accountDomain: accountDomain, accountDomain: accountDomain,
assistantConnectorUrl: assistantConnectorUrl, assistantConnectorUrl: assistantConnectorUrl,
jellyfinUrl: jellyfinUrl, jellyfinUrl: jellyfinUrl,
emailSignIn: emailSignIn,
); );
/// Returns a shallow copy of this [InstanceInfo] /// Returns a shallow copy of this [InstanceInfo]
@ -151,6 +165,7 @@ class _InstanceInfoImpl extends InstanceInfo {
Object? accountDomain = _Undefined, Object? accountDomain = _Undefined,
Object? assistantConnectorUrl = _Undefined, Object? assistantConnectorUrl = _Undefined,
Object? jellyfinUrl = _Undefined, Object? jellyfinUrl = _Undefined,
Object? emailSignIn = _Undefined,
}) { }) {
return InstanceInfo( return InstanceInfo(
gitHubClientId: gitHubClientId is String? gitHubClientId: gitHubClientId is String?
@ -167,6 +182,7 @@ class _InstanceInfoImpl extends InstanceInfo {
? assistantConnectorUrl ? assistantConnectorUrl
: this.assistantConnectorUrl, : this.assistantConnectorUrl,
jellyfinUrl: jellyfinUrl is String? ? jellyfinUrl : this.jellyfinUrl, jellyfinUrl: jellyfinUrl is String? ? jellyfinUrl : this.jellyfinUrl,
emailSignIn: emailSignIn is bool? ? emailSignIn : this.emailSignIn,
); );
} }
} }

View file

@ -40,8 +40,12 @@ import 'asset/file_usage.dart' as _iurwo9k8;
import 'assistant/assistant_code.dart' as _i8xi9jqo; import 'assistant/assistant_code.dart' as _i8xi9jqo;
import 'assistant/assistant_connection_info.dart' as _icukzjbz; import 'assistant/assistant_connection_info.dart' as _icukzjbz;
import 'auth/device.dart' as _i6c5ny7n; import 'auth/device.dart' as _i6c5ny7n;
import 'auth/email_code_accepted.dart' as _iys46ui1;
import 'auth/email_sign_in_refusal.dart' as _ieyvcag1;
import 'auth/email_sign_in_refused_exception.dart' as _iztfgag5;
import 'auth/github_sign_in_refusal.dart' as _ir1gd9p5; import 'auth/github_sign_in_refusal.dart' as _ir1gd9p5;
import 'auth/github_sign_in_refused_exception.dart' as _i0xfcu5n; import 'auth/github_sign_in_refused_exception.dart' as _i0xfcu5n;
import 'auth/sign_in_ways.dart' as _ie6tbh85;
import 'card/card.dart' as _ipiw1cmx; import 'card/card.dart' as _ipiw1cmx;
import 'card/card_revision.dart' as _ipbyuef1; import 'card/card_revision.dart' as _ipbyuef1;
import 'common/not_found_exception.dart' as _i8oee73b; import 'common/not_found_exception.dart' as _i8oee73b;
@ -91,8 +95,12 @@ export 'asset/file_usage.dart';
export 'assistant/assistant_code.dart'; export 'assistant/assistant_code.dart';
export 'assistant/assistant_connection_info.dart'; export 'assistant/assistant_connection_info.dart';
export 'auth/device.dart'; export 'auth/device.dart';
export 'auth/email_code_accepted.dart';
export 'auth/email_sign_in_refusal.dart';
export 'auth/email_sign_in_refused_exception.dart';
export 'auth/github_sign_in_refusal.dart'; export 'auth/github_sign_in_refusal.dart';
export 'auth/github_sign_in_refused_exception.dart'; export 'auth/github_sign_in_refused_exception.dart';
export 'auth/sign_in_ways.dart';
export 'card/card.dart'; export 'card/card.dart';
export 'card/card_revision.dart'; export 'card/card_revision.dart';
export 'common/not_found_exception.dart'; export 'common/not_found_exception.dart';
@ -2304,12 +2312,24 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (t == _i6c5ny7n.Device) { if (t == _i6c5ny7n.Device) {
return _i6c5ny7n.Device.fromJson(data) as T; return _i6c5ny7n.Device.fromJson(data) as T;
} }
if (t == _iys46ui1.EmailCodeAccepted) {
return _iys46ui1.EmailCodeAccepted.fromJson(data) as T;
}
if (t == _ieyvcag1.EmailSignInRefusal) {
return _ieyvcag1.EmailSignInRefusal.fromJson(data) as T;
}
if (t == _iztfgag5.EmailSignInRefusedException) {
return _iztfgag5.EmailSignInRefusedException.fromJson(data) as T;
}
if (t == _ir1gd9p5.GitHubSignInRefusal) { if (t == _ir1gd9p5.GitHubSignInRefusal) {
return _ir1gd9p5.GitHubSignInRefusal.fromJson(data) as T; return _ir1gd9p5.GitHubSignInRefusal.fromJson(data) as T;
} }
if (t == _i0xfcu5n.GitHubSignInRefusedException) { if (t == _i0xfcu5n.GitHubSignInRefusedException) {
return _i0xfcu5n.GitHubSignInRefusedException.fromJson(data) as T; return _i0xfcu5n.GitHubSignInRefusedException.fromJson(data) as T;
} }
if (t == _ie6tbh85.SignInWays) {
return _ie6tbh85.SignInWays.fromJson(data) as T;
}
if (t == _ipiw1cmx.Card) { if (t == _ipiw1cmx.Card) {
return _ipiw1cmx.Card.fromJson(data) as T; return _ipiw1cmx.Card.fromJson(data) as T;
} }
@ -2465,6 +2485,20 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (t == _isc.getType<_i6c5ny7n.Device?>()) { if (t == _isc.getType<_i6c5ny7n.Device?>()) {
return (data != null ? _i6c5ny7n.Device.fromJson(data) : null) as T; return (data != null ? _i6c5ny7n.Device.fromJson(data) : null) as T;
} }
if (t == _isc.getType<_iys46ui1.EmailCodeAccepted?>()) {
return (data != null ? _iys46ui1.EmailCodeAccepted.fromJson(data) : null)
as T;
}
if (t == _isc.getType<_ieyvcag1.EmailSignInRefusal?>()) {
return (data != null ? _ieyvcag1.EmailSignInRefusal.fromJson(data) : null)
as T;
}
if (t == _isc.getType<_iztfgag5.EmailSignInRefusedException?>()) {
return (data != null
? _iztfgag5.EmailSignInRefusedException.fromJson(data)
: null)
as T;
}
if (t == _isc.getType<_ir1gd9p5.GitHubSignInRefusal?>()) { if (t == _isc.getType<_ir1gd9p5.GitHubSignInRefusal?>()) {
return (data != null return (data != null
? _ir1gd9p5.GitHubSignInRefusal.fromJson(data) ? _ir1gd9p5.GitHubSignInRefusal.fromJson(data)
@ -2477,6 +2511,9 @@ class Protocol extends _isd.DatabaseSerializationManager {
: null) : null)
as T; as T;
} }
if (t == _isc.getType<_ie6tbh85.SignInWays?>()) {
return (data != null ? _ie6tbh85.SignInWays.fromJson(data) : null) as T;
}
if (t == _isc.getType<_ipiw1cmx.Card?>()) { if (t == _isc.getType<_ipiw1cmx.Card?>()) {
return (data != null ? _ipiw1cmx.Card.fromJson(data) : null) as T; return (data != null ? _ipiw1cmx.Card.fromJson(data) : null) as T;
} }
@ -2740,8 +2777,12 @@ class Protocol extends _isd.DatabaseSerializationManager {
_i8xi9jqo.AssistantCode => 'AssistantCode', _i8xi9jqo.AssistantCode => 'AssistantCode',
_icukzjbz.AssistantConnectionInfo => 'AssistantConnectionInfo', _icukzjbz.AssistantConnectionInfo => 'AssistantConnectionInfo',
_i6c5ny7n.Device => 'Device', _i6c5ny7n.Device => 'Device',
_iys46ui1.EmailCodeAccepted => 'EmailCodeAccepted',
_ieyvcag1.EmailSignInRefusal => 'EmailSignInRefusal',
_iztfgag5.EmailSignInRefusedException => 'EmailSignInRefusedException',
_ir1gd9p5.GitHubSignInRefusal => 'GitHubSignInRefusal', _ir1gd9p5.GitHubSignInRefusal => 'GitHubSignInRefusal',
_i0xfcu5n.GitHubSignInRefusedException => 'GitHubSignInRefusedException', _i0xfcu5n.GitHubSignInRefusedException => 'GitHubSignInRefusedException',
_ie6tbh85.SignInWays => 'SignInWays',
_ipiw1cmx.Card => 'Card', _ipiw1cmx.Card => 'Card',
_ipbyuef1.CardRevision => 'CardRevision', _ipbyuef1.CardRevision => 'CardRevision',
_i8oee73b.NotFoundException => 'NotFoundException', _i8oee73b.NotFoundException => 'NotFoundException',
@ -2815,10 +2856,18 @@ class Protocol extends _isd.DatabaseSerializationManager {
return 'AssistantConnectionInfo'; return 'AssistantConnectionInfo';
case _i6c5ny7n.Device(): case _i6c5ny7n.Device():
return 'Device'; return 'Device';
case _iys46ui1.EmailCodeAccepted():
return 'EmailCodeAccepted';
case _ieyvcag1.EmailSignInRefusal():
return 'EmailSignInRefusal';
case _iztfgag5.EmailSignInRefusedException():
return 'EmailSignInRefusedException';
case _ir1gd9p5.GitHubSignInRefusal(): case _ir1gd9p5.GitHubSignInRefusal():
return 'GitHubSignInRefusal'; return 'GitHubSignInRefusal';
case _i0xfcu5n.GitHubSignInRefusedException(): case _i0xfcu5n.GitHubSignInRefusedException():
return 'GitHubSignInRefusedException'; return 'GitHubSignInRefusedException';
case _ie6tbh85.SignInWays():
return 'SignInWays';
case _ipiw1cmx.Card(): case _ipiw1cmx.Card():
return 'Card'; return 'Card';
case _ipbyuef1.CardRevision(): case _ipbyuef1.CardRevision():
@ -2952,12 +3001,24 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (dataClassName == 'Device') { if (dataClassName == 'Device') {
return deserialize<_i6c5ny7n.Device>(data['data']); return deserialize<_i6c5ny7n.Device>(data['data']);
} }
if (dataClassName == 'EmailCodeAccepted') {
return deserialize<_iys46ui1.EmailCodeAccepted>(data['data']);
}
if (dataClassName == 'EmailSignInRefusal') {
return deserialize<_ieyvcag1.EmailSignInRefusal>(data['data']);
}
if (dataClassName == 'EmailSignInRefusedException') {
return deserialize<_iztfgag5.EmailSignInRefusedException>(data['data']);
}
if (dataClassName == 'GitHubSignInRefusal') { if (dataClassName == 'GitHubSignInRefusal') {
return deserialize<_ir1gd9p5.GitHubSignInRefusal>(data['data']); return deserialize<_ir1gd9p5.GitHubSignInRefusal>(data['data']);
} }
if (dataClassName == 'GitHubSignInRefusedException') { if (dataClassName == 'GitHubSignInRefusedException') {
return deserialize<_i0xfcu5n.GitHubSignInRefusedException>(data['data']); return deserialize<_i0xfcu5n.GitHubSignInRefusedException>(data['data']);
} }
if (dataClassName == 'SignInWays') {
return deserialize<_ie6tbh85.SignInWays>(data['data']);
}
if (dataClassName == 'Card') { if (dataClassName == 'Card') {
return deserialize<_ipiw1cmx.Card>(data['data']); return deserialize<_ipiw1cmx.Card>(data['data']);
} }

View file

@ -480,12 +480,42 @@
"profileSwitchMessage": "Dieses GitHub-Konto hat bereits Fortschritt von einem anderen Gerät. Die Lernsets auf diesem Gerät lassen sich noch nicht damit zusammenführen: Wenn du wechselst, werden sie gelöscht.", "profileSwitchMessage": "Dieses GitHub-Konto hat bereits Fortschritt von einem anderen Gerät. Die Lernsets auf diesem Gerät lassen sich noch nicht damit zusammenführen: Wenn du wechselst, werden sie gelöscht.",
"profileSwitchStay": "Anonym bleiben", "profileSwitchStay": "Anonym bleiben",
"profileSwitchConfirm": "Wechseln", "profileSwitchConfirm": "Wechseln",
"profileContinueWithEmail": "Mit E-Mail fortfahren",
"profileSignInWays": "Anmeldewege",
"profileWayGitHub": "GitHub",
"profileWayEmail": "E-Mail-Adresse",
"profileWayLinked": "Meldet dich an",
"profileAddGitHub": "GitHub hinzufügen",
"profileAddEmail": "E-Mail-Adresse hinzufügen",
"profileGitHubTaken": "Mit diesem GitHub-Konto meldet sich schon ein anderes Konto an.",
"emailSignInTitle": "Mit E-Mail fortfahren",
"emailSignInIntro": "Wir schicken dir einen Code zum Anmelden. Diese Instanz behält nur einen Fingerabdruck deiner Adresse, nie die Adresse selbst, und schreibt ihr nur, wenn du einen Code anforderst.",
"emailSignInAddress": "E-Mail-Adresse",
"emailSignInSend": "Code senden",
"emailSignInCodeSent": "Wir haben einen Code an {address} geschickt. Er gilt 15 Minuten.",
"emailSignInResent": "Ein neuer Code ist unterwegs.",
"emailSignInCode": "Code",
"emailSignInContinue": "Weiter",
"emailSignInResend": "Neuen Code senden",
"emailSignInOtherAddress": "Andere Adresse verwenden",
"emailSignInSwitchTitle": "Zum Konto von {address} wechseln?",
"emailSignInSwitchMessage": "Diese Adresse hat bereits Fortschritt von einem anderen Gerät. Die Lernsets auf diesem Gerät lassen sich noch nicht damit zusammenführen: Wenn du wechselst, werden sie gelöscht.",
"emailSignInTaken": "Mit {address} meldet sich schon ein anderes Konto an. Um zu ihm zu wechseln, melde dich zuerst ab.",
"emailSignInDone": "Mit {address} angemeldet.",
"emailSignInUnavailable": "Diese Instanz bietet keine Anmeldung mit E-Mail an.",
"emailSignInNotAnAddress": "Das ist keine E-Mail-Adresse.",
"emailSignInTooManyCodes": "Zu viele Codes in der letzten Stunde. Versuche es später noch einmal.",
"emailSignInWrongCode": "Dieser Code stimmt nicht.",
"emailSignInTooManyTries": "Zu viele falsche Codes. Fordere einen neuen an.",
"emailSignInCodeExpired": "Dieser Code gilt nicht mehr. Fordere einen neuen an.",
"emailSignInOtherAddressLinked": "Du meldest dich schon mit einer anderen Adresse an.",
"emailSignInFailed": "Die Instanz war nicht erreichbar. Versuche es noch einmal.",
"publicProfileTitle": "Öffentliches Profil", "publicProfileTitle": "Öffentliches Profil",
"profileSheetTitle": "Profil", "profileSheetTitle": "Profil",
"profileSheetNotFound": "Niemand auf dieser Instanz hat diesen Handle.", "profileSheetNotFound": "Niemand auf dieser Instanz hat diesen Handle.",
"profileSheetUnreachable": "Das Profil konnte nicht geladen werden. Prüfe deine Verbindung.", "profileSheetUnreachable": "Das Profil konnte nicht geladen werden. Prüfe deine Verbindung.",
"publicProfileIntro": "Wie andere dich sehen, sobald du veröffentlichst: das Handle, unter dem sie dich finden, dein Name, ein paar Worte und Links. Es speichert sich beim Schreiben, sobald du dein Handle gewählt hast.", "publicProfileIntro": "Wie andere dich sehen, sobald du veröffentlichst: das Handle, unter dem sie dich finden, dein Name, ein paar Worte und Links. Es speichert sich beim Schreiben, sobald du dein Handle gewählt hast.",
"publicProfileAnonymousHint": "Melde dich mit GitHub an, um ein öffentliches Profil zu haben und zu veröffentlichen.", "publicProfileAnonymousHint": "Melde dich an, um ein öffentliches Profil zu haben und zu veröffentlichen.",
"publicProfileDisplayName": "Anzeigename", "publicProfileDisplayName": "Anzeigename",
"publicProfileDisplayNameHint": "Der Name neben deinem Handle", "publicProfileDisplayNameHint": "Der Name neben deinem Handle",
"publicProfileHandle": "Handle", "publicProfileHandle": "Handle",
@ -551,7 +581,7 @@
"accountAddSubtitle": "Wähle die Instanz für das Konto, das du hinzufügst.", "accountAddSubtitle": "Wähle die Instanz für das Konto, das du hinzufügst.",
"accountRemoveTooltip": "Konto entfernen", "accountRemoveTooltip": "Konto entfernen",
"accountRemoveTitle": "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 mit GitHub 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. In ein anonymes Konto kann man sich nicht wieder anmelden.",
"accountRemoveConfirm": "Entfernen", "accountRemoveConfirm": "Entfernen",
"settingsSignOut": "Abmelden", "settingsSignOut": "Abmelden",
"settingsSignOutTitle": "Abmelden?", "settingsSignOutTitle": "Abmelden?",
@ -1583,7 +1613,7 @@
"publishPrivate": "Privat · nur du siehst es", "publishPrivate": "Privat · nur du siehst es",
"publishPublished": "Veröffentlicht · v{version} · {date}", "publishPublished": "Veröffentlicht · v{version} · {date}",
"publishIntro": "Eine Veröffentlichung ist dieses Lernset mit allem darin, so wie es jetzt ist. Andere können es finden, damit lernen und es kopieren. Jede Veröffentlichung bleibt, wie sie war; spätere Änderungen kommen in die nächste.", "publishIntro": "Eine Veröffentlichung ist dieses Lernset mit allem darin, so wie es jetzt ist. Andere können es finden, damit lernen und es kopieren. Jede Veröffentlichung bleibt, wie sie war; spätere Änderungen kommen in die nächste.",
"publishAnonymous": "Melde dich mit GitHub an, um ein öffentliches Profil zu haben und zu veröffentlichen.", "publishAnonymous": "Melde dich an, um ein öffentliches Profil zu haben und zu veröffentlichen.",
"publishName": "Name", "publishName": "Name",
"publishNameHint": "japanisch", "publishNameHint": "japanisch",
"publishLocation": "Zu finden unter {location}", "publishLocation": "Zu finden unter {location}",
@ -1722,7 +1752,7 @@
"suggestIntro": "Im Original sieht man es so, wie du es jetzt hast, neben der eigenen Fassung. Öffentlich: alle, die es lernen, lesen es, mit deinem öffentlichen Profil.", "suggestIntro": "Im Original sieht man es so, wie du es jetzt hast, neben der eigenen Fassung. Öffentlich: alle, die es lernen, lesen es, mit deinem öffentlichen Profil.",
"suggestSent": "Vorschlag gesendet.", "suggestSent": "Vorschlag gesendet.",
"suggestNoDerivatives": "Seine Lizenz verbietet Bearbeitungen: keine Vorschläge.", "suggestNoDerivatives": "Seine Lizenz verbietet Bearbeitungen: keine Vorschläge.",
"feedbackNeedsIdentity": "Melde dich mit GitHub an, um im Original zu melden und vorzuschlagen.", "feedbackNeedsIdentity": "Melde dich an, um im Original zu melden und vorzuschlagen.",
"kanjiExampleWords": "Wörter damit", "kanjiExampleWords": "Wörter damit",
"settingsAssistant": "Assistent", "settingsAssistant": "Assistent",

View file

@ -541,12 +541,46 @@
"profileSwitchMessage": "This GitHub account already has progress from another device. The studysets on this device can't be combined with it yet: if you switch, they are deleted.", "profileSwitchMessage": "This GitHub account already has progress from another device. The studysets on this device can't be combined with it yet: if you switch, they are deleted.",
"profileSwitchStay": "Stay anonymous", "profileSwitchStay": "Stay anonymous",
"profileSwitchConfirm": "Switch", "profileSwitchConfirm": "Switch",
"profileContinueWithEmail": "Continue with email",
"profileSignInWays": "Ways to sign in",
"profileWayGitHub": "GitHub",
"profileWayEmail": "Email address",
"profileWayLinked": "Signs you in",
"profileAddGitHub": "Add GitHub",
"profileAddEmail": "Add an email address",
"profileGitHubTaken": "This GitHub account already signs in to another account.",
"emailSignInTitle": "Continue with email",
"emailSignInIntro": "We mail you a code to sign in. This instance keeps only a fingerprint of your address, never the address itself, and writes to it only when you ask for a code.",
"emailSignInAddress": "Email address",
"emailSignInSend": "Send code",
"emailSignInCodeSent": "We mailed a code to {address}. It's valid for 15 minutes.",
"@emailSignInCodeSent": {"placeholders": {"address": {"type": "String"}}},
"emailSignInResent": "A new code is on its way.",
"emailSignInCode": "Code",
"emailSignInContinue": "Continue",
"emailSignInResend": "Send a new code",
"emailSignInOtherAddress": "Use another address",
"emailSignInSwitchTitle": "Switch to the account of {address}?",
"@emailSignInSwitchTitle": {"placeholders": {"address": {"type": "String"}}},
"emailSignInSwitchMessage": "This address already has progress from another device. The studysets on this device can't be combined with it yet: if you switch, they are deleted.",
"emailSignInTaken": "{address} already signs in to another account. To switch to it, sign out first.",
"@emailSignInTaken": {"placeholders": {"address": {"type": "String"}}},
"emailSignInDone": "Signed in with {address}.",
"@emailSignInDone": {"placeholders": {"address": {"type": "String"}}},
"emailSignInUnavailable": "This instance doesn't offer signing in with email.",
"emailSignInNotAnAddress": "This isn't an email address.",
"emailSignInTooManyCodes": "Too many codes in the last hour. Try again later.",
"emailSignInWrongCode": "That code isn't right.",
"emailSignInTooManyTries": "Too many wrong codes. Ask for a new one.",
"emailSignInCodeExpired": "This code is no longer valid. Ask for a new one.",
"emailSignInOtherAddressLinked": "You already sign in with another address.",
"emailSignInFailed": "Couldn't reach the instance. Try again.",
"publicProfileTitle": "Public profile", "publicProfileTitle": "Public profile",
"profileSheetTitle": "Profile", "profileSheetTitle": "Profile",
"profileSheetNotFound": "Nobody on this instance has this handle.", "profileSheetNotFound": "Nobody on this instance has this handle.",
"profileSheetUnreachable": "The profile couldn’t be loaded. Check your connection.", "profileSheetUnreachable": "The profile couldn’t be loaded. Check your connection.",
"publicProfileIntro": "How others see you once you publish: the handle they find you by, your name, a few words and links. It saves as you write, once you have chosen your handle.", "publicProfileIntro": "How others see you once you publish: the handle they find you by, your name, a few words and links. It saves as you write, once you have chosen your handle.",
"publicProfileAnonymousHint": "Sign in with GitHub to have a public profile and publish.", "publicProfileAnonymousHint": "Sign in to have a public profile and publish.",
"publicProfileDisplayName": "Display name", "publicProfileDisplayName": "Display name",
"publicProfileDisplayNameHint": "The name shown beside your handle", "publicProfileDisplayNameHint": "The name shown beside your handle",
"publicProfileHandle": "Handle", "publicProfileHandle": "Handle",
@ -619,7 +653,7 @@
"accountAddSubtitle": "Choose the instance for the account to add.", "accountAddSubtitle": "Choose the instance for the account to add.",
"accountRemoveTooltip": "Remove account", "accountRemoveTooltip": "Remove account",
"accountRemoveTitle": "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 with GitHub to have it again. 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. An anonymous account can't be signed into again.",
"@accountRemoveMessage": {"placeholders": {"name": {"type": "String"}}}, "@accountRemoveMessage": {"placeholders": {"name": {"type": "String"}}},
"accountRemoveConfirm": "Remove", "accountRemoveConfirm": "Remove",
"settingsSignOut": "Sign out", "settingsSignOut": "Sign out",
@ -1855,7 +1889,7 @@
"publishPublished": "Published · v{version} · {date}", "publishPublished": "Published · v{version} · {date}",
"@publishPublished": {"placeholders": {"version": {"type": "int"}, "date": {"type": "String"}}}, "@publishPublished": {"placeholders": {"version": {"type": "int"}, "date": {"type": "String"}}},
"publishIntro": "A release is this studyset with everything inside it, as it is now. Others can find it, learn from it and copy it. Each release stays as it was; later changes go into the next one.", "publishIntro": "A release is this studyset with everything inside it, as it is now. Others can find it, learn from it and copy it. Each release stays as it was; later changes go into the next one.",
"publishAnonymous": "Sign in with GitHub to have a public profile and publish.", "publishAnonymous": "Sign in to have a public profile and publish.",
"publishName": "Name", "publishName": "Name",
"publishNameHint": "japanese", "publishNameHint": "japanese",
"publishLocation": "Found at {location}", "publishLocation": "Found at {location}",
@ -2018,7 +2052,7 @@
"suggestIntro": "The author sees it as you have it now, beside theirs. Public: everyone learning it reads it, with your public profile.", "suggestIntro": "The author sees it as you have it now, beside theirs. Public: everyone learning it reads it, with your public profile.",
"suggestSent": "Suggestion sent.", "suggestSent": "Suggestion sent.",
"suggestNoDerivatives": "Its licence forbids adaptations: no suggestions.", "suggestNoDerivatives": "Its licence forbids adaptations: no suggestions.",
"feedbackNeedsIdentity": "Sign in with GitHub to report to the author and suggest.", "feedbackNeedsIdentity": "Sign in to report to the author and suggest.",
"kanjiExampleWords": "Words with it", "kanjiExampleWords": "Words with it",
"settingsAssistant": "Assistant", "settingsAssistant": "Assistant",

View file

@ -2890,6 +2890,186 @@ abstract class AppLocalizations {
/// **'Switch'** /// **'Switch'**
String get profileSwitchConfirm; String get profileSwitchConfirm;
/// No description provided for @profileContinueWithEmail.
///
/// In en, this message translates to:
/// **'Continue with email'**
String get profileContinueWithEmail;
/// No description provided for @profileSignInWays.
///
/// In en, this message translates to:
/// **'Ways to sign in'**
String get profileSignInWays;
/// No description provided for @profileWayGitHub.
///
/// In en, this message translates to:
/// **'GitHub'**
String get profileWayGitHub;
/// No description provided for @profileWayEmail.
///
/// In en, this message translates to:
/// **'Email address'**
String get profileWayEmail;
/// No description provided for @profileWayLinked.
///
/// In en, this message translates to:
/// **'Signs you in'**
String get profileWayLinked;
/// No description provided for @profileAddGitHub.
///
/// In en, this message translates to:
/// **'Add GitHub'**
String get profileAddGitHub;
/// No description provided for @profileAddEmail.
///
/// In en, this message translates to:
/// **'Add an email address'**
String get profileAddEmail;
/// No description provided for @profileGitHubTaken.
///
/// In en, this message translates to:
/// **'This GitHub account already signs in to another account.'**
String get profileGitHubTaken;
/// No description provided for @emailSignInTitle.
///
/// In en, this message translates to:
/// **'Continue with email'**
String get emailSignInTitle;
/// No description provided for @emailSignInIntro.
///
/// In en, this message translates to:
/// **'We mail you a code to sign in. This instance keeps only a fingerprint of your address, never the address itself, and writes to it only when you ask for a code.'**
String get emailSignInIntro;
/// No description provided for @emailSignInAddress.
///
/// In en, this message translates to:
/// **'Email address'**
String get emailSignInAddress;
/// No description provided for @emailSignInSend.
///
/// In en, this message translates to:
/// **'Send code'**
String get emailSignInSend;
/// No description provided for @emailSignInCodeSent.
///
/// In en, this message translates to:
/// **'We mailed a code to {address}. It\'s valid for 15 minutes.'**
String emailSignInCodeSent(String address);
/// No description provided for @emailSignInResent.
///
/// In en, this message translates to:
/// **'A new code is on its way.'**
String get emailSignInResent;
/// No description provided for @emailSignInCode.
///
/// In en, this message translates to:
/// **'Code'**
String get emailSignInCode;
/// No description provided for @emailSignInContinue.
///
/// In en, this message translates to:
/// **'Continue'**
String get emailSignInContinue;
/// No description provided for @emailSignInResend.
///
/// In en, this message translates to:
/// **'Send a new code'**
String get emailSignInResend;
/// No description provided for @emailSignInOtherAddress.
///
/// In en, this message translates to:
/// **'Use another address'**
String get emailSignInOtherAddress;
/// No description provided for @emailSignInSwitchTitle.
///
/// In en, this message translates to:
/// **'Switch to the account of {address}?'**
String emailSignInSwitchTitle(String address);
/// No description provided for @emailSignInSwitchMessage.
///
/// In en, this message translates to:
/// **'This address already has progress from another device. The studysets on this device can\'t be combined with it yet: if you switch, they are deleted.'**
String get emailSignInSwitchMessage;
/// No description provided for @emailSignInTaken.
///
/// In en, this message translates to:
/// **'{address} already signs in to another account. To switch to it, sign out first.'**
String emailSignInTaken(String address);
/// No description provided for @emailSignInDone.
///
/// In en, this message translates to:
/// **'Signed in with {address}.'**
String emailSignInDone(String address);
/// No description provided for @emailSignInUnavailable.
///
/// In en, this message translates to:
/// **'This instance doesn\'t offer signing in with email.'**
String get emailSignInUnavailable;
/// No description provided for @emailSignInNotAnAddress.
///
/// In en, this message translates to:
/// **'This isn\'t an email address.'**
String get emailSignInNotAnAddress;
/// No description provided for @emailSignInTooManyCodes.
///
/// In en, this message translates to:
/// **'Too many codes in the last hour. Try again later.'**
String get emailSignInTooManyCodes;
/// No description provided for @emailSignInWrongCode.
///
/// In en, this message translates to:
/// **'That code isn\'t right.'**
String get emailSignInWrongCode;
/// No description provided for @emailSignInTooManyTries.
///
/// In en, this message translates to:
/// **'Too many wrong codes. Ask for a new one.'**
String get emailSignInTooManyTries;
/// No description provided for @emailSignInCodeExpired.
///
/// In en, this message translates to:
/// **'This code is no longer valid. Ask for a new one.'**
String get emailSignInCodeExpired;
/// No description provided for @emailSignInOtherAddressLinked.
///
/// In en, this message translates to:
/// **'You already sign in with another address.'**
String get emailSignInOtherAddressLinked;
/// No description provided for @emailSignInFailed.
///
/// In en, this message translates to:
/// **'Couldn\'t reach the instance. Try again.'**
String get emailSignInFailed;
/// No description provided for @publicProfileTitle. /// No description provided for @publicProfileTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -2923,7 +3103,7 @@ abstract class AppLocalizations {
/// No description provided for @publicProfileAnonymousHint. /// No description provided for @publicProfileAnonymousHint.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Sign in with GitHub to have a public profile and publish.'** /// **'Sign in to have a public profile and publish.'**
String get publicProfileAnonymousHint; String get publicProfileAnonymousHint;
/// No description provided for @publicProfileDisplayName. /// No description provided for @publicProfileDisplayName.
@ -3301,7 +3481,7 @@ abstract class AppLocalizations {
/// No description provided for @accountRemoveMessage. /// No description provided for @accountRemoveMessage.
/// ///
/// In en, this message translates to: /// 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 with GitHub to have it again. 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. An anonymous account can\'t be signed into again.'**
String accountRemoveMessage(String name); String accountRemoveMessage(String name);
/// No description provided for @accountRemoveConfirm. /// No description provided for @accountRemoveConfirm.
@ -9366,7 +9546,7 @@ abstract class AppLocalizations {
/// No description provided for @publishAnonymous. /// No description provided for @publishAnonymous.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Sign in with GitHub to have a public profile and publish.'** /// **'Sign in to have a public profile and publish.'**
String get publishAnonymous; String get publishAnonymous;
/// No description provided for @publishName. /// No description provided for @publishName.
@ -10200,7 +10380,7 @@ abstract class AppLocalizations {
/// No description provided for @feedbackNeedsIdentity. /// No description provided for @feedbackNeedsIdentity.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Sign in with GitHub to report to the author and suggest.'** /// **'Sign in to report to the author and suggest.'**
String get feedbackNeedsIdentity; String get feedbackNeedsIdentity;
/// No description provided for @kanjiExampleWords. /// No description provided for @kanjiExampleWords.

View file

@ -1778,6 +1778,113 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get profileSwitchConfirm => 'Wechseln'; String get profileSwitchConfirm => 'Wechseln';
@override
String get profileContinueWithEmail => 'Mit E-Mail fortfahren';
@override
String get profileSignInWays => 'Anmeldewege';
@override
String get profileWayGitHub => 'GitHub';
@override
String get profileWayEmail => 'E-Mail-Adresse';
@override
String get profileWayLinked => 'Meldet dich an';
@override
String get profileAddGitHub => 'GitHub hinzufügen';
@override
String get profileAddEmail => 'E-Mail-Adresse hinzufügen';
@override
String get profileGitHubTaken =>
'Mit diesem GitHub-Konto meldet sich schon ein anderes Konto an.';
@override
String get emailSignInTitle => 'Mit E-Mail fortfahren';
@override
String get emailSignInIntro =>
'Wir schicken dir einen Code zum Anmelden. Diese Instanz behält nur einen Fingerabdruck deiner Adresse, nie die Adresse selbst, und schreibt ihr nur, wenn du einen Code anforderst.';
@override
String get emailSignInAddress => 'E-Mail-Adresse';
@override
String get emailSignInSend => 'Code senden';
@override
String emailSignInCodeSent(String address) {
return 'Wir haben einen Code an $address geschickt. Er gilt 15 Minuten.';
}
@override
String get emailSignInResent => 'Ein neuer Code ist unterwegs.';
@override
String get emailSignInCode => 'Code';
@override
String get emailSignInContinue => 'Weiter';
@override
String get emailSignInResend => 'Neuen Code senden';
@override
String get emailSignInOtherAddress => 'Andere Adresse verwenden';
@override
String emailSignInSwitchTitle(String address) {
return 'Zum Konto von $address wechseln?';
}
@override
String get emailSignInSwitchMessage =>
'Diese Adresse hat bereits Fortschritt von einem anderen Gerät. Die Lernsets auf diesem Gerät lassen sich noch nicht damit zusammenführen: Wenn du wechselst, werden sie gelöscht.';
@override
String emailSignInTaken(String address) {
return 'Mit $address meldet sich schon ein anderes Konto an. Um zu ihm zu wechseln, melde dich zuerst ab.';
}
@override
String emailSignInDone(String address) {
return 'Mit $address angemeldet.';
}
@override
String get emailSignInUnavailable =>
'Diese Instanz bietet keine Anmeldung mit E-Mail an.';
@override
String get emailSignInNotAnAddress => 'Das ist keine E-Mail-Adresse.';
@override
String get emailSignInTooManyCodes =>
'Zu viele Codes in der letzten Stunde. Versuche es später noch einmal.';
@override
String get emailSignInWrongCode => 'Dieser Code stimmt nicht.';
@override
String get emailSignInTooManyTries =>
'Zu viele falsche Codes. Fordere einen neuen an.';
@override
String get emailSignInCodeExpired =>
'Dieser Code gilt nicht mehr. Fordere einen neuen an.';
@override
String get emailSignInOtherAddressLinked =>
'Du meldest dich schon mit einer anderen Adresse an.';
@override
String get emailSignInFailed =>
'Die Instanz war nicht erreichbar. Versuche es noch einmal.';
@override @override
String get publicProfileTitle => 'Öffentliches Profil'; String get publicProfileTitle => 'Öffentliches Profil';
@ -1798,7 +1905,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get publicProfileAnonymousHint => String get publicProfileAnonymousHint =>
'Melde dich mit GitHub an, um ein öffentliches Profil zu haben und zu veröffentlichen.'; 'Melde dich an, um ein öffentliches Profil zu haben und zu veröffentlichen.';
@override @override
String get publicProfileDisplayName => 'Anzeigename'; String get publicProfileDisplayName => 'Anzeigename';
@ -2014,7 +2121,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String accountRemoveMessage(String name) { 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 mit GitHub 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. In ein anonymes Konto kann man sich nicht wieder anmelden.';
} }
@override @override
@ -5899,7 +6006,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get publishAnonymous => String get publishAnonymous =>
'Melde dich mit GitHub an, um ein öffentliches Profil zu haben und zu veröffentlichen.'; 'Melde dich an, um ein öffentliches Profil zu haben und zu veröffentlichen.';
@override @override
String get publishName => 'Name'; String get publishName => 'Name';
@ -6411,7 +6518,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get feedbackNeedsIdentity => String get feedbackNeedsIdentity =>
'Melde dich mit GitHub an, um im Original zu melden und vorzuschlagen.'; 'Melde dich an, um im Original zu melden und vorzuschlagen.';
@override @override
String get kanjiExampleWords => 'Wörter damit'; String get kanjiExampleWords => 'Wörter damit';

View file

@ -1762,6 +1762,112 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get profileSwitchConfirm => 'Switch'; String get profileSwitchConfirm => 'Switch';
@override
String get profileContinueWithEmail => 'Continue with email';
@override
String get profileSignInWays => 'Ways to sign in';
@override
String get profileWayGitHub => 'GitHub';
@override
String get profileWayEmail => 'Email address';
@override
String get profileWayLinked => 'Signs you in';
@override
String get profileAddGitHub => 'Add GitHub';
@override
String get profileAddEmail => 'Add an email address';
@override
String get profileGitHubTaken =>
'This GitHub account already signs in to another account.';
@override
String get emailSignInTitle => 'Continue with email';
@override
String get emailSignInIntro =>
'We mail you a code to sign in. This instance keeps only a fingerprint of your address, never the address itself, and writes to it only when you ask for a code.';
@override
String get emailSignInAddress => 'Email address';
@override
String get emailSignInSend => 'Send code';
@override
String emailSignInCodeSent(String address) {
return 'We mailed a code to $address. It\'s valid for 15 minutes.';
}
@override
String get emailSignInResent => 'A new code is on its way.';
@override
String get emailSignInCode => 'Code';
@override
String get emailSignInContinue => 'Continue';
@override
String get emailSignInResend => 'Send a new code';
@override
String get emailSignInOtherAddress => 'Use another address';
@override
String emailSignInSwitchTitle(String address) {
return 'Switch to the account of $address?';
}
@override
String get emailSignInSwitchMessage =>
'This address already has progress from another device. The studysets on this device can\'t be combined with it yet: if you switch, they are deleted.';
@override
String emailSignInTaken(String address) {
return '$address already signs in to another account. To switch to it, sign out first.';
}
@override
String emailSignInDone(String address) {
return 'Signed in with $address.';
}
@override
String get emailSignInUnavailable =>
'This instance doesn\'t offer signing in with email.';
@override
String get emailSignInNotAnAddress => 'This isn\'t an email address.';
@override
String get emailSignInTooManyCodes =>
'Too many codes in the last hour. Try again later.';
@override
String get emailSignInWrongCode => 'That code isn\'t right.';
@override
String get emailSignInTooManyTries =>
'Too many wrong codes. Ask for a new one.';
@override
String get emailSignInCodeExpired =>
'This code is no longer valid. Ask for a new one.';
@override
String get emailSignInOtherAddressLinked =>
'You already sign in with another address.';
@override
String get emailSignInFailed => 'Couldn\'t reach the instance. Try again.';
@override @override
String get publicProfileTitle => 'Public profile'; String get publicProfileTitle => 'Public profile';
@ -1781,7 +1887,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get publicProfileAnonymousHint => String get publicProfileAnonymousHint =>
'Sign in with GitHub to have a public profile and publish.'; 'Sign in to have a public profile and publish.';
@override @override
String get publicProfileDisplayName => 'Display name'; String get publicProfileDisplayName => 'Display name';
@ -1996,7 +2102,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String accountRemoveMessage(String name) { 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 with GitHub to have it again. 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. An anonymous account can\'t be signed into again.';
} }
@override @override
@ -5846,7 +5952,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get publishAnonymous => String get publishAnonymous =>
'Sign in with GitHub to have a public profile and publish.'; 'Sign in to have a public profile and publish.';
@override @override
String get publishName => 'Name'; String get publishName => 'Name';
@ -6350,7 +6456,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get feedbackNeedsIdentity => String get feedbackNeedsIdentity =>
'Sign in with GitHub to report to the author and suggest.'; 'Sign in to report to the author and suggest.';
@override @override
String get kanjiExampleWords => 'Words with it'; String get kanjiExampleWords => 'Words with it';

View file

@ -32,6 +32,7 @@ import 'package:halkyon_learn_flutter/src/feature/assistant/data/serverpod_assis
import 'package:halkyon_learn_flutter/src/feature/assistant/data/web_search.dart'; import 'package:halkyon_learn_flutter/src/feature/assistant/data/web_search.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/data/serverpod_auth_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/auth/data/serverpod_auth_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/data/sign_in_addresses.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart'; import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_deletions.dart'; import 'package:halkyon_learn_flutter/src/feature/card/data/card_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
@ -213,7 +214,10 @@ class Dependencies({
var previousClosed = Future<void>.value(); var previousClosed = Future<void>.value();
return Dependencies( return Dependencies(
serverName: instance.name, serverName: instance.name,
auth: AuthRepository(authGateway), auth: AuthRepository(
authGateway,
FileSignInAddresses(device.storage.peopleOf(instance)),
),
about: AboutRepository(ServerpodAboutGateway(client)), about: AboutRepository(ServerpodAboutGateway(client)),
dictionaries: DictionaryRepository(ServerpodDictionaryGateway(client)), dictionaries: DictionaryRepository(ServerpodDictionaryGateway(client)),
maps: GeoMapRepository(), maps: GeoMapRepository(),

View file

@ -37,6 +37,9 @@ abstract final class TextLimits() {
/// A web address: a link, a search engine's, an instance's. /// A web address: a link, a search engine's, an instance's.
static const address = 2000; static const address = 2000;
/// An email address: as long as mail allows one (RFC 5321).
static const emailAddress = 254;
/// A longer text typed as one field: a passage with gaps, a formula. /// A longer text typed as one field: a passage with gaps, a formula.
static const passage = 5000; static const passage = 5000;

View file

@ -0,0 +1,256 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/model/email_sign_in.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
/// Signs in with an email address, by a code mailed to it (ADR 0010), as
/// signing in with GitHub does: a new address becomes the person's, who
/// keeps everything; an address that is another person's switches this
/// device to them, right away when it holds nothing, otherwise once the
/// person confirms that this device's studysets stay behind. Someone
/// identified is never switched: they are told the address is another
/// account's, as their own account would otherwise be left.
class EmailSignInBloc(
final AuthRepository _auth,
final StudysetRepository _studysets, {
/// The language the mail is worded in (`en`, `de`).
required final String language,
}) extends Bloc<EmailSignInEvent, EmailSignInState> {
this : super(const EmailSignInStateAddress()) {
on<EmailSignInEventCodeRequested>(_onCodeRequested);
on<EmailSignInEventCodeEntered>(_onCodeEntered);
on<EmailSignInEventCodeResent>(_onCodeResent);
on<EmailSignInEventAddressReopened>(
(event, emit) => emit(const EmailSignInStateAddress()),
);
on<EmailSignInEventSwitchConfirmed>(_onSwitchConfirmed);
on<EmailSignInEventSwitchDeclined>(
(event, emit) => emit(const EmailSignInStateAddress()),
);
}
Future<void> _onCodeRequested(
EmailSignInEventCodeRequested event,
Emitter<EmailSignInState> emit,
) async {
// As the instance takes it, and as the person reads it back.
final address = event.address.trim().toLowerCase();
emit(const EmailSignInStateAddress(sending: true));
try {
emit(
EmailSignInStateCode(
address: address,
request: await _auth.sendEmailCode(address, language: language),
),
);
} on Exception catch (error, st) {
emit(EmailSignInStateAddress(problem: _problemOf(error, st)));
}
}
Future<void> _onCodeResent(
EmailSignInEventCodeResent event,
Emitter<EmailSignInState> emit,
) async {
if (state case final EmailSignInStateCode code) {
emit(code.checked());
try {
emit(
EmailSignInStateCode(
address: code.address,
request: await _auth.sendEmailCode(
code.address,
language: language,
),
resent: true,
),
);
} on Exception catch (error, st) {
emit(code.failed(_problemOf(error, st)));
}
}
}
Future<void> _onCodeEntered(
EmailSignInEventCodeEntered event,
Emitter<EmailSignInState> emit,
) async {
final code = state;
if (code is! EmailSignInStateCode) {
return;
}
emit(code.checked());
try {
switch (await _auth.verifyEmailCode(
code.request,
event.code,
address: code.address,
)) {
case EmailLinked():
emit(EmailSignInStateLinked(address: code.address));
case EmailTakenByAnotherPerson() when _auth.isIdentified:
emit(EmailSignInStateTaken(address: code.address));
case EmailTakenByAnotherPerson(:final switchToken):
// This device's content is its studysets, for now, those waiting
// in Recently deleted too.
if ((await _studysets.withDeleted()).isEmpty) {
await _switch(code.address, switchToken, emit);
} else {
emit(
EmailSignInStateConfirmSwitch(
address: code.address,
switchToken: switchToken,
),
);
}
}
} on Exception catch (error, st) {
emit(code.failed(_problemOf(error, st)));
}
}
Future<void> _onSwitchConfirmed(
EmailSignInEventSwitchConfirmed event,
Emitter<EmailSignInState> emit,
) async {
if (state case EmailSignInStateConfirmSwitch(
:final address,
:final switchToken,
)) {
try {
await _switch(address, switchToken, emit);
} on Exception catch (error, st) {
emit(EmailSignInStateAddress(problem: _problemOf(error, st)));
}
}
}
// After a switch, the previous person's whole subtree goes away, this bloc
// with it, so nothing is emitted then.
Future<void> _switch(
String address,
String switchToken,
Emitter<EmailSignInState> emit,
) async {
emit(const EmailSignInStateSwitching());
await _auth.switchToEmail(switchToken, address: address);
}
EmailSignInProblem _problemOf(Exception error, StackTrace st) {
if (error is EmailSignInFailed) {
return error.problem;
}
addError(error, st);
return EmailSignInProblem.failed;
}
}
/// What happens to signing in with an email address.
sealed class const EmailSignInEvent() with Equatable {
@override
List<Object?> get props => const [];
}
/// Mail a code to [address].
final class const EmailSignInEventCodeRequested(final String address)
extends EmailSignInEvent {
@override
List<Object?> get props => [address];
}
/// The person typed [code], from the mail.
final class const EmailSignInEventCodeEntered(final String code)
extends EmailSignInEvent {
@override
List<Object?> get props => [code];
}
/// Mail a new code to the same address.
final class const EmailSignInEventCodeResent() extends EmailSignInEvent;
/// Type another address.
final class const EmailSignInEventAddressReopened() extends EmailSignInEvent;
/// Switch to the address's person; this device's studysets stay behind.
final class const EmailSignInEventSwitchConfirmed() extends EmailSignInEvent;
/// Stay anonymous.
final class const EmailSignInEventSwitchDeclined() extends EmailSignInEvent;
/// Signing in with an email address, as its sheet shows it.
sealed class const EmailSignInState() with Equatable {
@override
List<Object?> get props => const [];
}
/// Typing the address.
final class const EmailSignInStateAddress({
/// What went wrong the last time.
final EmailSignInProblem? problem,
/// Whether the code is being mailed.
final bool sending = false,
}) extends EmailSignInState {
@override
List<Object?> get props => [problem, sending];
}
/// Typing the code mailed to [address] for [request].
final class const EmailSignInStateCode({
required final String address,
required final String request,
/// What went wrong the last time.
final EmailSignInProblem? problem,
/// Whether the instance is being asked: about the code, or for a new one.
final bool checking = false,
/// Whether a new code was mailed on request.
final bool resent = false,
}) extends EmailSignInState {
/// This, while the instance is asked.
EmailSignInStateCode checked() =>
EmailSignInStateCode(address: address, request: request, checking: true);
/// This, once [problem] came back.
EmailSignInStateCode failed(EmailSignInProblem problem) =>
EmailSignInStateCode(
address: address,
request: request,
problem: problem,
);
@override
List<Object?> get props => [address, request, problem, checking, resent];
}
/// The address is another person's and this device holds studysets: switch
/// and leave them behind, or stay anonymous?
final class const EmailSignInStateConfirmSwitch({
required final String address,
required final String switchToken,
}) extends EmailSignInState {
@override
List<Object?> get props => [address, switchToken];
}
/// Switching this device to the address's person.
final class const EmailSignInStateSwitching() extends EmailSignInState;
/// The address is another account's, and the person signed in is
/// identified: nothing changed.
final class const EmailSignInStateTaken({required final String address})
extends EmailSignInState {
@override
List<Object?> get props => [address];
}
/// The address is now the person's.
final class const EmailSignInStateLinked({required final String address})
extends EmailSignInState {
@override
List<Object?> get props => [address];
}

View file

@ -4,12 +4,16 @@ import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_repository.dart
import 'package:halkyon_learn_flutter/src/feature/auth/model/github_link_outcome.dart'; import 'package:halkyon_learn_flutter/src/feature/auth/model/github_link_outcome.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
/// Signs an anonymous person in with GitHub, keeping everything they have. /// 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 /// If the GitHub account already belongs to another person (typically their
/// account from another device), this device switches to that person: right /// account from another device), this device switches to that person: right
/// away when it holds nothing, otherwise only once the person confirms that /// away when it holds nothing, otherwise only once the person confirms that
/// this device's studysets stay behind (decided 2026-09-30). /// this device's studysets stay behind (decided 2026-09-30). Someone
/// identified is never switched, as their own account would be left: they
/// are told the GitHub account is another's.
class GitHubSignInBloc( class GitHubSignInBloc(
final AuthRepository _auth, final AuthRepository _auth,
final StudysetRepository _studysets, final StudysetRepository _studysets,
@ -64,6 +68,8 @@ class GitHubSignInBloc(
emit(const GitHubSignInStateLinked()); emit(const GitHubSignInStateLinked());
case GitHubLinkOutcome.cancelled: case GitHubLinkOutcome.cancelled:
emit(const GitHubSignInStateReady()); emit(const GitHubSignInStateReady());
case GitHubLinkOutcome.takenByAnotherPerson when _auth.isIdentified:
emit(const GitHubSignInStateTaken());
case GitHubLinkOutcome.takenByAnotherPerson: case GitHubLinkOutcome.takenByAnotherPerson:
// This device's content is its studysets, for now, those waiting // This device's content is its studysets, for now, those waiting
// in Recently deleted too. // in Recently deleted too.
@ -160,3 +166,7 @@ final class const GitHubSignInStateConfirmSwitch() extends GitHubSignInState;
/// Linked: the person now signs in with GitHub. /// Linked: the person now signs in with GitHub.
final class const GitHubSignInStateLinked() extends GitHubSignInState; 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;

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