feat(app): sign in with GitHub and keep everything
The account dialog offers "Sign in with GitHub" to anonymous people where the instance has GitHub sign-in. It links the GitHub account to the person, who keeps all their studysets. If the account already belongs to another person (typically their account from another device), the app switches to that person right away when this device holds nothing, and otherwise asks first: switch and leave this device's studysets behind, or stay anonymous. ServerpodAuthGateway uses Serverpod's GitHubSignInService for the browser part (PKCE), with the system browser and a loopback address on Linux and Windows (http://127.0.0.1:43823/github-callback) rather than an embedded webview, and app.halkyon.learn://github-callback elsewhere; Android gets the callback activity. After linking, the device's copy of the session gets the identified scope. A switch signs in through a client without a session, swaps sessions directly (never through "signed out", which would start another anonymous account), then ends the anonymous session left behind. GitHubSignInBloc holds the flow; tests cover every branch (GitHub sign-in off, linked, cancelled on GitHub, switching right away, asking with staying anonymous as the refusal, switching once confirmed, a failure), and the widget tests the dialog. StudysetRepository gains all(), a one-off read: Stream.first on its stream never completes under the widget tests' fake clock, because the broadcast subscription's cancel future belongs to the root zone; AGENTS.md records it along with the GitHub pattern. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
17dcf75219
commit
312b18be5a
13 changed files with 775 additions and 32 deletions
|
|
@ -95,6 +95,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
|||
- **Test dependencies come from a test factory**, `testDependencies(...)` in `test/support/test_dependencies.dart`, filling in fakes by default (a factory in `lib/` couldn't reach the fakes in `test/`), so a new dependency changes one place. Don't make dependencies nullable to spare tests: that moves null checks into production code.
|
||||
- Shared fakes live in `test/support/`; fakes pre-stub their streams so an incidental subscription doesn't fail the test.
|
||||
- **Editor in widget tests**: host `AppFlowyEditor` in a scrollable body with a fixed-height slot (an over-constrained `Expanded` hangs the test), and `pump(const Duration(seconds: 1))` after editor changes to flush its timers. Test the document ↔ JSON round-trip as plain unit tests.
|
||||
- **No `Stream.first` on a repository's stream in code that widget tests run**: `first` waits for the subscription's cancel future, which for a broadcast stream is a completed future of the root zone, so under the widget test's fake clock it never completes. Read once with the repository's `all()` instead.
|
||||
- **Layout-sensitive widget tests** set `tester.view.physicalSize` explicitly, so a responsive branch isn't missed for the wrong reason.
|
||||
- **Sync tests run against a running server** (ADR 0001, rule 9: the client's and the server's sync engines can't share one process): `halkyon_learn_flutter/test/sync/`, tagged `requires-server`, skipped by `tool/check.sh`; run them with `flutter test -t requires-server` while `serverpod start` runs. Every device is a client with in-memory session storage and its own local database, with the app's real gateway and repository on top; tests sync explicitly. What only the operator could see (a blanked payload, a logged refusal) is checked with SQL against the development database. They live in the app package because the server's Docker build resolves the server's dependencies without the client package. They must not initialize a Flutter test binding (no `testWidgets`), which would replace real HTTP with a mock.
|
||||
- **Widget tests of the signed-in app** pump `SessionGate` with `testDependencies(...)` and `pumpAndSettle()` until `PersonScope` has opened the person's data; the fake gateways work in memory (`FakeStudysetGateway`, with `failReads` for the error path).
|
||||
|
|
@ -103,6 +104,9 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
|||
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **Serverpod's own helpers aren't guarded**: `ServerSideSessions.revokeSession` deletes by id alone, and `listSessions` without `authUserId` returns everyone's sessions. Never pass them anything a client sent.
|
||||
|
|
|
|||
|
|
@ -25,6 +25,18 @@
|
|||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Receives GitHub's redirect after signing in (app.halkyon.learn://github-callback). -->
|
||||
<activity
|
||||
android:name="com.linusu.flutter_web_auth_2.CallbackActivity"
|
||||
android:exported="true"
|
||||
android:taskAffinity="">
|
||||
<intent-filter android:label="flutter_web_auth_2">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="app.halkyon.learn" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
|
|
|
|||
|
|
@ -0,0 +1,138 @@
|
|||
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/github_link_outcome.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
|
||||
/// Signs an anonymous person in with GitHub, keeping everything they have.
|
||||
///
|
||||
/// If the GitHub account already belongs to another person (typically their
|
||||
/// account from another device), this device switches to that person: right
|
||||
/// away when it holds nothing, otherwise only once the person confirms that
|
||||
/// this device's studysets stay behind (decided 2026-09-30).
|
||||
class GitHubSignInBloc(
|
||||
final AuthRepository _auth,
|
||||
final StudysetRepository _studysets,
|
||||
) extends Bloc<GitHubSignInEvent, GitHubSignInState> {
|
||||
this : super(const GitHubSignInStateChecking()) {
|
||||
on<GitHubSignInEventStarted>(_onStarted);
|
||||
on<GitHubSignInEventRequested>(_onRequested);
|
||||
on<GitHubSignInEventSwitchConfirmed>(_onSwitchConfirmed);
|
||||
on<GitHubSignInEventSwitchDeclined>(
|
||||
(event, emit) => emit(const GitHubSignInStateReady()),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onStarted(
|
||||
GitHubSignInEventStarted event,
|
||||
Emitter<GitHubSignInState> emit,
|
||||
) async {
|
||||
try {
|
||||
emit(
|
||||
await _auth.canSignInWithGitHub()
|
||||
? const GitHubSignInStateReady()
|
||||
: const GitHubSignInStateUnavailable(),
|
||||
);
|
||||
} on Exception catch (error, st) {
|
||||
// Without the instance, signing in couldn't work either.
|
||||
addError(error, st);
|
||||
emit(const GitHubSignInStateUnavailable());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onRequested(
|
||||
GitHubSignInEventRequested event,
|
||||
Emitter<GitHubSignInState> emit,
|
||||
) async {
|
||||
emit(const GitHubSignInStateBusy());
|
||||
try {
|
||||
switch (await _auth.linkGitHub()) {
|
||||
case GitHubLinkOutcome.linked:
|
||||
emit(const GitHubSignInStateLinked());
|
||||
case GitHubLinkOutcome.cancelled:
|
||||
emit(const GitHubSignInStateReady());
|
||||
case GitHubLinkOutcome.takenByAnotherPerson:
|
||||
// This device's content is its studysets, for now.
|
||||
if ((await _studysets.all()).isEmpty) {
|
||||
await _switch(emit);
|
||||
} else {
|
||||
emit(const GitHubSignInStateConfirmSwitch());
|
||||
}
|
||||
}
|
||||
} on Exception catch (error, st) {
|
||||
addError(error, st);
|
||||
emit(const GitHubSignInStateReady(failed: true));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onSwitchConfirmed(
|
||||
GitHubSignInEventSwitchConfirmed event,
|
||||
Emitter<GitHubSignInState> emit,
|
||||
) async {
|
||||
emit(const GitHubSignInStateBusy());
|
||||
try {
|
||||
await _switch(emit);
|
||||
} on Exception catch (error, st) {
|
||||
addError(error, st);
|
||||
emit(const GitHubSignInStateReady(failed: true));
|
||||
}
|
||||
}
|
||||
|
||||
// After a switch, the previous person's whole subtree goes away, this bloc
|
||||
// with it, so nothing is emitted then.
|
||||
Future<void> _switch(Emitter<GitHubSignInState> emit) async {
|
||||
if (!await _auth.switchToGitHub()) {
|
||||
emit(const GitHubSignInStateReady());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What happens to signing in with GitHub.
|
||||
sealed class const GitHubSignInEvent() with Equatable {
|
||||
@override
|
||||
List<Object?> get props => const [];
|
||||
}
|
||||
|
||||
/// The account dialog opens: does this instance offer GitHub sign-in?
|
||||
final class const GitHubSignInEventStarted() extends GitHubSignInEvent;
|
||||
|
||||
/// Sign in with GitHub.
|
||||
final class const GitHubSignInEventRequested() extends GitHubSignInEvent;
|
||||
|
||||
/// Switch to the GitHub account's person; this device's studysets stay
|
||||
/// behind.
|
||||
final class const GitHubSignInEventSwitchConfirmed() extends GitHubSignInEvent;
|
||||
|
||||
/// Stay anonymous.
|
||||
final class const GitHubSignInEventSwitchDeclined() extends GitHubSignInEvent;
|
||||
|
||||
/// Signing in with GitHub, as the account dialog shows it.
|
||||
sealed class const GitHubSignInState() with Equatable {
|
||||
@override
|
||||
List<Object?> get props => const [];
|
||||
}
|
||||
|
||||
/// Asking the instance whether it offers GitHub sign-in.
|
||||
final class const GitHubSignInStateChecking() extends GitHubSignInState;
|
||||
|
||||
/// This instance has no GitHub sign-in, or can't be reached.
|
||||
final class const GitHubSignInStateUnavailable() extends GitHubSignInState;
|
||||
|
||||
/// Ready to sign in with GitHub.
|
||||
final class const GitHubSignInStateReady({
|
||||
/// Whether the last attempt failed.
|
||||
final bool failed = false,
|
||||
}) extends GitHubSignInState {
|
||||
@override
|
||||
List<Object?> get props => [failed];
|
||||
}
|
||||
|
||||
/// Signing in: GitHub's page is open, or the server is being asked.
|
||||
final class const GitHubSignInStateBusy() extends GitHubSignInState;
|
||||
|
||||
/// The GitHub account belongs to another person and this device holds
|
||||
/// studysets: switch and leave them behind, or stay anonymous?
|
||||
final class const GitHubSignInStateConfirmSwitch() extends GitHubSignInState;
|
||||
|
||||
/// Linked: the person now signs in with GitHub.
|
||||
final class const GitHubSignInStateLinked() extends GitHubSignInState;
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/model/github_link_outcome.dart';
|
||||
|
||||
/// The boundary to the server's authentication: who is signed in, and signing
|
||||
/// in and out. Tests replace it with a fake.
|
||||
|
|
@ -14,4 +15,16 @@ abstract interface class AuthGateway() {
|
|||
|
||||
/// Signs out on this device.
|
||||
Future<void> signOut();
|
||||
|
||||
/// The client ID of this instance's GitHub OAuth app; null when GitHub
|
||||
/// sign-in is off on this instance.
|
||||
Future<String?> gitHubClientId();
|
||||
|
||||
/// Authorizes with GitHub in the browser and links the GitHub account to the
|
||||
/// signed-in person: the lossless upgrade from an anonymous account.
|
||||
Future<GitHubLinkOutcome> linkGitHub();
|
||||
|
||||
/// Authorizes with GitHub again and switches this device to the person who
|
||||
/// has that GitHub account; false when the person cancelled on GitHub.
|
||||
Future<bool> switchToGitHub();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/model/github_link_outcome.dart';
|
||||
|
||||
/// Who is signed in, and signing in and out, as the features use it.
|
||||
class AuthRepository(final AuthGateway _gateway) {
|
||||
|
|
@ -20,4 +21,16 @@ class AuthRepository(final AuthGateway _gateway) {
|
|||
/// Signs out on this device. An anonymous person can never sign back in, so
|
||||
/// the UI warns before calling this.
|
||||
Future<void> signOut() => _gateway.signOut();
|
||||
|
||||
/// Whether this instance offers signing in with GitHub.
|
||||
Future<bool> canSignInWithGitHub() async =>
|
||||
await _gateway.gitHubClientId() != null;
|
||||
|
||||
/// Links a GitHub account to the signed-in person; the person, and all their
|
||||
/// studysets, stay the same.
|
||||
Future<GitHubLinkOutcome> linkGitHub() => _gateway.linkGitHub();
|
||||
|
||||
/// Switches this device to the person who has the GitHub account. The
|
||||
/// anonymous account this device leaves behind can't be signed in again.
|
||||
Future<bool> switchToGitHub() => _gateway.switchToGitHub();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:halkyon_learn_client/halkyon_learn_client.dart' show Client;
|
||||
import 'package:halkyon_learn_client/halkyon_learn_client.dart'
|
||||
show Client, GitHubSignInRefusal, GitHubSignInRefusedException;
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/model/github_link_outcome.dart';
|
||||
import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart';
|
||||
|
||||
/// [AuthGateway] on top of the Serverpod client and its session manager,
|
||||
|
|
@ -17,6 +21,20 @@ class ServerpodAuthGateway(final Client _client) implements AuthGateway {
|
|||
/// The scope a person gets once they have an identity (step 6).
|
||||
static const _identifiedScope = 'identified';
|
||||
|
||||
/// Where GitHub sends the browser back after authorizing: the app's own
|
||||
/// scheme on Android, iOS and macOS; on Linux and Windows a loopback address
|
||||
/// the app listens on meanwhile (GitHub accepts any port there, so the
|
||||
/// OAuth app registers `http://127.0.0.1/github-callback`).
|
||||
static final _gitHubCallback = Platform.isLinux || Platform.isWindows
|
||||
? (
|
||||
redirectUri: 'http://127.0.0.1:43823/github-callback',
|
||||
urlScheme: 'http://127.0.0.1:43823',
|
||||
)
|
||||
: (
|
||||
redirectUri: 'app.halkyon.learn://github-callback',
|
||||
urlScheme: 'app.halkyon.learn',
|
||||
);
|
||||
|
||||
@override
|
||||
AuthStatus get status => switch (_client.auth.authInfo) {
|
||||
null => const SignedOut(),
|
||||
|
|
@ -40,6 +58,115 @@ class ServerpodAuthGateway(final Client _client) implements AuthGateway {
|
|||
await _client.auth.signOutDevice();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> gitHubClientId() async =>
|
||||
(await _client.instance.info()).gitHubClientId;
|
||||
|
||||
@override
|
||||
Future<GitHubLinkOutcome> linkGitHub() async {
|
||||
final authorization = await _authorizeWithGitHub();
|
||||
if (authorization == null) {
|
||||
return GitHubLinkOutcome.cancelled;
|
||||
}
|
||||
try {
|
||||
await _client.gitHubUpgrade.upgrade(
|
||||
code: authorization.code,
|
||||
codeVerifier: authorization.codeVerifier,
|
||||
redirectUri: authorization.redirectUri,
|
||||
);
|
||||
} on GitHubSignInRefusedException catch (error) {
|
||||
if (error.reason != GitHubSignInRefusal.takenByAnotherPerson) {
|
||||
rethrow;
|
||||
}
|
||||
return GitHubLinkOutcome.takenByAnotherPerson;
|
||||
}
|
||||
// The server gave the person's sessions the identified scope; the copy of
|
||||
// this device's session follows.
|
||||
if (_client.auth.authInfo case final authInfo?) {
|
||||
await _client.auth.updateSignedInUser(
|
||||
authInfo.copyWith(
|
||||
scopeNames: {...authInfo.scopeNames, _identifiedScope},
|
||||
),
|
||||
);
|
||||
}
|
||||
return GitHubLinkOutcome.linked;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> switchToGitHub() async {
|
||||
final authorization = await _authorizeWithGitHub();
|
||||
if (authorization == null) {
|
||||
return false;
|
||||
}
|
||||
// Serverpod issues no session for another person inside a signed-in
|
||||
// call, so a client without a session asks for it.
|
||||
final signedOut = Client(_client.host);
|
||||
final AuthSuccess person;
|
||||
try {
|
||||
person = await signedOut.gitHubSignIn.signIn(
|
||||
code: authorization.code,
|
||||
codeVerifier: authorization.codeVerifier,
|
||||
redirectUri: authorization.redirectUri,
|
||||
);
|
||||
} finally {
|
||||
signedOut.close();
|
||||
}
|
||||
final leftBehind = _client.auth.authInfo;
|
||||
// Swapping directly means the app never passes through "signed out",
|
||||
// which would start yet another anonymous account.
|
||||
await _client.auth.updateSignedInUser(person);
|
||||
if (leftBehind != null) {
|
||||
await _endSession(leftBehind);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// GitHub's authorization in the browser, or null when the person closed
|
||||
/// GitHub's page without authorizing.
|
||||
Future<GitHubSignInResult?> _authorizeWithGitHub() async {
|
||||
final clientId = await gitHubClientId();
|
||||
if (clientId == null) {
|
||||
throw StateError('This instance offers no GitHub sign-in.');
|
||||
}
|
||||
await GitHubSignInService.instance.ensureInitialized(
|
||||
clientId: clientId,
|
||||
redirectUri: _gitHubCallback.redirectUri,
|
||||
callbackUrlScheme: _gitHubCallback.urlScheme,
|
||||
// On Linux and Windows the system browser and a loopback address, not
|
||||
// an embedded webview, as the OAuth best practice for native apps
|
||||
// (RFC 8252) asks.
|
||||
useWebview: false,
|
||||
);
|
||||
try {
|
||||
return await GitHubSignInService.instance.signIn();
|
||||
} on OAuth2PkceUserCancelledException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Ends the anonymous [session] this device leaves behind when switching;
|
||||
/// nobody could use it again anyway, since anonymous accounts can't sign
|
||||
/// back in. A failure only leaves it to expire.
|
||||
Future<void> _endSession(AuthSuccess session) async {
|
||||
final sessionManager = FlutterAuthSessionManager(
|
||||
storage: _MemoryAuthStorage(),
|
||||
);
|
||||
final client = Client(_client.host)..authSessionManager = sessionManager;
|
||||
try {
|
||||
await sessionManager.updateSignedInUser(session);
|
||||
await sessionManager.signOutDevice();
|
||||
} on Exception catch (error, st) {
|
||||
log(
|
||||
'Ending the anonymous session left behind failed',
|
||||
name: 'Auth',
|
||||
error: error,
|
||||
stackTrace: st,
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops listening; the owner calls it when the app shuts down.
|
||||
Future<void> dispose() async {
|
||||
_client.auth.authInfoListenable.removeListener(_onAuthInfoChanged);
|
||||
|
|
@ -48,3 +175,16 @@ class ServerpodAuthGateway(final Client _client) implements AuthGateway {
|
|||
|
||||
void _onAuthInfoChanged() => _changes.add(status);
|
||||
}
|
||||
|
||||
/// Keeps one session in memory, for a client used once.
|
||||
final class _MemoryAuthStorage() implements ClientAuthSuccessStorage {
|
||||
AuthSuccess? _session;
|
||||
|
||||
@override
|
||||
Future<AuthSuccess?> get() async => _session;
|
||||
|
||||
@override
|
||||
Future<void> set(AuthSuccess? data) async {
|
||||
_session = data;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
/// How linking a GitHub account to the signed-in person ended.
|
||||
enum GitHubLinkOutcome() {
|
||||
/// Linked: the person now signs in with GitHub and counts as identified.
|
||||
linked,
|
||||
|
||||
/// The GitHub account belongs to another person; nothing changed.
|
||||
takenByAnotherPerson,
|
||||
|
||||
/// The person closed GitHub's page without authorizing; nothing changed.
|
||||
cancelled,
|
||||
}
|
||||
|
|
@ -1,21 +1,36 @@
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/github_sign_in_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/session_bloc.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Opens the account dialog: what the current account means, and signing out.
|
||||
/// Opens the account dialog: what the current account means, signing in with
|
||||
/// GitHub to keep it, and signing out.
|
||||
class const AccountButton({super.key}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => IconButton(
|
||||
icon: const Icon(Icons.account_circle_outlined),
|
||||
tooltip: 'Account',
|
||||
onPressed: () {
|
||||
// The dialog's route sits above the SessionBloc's provider, so the bloc
|
||||
// is handed over explicitly.
|
||||
final bloc = context.read<SessionBloc>();
|
||||
// The dialog's route sits above the SessionBloc's provider and the
|
||||
// person's scope, so what it needs is handed over explicitly.
|
||||
final session = context.read<SessionBloc>();
|
||||
final auth = DependenciesScope.of(context).auth;
|
||||
final studysets = PersonScope.of(context).studysets;
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) =>
|
||||
BlocProvider.value(value: bloc, child: const _AccountDialog()),
|
||||
builder: (dialogContext) => MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(value: session),
|
||||
BlocProvider(
|
||||
create: (context) =>
|
||||
GitHubSignInBloc(auth, studysets)
|
||||
..add(const GitHubSignInEventStarted()),
|
||||
),
|
||||
],
|
||||
child: const _AccountDialog(),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -29,34 +44,128 @@ class const _AccountDialog() extends StatelessWidget {
|
|||
SessionStateActive(:final person) => person.isIdentified,
|
||||
_ => false,
|
||||
};
|
||||
return AlertDialog(
|
||||
title: const Text('Account'),
|
||||
content: Text(
|
||||
isIdentified
|
||||
? 'Your progress is kept with your account.'
|
||||
: 'Progress is saved on this device. Sign in to keep it.',
|
||||
return BlocListener<GitHubSignInBloc, GitHubSignInState>(
|
||||
listenWhen: (previous, current) =>
|
||||
current is GitHubSignInStateConfirmSwitch,
|
||||
listener: (context, state) async {
|
||||
final bloc = context.read<GitHubSignInBloc>();
|
||||
final confirmed = await _confirmSwitch(context);
|
||||
bloc.add(
|
||||
confirmed
|
||||
? const GitHubSignInEventSwitchConfirmed()
|
||||
: const GitHubSignInEventSwitchDeclined(),
|
||||
);
|
||||
},
|
||||
child: AlertDialog(
|
||||
title: const Text('Account'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
isIdentified
|
||||
? 'Your progress is kept with your account.'
|
||||
: 'Progress is saved on this device. Sign in to keep it.',
|
||||
),
|
||||
if (!isIdentified) const _GitHubSignIn(),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
if (isIdentified || await _confirmSignOut(context)) {
|
||||
if (context.mounted) {
|
||||
context.read<SessionBloc>().add(
|
||||
const SessionEventSignOutRequested(),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text('Sign out'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The way to keep an anonymous account: signing in with GitHub, where the
|
||||
/// instance offers it.
|
||||
class const _GitHubSignIn() extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
BlocBuilder<GitHubSignInBloc, GitHubSignInState>(
|
||||
builder: (context, state) => switch (state) {
|
||||
GitHubSignInStateChecking() ||
|
||||
GitHubSignInStateUnavailable() ||
|
||||
GitHubSignInStateLinked() => const SizedBox.shrink(),
|
||||
GitHubSignInStateReady(:final failed) => _button(
|
||||
context,
|
||||
enabled: true,
|
||||
failed: failed,
|
||||
),
|
||||
GitHubSignInStateBusy() ||
|
||||
GitHubSignInStateConfirmSwitch() => _button(context, enabled: false),
|
||||
},
|
||||
);
|
||||
|
||||
Widget _button(
|
||||
BuildContext context, {
|
||||
required bool enabled,
|
||||
bool failed = false,
|
||||
}) => Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
FilledButton(
|
||||
onPressed: enabled
|
||||
? () => context.read<GitHubSignInBloc>().add(
|
||||
const GitHubSignInEventRequested(),
|
||||
)
|
||||
: null,
|
||||
child: const Text('Sign in with GitHub'),
|
||||
),
|
||||
if (failed) ...[
|
||||
const SizedBox(height: 8),
|
||||
const Text("Couldn't sign in with GitHub."),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Asks before switching to a GitHub account that already has progress,
|
||||
/// leaving this device's studysets behind.
|
||||
Future<bool> _confirmSwitch(BuildContext context) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Switch to your GitHub account?'),
|
||||
content: const Text(
|
||||
'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 stay behind.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
if (isIdentified || await _confirmSignOut(context)) {
|
||||
if (context.mounted) {
|
||||
context.read<SessionBloc>().add(
|
||||
const SessionEventSignOutRequested(),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text('Sign out'),
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Stay anonymous'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Close'),
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Switch'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
return confirmed ?? false;
|
||||
}
|
||||
|
||||
/// Asks before signing out of an anonymous account, which can't be restored.
|
||||
|
|
|
|||
|
|
@ -30,6 +30,12 @@ class StudysetRepository(final StudysetGateway _gateway) {
|
|||
unawaited(_refresh());
|
||||
});
|
||||
|
||||
/// The studysets on this device right now, read once; [watchAll] follows
|
||||
/// every change.
|
||||
Future<List<Studyset>> all() async => [
|
||||
for (final row in await _gateway.all()) ?_decode(row),
|
||||
];
|
||||
|
||||
/// Creates a studyset with an empty description; returns its id.
|
||||
Future<String> create({required String title}) async {
|
||||
final id = await _gateway.insert(
|
||||
|
|
@ -73,9 +79,7 @@ class StudysetRepository(final StudysetGateway _gateway) {
|
|||
return;
|
||||
}
|
||||
try {
|
||||
final studysets = [
|
||||
for (final row in await _gateway.all()) ?_decode(row),
|
||||
];
|
||||
final studysets = await all();
|
||||
if (!_studysets.isClosed) {
|
||||
_studysets.add(studysets);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,137 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/github_sign_in_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.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 '../../../support/fake_auth_gateway.dart';
|
||||
import '../../../support/fake_studyset_gateway.dart';
|
||||
|
||||
void main() {
|
||||
const anonymous = SignedIn(authUserId: 'anonymous-1', isIdentified: false);
|
||||
|
||||
late FakeAuthGateway gateway;
|
||||
late StudysetRepository studysets;
|
||||
|
||||
setUp(() {
|
||||
gateway = FakeAuthGateway(status: anonymous);
|
||||
studysets = StudysetRepository(FakeStudysetGateway());
|
||||
});
|
||||
tearDown(() => studysets.dispose());
|
||||
|
||||
/// A bloc as the account dialog starts it, once it knows whether this
|
||||
/// instance offers GitHub sign-in.
|
||||
Future<GitHubSignInBloc> ready() async {
|
||||
final bloc = GitHubSignInBloc(AuthRepository(gateway), studysets)
|
||||
..add(const GitHubSignInEventStarted());
|
||||
addTearDown(bloc.close);
|
||||
await expectLater(bloc.stream, emits(const GitHubSignInStateReady()));
|
||||
return bloc;
|
||||
}
|
||||
|
||||
test('offers nothing where the instance has no GitHub sign-in', () async {
|
||||
gateway.offersGitHub = false;
|
||||
final bloc = GitHubSignInBloc(AuthRepository(gateway), studysets)
|
||||
..add(const GitHubSignInEventStarted());
|
||||
addTearDown(bloc.close);
|
||||
|
||||
await expectLater(bloc.stream, emits(const GitHubSignInStateUnavailable()));
|
||||
});
|
||||
|
||||
test('links the GitHub account to the person', () async {
|
||||
final bloc = await ready();
|
||||
|
||||
bloc.add(const GitHubSignInEventRequested());
|
||||
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([
|
||||
const GitHubSignInStateBusy(),
|
||||
const GitHubSignInStateLinked(),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
gateway.status,
|
||||
const SignedIn(authUserId: 'anonymous-1', isIdentified: true),
|
||||
);
|
||||
});
|
||||
|
||||
test('changes nothing when the person cancels on GitHub', () async {
|
||||
gateway.gitHubLinkOutcome = GitHubLinkOutcome.cancelled;
|
||||
final bloc = await ready();
|
||||
|
||||
bloc.add(const GitHubSignInEventRequested());
|
||||
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([
|
||||
const GitHubSignInStateBusy(),
|
||||
const GitHubSignInStateReady(),
|
||||
]),
|
||||
);
|
||||
expect(gateway.status, anonymous);
|
||||
});
|
||||
|
||||
test("switches right away when the GitHub account is someone else's and "
|
||||
'this device holds nothing', () async {
|
||||
gateway.gitHubLinkOutcome = GitHubLinkOutcome.takenByAnotherPerson;
|
||||
final bloc = await ready();
|
||||
|
||||
bloc.add(const GitHubSignInEventRequested());
|
||||
await expectLater(bloc.stream, emits(const GitHubSignInStateBusy()));
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(gateway.gitHubSwitches, 1);
|
||||
});
|
||||
|
||||
group("when the GitHub account is someone else's and this device holds "
|
||||
'studysets', () {
|
||||
late GitHubSignInBloc bloc;
|
||||
|
||||
setUp(() async {
|
||||
gateway.gitHubLinkOutcome = GitHubLinkOutcome.takenByAnotherPerson;
|
||||
await studysets.create(title: 'Japanese');
|
||||
bloc = (await ready())..add(const GitHubSignInEventRequested());
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([
|
||||
const GitHubSignInStateBusy(),
|
||||
const GitHubSignInStateConfirmSwitch(),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('asks first; staying anonymous changes nothing', () async {
|
||||
bloc.add(const GitHubSignInEventSwitchDeclined());
|
||||
|
||||
await expectLater(bloc.stream, emits(const GitHubSignInStateReady()));
|
||||
expect(gateway.gitHubSwitches, 0);
|
||||
expect(gateway.status, anonymous);
|
||||
});
|
||||
|
||||
test('switches once confirmed', () async {
|
||||
bloc.add(const GitHubSignInEventSwitchConfirmed());
|
||||
await expectLater(bloc.stream, emits(const GitHubSignInStateBusy()));
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(gateway.gitHubSwitches, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test('says so when signing in fails', () async {
|
||||
final bloc = await ready();
|
||||
gateway.gitHubError = Exception('No connection');
|
||||
|
||||
bloc.add(const GitHubSignInEventRequested());
|
||||
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([
|
||||
const GitHubSignInStateBusy(),
|
||||
const GitHubSignInStateReady(failed: true),
|
||||
]),
|
||||
);
|
||||
expect(gateway.status, anonymous);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/model/github_link_outcome.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/widget/account_button.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/widget/session_gate.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../../../support/fake_auth_gateway.dart';
|
||||
import '../../../support/fake_studyset_gateway.dart';
|
||||
import '../../../support/test_dependencies.dart';
|
||||
|
||||
void main() {
|
||||
late FakeAuthGateway auth;
|
||||
|
||||
setUp(() => auth = FakeAuthGateway());
|
||||
|
||||
/// Signs in anonymously and opens the account dialog; this device holds
|
||||
/// [studysets] studysets.
|
||||
Future<void> openAccount(WidgetTester tester, {int studysets = 0}) async {
|
||||
final gateway = FakeStudysetGateway();
|
||||
for (var i = 1; i <= studysets; i++) {
|
||||
gateway.receiveRemoteChange(
|
||||
'studyset-$i',
|
||||
encodeStudysetPayload((
|
||||
title: 'Studyset $i',
|
||||
description: RichContent.empty(),
|
||||
)),
|
||||
);
|
||||
}
|
||||
await tester.pumpWidget(
|
||||
DependenciesScope(
|
||||
dependencies: testDependencies(
|
||||
authGateway: auth,
|
||||
studysetGateway: gateway,
|
||||
),
|
||||
child: MaterialApp(
|
||||
home: SessionGate(
|
||||
child: Scaffold(
|
||||
appBar: AppBar(actions: const [AccountButton()]),
|
||||
body: const Text('Home'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byTooltip('Account'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('offers GitHub sign-in to anonymous people', (tester) async {
|
||||
await openAccount(tester);
|
||||
|
||||
expect(find.text('Sign in with GitHub'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('offers none where the instance has no GitHub sign-in', (
|
||||
tester,
|
||||
) async {
|
||||
auth.offersGitHub = false;
|
||||
|
||||
await openAccount(tester);
|
||||
|
||||
expect(find.text('Sign in with GitHub'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('signing in with GitHub keeps the progress with the account', (
|
||||
tester,
|
||||
) async {
|
||||
await openAccount(tester);
|
||||
|
||||
await tester.tap(find.text('Sign in with GitHub'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text('Your progress is kept with your account.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Sign in with GitHub'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets("asks before leaving this device's studysets behind; staying "
|
||||
'keeps them', (tester) async {
|
||||
auth.gitHubLinkOutcome = GitHubLinkOutcome.takenByAnotherPerson;
|
||||
await openAccount(tester, studysets: 1);
|
||||
|
||||
await tester.tap(find.text('Sign in with GitHub'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Switch to your GitHub account?'), findsOneWidget);
|
||||
await tester.tap(find.text('Stay anonymous'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(auth.gitHubSwitches, 0);
|
||||
expect(
|
||||
find.text('Progress is saved on this device. Sign in to keep it.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('switches once confirmed, and the dialog closes', (tester) async {
|
||||
auth.gitHubLinkOutcome = GitHubLinkOutcome.takenByAnotherPerson;
|
||||
await openAccount(tester, studysets: 1);
|
||||
|
||||
await tester.tap(find.text('Sign in with GitHub'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Switch'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(auth.gitHubSwitches, 1);
|
||||
expect(find.text('Account'), findsNothing);
|
||||
expect(find.text('Home'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
|
@ -38,6 +38,13 @@ void main() {
|
|||
await expectation;
|
||||
});
|
||||
|
||||
test('reads the studysets on this device once', () async {
|
||||
await repository.create(title: 'Japanese');
|
||||
await repository.create(title: 'Kanji');
|
||||
|
||||
expect(titles(await repository.all()), ['Japanese', 'Kanji']);
|
||||
});
|
||||
|
||||
test('creates studysets with an empty description', () async {
|
||||
final id = await repository.create(title: 'Japanese');
|
||||
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@ import 'dart:async';
|
|||
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/model/github_link_outcome.dart';
|
||||
|
||||
/// An [AuthGateway] without a server: signing in creates people named
|
||||
/// `anonymous-1`, `anonymous-2`, …, and [signInError] makes it fail.
|
||||
/// `anonymous-1`, `anonymous-2`, …, and [signInError] makes it fail. GitHub
|
||||
/// sign-in links the signed-in person, or switches to `github-person`.
|
||||
class FakeAuthGateway({var AuthStatus _status = const SignedOut()})
|
||||
implements AuthGateway {
|
||||
final _changes = StreamController<AuthStatus>.broadcast();
|
||||
|
|
@ -18,6 +20,18 @@ class FakeAuthGateway({var AuthStatus _status = const SignedOut()})
|
|||
/// How often signing out was attempted.
|
||||
int signOuts = 0;
|
||||
|
||||
/// Whether the instance offers GitHub sign-in.
|
||||
bool offersGitHub = true;
|
||||
|
||||
/// How the next GitHub links end.
|
||||
GitHubLinkOutcome gitHubLinkOutcome = GitHubLinkOutcome.linked;
|
||||
|
||||
/// Thrown by the next GitHub sign-ins while set.
|
||||
Exception? gitHubError;
|
||||
|
||||
/// How often this device switched to a GitHub person.
|
||||
int gitHubSwitches = 0;
|
||||
|
||||
@override
|
||||
AuthStatus get status => _status;
|
||||
|
||||
|
|
@ -42,6 +56,32 @@ class FakeAuthGateway({var AuthStatus _status = const SignedOut()})
|
|||
/// Ends the session as when the server revokes this device's session.
|
||||
void revokeSession() => _set(const SignedOut());
|
||||
|
||||
@override
|
||||
Future<String?> gitHubClientId() async =>
|
||||
offersGitHub ? 'github-client-id' : null;
|
||||
|
||||
@override
|
||||
Future<GitHubLinkOutcome> linkGitHub() async {
|
||||
if (gitHubError case final error?) {
|
||||
throw error;
|
||||
}
|
||||
if (gitHubLinkOutcome == GitHubLinkOutcome.linked) {
|
||||
final person = _status as SignedIn;
|
||||
_set(SignedIn(authUserId: person.authUserId, isIdentified: true));
|
||||
}
|
||||
return gitHubLinkOutcome;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> switchToGitHub() async {
|
||||
if (gitHubError case final error?) {
|
||||
throw error;
|
||||
}
|
||||
gitHubSwitches++;
|
||||
_set(const SignedIn(authUserId: 'github-person', isIdentified: true));
|
||||
return true;
|
||||
}
|
||||
|
||||
void _set(AuthStatus status) {
|
||||
_status = status;
|
||||
_changes.add(status);
|
||||
|
|
|
|||
Loading…
Reference in a new issue