halkyon/packages/halkyon_app/lib/src/auth/widget/account_tiles.dart
Tizian 「ティツィアーン」 012f2375a5
fix(app): the short reading's nits on the device
- Deleting an identified account whose security can't be read (the
  instance out of reach) says so and deletes nothing; it went on to a
  deletion by the session alone, refused for an account with a password,
  and the person saw a spinner, then nothing. A refusal still leaves the
  session's own deletion.
- InstanceChoice's sharesStorage is required, so no app leaves it out and
  removes a folder another instance uses.
- A PasswordUnlock forgotten is spent: its secrets read again fail
  (StateError) rather than make a proof or a wrapping of zeros.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:05 +02:00

164 lines
6.3 KiB
Dart

import 'dart:developer';
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/auth/bloc/session_bloc.dart';
import 'package:halkyon_app/src/instance/instance_scope.dart';
import 'package:halkyon_app/src/instance/widget/account_switcher_sheet.dart';
import 'package:halkyon_app/src/password/model/account_security.dart';
import 'package:halkyon_app/src/password/model/password_failed.dart';
import 'package:halkyon_app/src/password/widget/delete_account_sheet.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// The account in use, as an app's settings show it (decided 2026-10-16):
/// whose it is, anonymous or signed in, on which instance; switching to the
/// account on another instance, where the device keeps accounts on several
/// ([InstanceScope]); signing out, which of an anonymous account deletes it
/// and so asks first; deleting it, after asking. What only the app can say,
/// it gives: what an anonymous account keeps on the device, and what signing
/// out of one and deleting the account take with them.
class const AccountTiles({
/// Under an anonymous account: what it keeps on this device ("Progress is
/// saved on this device. Sign in to keep it."); none where null.
final String? anonymousNote,
/// What signing out of an anonymous account takes with it, as the
/// question before asks.
required final String signOutAnonymousMessage,
/// What deleting the account takes with it, as the question before asks.
required final String deleteMessage,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = HalkyonAppLocalizations.of(context);
final theme = Theme.of(context);
final identified = switch (context.watch<SessionBloc>().state) {
SessionStateActive(:final person) => person.isIdentified,
_ => false,
};
final instances = InstanceScope.maybeOf(context);
final instance =
instances?.choice.current?.name ??
AppScope.of<AppDependencies<Disposable>>(context).serverName;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ListTile(
leading: Icon(
identified ? LucideIcons.userCheck : LucideIcons.userRound,
),
title: Text(
identified
? l10n.accountSignedInOn(instance)
: l10n.accountAnonymousOn(instance),
),
subtitle: switch (anonymousNote) {
final note? when !identified => Text(note),
_ => null,
},
),
// The accounts on this device, one per instance.
if (instances != null)
ListTile(
leading: const Icon(LucideIcons.users),
title: Text(l10n.accountSwitch),
subtitle: Text(instances.choice.current?.name ?? ''),
onTap: () => showAccountSwitcher(context),
),
ListTile(
leading: const Icon(LucideIcons.logOut),
title: Text(l10n.accountSignOut),
onTap: () => _signOut(context),
),
ListTile(
leading: Icon(LucideIcons.trash2, color: theme.colorScheme.error),
title: Text(
l10n.accountDelete,
style: TextStyle(color: theme.colorScheme.error),
),
onTap: () => _delete(context),
),
],
);
}
/// Signs out; an anonymous account can't be signed into again, so signing
/// out of one deletes it, and asks first. The app then asks how to start.
Future<void> _signOut(BuildContext context) async {
final l10n = HalkyonAppLocalizations.of(context);
final session = context.read<SessionBloc>();
final identified = switch (session.state) {
SessionStateActive(:final person) => person.isIdentified,
_ => false,
};
if (!identified &&
!await confirmDestructiveAction(
context,
title: l10n.accountSignOutTitle,
message: signOutAnonymousMessage,
confirmLabel: l10n.accountSignOut,
)) {
return;
}
session.add(const SessionEventSignOutRequested());
}
/// Deletes the account with everything in it, after asking; an account
/// with a password only with it and a code (ADR 0017), which a sheet asks
/// for.
Future<void> _delete(BuildContext context) async {
final l10n = HalkyonAppLocalizations.of(context);
final session = context.read<SessionBloc>();
final passwords = AppScope.of(context).passwords;
final identified = switch (session.state) {
SessionStateActive(:final person) => person.isIdentified,
_ => false,
};
if (!await confirmDestructiveAction(
context,
title: l10n.accountDeleteTitle,
message: deleteMessage,
confirmLabel: l10n.accountDelete,
)) {
return;
}
final AccountSecurity? security;
try {
security = identified ? await passwords.security() : null;
} on Object catch (error) {
if (error is PasswordFailed && error.problem != PasswordProblem.failed) {
// The instance refused to say (no passwords offered there): the
// session's own deletion, which the instance checks.
session.add(const SessionEventDeleteRequested());
return;
}
// Out of reach, or anything else: said so, nothing deleted (the short
// reading: a deletion by the session alone, refused for an account
// with a password, showed a spinner, then nothing).
if (error is! PasswordFailed) {
log('Reading the account failed: ${error.runtimeType}', name: 'Auth');
}
if (context.mounted) {
ScaffoldMessenger.maybeOf(
context,
)?.showSnackBar(SnackBar(content: Text(l10n.passwordFailed)));
}
return;
}
if (security == null || !security.hasPassword) {
session.add(const SessionEventDeleteRequested());
} else if (context.mounted) {
// Without a second factor that works, a code by mail confirms it.
await showDeleteAccountWithPassword(
context,
byMail: !security.secondFactorWorks,
);
}
}
}