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 }));
+ }}
+ />
+
+ Message
+
+
+
+ {successMessage ? (
+
+ {successMessage}
+
+ ) : null}
+
+ {errorMessage ? (
+
+ {errorMessage}
+
+ ) : null}
+
+
+
+
+ {isSubmitting ? "Sending..." : "Send login-help case"}
+
+
+ Return to login
+
+
+
+
+
+
+
+
Before you send it
+
Useful details for admin
+
+
Support
+
+
+
+ {helpNotes.map((item) => (
+ {item}
+ ))}
+
+
+
+
+ );
+}
diff --git a/flowbit-frontend/src/components/auth/login-help-page.tsx b/flowbit-frontend/src/components/auth/login-help-page.tsx
new file mode 100644
index 0000000..f2978df
--- /dev/null
+++ b/flowbit-frontend/src/components/auth/login-help-page.tsx
@@ -0,0 +1,10 @@
+import { AuthShell } from "./auth-shell";
+import { LoginHelpFormCard } from "./login-help-form-card";
+
+export function LoginHelpPage() {
+ return (
+
+
+
+ );
+}
diff --git a/flowbit-frontend/src/components/support/customer-service-page.tsx b/flowbit-frontend/src/components/support/customer-service-page.tsx
index e3e7dc4..6e82b01 100644
--- a/flowbit-frontend/src/components/support/customer-service-page.tsx
+++ b/flowbit-frontend/src/components/support/customer-service-page.tsx
@@ -45,6 +45,12 @@ function statusTone(status: "OPEN" | "CLOSED") {
: "bg-stone-200 text-stone-600";
}
+function intakeTone(intakeType: "STANDARD" | "LOGIN_HELP") {
+ return intakeType === "LOGIN_HELP"
+ ? "bg-amber-100 text-amber-700"
+ : "bg-stone-200 text-stone-600";
+}
+
export function CustomerServicePage() {
const currentUserState = useCurrentUserState();
const [user, setUser] = useState(getStoredUser());
@@ -216,6 +222,7 @@ export function CustomerServicePage() {
item.subject,
item.created_by_username,
item.created_by_full_name,
+ item.requester_login_identifier,
item.last_message_preview,
]
.join(" ")
@@ -328,7 +335,15 @@ export function CustomerServicePage() {
{isAdmin ? "Customers" : "My cases"}
- {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}
-
+
+
+ {item.status}
+
+ {item.intake_type === "LOGIN_HELP" ? (
+
+ Login help
+
+ ) : null}
+
{item.message_count} message{item.message_count === 1 ? "" : "s"}
@@ -414,9 +436,17 @@ export function CustomerServicePage() {
{selectedCase.status}
+ {selectedCase.intake_type === "LOGIN_HELP" ? (
+
+ Login help
+
+ ) : null}
{selectedCase.created_by_full_name}
+ {selectedCase.intake_type === "LOGIN_HELP" ? (
+ Login: {selectedCase.requester_login_identifier}
+ ) : null}
{formatDateTime(selectedCase.created_at)}
{selectedCase.closed_at ? Closed {formatDateTime(selectedCase.closed_at)} : null}
diff --git a/flowbit-frontend/src/lib/support-client.ts b/flowbit-frontend/src/lib/support-client.ts
index ede2c15..2ec9d0f 100644
--- a/flowbit-frontend/src/lib/support-client.ts
+++ b/flowbit-frontend/src/lib/support-client.ts
@@ -15,6 +15,9 @@ export type FlowBitSupportMessage = {
export type FlowBitSupportCase = {
id: number;
subject: string;
+ intake_type: "STANDARD" | "LOGIN_HELP";
+ requester_name: string;
+ requester_login_identifier: string;
status: "OPEN" | "CLOSED";
created_by: number;
created_by_username: string;
@@ -64,6 +67,18 @@ export async function createSupportCase(payload: { subject: string; message: str
});
}
+export async function createPublicLoginHelpCase(payload: {
+ login_identifier: string;
+ requester_name?: string;
+ subject: string;
+ message: string;
+}) {
+ return apiRequest<{ message: string; case: FlowBitSupportCase }>("/support-cases/login-help/", {
+ method: "POST",
+ body: JSON.stringify(payload),
+ });
+}
+
export async function replyToSupportCase(caseId: number, message: string) {
return apiRequest(`/support-cases/${caseId}/reply/`, {
method: "POST",
diff --git a/flowbit-frontend/src/proxy.ts b/flowbit-frontend/src/proxy.ts
index 479f7d5..dc8ba3c 100644
--- a/flowbit-frontend/src/proxy.ts
+++ b/flowbit-frontend/src/proxy.ts
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { AUTH_COOKIE_NAME } from "@/lib/auth";
-const authRoutes = new Set(["/login", "/sign-in", "/forgot-password", "/reset-password", "/sign-up", "/verify-email"]);
+const authRoutes = new Set(["/login", "/login-help", "/sign-in", "/forgot-password", "/reset-password", "/sign-up", "/verify-email"]);
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
From 83c3b4e4d54fdd3d9af8c59cab43e98a37b6eee3 Mon Sep 17 00:00:00 2001
From: Dana Khaing
Date: Tue, 26 May 2026 05:33:56 +0100
Subject: [PATCH 3/6] Polish login help requester display
---
flowbit-backend/core/serializers.py | 12 ++++++++++++
flowbit-backend/core/tests.py | 2 ++
2 files changed, 14 insertions(+)
diff --git a/flowbit-backend/core/serializers.py b/flowbit-backend/core/serializers.py
index 6663e66..beaa960 100644
--- a/flowbit-backend/core/serializers.py
+++ b/flowbit-backend/core/serializers.py
@@ -1097,17 +1097,29 @@ 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)
diff --git a/flowbit-backend/core/tests.py b/flowbit-backend/core/tests.py
index 3857be5..81e6524 100644
--- a/flowbit-backend/core/tests.py
+++ b/flowbit-backend/core/tests.py
@@ -2576,6 +2576,8 @@ def test_public_login_help_case_can_be_created_without_authentication(self):
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,
From 88290284e2f08779fba0b8fa79564e2fe9a219d2 Mon Sep 17 00:00:00 2001
From: Dana Khaing
Date: Tue, 26 May 2026 05:37:30 +0100
Subject: [PATCH 4/6] Improve login help admin visibility
---
flowbit-backend/core/admin.py | 37 ++++++++++++++++++++++++++++++-----
flowbit-backend/core/tests.py | 18 +++++++++++++++++
2 files changed, 50 insertions(+), 5 deletions(-)
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/tests.py b/flowbit-backend/core/tests.py
index 81e6524..94b2520 100644
--- a/flowbit-backend/core/tests.py
+++ b/flowbit-backend/core/tests.py
@@ -2592,6 +2592,24 @@ def test_public_login_help_does_not_open_authenticated_support_case_routes(self)
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,
From 09a2f8d6d58e89404152c3c9c689bfa5ceb1d5b4 Mon Sep 17 00:00:00 2001
From: Dana Khaing
Date: Tue, 26 May 2026 10:48:27 +0100
Subject: [PATCH 5/6] Move login help link into support card
---
.../src/components/auth/login-form-card.tsx | 18 +++++++++---------
1 file changed, 9 insertions(+), 9 deletions(-)
diff --git a/flowbit-frontend/src/components/auth/login-form-card.tsx b/flowbit-frontend/src/components/auth/login-form-card.tsx
index 806713b..dca1dd8 100644
--- a/flowbit-frontend/src/components/auth/login-form-card.tsx
+++ b/flowbit-frontend/src/components/auth/login-form-card.tsx
@@ -274,14 +274,9 @@ export function LoginFormCard() {
Keep me signed in on this device
-
-
- Forgot password?
-
-
- Can't log in? Contact admin
-
-
+
+ Forgot password?
+
@@ -321,7 +316,12 @@ export function LoginFormCard() {
Sign-In Help
Need help accessing your account?
- Support
+
+ Support
+