feat(app): adding SimpleLogin to an account (ADR 0020, D21, D23)
All checks were successful
CI / check (push) Successful in 32m34s
CI / deploy (push) Has been skipped

halkyon_app reads where the device's person stands with SimpleLogin
(nothing to show, linked, or missing) and shows it in SimpleLoginSection,
which each app places above the password part: Add SimpleLogin, once
added one more way in. GitHub is added to nobody where SimpleLogin is
offered, and Learn's Profile offers an anonymous person SimpleLogin
alone there (D23).

No cut-off day, reminder or banner (D24): with the developer the only
person on learn.halkyon.eu, the cut-off is the operator taking GitHub's
OAuth app out of the deploy's settings once that account has SimpleLogin.
ADR 0020 records it and drops step 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-24 11:56:26 +02:00
commit fad12b4411
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
23 changed files with 829 additions and 77 deletions

View file

@ -143,11 +143,18 @@ Written as the apps are: Dart 3.13's primary constructors, Material from
keys on the device before any proof is sent; an account opens its keys
with its password; an account of before gets its keys, and its handle
where it has none. With `link`, SimpleLogin joins the account of the
device's person instead, an anonymous person's device keys kept (no
screen offers it yet). The screens: the start screen's Continue with
SimpleLogin, first where the instance offers it, and
`showSimpleLoginSheet` (`SimpleLoginCubit`), which waits for the browser
with Cancel and stops waiting on the loopback once it closes.
device's person instead, an anonymous person's device keys kept. The
screens: the start screen's Continue with SimpleLogin, first where the
instance offers it, and `showSimpleLoginSheet` (`SimpleLoginCubit`),
which waits for the browser with Cancel and stops waiting on the loopback
once it closes. Adding it to an account (ADR 0020, D21, D23): where the
person stands (`SimpleLoginRepository.standing`, read anew after every
change, `SimpleLoginStandingCubit`: nothing to show, linked, or
missing), shown by `SimpleLoginSection` (each app places it above
`AccountSecuritySection`: a heading and Add SimpleLogin; once added a row
among the ways in), which reads it once where a
`SimpleLoginStandingScope` is above it. Where the instance offers
SimpleLogin, GitHub is added to nobody (`GitHubOffer.linking`).
- **Files** (`FileSaver`, `PlatformFileSaver`): keeping a file the app made
where the person chooses (a recovery phrase, recovery codes, an app's
exports): on Linux and macOS the own user's alone (`writePrivately`). On

View file

@ -93,12 +93,16 @@ export 'src/password/widget/secret_screen.dart';
export 'src/person/person_folders.dart';
export 'src/privacy/content_cipher.dart';
export 'src/simplelogin/bloc/simplelogin_cubit.dart';
export 'src/simplelogin/bloc/simplelogin_standing_cubit.dart';
export 'src/simplelogin/data/serverpod_simplelogin_gateway.dart';
export 'src/simplelogin/data/simplelogin_gateway.dart';
export 'src/simplelogin/data/simplelogin_repository.dart';
export 'src/simplelogin/model/simplelogin_arrival.dart';
export 'src/simplelogin/model/simplelogin_failed.dart';
export 'src/simplelogin/model/simplelogin_standing.dart';
export 'src/simplelogin/widget/simplelogin_section.dart';
export 'src/simplelogin/widget/simplelogin_sheet.dart';
export 'src/simplelogin/widget/simplelogin_standing_scope.dart';
export 'src/simplelogin/widget/simplelogin_texts.dart';
export 'src/sync/app_compatibility.dart';
export 'src/sync/bloc/sync_status_bloc.dart';

View file

@ -278,5 +278,9 @@
"simpleLoginHandleInvalid": "Ein Handle besteht aus 1 bis 30 Buchstaben (a bis z), Ziffern oder Unterstrichen.",
"simpleLoginHandleReserved": "Dieses Handle ist der Instanz vorbehalten.",
"simpleLoginHandleTaken": "Dieses Handle hat schon jemand anderes.",
"simpleLoginTooManyHandleChecks": "Es wurden zu viele Handles ausprobiert. Versuch es in einer Stunde noch einmal."
"simpleLoginTooManyHandleChecks": "Es wurden zu viele Handles ausprobiert. Versuch es in einer Stunde noch einmal.",
"simpleLoginSectionTitle": "SimpleLogin",
"simpleLoginSignsYouIn": "Meldet dich an",
"simpleLoginAddHint": "Melde dich mit SimpleLogin an, oder mit deinem Proton-Konto.",
"simpleLoginAdded": "SimpleLogin hinzugefügt."
}

View file

@ -604,5 +604,9 @@
"simpleLoginHandleInvalid": "A handle is 1 to 30 letters (a to z), digits or underscores.",
"simpleLoginHandleReserved": "This handle is kept for the instance.",
"simpleLoginHandleTaken": "Someone else has this handle.",
"simpleLoginTooManyHandleChecks": "Too many handles were tried. Try again in an hour."
"simpleLoginTooManyHandleChecks": "Too many handles were tried. Try again in an hour.",
"simpleLoginSectionTitle": "SimpleLogin",
"simpleLoginSignsYouIn": "Signs you in",
"simpleLoginAddHint": "Sign in with SimpleLogin, or with your Proton account.",
"simpleLoginAdded": "SimpleLogin added."
}

View file

@ -1776,6 +1776,30 @@ abstract class HalkyonAppLocalizations {
/// In en, this message translates to:
/// **'Too many handles were tried. Try again in an hour.'**
String get simpleLoginTooManyHandleChecks;
/// No description provided for @simpleLoginSectionTitle.
///
/// In en, this message translates to:
/// **'SimpleLogin'**
String get simpleLoginSectionTitle;
/// No description provided for @simpleLoginSignsYouIn.
///
/// In en, this message translates to:
/// **'Signs you in'**
String get simpleLoginSignsYouIn;
/// No description provided for @simpleLoginAddHint.
///
/// In en, this message translates to:
/// **'Sign in with SimpleLogin, or with your Proton account.'**
String get simpleLoginAddHint;
/// No description provided for @simpleLoginAdded.
///
/// In en, this message translates to:
/// **'SimpleLogin added.'**
String get simpleLoginAdded;
}
class _HalkyonAppLocalizationsDelegate

View file

@ -1051,4 +1051,17 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
@override
String get simpleLoginTooManyHandleChecks =>
'Es wurden zu viele Handles ausprobiert. Versuch es in einer Stunde noch einmal.';
@override
String get simpleLoginSectionTitle => 'SimpleLogin';
@override
String get simpleLoginSignsYouIn => 'Meldet dich an';
@override
String get simpleLoginAddHint =>
'Melde dich mit SimpleLogin an, oder mit deinem Proton-Konto.';
@override
String get simpleLoginAdded => 'SimpleLogin hinzugefügt.';
}

View file

@ -1032,6 +1032,19 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
@override
String get simpleLoginTooManyHandleChecks =>
'Too many handles were tried. Try again in an hour.';
@override
String get simpleLoginSectionTitle => 'SimpleLogin';
@override
String get simpleLoginSignsYouIn => 'Signs you in';
@override
String get simpleLoginAddHint =>
'Sign in with SimpleLogin, or with your Proton account.';
@override
String get simpleLoginAdded => 'SimpleLogin added.';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -115,7 +115,7 @@ class ServerpodAuthGateway(
final signIn = basics.gitHubClientId != null;
return GitHubOffer(
signIn: signIn,
linking: signIn && !basics.passwordSignIn,
linking: signIn && !basics.passwordSignIn && !basics.simpleLogin,
);
}

View file

@ -35,8 +35,8 @@ final class const GitHubOffer({
required final bool signIn,
/// Whether GitHub is added to people as a way in: not where they sign in
/// with a password (ADR 0017, D2), where only those who have it already
/// sign in with it.
/// with a password (ADR 0017, D2) or with SimpleLogin (ADR 0020, D23),
/// where only those who have it already sign in with it.
required final bool linking,
}) with Equatable {
@override

View file

@ -0,0 +1,43 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/src/password/bloc/unexpected_failure.dart';
import 'package:halkyon_app/src/simplelogin/data/simplelogin_repository.dart';
import 'package:halkyon_app/src/simplelogin/model/simplelogin_failed.dart';
import 'package:halkyon_app/src/simplelogin/model/simplelogin_standing.dart';
/// Reads where the device's person stands with SimpleLogin (ADR 0020,
/// D21), and again after every change ([SimpleLoginRepository.changes]),
/// for the account section and what an app says beside it. Nothing is
/// shown until the instance has answered ([SimpleLoginChecking]); while it
/// can't be reached, what it last said stays.
class SimpleLoginStandingCubit(final SimpleLoginRepository _simpleLogin)
extends Cubit<SimpleLoginStanding> {
this : super(const SimpleLoginChecking()) {
_changes = _simpleLogin.changes.listen((_) => load());
}
late final StreamSubscription<void> _changes;
@override
Future<void> close() {
// Not awaited: a broadcast stream's cancel completes in the root zone,
// which a widget test's fake clock never reaches.
unawaited(_changes.cancel());
return super.close();
}
/// Reads it anew.
Future<void> load() async {
try {
final standing = await _simpleLogin.standing();
if (!isClosed) {
emit(standing);
}
} on Object catch (error) {
if (error is! SimpleLoginFailed) {
logUnexpected(error);
}
}
}
}

View file

@ -32,6 +32,10 @@ class ServerpodSimpleLoginGateway(
Future<bool> offered() =>
_failing(() async => (await _base.instance.basics()).simpleLogin);
@override
Future<bool> hasSimpleLogin() =>
_failing(() async => (await _base.account.signInWays()).simpleLogin);
@override
Future<OidcStart> begin(
protocol.SimpleLoginReturn back, {

View file

@ -18,6 +18,9 @@ abstract interface class SimpleLoginGateway() {
/// Whether the instance offers SimpleLogin.
Future<bool> offered();
/// Whether the device's person's account signs in with SimpleLogin.
Future<bool> hasSimpleLogin();
/// Begins a sign-in that comes [back] by the loopback or the app's scheme:
/// the attempt, SimpleLogin's page and the state. With [link], as the
/// device's person, to add SimpleLogin to their account.

View file

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:halkyon_app/src/auth/data/auth_gateway.dart';
import 'package:halkyon_app/src/auth/model/auth_status.dart';
@ -8,6 +10,7 @@ import 'package:halkyon_app/src/password/model/unlocked_account_keys.dart';
import 'package:halkyon_app/src/simplelogin/data/simplelogin_gateway.dart';
import 'package:halkyon_app/src/simplelogin/model/simplelogin_arrival.dart';
import 'package:halkyon_app/src/simplelogin/model/simplelogin_failed.dart';
import 'package:halkyon_app/src/simplelogin/model/simplelogin_standing.dart';
import 'package:halkyon_base_client/halkyon_base_client.dart' as protocol;
import 'package:halkyon_oidc_flutter/halkyon_oidc_flutter.dart';
@ -53,9 +56,54 @@ class SimpleLoginRepository(
/// The platform the app runs on; tests name one.
final TargetPlatform? _platform,
}) {
this {
_status = _auth.statusChanges.listen((_) => _changed());
}
late final StreamSubscription<AuthStatus> _status;
final _changes = StreamController<void>.broadcast();
/// Every change to where the device's person stands with SimpleLogin
/// ([standing]): SimpleLogin added to their account, or another person
/// signed in, or nobody, for what shows it to read anew.
Stream<void> get changes => _changes.stream;
void _changed() {
if (!_changes.isClosed) {
_changes.add(null);
}
}
/// Stops following who is signed in; the owner calls it as it closes.
Future<void> dispose() async {
// Not awaited: a broadcast stream's cancel completes in the root zone,
// which a widget test's fake clock never reaches.
unawaited(_status.cancel());
unawaited(_changes.close());
}
/// Whether the instance offers SimpleLogin.
Future<bool> offered() => _gateway.offered();
/// Where the device's person stands with SimpleLogin (D21): nothing to
/// show where the instance offers none or nobody is signed in; otherwise
/// whether their account has it.
Future<SimpleLoginStanding> standing() async {
final status = _auth.status;
if (status is! SignedIn) {
return const SimpleLoginUnavailable();
}
if (!await _gateway.offered()) {
return const SimpleLoginUnavailable();
}
// An anonymous person has no way in to ask about: SimpleLogin, once
// added, makes them identified.
return status.isIdentified && await _gateway.hasSimpleLogin()
? const SimpleLoginLinked()
: const SimpleLoginMissing();
}
/// Signs in with SimpleLogin in the browser: someone new, or an account
/// signed in to; with [link], SimpleLogin added to the device's person's
/// account. Null where the person closed the browser, or [cancel] ended
@ -90,7 +138,7 @@ class SimpleLoginRepository(
OidcCallbackProblem.failed => SimpleLoginProblem.failed,
});
}
return switch (back) {
final arrival = switch (back) {
null => null,
OidcDenied() => throw const SimpleLoginFailed(SimpleLoginProblem.denied),
OidcCode(:final code) => await _gateway.complete(
@ -99,6 +147,11 @@ class SimpleLoginRepository(
link: link,
),
};
if (link && arrival != null) {
// Added already: an account with its keys is done here.
_changed();
}
return arrival;
}
/// Stops waiting for the browser on the loopback: [signIn] returns null.
@ -235,5 +288,6 @@ class SimpleLoginRepository(
}
await _auth.markIdentified();
}
_changed();
}
}

View file

@ -0,0 +1,31 @@
import 'package:equatable/equatable.dart';
/// Where the device's person stands with SimpleLogin on their instance
/// (ADR 0020, D21): what the account section shows.
sealed class const SimpleLoginStanding() with Equatable;
/// Not known yet: the instance hasn't answered. Nothing is shown.
final class const SimpleLoginChecking() extends SimpleLoginStanding {
@override
List<Object?> get props => const [];
}
/// Nothing to show: the instance offers no SimpleLogin, or nobody is signed
/// in to add it to.
final class const SimpleLoginUnavailable() extends SimpleLoginStanding {
@override
List<Object?> get props => const [];
}
/// The person's account signs in with SimpleLogin.
final class const SimpleLoginLinked() extends SimpleLoginStanding {
@override
List<Object?> get props => const [];
}
/// The person's account doesn't sign in with SimpleLogin yet: they add it
/// to keep the account, an anonymous person with it alone (D23).
final class const SimpleLoginMissing() extends SimpleLoginStanding {
@override
List<Object?> get props => const [];
}

View file

@ -0,0 +1,88 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/l10n/halkyon_app_localizations.dart';
import 'package:halkyon_app/src/app/app_scope.dart';
import 'package:halkyon_app/src/instance/instance_scope.dart';
import 'package:halkyon_app/src/simplelogin/bloc/simplelogin_standing_cubit.dart';
import 'package:halkyon_app/src/simplelogin/model/simplelogin_standing.dart';
import 'package:halkyon_app/src/simplelogin/widget/simplelogin_sheet.dart';
import 'package:halkyon_app/src/simplelogin/widget/simplelogin_standing_scope.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// The SimpleLogin part of an app's account section (ADR 0020, D21), which
/// each app places above `AccountSecuritySection`: for someone without
/// SimpleLogin a heading and Add SimpleLogin; once added, one more way in,
/// a row without heading lined up with the others. Nothing where the
/// instance offers no SimpleLogin or nobody is signed in. [gapAbove] is
/// the space above its heading, none above the row.
class const SimpleLoginSection({
final double gapAbove = 0,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => SimpleLoginStandingScope.around(
context,
_SimpleLoginSection(gapAbove: gapAbove),
);
}
class const _SimpleLoginSection({required final double gapAbove})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = HalkyonAppLocalizations.of(context);
final theme = Theme.of(context);
return switch (context.watch<SimpleLoginStandingCubit>().state) {
SimpleLoginChecking() ||
SimpleLoginUnavailable() => const SizedBox.shrink(),
SimpleLoginLinked() => ListTile(
contentPadding: EdgeInsets.zero,
title: Text(l10n.simpleLoginSectionTitle),
subtitle: Text(l10n.simpleLoginSignsYouIn),
trailing: Icon(
LucideIcons.circleCheck,
color: theme.colorScheme.primary,
),
),
SimpleLoginMissing() => Padding(
padding: EdgeInsets.only(top: gapAbove),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.simpleLoginSectionTitle,
style: theme.textTheme.titleSmall,
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.link),
title: Text(l10n.simpleLoginAddTitle),
subtitle: Text(l10n.simpleLoginAddHint),
onTap: () => addSimpleLogin(context),
),
],
),
),
};
}
}
/// The instance's name as people know it, for the texts about it.
String simpleLoginInstanceName(BuildContext context) =>
InstanceScope.maybeOf(context)?.choice.current?.name ??
AppScope.of(context).serverName;
/// Adds SimpleLogin to the device's person's account through its sheet
/// (`showSimpleLoginSheet`, `link`), saying so once it is added; where they
/// stand is read anew everywhere ([SimpleLoginStandingCubit]).
Future<void> addSimpleLogin(BuildContext context) async {
final messenger = ScaffoldMessenger.maybeOf(context);
final added = HalkyonAppLocalizations.of(context).simpleLoginAdded;
if (await showSimpleLoginSheet(
context,
instance: simpleLoginInstanceName(context),
link: true,
)) {
messenger?.showSnackBar(SnackBar(content: Text(added)));
}
}

View file

@ -0,0 +1,32 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/src/app/app_scope.dart';
import 'package:halkyon_app/src/simplelogin/bloc/simplelogin_standing_cubit.dart';
import 'package:material_ui/material_ui.dart';
/// Reads where the device's person stands with SimpleLogin once for all
/// below it that show it: `SimpleLoginSection` and an app's own words
/// (`context.watch<SimpleLoginStandingCubit>()`). Where none is above it,
/// the section reads it on its own ([around]).
class const SimpleLoginStandingScope({
required final Widget child,
super.key,
}) extends StatelessWidget {
/// [child] under the scope above [context], or under one of its own.
static Widget around(BuildContext context, Widget child) =>
context.getInheritedWidgetOfExactType<_Scoped>() != null
? child
: SimpleLoginStandingScope(child: child);
@override
Widget build(BuildContext context) => BlocProvider(
create: (context) =>
SimpleLoginStandingCubit(AppScope.of(context).simpleLogin)..load(),
child: _Scoped(child: child),
);
}
/// Marks a [SimpleLoginStandingScope] for those below it.
class const _Scoped({required super.child}) extends InheritedWidget {
@override
bool updateShouldNotify(_Scoped oldWidget) => false;
}

View file

@ -44,7 +44,7 @@ class FakeAuthGateway({var AuthStatus _status = const SignedOut()})
bool offersGitHub = true;
/// Whether the instance adds GitHub to people as a way in: not where they
/// sign in with a password (ADR 0017).
/// sign in with a password (ADR 0017) or with SimpleLogin (ADR 0020).
bool linksGitHub = true;
/// How the next GitHub links end.

View file

@ -114,6 +114,12 @@ class FakeSimpleLoginGateway(final AuthGateway _auth)
return isOffered;
}
@override
Future<bool> hasSimpleLogin() async {
await _call('hasSimpleLogin');
return linked.containsValue(_owner(null));
}
@override
Future<OidcStart> begin(
protocol.SimpleLoginReturn back, {

View file

@ -188,6 +188,7 @@ class TestAppDependencies({
Future<void> dispose() async {
disposed = true;
await passwords.dispose();
await simpleLogin.dispose();
}
}

View file

@ -21,10 +21,12 @@ final class _MemoryStorage() implements ClientAuthSuccessStorage {
/// An instance's halkyon_base as the network shows it: anonymous sign-ins
/// get `anonymous-token`, its basics offer GitHub (and passwords while
/// [passwords]), and a sign-up makes Ben, signed in with `ben-token`.
/// [passwords], SimpleLogin while [simpleLogin]), and a sign-up makes Ben,
/// signed in with `ben-token`.
final class _Instance() {
/// Whether its basics offer password sign-in.
/// Whether its basics offer password sign-in, and SimpleLogin.
bool passwords = false;
bool simpleLogin = false;
static final anonymous = UuidValue.withValidation(
'0191f0c4-1111-7000-8000-000000000001',
@ -54,6 +56,7 @@ final class _Instance() {
sourceCodeUrl: 'https://code.example.org/learn',
minimumAppBuild: 1,
passwordSignIn: passwords,
simpleLogin: simpleLogin,
).toJson(),
),
200,
@ -121,7 +124,8 @@ void main() {
);
test('tells what the instance offers from its basics: GitHub sign-in, '
'and linking GitHub to people only where passwords are not', () async {
'and linking GitHub to people only where neither passwords nor '
'SimpleLogin are (ADR 0020, D23)', () async {
expect(
await gateway.gitHubOffer(),
const GitHubOffer(signIn: true, linking: true),
@ -139,6 +143,14 @@ void main() {
hasLength(2),
reason: 'one read of the basics each',
);
instance
..passwords = false
..simpleLogin = true;
expect(
await gateway.gitHubOffer(),
const GitHubOffer(signIn: true, linking: false),
);
});
test("puts the instance's refusals of GitHub as the app's problems", () {

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