diff --git a/flowbit-backend/core/admin.py b/flowbit-backend/core/admin.py index 97bc463..aee07f5 100644 --- a/flowbit-backend/core/admin.py +++ b/flowbit-backend/core/admin.py @@ -220,17 +220,44 @@ class SupportMessageInline(admin.TabularInline): @admin.register(SupportCase) class SupportCaseAdmin(admin.ModelAdmin): - list_display = ('subject', 'created_by', 'status', 'closed_by', 'last_message_at', 'created_at') - list_filter = ('status', 'created_at', 'closed_at') - search_fields = ('subject', 'created_by__username', 'messages__body') + list_display = ('subject', 'intake_type', 'requester_display', 'status', 'closed_by', 'last_message_at', 'created_at') + list_filter = ('intake_type', 'status', 'created_at', 'closed_at') + search_fields = ( + 'subject', + 'created_by__username', + 'requester_name', + 'requester_login_identifier', + 'messages__body', + ) inlines = [SupportMessageInline] + @admin.display(description='Requester') + def requester_display(self, obj): + if obj.intake_type == SupportCase.INTAKE_LOGIN_HELP: + return obj.requester_name or obj.requester_login_identifier + return obj.created_by + @admin.register(SupportMessage) class SupportMessageAdmin(admin.ModelAdmin): - list_display = ('support_case', 'sender', 'created_at') + list_display = ('support_case', 'sender_display', 'created_at') list_filter = ('created_at',) - search_fields = ('support_case__subject', 'sender__username', 'body') + search_fields = ( + 'support_case__subject', + 'sender__username', + 'support_case__requester_name', + 'support_case__requester_login_identifier', + 'body', + ) + + @admin.display(description='Sender') + def sender_display(self, obj): + if ( + obj.support_case.intake_type == SupportCase.INTAKE_LOGIN_HELP + and obj.support_case.created_by_id == obj.sender_id + ): + return obj.support_case.requester_name or obj.support_case.requester_login_identifier + return obj.sender @admin.register(Collaborator) diff --git a/flowbit-backend/core/migrations/0023_supportcase_intake_type_and_more.py b/flowbit-backend/core/migrations/0023_supportcase_intake_type_and_more.py new file mode 100644 index 0000000..ba7bc8b --- /dev/null +++ b/flowbit-backend/core/migrations/0023_supportcase_intake_type_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.28 on 2026-05-26 02:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0022_emailverificationtoken"), + ] + + operations = [ + migrations.AddField( + model_name="supportcase", + name="intake_type", + field=models.CharField( + choices=[("STANDARD", "Standard"), ("LOGIN_HELP", "Login help")], + default="STANDARD", + max_length=24, + ), + ), + migrations.AddField( + model_name="supportcase", + name="requester_login_identifier", + field=models.CharField(blank=True, default="", max_length=160), + ), + migrations.AddField( + model_name="supportcase", + name="requester_name", + field=models.CharField(blank=True, default="", max_length=160), + ), + ] diff --git a/flowbit-backend/core/models.py b/flowbit-backend/core/models.py index 259329e..48ec4cd 100644 --- a/flowbit-backend/core/models.py +++ b/flowbit-backend/core/models.py @@ -1700,6 +1700,13 @@ def _create_period_event_notification( class SupportCase(models.Model): + INTAKE_STANDARD = 'STANDARD' + INTAKE_LOGIN_HELP = 'LOGIN_HELP' + INTAKE_TYPE_CHOICES = ( + (INTAKE_STANDARD, 'Standard'), + (INTAKE_LOGIN_HELP, 'Login help'), + ) + STATUS_OPEN = 'OPEN' STATUS_CLOSED = 'CLOSED' STATUS_CHOICES = ( @@ -1713,6 +1720,9 @@ class SupportCase(models.Model): related_name='support_cases', ) subject = models.CharField(max_length=160) + intake_type = models.CharField(max_length=24, choices=INTAKE_TYPE_CHOICES, default=INTAKE_STANDARD) + requester_name = models.CharField(max_length=160, blank=True, default='') + requester_login_identifier = models.CharField(max_length=160, blank=True, default='') status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_OPEN) closed_at = models.DateTimeField(null=True, blank=True) closed_by = models.ForeignKey( @@ -1727,9 +1737,12 @@ class SupportCase(models.Model): updated_at = models.DateTimeField(auto_now=True) class Meta: - ordering = ['-last_message_at', '-updated_at', '-id'] + ordering = ['-created_at', '-id'] def __str__(self): + if self.intake_type == self.INTAKE_LOGIN_HELP: + identity = self.requester_name or self.requester_login_identifier or self.created_by.username + return f"{self.subject} ({identity})" return f"{self.subject} ({self.created_by.username})" def close(self, closed_by=None, save=True): diff --git a/flowbit-backend/core/serializers.py b/flowbit-backend/core/serializers.py index d683e3f..beaa960 100644 --- a/flowbit-backend/core/serializers.py +++ b/flowbit-backend/core/serializers.py @@ -1078,7 +1078,7 @@ class NotificationBroadcastSerializer(serializers.Serializer): class SupportMessageSerializer(serializers.ModelSerializer): - sender_username = serializers.CharField(source='sender.username', read_only=True) + sender_username = serializers.SerializerMethodField() sender_full_name = serializers.SerializerMethodField() sender_role = serializers.SerializerMethodField() is_admin_sender = serializers.SerializerMethodField() @@ -1097,10 +1097,31 @@ class Meta: ] read_only_fields = fields + def _is_login_help_requester_message(self, obj): + return ( + obj.support_case.intake_type == SupportCase.INTAKE_LOGIN_HELP + and obj.support_case.created_by_id == obj.sender_id + ) + + def get_sender_username(self, obj): + if self._is_login_help_requester_message(obj): + return obj.support_case.requester_login_identifier + if obj.sender_id is None: + return '' + return obj.sender.username + def get_sender_full_name(self, obj): + if self._is_login_help_requester_message(obj): + return obj.support_case.requester_name or obj.support_case.requester_login_identifier + if obj.sender_id is None: + return 'Login help requester' return obj.sender.get_full_name().strip() or obj.sender.username def get_sender_role(self, obj): + if self._is_login_help_requester_message(obj): + return 'login_help' + if obj.sender_id is None: + return '' profile = getattr(obj.sender, 'profile', None) return getattr(profile, 'role', '') @@ -1110,7 +1131,7 @@ def get_is_admin_sender(self, obj): class SupportCaseSerializer(serializers.ModelSerializer): - created_by_username = serializers.CharField(source='created_by.username', read_only=True) + created_by_username = serializers.SerializerMethodField() created_by_full_name = serializers.SerializerMethodField() created_by_role = serializers.SerializerMethodField() closed_by_username = serializers.CharField(source='closed_by.username', read_only=True, allow_null=True) @@ -1121,6 +1142,9 @@ class Meta: fields = [ 'id', 'subject', + 'intake_type', + 'requester_name', + 'requester_login_identifier', 'status', 'created_by', 'created_by_username', @@ -1137,10 +1161,19 @@ class Meta: ] read_only_fields = fields + def get_created_by_username(self, obj): + if obj.intake_type == SupportCase.INTAKE_LOGIN_HELP: + return obj.requester_login_identifier or obj.created_by.username + return obj.created_by.username + def get_created_by_full_name(self, obj): + if obj.intake_type == SupportCase.INTAKE_LOGIN_HELP: + return obj.requester_name or obj.requester_login_identifier or obj.created_by.username return obj.created_by.get_full_name().strip() or obj.created_by.username def get_created_by_role(self, obj): + if obj.intake_type == SupportCase.INTAKE_LOGIN_HELP: + return 'login_help' profile = getattr(obj.created_by, 'profile', None) return getattr(profile, 'role', '') @@ -1172,6 +1205,34 @@ def validate_message(self, value): return value +class PublicLoginHelpCaseCreateSerializer(serializers.Serializer): + login_identifier = serializers.CharField(max_length=160) + requester_name = serializers.CharField(max_length=160, required=False, allow_blank=True) + subject = serializers.CharField(max_length=160) + message = serializers.CharField() + + def validate_login_identifier(self, value): + value = value.strip() + if not value: + raise serializers.ValidationError('Username or email is required.') + return value + + def validate_requester_name(self, value): + return value.strip() + + def validate_subject(self, value): + value = value.strip() + if not value: + raise serializers.ValidationError('Subject is required.') + return value + + def validate_message(self, value): + value = value.strip() + if not value: + raise serializers.ValidationError('Message is required.') + return value + + class SupportCaseReplySerializer(serializers.Serializer): message = serializers.CharField() diff --git a/flowbit-backend/core/tests.py b/flowbit-backend/core/tests.py index 723be71..c61f114 100644 --- a/flowbit-backend/core/tests.py +++ b/flowbit-backend/core/tests.py @@ -2513,6 +2513,31 @@ def test_user_support_case_list_is_scoped_to_owner(self): subjects = {item['subject'] for item in response.data} self.assertEqual(subjects, {'User One Case'}) + def test_support_cases_are_listed_latest_activity_first(self): + earlier_case = SupportCase.objects.create( + created_by=self.user_one, + subject='Earlier Case', + last_message_at=timezone.now() - timezone.timedelta(hours=2), + ) + later_case = SupportCase.objects.create( + created_by=self.user_one, + subject='Later Case', + last_message_at=timezone.now() - timezone.timedelta(hours=1), + ) + SupportMessage.objects.create(support_case=earlier_case, sender=self.user_one, body='Earlier message') + SupportMessage.objects.create(support_case=later_case, sender=self.user_one, body='Later message') + + later_case.last_message_at = timezone.now() - timezone.timedelta(minutes=10) + later_case.save(update_fields=['last_message_at', 'updated_at']) + earlier_case.last_message_at = timezone.now() + earlier_case.save(update_fields=['last_message_at', 'updated_at']) + + self.client.force_authenticate(user=self.user_one) + response = self.client.get('/api/support-cases/') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual([item['subject'] for item in response.data], ['Earlier Case', 'Later Case']) + def test_admin_can_view_all_support_cases(self): case_one = SupportCase.objects.create( created_by=self.user_one, @@ -2559,6 +2584,57 @@ def test_user_can_create_support_case_with_initial_message(self): ).exists() ) + def test_public_login_help_case_can_be_created_without_authentication(self): + response = self.client.post('/api/support-cases/login-help/', { + 'login_identifier': 'locked.user', + 'requester_name': 'Locked User', + 'subject': 'Cannot log in', + 'message': 'I cannot access my account after several attempts.', + }, format='json') + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data['message'], 'Your login-help case has been sent to the admin.') + support_case = SupportCase.objects.get(subject='Cannot log in') + self.assertEqual(support_case.intake_type, SupportCase.INTAKE_LOGIN_HELP) + self.assertEqual(support_case.requester_name, 'Locked User') + self.assertEqual(support_case.requester_login_identifier, 'locked.user') + self.assertEqual(support_case.created_by.username, '_login_help_intake') + self.assertEqual(support_case.messages.count(), 1) + self.assertEqual(support_case.messages.first().sender.username, '_login_help_intake') + self.assertEqual(response.data['case']['created_by_full_name'], 'Locked User') + self.assertEqual(response.data['case']['created_by_username'], 'locked.user') + self.assertTrue( + UserNotification.objects.filter( + recipient=self.admin_user, + title='New login help case', + ).exists() + ) + audit_entry = AuditLog.objects.get(action='support.login_help_case_created') + self.assertIsNone(audit_entry.user) + + def test_public_login_help_does_not_open_authenticated_support_case_routes(self): + response = self.client.get('/api/support-cases/') + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_admin_case_detail_shows_requester_identity_for_login_help_message(self): + self.client.post('/api/support-cases/login-help/', { + 'login_identifier': 'locked.user', + 'requester_name': 'Locked User', + 'subject': 'Cannot log in', + 'message': 'I cannot access my account after several attempts.', + }, format='json') + support_case = SupportCase.objects.get(subject='Cannot log in') + + self.client.force_authenticate(user=self.admin_user) + response = self.client.get(f'/api/support-cases/{support_case.id}/') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['created_by_full_name'], 'Locked User') + self.assertEqual(response.data['created_by_username'], 'locked.user') + self.assertEqual(response.data['messages'][0]['sender_full_name'], 'Locked User') + self.assertEqual(response.data['messages'][0]['sender_username'], 'locked.user') + def test_case_can_be_replied_closed_and_reopened_by_both_sides(self): support_case = SupportCase.objects.create( created_by=self.user_one, diff --git a/flowbit-backend/core/urls.py b/flowbit-backend/core/urls.py index 021ec66..4c3a5aa 100644 --- a/flowbit-backend/core/urls.py +++ b/flowbit-backend/core/urls.py @@ -29,6 +29,7 @@ VerifyEmailView, ResendVerificationView, ResetPasswordConfirmView, + PublicLoginHelpCaseCreateView, TicketListView, TicketDetailView, TicketRefundView, @@ -51,6 +52,7 @@ router.register(r'repeat-tickets', RepeatTicketViewSet, basename='repeat-ticket') urlpatterns = [ + path('support-cases/login-help/', PublicLoginHelpCaseCreateView.as_view(), name='support-case-login-help'), # All router endpoints (ledgers, identifiers, transactions, overflows) path('', include(router.urls)), diff --git a/flowbit-backend/core/views.py b/flowbit-backend/core/views.py index 173e90b..2649095 100644 --- a/flowbit-backend/core/views.py +++ b/flowbit-backend/core/views.py @@ -95,6 +95,7 @@ SupportCaseSerializer, SupportCaseDetailSerializer, SupportCaseCreateSerializer, + PublicLoginHelpCaseCreateSerializer, SupportCaseReplySerializer, TicketSerializer, TicketDetailSerializer, @@ -121,6 +122,9 @@ logger = logging.getLogger(__name__) +LOGIN_HELP_INTAKE_USERNAME = '_login_help_intake' +LOGIN_HELP_INTAKE_EMAIL = 'login-help-intake@flowbit.local' + def parse_period_value(value): if not value: @@ -565,7 +569,7 @@ def notify_support_case_participants( action_href='/contact-support', ): recipients = set() - if support_case.created_by_id != actor.id: + if support_case.intake_type != SupportCase.INTAKE_LOGIN_HELP and support_case.created_by_id != actor.id: recipients.add(support_case.created_by) if include_admins: recipients.update( @@ -591,6 +595,22 @@ def notification_actor_label(user): return user.get_full_name().strip() or user.username +def get_login_help_intake_user(): + intake_user, created = User.objects.get_or_create( + username=LOGIN_HELP_INTAKE_USERNAME, + defaults={ + 'email': LOGIN_HELP_INTAKE_EMAIL, + 'first_name': 'Login', + 'last_name': 'Help Intake', + 'is_active': False, + }, + ) + if created: + intake_user.set_unusable_password() + intake_user.save(update_fields=['password']) + return intake_user + + def refresh_dashboard_for_user(user): if user and getattr(user, 'id', None): push_dashboard_refresh_for_user(user.id) @@ -3561,7 +3581,7 @@ def get_queryset(self): ) ).annotate( message_count_annotated=Count('messages', distinct=True), - ) + ).order_by('-last_message_at', '-updated_at', '-created_at', '-id') if is_admin_user(self.request.user): return queryset @@ -3711,6 +3731,69 @@ def reopen_case(self, request, pk=None): return Response(SupportCaseSerializer(support_case).data, status=status.HTTP_200_OK) +class PublicLoginHelpCaseCreateView(APIView): + permission_classes = [AllowAny] + + def post(self, request): + serializer = PublicLoginHelpCaseCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + intake_user = get_login_help_intake_user() + with db_transaction.atomic(): + support_case = SupportCase.objects.create( + created_by=intake_user, + subject=serializer.validated_data['subject'], + intake_type=SupportCase.INTAKE_LOGIN_HELP, + requester_name=serializer.validated_data.get('requester_name', ''), + requester_login_identifier=serializer.validated_data['login_identifier'], + last_message_at=timezone.now(), + ) + SupportMessage.objects.create( + support_case=support_case, + sender=intake_user, + body=serializer.validated_data['message'], + ) + + support_case = SupportCase.objects.select_related( + 'created_by', + 'created_by__profile', + 'closed_by', + ).prefetch_related( + Prefetch( + 'messages', + queryset=SupportMessage.objects.select_related('sender', 'sender__profile').order_by('created_at', 'id'), + ) + ).annotate( + message_count_annotated=Count('messages', distinct=True), + ).get(pk=support_case.pk) + notify_support_case_participants( + support_case=support_case, + actor=intake_user, + title='New login help case', + message=f"Login help case opened: {support_case.subject}.", + include_admins=True, + ) + record_audit_log( + request, + 'support.login_help_case_created', + target=support_case, + details=f"Created public login help case '{support_case.subject}'", + changes={ + 'case_id': support_case.id, + 'subject': support_case.subject, + 'requester_name': support_case.requester_name, + 'requester_login_identifier': support_case.requester_login_identifier, + }, + ) + return Response( + { + 'message': 'Your login-help case has been sent to the admin.', + 'case': SupportCaseSerializer(support_case).data, + }, + status=status.HTTP_201_CREATED, + ) + + class AuditLogViewSet(viewsets.ReadOnlyModelViewSet): queryset = AuditLog.objects.select_related('user') serializer_class = AuditLogSerializer diff --git a/flowbit-frontend/src/app/login-help/page.tsx b/flowbit-frontend/src/app/login-help/page.tsx new file mode 100644 index 0000000..e7c1761 --- /dev/null +++ b/flowbit-frontend/src/app/login-help/page.tsx @@ -0,0 +1,5 @@ +import { LoginHelpPage } from "@/components/auth/login-help-page"; + +export default function LoginHelpRoute() { + return ; +} diff --git a/flowbit-frontend/src/components/auth/login-form-card.tsx b/flowbit-frontend/src/components/auth/login-form-card.tsx index c2ce307..dca1dd8 100644 --- a/flowbit-frontend/src/components/auth/login-form-card.tsx +++ b/flowbit-frontend/src/components/auth/login-form-card.tsx @@ -316,7 +316,12 @@ export function LoginFormCard() {

Sign-In Help

Need help accessing your account?

-
Support
+ + Support +