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:
parent
58e13521b1
commit
1cfd391dbc
76 changed files with 21720 additions and 103 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
11
AGENTS.md
11
AGENTS.md
|
|
@ -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`.
|
||||||
|
|
|
||||||
80
docs/adr/0010-email-sign-in.md
Normal file
80
docs/adr/0010-email-sign-in.md
Normal 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.
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
92
halkyon_learn_client/lib/src/protocol/auth/sign_in_ways.dart
Normal file
92
halkyon_learn_client/lib/src/protocol/auth/sign_in_ways.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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']);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
|
}
|
||||||
|
|
@ -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
Loading…
Reference in a new issue