From cd61af83152163d3222efcf96494f71c2e91d4ef Mon Sep 17 00:00:00 2001 From: Dana Khaing Date: Tue, 26 May 2026 05:29:06 +0100 Subject: [PATCH 1/6] Add public login help case intake backend --- .../0023_supportcase_intake_type_and_more.py | 32 +++++++ flowbit-backend/core/models.py | 13 +++ flowbit-backend/core/serializers.py | 53 +++++++++++- flowbit-backend/core/tests.py | 31 +++++++ flowbit-backend/core/urls.py | 2 + flowbit-backend/core/views.py | 85 ++++++++++++++++++- 6 files changed, 213 insertions(+), 3 deletions(-) create mode 100644 flowbit-backend/core/migrations/0023_supportcase_intake_type_and_more.py 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..bc432f7 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( @@ -1730,6 +1740,9 @@ class Meta: ordering = ['-last_message_at', '-updated_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..6663e66 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,19 @@ class Meta: ] read_only_fields = fields + def get_sender_username(self, obj): + if obj.sender_id is None: + return '' + return obj.sender.username + def get_sender_full_name(self, obj): + 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 obj.sender_id is None: + return '' profile = getattr(obj.sender, 'profile', None) return getattr(profile, 'role', '') @@ -1110,7 +1119,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 +1130,9 @@ class Meta: fields = [ 'id', 'subject', + 'intake_type', + 'requester_name', + 'requester_login_identifier', 'status', 'created_by', 'created_by_username', @@ -1137,10 +1149,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 +1193,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..3857be5 100644 --- a/flowbit-backend/core/tests.py +++ b/flowbit-backend/core/tests.py @@ -2559,6 +2559,37 @@ 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.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_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..e6c704e 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) @@ -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 From 8a0ea24303a4cb8bfcce187d1b9331a86cd9b02c Mon Sep 17 00:00:00 2001 From: Dana Khaing Date: Tue, 26 May 2026 05:31:58 +0100 Subject: [PATCH 2/6] Add public login help intake UI --- flowbit-frontend/src/app/login-help/page.tsx | 5 + .../src/components/auth/login-form-card.tsx | 11 +- .../components/auth/login-help-form-card.tsx | 206 ++++++++++++++++++ .../src/components/auth/login-help-page.tsx | 10 + .../support/customer-service-page.tsx | 38 +++- flowbit-frontend/src/lib/support-client.ts | 15 ++ flowbit-frontend/src/proxy.ts | 2 +- 7 files changed, 279 insertions(+), 8 deletions(-) create mode 100644 flowbit-frontend/src/app/login-help/page.tsx create mode 100644 flowbit-frontend/src/components/auth/login-help-form-card.tsx create mode 100644 flowbit-frontend/src/components/auth/login-help-page.tsx 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..806713b 100644 --- a/flowbit-frontend/src/components/auth/login-form-card.tsx +++ b/flowbit-frontend/src/components/auth/login-form-card.tsx @@ -274,9 +274,14 @@ export function LoginFormCard() { Keep me signed in on this device - - Forgot password? - +
+ + Forgot password? + + + Can't log in? Contact admin + +
diff --git a/flowbit-frontend/src/components/auth/login-help-form-card.tsx b/flowbit-frontend/src/components/auth/login-help-form-card.tsx new file mode 100644 index 0000000..b73241d --- /dev/null +++ b/flowbit-frontend/src/components/auth/login-help-form-card.tsx @@ -0,0 +1,206 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faArrowLeft, faLifeRing } from "@fortawesome/free-solid-svg-icons"; +import { AuthInput } from "./auth-input"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { createPublicLoginHelpCase } from "@/lib/support-client"; + +const helpNotes = [ + "Use the username or email you normally use to sign in.", + "Explain what happened and what you already tried, such as reset password or resend verification.", + "The admin will review the case in FlowBit and contact you outside the app if needed.", +]; + +export function LoginHelpFormCard() { + const [formValues, setFormValues] = useState({ + login_identifier: "", + requester_name: "", + subject: "", + message: "", + }); + const [fieldErrors, setFieldErrors] = useState>>({}); + const [errorMessage, setErrorMessage] = useState(""); + const [successMessage, setSuccessMessage] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + function validateForm() { + const nextErrors: Partial> = {}; + + if (!formValues.login_identifier.trim()) { + nextErrors.login_identifier = "Enter your username or email."; + } + if (!formValues.subject.trim()) { + nextErrors.subject = "Enter a short issue summary."; + } + if (!formValues.message.trim()) { + nextErrors.message = "Describe the login problem."; + } + + setFieldErrors(nextErrors); + return Object.keys(nextErrors).length === 0; + } + + async function handleSubmit() { + setErrorMessage(""); + setSuccessMessage(""); + if (!validateForm()) { + return; + } + + setIsSubmitting(true); + try { + const response = await createPublicLoginHelpCase({ + login_identifier: formValues.login_identifier.trim(), + requester_name: formValues.requester_name.trim(), + subject: formValues.subject.trim(), + message: formValues.message.trim(), + }); + setSuccessMessage(response.message); + setFormValues({ + login_identifier: "", + requester_name: "", + subject: "", + message: "", + }); + setFieldErrors({}); + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : "Unable to send your login-help case."); + } finally { + setIsSubmitting(false); + } + } + + return ( + +
+
+

Login Help

+

Contact admin about sign-in problems

+
+ + + Back to login + +
+ +

+ Use this if reset password and verification steps did not solve the issue. The request goes into the same + customer service queue the admin already manages. +

+ +
+ { + setFormValues((current) => ({ ...current, login_identifier: event.target.value })); + setFieldErrors((current) => ({ ...current, login_identifier: undefined })); + }} + /> + { + setFormValues((current) => ({ ...current, requester_name: event.target.value })); + setFieldErrors((current) => ({ ...current, requester_name: undefined })); + }} + /> +
+ +
+ { + setFormValues((current) => ({ ...current, subject: event.target.value })); + setFieldErrors((current) => ({ ...current, subject: undefined })); + }} + /> +