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
Sign-In Help
Login Help
++ Use this if reset password and verification steps did not solve the issue. +
+ +Before you send it
+- {isAdmin ? new Set(cases.map((item) => item.created_by)).size : cases.length} + {isAdmin + ? new Set( + cases.map((item) => + item.intake_type === "LOGIN_HELP" + ? item.requester_login_identifier || `case-${item.id}` + : `user-${item.created_by}`, + ), + ).size + : cases.length}
@@ -385,9 +400,16 @@ export function CustomerServicePage() { {isAdmin ? item.created_by_full_name : "My case"} - - {item.status} - +