From ad3087f1dd6de5b41e155b7a7b5d57bb8f22c21b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Sun, 22 Feb 2026 21:22:32 -0500 Subject: [PATCH] Improvements to Flutter client (#1042) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Chat improvements * Delete/reset account via API for Flutter app * Fix tests. * Add "contact us" to settings * Update mobile/lib/screens/chat_conversation_screen.dart Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Juan José Mata * Improve LLM special token detection * Deactivated user shouldn't have API working * Fix tests * API-Key usage * Flutter app launch failure on no network * Handle deletion/reset delays * Local cached data may become stale * Use X-Api-Key correctly! --------- Signed-off-by: Juan José Mata Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- app/controllers/api/v1/base_controller.rb | 10 + app/controllers/api/v1/users_controller.rb | 27 +++ config/routes.rb | 3 + mobile/lib/models/chat.dart | 11 + mobile/lib/models/message.dart | 33 ++- mobile/lib/providers/auth_provider.dart | 16 +- mobile/lib/providers/chat_provider.dart | 70 +++++-- .../lib/screens/chat_conversation_screen.dart | 83 +++++--- mobile/lib/screens/chat_list_screen.dart | 3 +- mobile/lib/screens/settings_screen.dart | 191 ++++++++++++++++++ mobile/lib/services/chat_service.dart | 5 +- mobile/lib/services/user_service.dart | 71 +++++++ spec/requests/api/v1/users_spec.rb | 123 +++++++++++ spec/swagger_helper.rb | 7 + .../api/v1/users_controller_test.rb | 116 +++++++++++ 15 files changed, 716 insertions(+), 53 deletions(-) create mode 100644 app/controllers/api/v1/users_controller.rb create mode 100644 mobile/lib/services/user_service.dart create mode 100644 spec/requests/api/v1/users_spec.rb create mode 100644 test/controllers/api/v1/users_controller_test.rb diff --git a/app/controllers/api/v1/base_controller.rb b/app/controllers/api/v1/base_controller.rb index f176fff4b..d768bdf3f 100644 --- a/app/controllers/api/v1/base_controller.rb +++ b/app/controllers/api/v1/base_controller.rb @@ -73,6 +73,11 @@ class Api::V1::BaseController < ApplicationController render_json({ error: "unauthorized", message: "Access token is invalid - user not found" }, status: :unauthorized) return false end + + unless @current_user.active? + render_json({ error: "unauthorized", message: "Account has been deactivated" }, status: :unauthorized) + return false + end else Rails.logger.warn "API OAuth Token Invalid: Access token missing resource_owner_id" render_json({ error: "unauthorized", message: "Access token is invalid - missing resource owner" }, status: :unauthorized) @@ -96,6 +101,11 @@ class Api::V1::BaseController < ApplicationController return false unless @api_key && @api_key.active? @current_user = @api_key.user + unless @current_user.active? + render_json({ error: "unauthorized", message: "Account has been deactivated" }, status: :unauthorized) + return false + end + @api_key.update_last_used! @authentication_method = :api_key @rate_limiter = ApiRateLimiter.limit(@api_key) diff --git a/app/controllers/api/v1/users_controller.rb b/app/controllers/api/v1/users_controller.rb new file mode 100644 index 000000000..be01669f4 --- /dev/null +++ b/app/controllers/api/v1/users_controller.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class Api::V1::UsersController < Api::V1::BaseController + before_action :ensure_write_scope + + def reset + FamilyResetJob.perform_later(Current.family) + render json: { message: "Account reset has been initiated" } + end + + def destroy + user = current_resource_owner + + if user.deactivate + Current.session&.destroy + render json: { message: "Account has been deleted" } + else + render json: { error: "Failed to delete account", details: user.errors.full_messages }, status: :unprocessable_entity + end + end + + private + + def ensure_write_scope + authorize_scope!(:write) + end +end diff --git a/config/routes.rb b/config/routes.rb index 5badb50fa..b72530f40 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -394,6 +394,9 @@ Rails.application.routes.draw do end end + delete "users/reset", to: "users#reset" + delete "users/me", to: "users#destroy" + # Test routes for API controller testing (only available in test environment) if Rails.env.test? get "test", to: "test#index" diff --git a/mobile/lib/models/chat.dart b/mobile/lib/models/chat.dart index 480398f94..9082b2cc8 100644 --- a/mobile/lib/models/chat.dart +++ b/mobile/lib/models/chat.dart @@ -53,6 +53,17 @@ class Chat { }; } + static const String defaultTitle = 'New Chat'; + static const int maxTitleLength = 80; + + static String generateTitle(String prompt) { + final trimmed = prompt.trim(); + if (trimmed.length <= maxTitleLength) return trimmed; + return trimmed.substring(0, maxTitleLength); + } + + bool get hasDefaultTitle => title == defaultTitle; + Chat copyWith({ String? id, String? title, diff --git a/mobile/lib/models/message.dart b/mobile/lib/models/message.dart index d3b71949e..264f4e940 100644 --- a/mobile/lib/models/message.dart +++ b/mobile/lib/models/message.dart @@ -1,6 +1,32 @@ import 'tool_call.dart'; class Message { + /// Known LLM special tokens that may leak into responses (strip from display). + /// Includes ASCII ChatML (<|...|>) and DeepSeek full-width variants (<|...|>). + static const _llmTokenPatterns = [ + '<|start_of_sentence|>', + '<|im_start|>', + '<|im_end|>', + '<|endoftext|>', + '', + // DeepSeek full-width pipe variants (U+FF5C |) + '<\uFF5Cstart_of_sentence\uFF5C>', + '<\uFF5Cim_start\uFF5C>', + '<\uFF5Cim_end\uFF5C>', + '<\uFF5Cendoftext\uFF5C>', + ]; + + /// Removes LLM tokens and trims trailing whitespace from assistant content. + static String sanitizeContent(String content) { + var out = content; + for (final token in _llmTokenPatterns) { + out = out.replaceAll(token, ''); + } + out = out.replaceAll(RegExp(r'<\|[^|]*\|>'), ''); + out = out.replaceAll(RegExp('<\u{FF5C}[^\u{FF5C}]*\u{FF5C}>'), ''); + return out.trim(); + } + final String id; final String type; final String role; @@ -22,11 +48,14 @@ class Message { }); factory Message.fromJson(Map json) { + final rawContent = json['content'] as String; + final role = json['role'] as String; + final content = role == 'assistant' ? sanitizeContent(rawContent) : rawContent; return Message( id: json['id'].toString(), type: json['type'] as String, - role: json['role'] as String, - content: json['content'] as String, + role: role, + content: content, model: json['model'] as String?, createdAt: DateTime.parse(json['created_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String), diff --git a/mobile/lib/providers/auth_provider.dart b/mobile/lib/providers/auth_provider.dart index a75826b1c..3b6a6510e 100644 --- a/mobile/lib/providers/auth_provider.dart +++ b/mobile/lib/providers/auth_provider.dart @@ -1,3 +1,4 @@ +import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/foundation.dart'; import 'package:url_launcher/url_launcher.dart'; import '../models/user.dart'; @@ -57,9 +58,20 @@ class AuthProvider with ChangeNotifier { _tokens = await _authService.getStoredTokens(); _user = await _authService.getStoredUser(); - // If tokens exist but are expired, try to refresh + // If tokens exist but are expired, try to refresh only when online if (_tokens != null && _tokens!.isExpired) { - await _refreshToken(); + final results = await Connectivity().checkConnectivity(); + final isOnline = results.any((r) => + r == ConnectivityResult.mobile || + r == ConnectivityResult.wifi || + r == ConnectivityResult.ethernet || + r == ConnectivityResult.vpn || + r == ConnectivityResult.bluetooth); + if (isOnline) { + await _refreshToken(); + } else { + await logout(); + } } } } catch (e) { diff --git a/mobile/lib/providers/chat_provider.dart b/mobile/lib/providers/chat_provider.dart index 2760aec23..5016c934f 100644 --- a/mobile/lib/providers/chat_provider.dart +++ b/mobile/lib/providers/chat_provider.dart @@ -14,6 +14,10 @@ class ChatProvider with ChangeNotifier { String? _errorMessage; Timer? _pollingTimer; + /// Content length of the last assistant message from the previous poll. + /// Used to detect when the LLM has finished writing (no growth between polls). + int? _lastAssistantContentLength; + List get chats => _chats; Chat? get currentChat => _currentChat; bool get isLoading => _isLoading; @@ -85,7 +89,6 @@ class ChatProvider with ChangeNotifier { required String accessToken, String? title, String? initialMessage, - String model = 'gpt-4', }) async { _isLoading = true; _errorMessage = null; @@ -96,7 +99,6 @@ class ChatProvider with ChangeNotifier { accessToken: accessToken, title: title, initialMessage: initialMessage, - model: model, ); if (result['success'] == true) { @@ -127,8 +129,9 @@ class ChatProvider with ChangeNotifier { } } - /// Send a message to the current chat - Future sendMessage({ + /// Send a message to the current chat. + /// Returns true if delivery succeeded, false otherwise. + Future sendMessage({ required String accessToken, required String chatId, required String content, @@ -158,11 +161,14 @@ class ChatProvider with ChangeNotifier { // Start polling for AI response _startPolling(accessToken, chatId); + return true; } else { _errorMessage = result['error'] ?? 'Failed to send message'; + return false; } } catch (e) { _errorMessage = 'Error: ${e.toString()}'; + return false; } finally { _isSendingMessage = false; notifyListeners(); @@ -239,6 +245,7 @@ class ChatProvider with ChangeNotifier { /// Start polling for new messages (AI responses) void _startPolling(String accessToken, String chatId) { _stopPolling(); + _lastAssistantContentLength = null; _pollingTimer = Timer.periodic(const Duration(seconds: 2), (timer) async { await _pollForUpdates(accessToken, chatId); @@ -262,26 +269,55 @@ class ChatProvider with ChangeNotifier { if (result['success'] == true) { final updatedChat = result['chat'] as Chat; - // Check if we have new messages - if (_currentChat != null && _currentChat!.id == chatId) { - final oldMessageCount = _currentChat!.messages.length; - final newMessageCount = updatedChat.messages.length; + if (_currentChat == null || _currentChat!.id != chatId) return; - if (newMessageCount > oldMessageCount) { - _currentChat = updatedChat; - notifyListeners(); + final oldMessages = _currentChat!.messages; + final newMessages = updatedChat.messages; + final oldMessageCount = oldMessages.length; + final newMessageCount = newMessages.length; - // Check if the last message is from assistant and complete - final lastMessage = updatedChat.messages.lastOrNull; - if (lastMessage != null && lastMessage.isAssistant) { - // Stop polling after getting assistant response - _stopPolling(); + final oldContentLengthById = {}; + for (final m in oldMessages) { + if (m.isAssistant) oldContentLengthById[m.id] = m.content.length; + } + + bool shouldUpdate = false; + + // New messages added + if (newMessageCount > oldMessageCount) { + shouldUpdate = true; + _lastAssistantContentLength = null; + } else if (newMessageCount == oldMessageCount) { + // Same count: check if any assistant message has more content + for (final m in newMessages) { + if (m.isAssistant) { + final oldLen = oldContentLengthById[m.id] ?? 0; + if (m.content.length > oldLen) { + shouldUpdate = true; + break; + } } } } + + if (shouldUpdate) { + _currentChat = updatedChat; + notifyListeners(); + } + + final lastMessage = updatedChat.messages.lastOrNull; + if (lastMessage != null && lastMessage.isAssistant) { + final newLen = lastMessage.content.length; + if (newLen > (_lastAssistantContentLength ?? 0)) { + _lastAssistantContentLength = newLen; + } else { + // Content stable: no growth since last poll + _stopPolling(); + _lastAssistantContentLength = null; + } + } } } catch (e) { - // Silently fail polling errors to avoid interrupting user experience debugPrint('Polling error: ${e.toString()}'); } } diff --git a/mobile/lib/screens/chat_conversation_screen.dart b/mobile/lib/screens/chat_conversation_screen.dart index 0de3fee61..66b6d20c4 100644 --- a/mobile/lib/screens/chat_conversation_screen.dart +++ b/mobile/lib/screens/chat_conversation_screen.dart @@ -1,9 +1,15 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import '../models/chat.dart'; import '../providers/auth_provider.dart'; import '../providers/chat_provider.dart'; import '../models/message.dart'; +class _SendMessageIntent extends Intent { + const _SendMessageIntent(); +} + class ChatConversationScreen extends StatefulWidget { final String chatId; @@ -69,15 +75,24 @@ class _ChatConversationScreenState extends State { return; } - // Clear input field immediately + final shouldUpdateTitle = chatProvider.currentChat?.hasDefaultTitle == true; + _messageController.clear(); - await chatProvider.sendMessage( + final delivered = await chatProvider.sendMessage( accessToken: accessToken, chatId: widget.chatId, content: content, ); + if (delivered && shouldUpdateTitle) { + await chatProvider.updateChatTitle( + accessToken: accessToken, + chatId: widget.chatId, + title: Chat.generateTitle(content), + ); + } + // Scroll to bottom after sending WidgetsBinding.instance.addPostFrameCallback((_) { if (_scrollController.hasClients) { @@ -298,34 +313,48 @@ class _ChatConversationScreenState extends State { ), ], ), - child: Row( - children: [ - Expanded( - child: TextField( - controller: _messageController, - decoration: InputDecoration( - hintText: 'Type a message...', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(24), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, + child: Shortcuts( + shortcuts: const { + SingleActivator(LogicalKeyboardKey.enter): _SendMessageIntent(), + }, + child: Actions( + actions: >{ + _SendMessageIntent: CallbackAction<_SendMessageIntent>( + onInvoke: (_) { + if (!chatProvider.isSendingMessage) _sendMessage(); + return null; + }, + ), + }, + child: Row( + children: [ + Expanded( + child: TextField( + controller: _messageController, + decoration: InputDecoration( + hintText: 'Type a message...', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + maxLines: null, + textCapitalization: TextCapitalization.sentences, ), ), - maxLines: null, - textCapitalization: TextCapitalization.sentences, - onSubmitted: (_) => _sendMessage(), - ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.send), + onPressed: chatProvider.isSendingMessage ? null : _sendMessage, + color: colorScheme.primary, + iconSize: 28, + ), + ], ), - const SizedBox(width: 8), - IconButton( - icon: const Icon(Icons.send), - onPressed: chatProvider.isSendingMessage ? null : _sendMessage, - color: colorScheme.primary, - iconSize: 28, - ), - ], + ), ), ), ], diff --git a/mobile/lib/screens/chat_list_screen.dart b/mobile/lib/screens/chat_list_screen.dart index eece17448..d49bbd3fd 100644 --- a/mobile/lib/screens/chat_list_screen.dart +++ b/mobile/lib/screens/chat_list_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../models/chat.dart'; import '../providers/auth_provider.dart'; import '../providers/chat_provider.dart'; import 'chat_conversation_screen.dart'; @@ -58,7 +59,7 @@ class _ChatListScreenState extends State { final chat = await chatProvider.createChat( accessToken: accessToken, - title: 'New Chat', + title: Chat.defaultTitle, ); // Close loading dialog diff --git a/mobile/lib/screens/settings_screen.dart b/mobile/lib/screens/settings_screen.dart index 16a51fc77..b8ddc0a4e 100644 --- a/mobile/lib/screens/settings_screen.dart +++ b/mobile/lib/screens/settings_screen.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../providers/auth_provider.dart'; import '../services/offline_storage_service.dart'; import '../services/log_service.dart'; import '../services/preferences_service.dart'; +import '../services/user_service.dart'; class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); @@ -16,6 +18,8 @@ class SettingsScreen extends StatefulWidget { class _SettingsScreenState extends State { bool _groupByType = false; String? _appVersion; + bool _isResettingAccount = false; + bool _isDeletingAccount = false; @override void initState() { @@ -101,6 +105,139 @@ class _SettingsScreenState extends State { } } + Future _launchContactUrl(BuildContext context) async { + final uri = Uri.parse('https://discord.com/invite/36ZGBsxYEK'); + final launched = await launchUrl(uri, mode: LaunchMode.externalApplication); + if (!launched && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Unable to open link')), + ); + } + } + + Future _handleResetAccount(BuildContext context) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Reset Account'), + content: const Text( + 'Resetting your account will delete all your accounts, categories, ' + 'merchants, tags, and other data, but keep your user account intact.\n\n' + 'This action cannot be undone. Are you sure?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + child: const Text('Reset Account'), + ), + ], + ), + ); + + if (confirmed != true || !context.mounted) return; + + setState(() => _isResettingAccount = true); + try { + final authProvider = Provider.of(context, listen: false); + final accessToken = await authProvider.getValidAccessToken(); + if (accessToken == null) { + await authProvider.logout(); + return; + } + + final result = await UserService().resetAccount(accessToken: accessToken); + + if (!context.mounted) return; + + if (result['success'] == true) { + await OfflineStorageService().clearAllData(); + + if (!context.mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Account reset has been initiated. This may take a moment.'), + backgroundColor: Colors.green, + ), + ); + + await authProvider.logout(); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(result['error'] ?? 'Failed to reset account'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + if (mounted) setState(() => _isResettingAccount = false); + } + } + + Future _handleDeleteAccount(BuildContext context) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete Account'), + content: const Text( + 'Deleting your account will permanently remove all your data ' + 'and cannot be undone.\n\n' + 'Are you sure you want to delete your account?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + child: const Text('Delete Account'), + ), + ], + ), + ); + + if (confirmed != true || !context.mounted) return; + + setState(() => _isDeletingAccount = true); + try { + final authProvider = Provider.of(context, listen: false); + final accessToken = await authProvider.getValidAccessToken(); + if (accessToken == null) { + await authProvider.logout(); + return; + } + + final result = await UserService().deleteAccount(accessToken: accessToken); + + if (!context.mounted) return; + + if (result['success'] == true) { + await authProvider.logout(); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(result['error'] ?? 'Failed to delete account'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + if (mounted) setState(() => _isDeletingAccount = false); + } + } + Future _handleLogout(BuildContext context) async { final confirmed = await showDialog( context: context, @@ -200,6 +337,19 @@ class _SettingsScreenState extends State { ), ), + ListTile( + leading: const Icon(Icons.chat_bubble_outline), + title: const Text('Contact us'), + subtitle: Text( + 'https://discord.com/invite/36ZGBsxYEK', + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + ), + ), + onTap: () => _launchContactUrl(context), + ), + const Divider(), // Display Settings Section @@ -253,6 +403,47 @@ class _SettingsScreenState extends State { const Divider(), + // Danger Zone Section + const Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + 'Danger Zone', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Colors.red, + ), + ), + ), + + ListTile( + leading: const Icon(Icons.restart_alt, color: Colors.red), + title: const Text('Reset Account'), + subtitle: const Text( + 'Delete all accounts, categories, merchants, and tags but keep your user account', + ), + trailing: _isResettingAccount + ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) + : null, + enabled: !_isResettingAccount && !_isDeletingAccount, + onTap: _isResettingAccount || _isDeletingAccount ? null : () => _handleResetAccount(context), + ), + + ListTile( + leading: const Icon(Icons.delete_forever, color: Colors.red), + title: const Text('Delete Account'), + subtitle: const Text( + 'Permanently remove all your data. This cannot be undone.', + ), + trailing: _isDeletingAccount + ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) + : null, + enabled: !_isDeletingAccount && !_isResettingAccount, + onTap: _isDeletingAccount || _isResettingAccount ? null : () => _handleDeleteAccount(context), + ), + + const Divider(), + // Sign out button Padding( padding: const EdgeInsets.all(16), diff --git a/mobile/lib/services/chat_service.dart b/mobile/lib/services/chat_service.dart index 080378c51..1e55e57da 100644 --- a/mobile/lib/services/chat_service.dart +++ b/mobile/lib/services/chat_service.dart @@ -118,14 +118,11 @@ class ChatService { required String accessToken, String? title, String? initialMessage, - String model = 'gpt-4', }) async { try { final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/chats'); - final body = { - 'model': model, - }; + final body = {}; if (title != null) { body['title'] = title; diff --git a/mobile/lib/services/user_service.dart b/mobile/lib/services/user_service.dart new file mode 100644 index 000000000..f410bcf7f --- /dev/null +++ b/mobile/lib/services/user_service.dart @@ -0,0 +1,71 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'api_config.dart'; + +class UserService { + Future> resetAccount({ + required String accessToken, + }) async { + try { + final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/users/reset'); + + final response = await http.delete( + url, + headers: ApiConfig.getAuthHeaders(accessToken), + ).timeout(const Duration(seconds: 30)); + + if (response.statusCode == 200) { + return {'success': true}; + } else if (response.statusCode == 401) { + return { + 'success': false, + 'error': 'Session expired. Please login again.', + }; + } else { + final responseData = jsonDecode(response.body); + return { + 'success': false, + 'error': responseData['error'] ?? 'Failed to reset account', + }; + } + } catch (e) { + return { + 'success': false, + 'error': 'Network error: ${e.toString()}', + }; + } + } + + Future> deleteAccount({ + required String accessToken, + }) async { + try { + final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/users/me'); + + final response = await http.delete( + url, + headers: ApiConfig.getAuthHeaders(accessToken), + ).timeout(const Duration(seconds: 30)); + + if (response.statusCode == 200) { + return {'success': true}; + } else if (response.statusCode == 401) { + return { + 'success': false, + 'error': 'Session expired. Please login again.', + }; + } else { + final responseData = jsonDecode(response.body); + return { + 'success': false, + 'error': responseData['error'] ?? 'Failed to delete account', + }; + } + } catch (e) { + return { + 'success': false, + 'error': 'Network error: ${e.toString()}', + }; + } + } +} diff --git a/spec/requests/api/v1/users_spec.rb b/spec/requests/api/v1/users_spec.rb new file mode 100644 index 000000000..65c745ede --- /dev/null +++ b/spec/requests/api/v1/users_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'API V1 Users', type: :request do + let(:family) do + Family.create!( + name: 'API Family', + currency: 'USD', + locale: 'en', + date_format: '%m-%d-%Y' + ) + end + + let(:user) do + family.users.create!( + email: 'api-user@example.com', + password: 'password123', + password_confirmation: 'password123' + ) + end + + let(:api_key) do + key = ApiKey.generate_secure_key + ApiKey.create!( + user: user, + name: 'API Docs Key', + key: key, + scopes: %w[read_write], + source: 'web' + ) + end + + let(:'X-Api-Key') { api_key.plain_key } + + path '/api/v1/users/reset' do + delete 'Reset account' do + tags 'Users' + description 'Resets all financial data (accounts, categories, merchants, tags, etc.) ' \ + 'for the current user\'s family while keeping the user account intact. ' \ + 'The reset runs asynchronously in the background.' + security [ { apiKeyAuth: [] } ] + produces 'application/json' + + response '200', 'account reset initiated' do + schema '$ref' => '#/components/schemas/SuccessMessage' + + run_test! + end + + response '401', 'unauthorized' do + let(:'X-Api-Key') { 'invalid-key' } + + run_test! + end + + response '403', 'insufficient scope' do + let(:api_key) do + key = ApiKey.generate_secure_key + ApiKey.create!( + user: user, + name: 'Read Only Key', + key: key, + scopes: %w[read], + source: 'web' + ) + end + + run_test! + end + end + end + + path '/api/v1/users/me' do + delete 'Delete account' do + tags 'Users' + description 'Permanently deactivates the current user account and all associated data. ' \ + 'This action cannot be undone.' + security [ { apiKeyAuth: [] } ] + produces 'application/json' + + response '200', 'account deleted' do + schema '$ref' => '#/components/schemas/SuccessMessage' + + run_test! + end + + response '401', 'unauthorized' do + let(:'X-Api-Key') { 'invalid-key' } + + run_test! + end + + response '403', 'insufficient scope' do + let(:api_key) do + key = ApiKey.generate_secure_key + ApiKey.create!( + user: user, + name: 'Read Only Key', + key: key, + scopes: %w[read], + source: 'web' + ) + end + + run_test! + end + + response '422', 'deactivation failed' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + before do + allow_any_instance_of(User).to receive(:deactivate).and_return(false) + allow_any_instance_of(User).to receive(:errors).and_return( + double(full_messages: [ 'Cannot deactivate admin with other users' ]) + ) + end + + run_test! + end + end + end +end diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb index 8a990075b..c02b0831c 100644 --- a/spec/swagger_helper.rb +++ b/spec/swagger_helper.rb @@ -517,6 +517,13 @@ RSpec.configure do |config| }, pagination: { '$ref' => '#/components/schemas/Pagination' } } + }, + SuccessMessage: { + type: :object, + required: %w[message], + properties: { + message: { type: :string } + } } } } diff --git a/test/controllers/api/v1/users_controller_test.rb b/test/controllers/api/v1/users_controller_test.rb new file mode 100644 index 000000000..9ea8b89cb --- /dev/null +++ b/test/controllers/api/v1/users_controller_test.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require "test_helper" + +class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:family_admin) + + @user.api_keys.active.destroy_all + + @api_key = ApiKey.create!( + user: @user, + name: "Test Read-Write Key", + scopes: [ "read_write" ], + display_key: "test_rw_#{SecureRandom.hex(8)}" + ) + + @read_only_api_key = ApiKey.create!( + user: @user, + name: "Test Read-Only Key", + scopes: [ "read" ], + display_key: "test_ro_#{SecureRandom.hex(8)}", + source: "mobile" + ) + end + + # -- Authentication -------------------------------------------------------- + + test "reset requires authentication" do + delete "/api/v1/users/reset" + assert_response :unauthorized + end + + test "destroy requires authentication" do + delete "/api/v1/users/me" + assert_response :unauthorized + end + + # -- Scope enforcement ----------------------------------------------------- + + test "reset requires write scope" do + delete "/api/v1/users/reset", headers: api_headers(@read_only_api_key) + assert_response :forbidden + end + + test "destroy requires write scope" do + delete "/api/v1/users/me", headers: api_headers(@read_only_api_key) + assert_response :forbidden + end + + # -- Reset ----------------------------------------------------------------- + + test "reset enqueues FamilyResetJob and returns 200" do + assert_enqueued_with(job: FamilyResetJob) do + delete "/api/v1/users/reset", headers: api_headers(@api_key) + end + + assert_response :ok + body = JSON.parse(response.body) + assert_equal "Account reset has been initiated", body["message"] + end + + # -- Delete account -------------------------------------------------------- + + test "destroy deactivates user and returns 200" do + solo_family = Family.create!(name: "Solo Family", currency: "USD", locale: "en", date_format: "%m-%d-%Y") + solo_user = solo_family.users.create!( + email: "solo@example.com", + password: "password123", + password_confirmation: "password123", + role: :admin + ) + solo_api_key = ApiKey.create!( + user: solo_user, + name: "Solo Key", + scopes: [ "read_write" ], + display_key: "test_solo_#{SecureRandom.hex(8)}" + ) + + delete "/api/v1/users/me", headers: api_headers(solo_api_key) + assert_response :ok + + body = JSON.parse(response.body) + assert_equal "Account has been deleted", body["message"] + + solo_user.reload + assert_not solo_user.active? + assert_not_equal "solo@example.com", solo_user.email + end + + test "destroy returns 422 when admin has other family members" do + delete "/api/v1/users/me", headers: api_headers(@api_key) + assert_response :unprocessable_entity + + body = JSON.parse(response.body) + assert_equal "Failed to delete account", body["error"] + end + + # -- Deactivated user ------------------------------------------------------ + + test "rejects deactivated user with 401" do + @user.update_column(:active, false) + + delete "/api/v1/users/reset", headers: api_headers(@api_key) + assert_response :unauthorized + + body = JSON.parse(response.body) + assert_equal "Account has been deactivated", body["message"] + end + + private + + def api_headers(api_key) + { "X-Api-Key" => api_key.display_key } + end +end