- 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>
164 lines
6.3 KiB
Dart
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,
|
|
);
|
|
}
|
|
}
|
|
}
|