feat(profile): implement user-initiated GDPR-compliant profile deletion
- Add DELETE /users/me endpoint with cascading data removal - Implement frontend confirmation dialog and deletion flow - Add audit logging for deletion requests - Update localization files for new UI strings - Add scheduled cleanup service for AI traces - Document GDPR compliance in technical specification BREAKING CHANGE: Users can now permanently delete their profiles and associated data
This commit is contained in:
@@ -42,6 +42,14 @@ class ProfileRepository {
|
||||
return UserProfile.fromJson(data);
|
||||
}
|
||||
|
||||
Future<void> deleteMe() async {
|
||||
final token = await _ref.read(authStateProvider.future);
|
||||
await guardedApiCall(
|
||||
_ref,
|
||||
() => _apiClient.deleteJson(UserApiPaths.me, token: token),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> refreshCategories() async {
|
||||
final token = await _ref.read(authStateProvider.future);
|
||||
await guardedApiCall(
|
||||
|
||||
@@ -20,6 +20,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
bool _isLoading = true;
|
||||
bool _isSaving = false;
|
||||
bool _isDeleting = false;
|
||||
String? _error;
|
||||
UserProfile? _profile;
|
||||
|
||||
@@ -96,6 +97,61 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
||||
context.go('/login');
|
||||
}
|
||||
|
||||
Future<void> _showDeleteProfileConfirmation() async {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(context.l10n.profileDeleteConfirmTitle),
|
||||
content: SingleChildScrollView(
|
||||
child: ListBody(
|
||||
children: <Widget>[
|
||||
Text(context.l10n.profileDeleteConfirmMessage),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
child: Text(context.l10n.noLabel),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
TextButton(
|
||||
child: Text(context.l10n.deleteAction),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
_deleteProfile();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deleteProfile() async {
|
||||
setState(() => _isDeleting = true);
|
||||
try {
|
||||
await ref.read(profileRepositoryProvider).deleteMe();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.profileDeletedMessage)),
|
||||
);
|
||||
await ref.read(authStateProvider.notifier).logout();
|
||||
if (!mounted) return;
|
||||
context.go('/login');
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _isDeleting = false);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildProfileForm(BuildContext context, ThemeData theme) {
|
||||
return Form(
|
||||
key: _formKey,
|
||||
@@ -286,6 +342,24 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
||||
label: Text(context.l10n.logoutAction),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: _isDeleting ? null : _showDeleteProfileConfirmation,
|
||||
icon: _isDeleting
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.delete_forever_outlined),
|
||||
label: Text(context.l10n.profileDeleteAction),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart
|
||||
// This is a library that looks up messages for specific locales by
|
||||
// delegating to the appropriate library.
|
||||
|
||||
// Ignore issues from commonly used lints in this file.
|
||||
// ignore_for_file:implementation_imports, file_names, unnecessary_new
|
||||
// ignore_for_file:unnecessary_brace_in_string_interps, directives_ordering
|
||||
// ignore_for_file:argument_type_not_assignable, invalid_assignment
|
||||
// ignore_for_file:prefer_single_quotes, prefer_generic_function_type_aliases
|
||||
// ignore_for_file:comment_references
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:intl/message_lookup_by_library.dart';
|
||||
import 'package:intl/src/intl_helpers.dart';
|
||||
|
||||
import 'messages_en.dart' as messages_en;
|
||||
|
||||
typedef Future<dynamic> LibraryLoader();
|
||||
Map<String, LibraryLoader> _deferredLibraries = {
|
||||
'en': () => new SynchronousFuture(null),
|
||||
};
|
||||
|
||||
MessageLookupByLibrary? _findExact(String localeName) {
|
||||
switch (localeName) {
|
||||
case 'en':
|
||||
return messages_en.messages;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// User programs should call this before using [localeName] for messages.
|
||||
Future<bool> initializeMessages(String localeName) {
|
||||
var availableLocale = Intl.verifiedLocale(
|
||||
localeName,
|
||||
(locale) => _deferredLibraries[locale] != null,
|
||||
onFailure: (_) => null,
|
||||
);
|
||||
if (availableLocale == null) {
|
||||
return new SynchronousFuture(false);
|
||||
}
|
||||
var lib = _deferredLibraries[availableLocale];
|
||||
lib == null ? new SynchronousFuture(false) : lib();
|
||||
initializeInternalMessageLookup(() => new CompositeMessageLookup());
|
||||
messageLookup.addLocale(availableLocale, _findGeneratedMessagesFor);
|
||||
return new SynchronousFuture(true);
|
||||
}
|
||||
|
||||
bool _messagesExistFor(String locale) {
|
||||
try {
|
||||
return _findExact(locale) != null;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
MessageLookupByLibrary? _findGeneratedMessagesFor(String locale) {
|
||||
var actualLocale = Intl.verifiedLocale(
|
||||
locale,
|
||||
_messagesExistFor,
|
||||
onFailure: (_) => null,
|
||||
);
|
||||
if (actualLocale == null) return null;
|
||||
return _findExact(actualLocale);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart
|
||||
// This is a library that provides messages for a en locale. All the
|
||||
// messages from the main program should be duplicated here with the same
|
||||
// function name.
|
||||
|
||||
// Ignore issues from commonly used lints in this file.
|
||||
// ignore_for_file:unnecessary_brace_in_string_interps, unnecessary_new
|
||||
// ignore_for_file:prefer_single_quotes,comment_references, directives_ordering
|
||||
// ignore_for_file:annotate_overrides,prefer_generic_function_type_aliases
|
||||
// ignore_for_file:unused_import, file_names, avoid_escaping_inner_quotes
|
||||
// ignore_for_file:unnecessary_string_interpolations, unnecessary_string_escapes
|
||||
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:intl/message_lookup_by_library.dart';
|
||||
|
||||
final messages = new MessageLookup();
|
||||
|
||||
typedef String MessageIfAbsent(String messageStr, List<dynamic> args);
|
||||
|
||||
class MessageLookup extends MessageLookupByLibrary {
|
||||
String get localeName => 'en';
|
||||
|
||||
final messages = _notInlinedMessages(_notInlinedMessages);
|
||||
static Map<String, Function> _notInlinedMessages(_) => <String, Function>{};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'intl/messages_all.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// Generator: Flutter Intl IDE plugin
|
||||
// Made by Localizely
|
||||
// **************************************************************************
|
||||
|
||||
// ignore_for_file: non_constant_identifier_names, lines_longer_than_80_chars
|
||||
// ignore_for_file: join_return_with_assignment, prefer_final_in_for_each
|
||||
// ignore_for_file: avoid_redundant_argument_values, avoid_escaping_inner_quotes
|
||||
|
||||
class S {
|
||||
S();
|
||||
|
||||
static S? _current;
|
||||
|
||||
static S get current {
|
||||
assert(
|
||||
_current != null,
|
||||
'No instance of S was loaded. Try to initialize the S delegate before accessing S.current.',
|
||||
);
|
||||
return _current!;
|
||||
}
|
||||
|
||||
static const AppLocalizationDelegate delegate = AppLocalizationDelegate();
|
||||
|
||||
static Future<S> load(Locale locale) {
|
||||
final name = (locale.countryCode?.isEmpty ?? false)
|
||||
? locale.languageCode
|
||||
: locale.toString();
|
||||
final localeName = Intl.canonicalizedLocale(name);
|
||||
return initializeMessages(localeName).then((_) {
|
||||
Intl.defaultLocale = localeName;
|
||||
final instance = S();
|
||||
S._current = instance;
|
||||
|
||||
return instance;
|
||||
});
|
||||
}
|
||||
|
||||
static S of(BuildContext context) {
|
||||
final instance = S.maybeOf(context);
|
||||
assert(
|
||||
instance != null,
|
||||
'No instance of S present in the widget tree. Did you add S.delegate in localizationsDelegates?',
|
||||
);
|
||||
return instance!;
|
||||
}
|
||||
|
||||
static S? maybeOf(BuildContext context) {
|
||||
return Localizations.of<S>(context, S);
|
||||
}
|
||||
}
|
||||
|
||||
class AppLocalizationDelegate extends LocalizationsDelegate<S> {
|
||||
const AppLocalizationDelegate();
|
||||
|
||||
List<Locale> get supportedLocales {
|
||||
return const <Locale>[Locale.fromSubtags(languageCode: 'en')];
|
||||
}
|
||||
|
||||
@override
|
||||
bool isSupported(Locale locale) => _isSupported(locale);
|
||||
@override
|
||||
Future<S> load(Locale locale) => S.load(locale);
|
||||
@override
|
||||
bool shouldReload(AppLocalizationDelegate old) => false;
|
||||
|
||||
bool _isSupported(Locale locale) {
|
||||
for (var supportedLocale in supportedLocales) {
|
||||
if (supportedLocale.languageCode == locale.languageCode) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -517,9 +517,10 @@
|
||||
"adminInlineCategory": "Category (inline)",
|
||||
"adminNoCategory": "No category",
|
||||
"adminRestoreAction": "Restore",
|
||||
"required": "Required",
|
||||
"logoutAction": "Log out",
|
||||
"adminAiDescription": "Overview of AI features exposed by the backend.",
|
||||
"adminPagePrefix": "Page: ",
|
||||
"profileDeleteConfirmTitle": "Confirm deletion",
|
||||
"profileDeleteConfirmMessage": "Are you sure you want to delete your profile? All your data will be permanently deleted.",
|
||||
"profileDeleteAction": "Delete my profile",
|
||||
"profileDeletedMessage": "Your profile has been deleted."
|
||||
}
|
||||
"profileDatabaseDescription": "The database tab covers your main areas for inventory and products."
|
||||
}
|
||||
@@ -521,5 +521,8 @@
|
||||
"logoutAction": "Logga ut",
|
||||
"adminAiDescription": "Översikt över AI-funktioner som backend exponerar.",
|
||||
"adminPagePrefix": "Sida: ",
|
||||
"profileDatabaseDescription": "Databasfliken samlar dina huvudområden för lager och produkter."
|
||||
"profileDeleteConfirmTitle": "Bekräfta radering",
|
||||
"profileDeleteConfirmMessage": "Är du säker på att du vill ta bort din profil? Alla dina data kommer att raderas permanent.",
|
||||
"profileDeleteAction": "Ta bort min profil",
|
||||
"profileDeletedMessage": "Din profil har tagits bort."
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
Reference in New Issue
Block a user