mirror of
https://github.com/we-promise/sure
synced 2026-04-25 17:15:07 +02:00
Improvements to Flutter client (#1042)
* 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 <jjmata@jjmata.com> * 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 <jjmata@jjmata.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
27
app/controllers/api/v1/users_controller.rb
Normal file
27
app/controllers/api/v1/users_controller.rb
Normal file
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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|>',
|
||||
'</s>',
|
||||
// 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<String, dynamic> 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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<Chat> 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<void> sendMessage({
|
||||
/// Send a message to the current chat.
|
||||
/// Returns true if delivery succeeded, false otherwise.
|
||||
Future<bool> 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 = <String, int>{};
|
||||
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()}');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ChatConversationScreen> {
|
||||
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<ChatConversationScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
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: <Type, Action<Intent>>{
|
||||
_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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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<ChatListScreen> {
|
||||
|
||||
final chat = await chatProvider.createChat(
|
||||
accessToken: accessToken,
|
||||
title: 'New Chat',
|
||||
title: Chat.defaultTitle,
|
||||
);
|
||||
|
||||
// Close loading dialog
|
||||
|
||||
@@ -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<SettingsScreen> {
|
||||
bool _groupByType = false;
|
||||
String? _appVersion;
|
||||
bool _isResettingAccount = false;
|
||||
bool _isDeletingAccount = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -101,6 +105,139 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _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<void> _handleResetAccount(BuildContext context) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
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<AuthProvider>(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<void> _handleDeleteAccount(BuildContext context) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
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<AuthProvider>(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<void> _handleLogout(BuildContext context) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
@@ -200,6 +337,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
),
|
||||
|
||||
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<SettingsScreen> {
|
||||
|
||||
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),
|
||||
|
||||
@@ -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 = <String, dynamic>{
|
||||
'model': model,
|
||||
};
|
||||
final body = <String, dynamic>{};
|
||||
|
||||
if (title != null) {
|
||||
body['title'] = title;
|
||||
|
||||
71
mobile/lib/services/user_service.dart
Normal file
71
mobile/lib/services/user_service.dart
Normal file
@@ -0,0 +1,71 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'api_config.dart';
|
||||
|
||||
class UserService {
|
||||
Future<Map<String, dynamic>> 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<Map<String, dynamic>> 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()}',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
123
spec/requests/api/v1/users_spec.rb
Normal file
123
spec/requests/api/v1/users_spec.rb
Normal file
@@ -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
|
||||
@@ -517,6 +517,13 @@ RSpec.configure do |config|
|
||||
},
|
||||
pagination: { '$ref' => '#/components/schemas/Pagination' }
|
||||
}
|
||||
},
|
||||
SuccessMessage: {
|
||||
type: :object,
|
||||
required: %w[message],
|
||||
properties: {
|
||||
message: { type: :string }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
116
test/controllers/api/v1/users_controller_test.rb
Normal file
116
test/controllers/api/v1/users_controller_test.rb
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user