From f19375254feff127a2a178086ff6cf0d8650e820 Mon Sep 17 00:00:00 2001 From: Brunobrno Date: Thu, 28 May 2026 08:40:55 +0200 Subject: [PATCH] Create chat modal UI & session login for WS auth Backend: initialize a Django session on token obtain (login) so AuthMiddlewareStack can authenticate WebSocket connections; validate the token serializer and map TokenError -> InvalidToken; call django_logout on logout to destroy the session. Frontend: add a CreateChatModal component (DM/group creation) with user search, selection, validation, API mutation and cache invalidation; wire modal into ChatSidebar and add Czech translations for the new UI strings. --- backend/account/views.py | 25 +- .../components/social/chat/ChatSidebar.tsx | 13 +- .../social/chat/CreateChatModal.tsx | 332 ++++++++++++++++++ frontend/src/i18n/locales/cs/social.json | 17 + 4 files changed, 375 insertions(+), 12 deletions(-) create mode 100644 frontend/src/components/social/chat/CreateChatModal.tsx diff --git a/backend/account/views.py b/backend/account/views.py index b8df7c2..1fda2e3 100644 --- a/backend/account/views.py +++ b/backend/account/views.py @@ -1,4 +1,4 @@ -from django.contrib.auth import get_user_model, authenticate +from django.contrib.auth import get_user_model, authenticate, login as django_login, logout as django_logout from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode from django.utils.encoding import force_bytes, force_str @@ -21,7 +21,7 @@ from rest_framework.viewsets import ModelViewSet from rest_framework.permissions import IsAuthenticated, AllowAny from rest_framework_simplejwt.tokens import RefreshToken -from rest_framework_simplejwt.exceptions import TokenError, AuthenticationFailed +from rest_framework_simplejwt.exceptions import TokenError, AuthenticationFailed, InvalidToken from django_filters.rest_framework import DjangoFilterBackend from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiExample, OpenApiParameter @@ -52,14 +52,19 @@ class CookieTokenObtainPairView(TokenObtainPairView): def post(self, request, *args, **kwargs): - response = super().post(request, *args, **kwargs) + serializer = self.get_serializer(data=request.data) + try: + serializer.is_valid(raise_exception=True) + except TokenError as e: + raise InvalidToken(e.args[0]) - # Získáme tokeny z odpovědi - access = response.data.get("access") - refresh = response.data.get("refresh") + access = serializer.validated_data.get("access") + refresh = serializer.validated_data.get("refresh") - if not access or not refresh: - return response # Např. při chybě přihlášení + # Create a Django session so AuthMiddlewareStack authenticates WebSocket connections + django_login(request, serializer.user, backend='django.contrib.auth.backends.ModelBackend') + + response = Response(serializer.validated_data, status=status.HTTP_200_OK) jwt_settings = settings.SIMPLE_JWT @@ -155,12 +160,10 @@ class LogoutView(APIView): permission_classes = [AllowAny] def post(self, request): + django_logout(request) # destroy Django session (used for WebSocket auth) response = Response({"detail": "Logout successful"}, status=status.HTTP_200_OK) - - # Smazání cookies response.delete_cookie("access_token", path="/") response.delete_cookie("refresh_token", path="/") - return response #-------------------------------------------------------------------------------------------------------------- diff --git a/frontend/src/components/social/chat/ChatSidebar.tsx b/frontend/src/components/social/chat/ChatSidebar.tsx index c87a061..106c25f 100644 --- a/frontend/src/components/social/chat/ChatSidebar.tsx +++ b/frontend/src/components/social/chat/ChatSidebar.tsx @@ -7,10 +7,12 @@ import Avatar from "@/components/ui/Avatar"; import Spinner from "@/components/ui/Spinner"; import EmptyState from "@/components/ui/EmptyState"; import IconButton from "@/components/ui/IconButton"; +import CreateChatModal from "./CreateChatModal"; export default function ChatSidebar() { const { t } = useTranslation("social"); const [query, setQuery] = useState(""); + const [createOpen, setCreateOpen] = useState(false); const { data, isLoading } = useApiSocialChatsList( query ? { search: query } : undefined, @@ -24,7 +26,11 @@ export default function ChatSidebar() {

{t("chat.sidebar.title")}

- } label={t("chat.sidebar.new")} /> + } + label={t("chat.sidebar.new")} + onClick={() => setCreateOpen(true)} + />
@@ -86,6 +92,11 @@ export default function ChatSidebar() { ))}
+ + setCreateOpen(false)} + /> ); } diff --git a/frontend/src/components/social/chat/CreateChatModal.tsx b/frontend/src/components/social/chat/CreateChatModal.tsx new file mode 100644 index 0000000..0ee543b --- /dev/null +++ b/frontend/src/components/social/chat/CreateChatModal.tsx @@ -0,0 +1,332 @@ +import { useState, useRef, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { FiX, FiSearch, FiCheck } from "react-icons/fi"; +import { useQueryClient } from "@tanstack/react-query"; +import { + useApiSocialChatsCreate, + getApiSocialChatsListQueryKey, +} from "@/api/generated/private/chat/chat"; +import { useApiAccountUsersList } from "@/api/generated/private/account/account"; +import type { CustomUser } from "@/api/generated/private/models"; +import { ChatTypeEnum } from "@/api/generated/private/models"; +import Button from "@/components/ui/Button"; +import Input from "@/components/ui/Input"; +import FormErrorBanner from "@/components/ui/FormErrorBanner"; +import Avatar from "@/components/ui/Avatar"; +import Spinner from "@/components/ui/Spinner"; + +interface Props { + open: boolean; + onClose: () => void; +} + +type ChatTab = "DM" | "GROUP"; + +export default function CreateChatModal({ open, onClose }: Props) { + const { t } = useTranslation("social"); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const [tab, setTab] = useState("DM"); + const [name, setName] = useState(""); + const [userQuery, setUserQuery] = useState(""); + const [debouncedQuery, setDebouncedQuery] = useState(""); + const [selectedUsers, setSelectedUsers] = useState([]); + const [showDropdown, setShowDropdown] = useState(false); + const [error, setError] = useState(null); + + const searchRef = useRef(null); + + // Debounce the user search + useEffect(() => { + const t = setTimeout(() => setDebouncedQuery(userQuery.trim()), 300); + return () => clearTimeout(t); + }, [userQuery]); + + // Close dropdown on outside click + useEffect(() => { + function handler(e: MouseEvent) { + if (searchRef.current && !searchRef.current.contains(e.target as Node)) { + setShowDropdown(false); + } + } + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, []); + + // Reset form on tab change + const switchTab = (t: ChatTab) => { + setTab(t); + setName(""); + setUserQuery(""); + setDebouncedQuery(""); + setSelectedUsers([]); + setError(null); + }; + + // User search query + const { data: usersData, isLoading: usersLoading } = useApiAccountUsersList( + debouncedQuery.length >= 2 ? { username: debouncedQuery } : undefined, + { query: { enabled: debouncedQuery.length >= 2 } }, + ); + + const users = usersData?.results ?? []; + const filteredUsers = users.filter( + (u) => !selectedUsers.some((s) => s.id === u.id), + ); + + // Create mutation + const { mutate: createChat, isPending } = useApiSocialChatsCreate({ + mutation: { + onSuccess: async (chat) => { + await queryClient.invalidateQueries({ + queryKey: getApiSocialChatsListQueryKey(), + }); + onClose(); + navigate(`/social/chats/${chat.id}`); + }, + onError: (err: unknown) => { + const msg = + (err as { response?: { data?: { detail?: string; name?: string[] } } }) + ?.response?.data?.detail ?? + (err as { response?: { data?: { name?: string[] } } })?.response?.data + ?.name?.[0] ?? + t("chat.create.error"); + setError(msg); + }, + }, + }); + + function toggleUser(user: CustomUser) { + setSelectedUsers((prev) => + prev.some((u) => u.id === user.id) + ? prev.filter((u) => u.id !== user.id) + : [...prev, user], + ); + setUserQuery(""); + setShowDropdown(false); + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + + if (tab === "GROUP" && !name.trim()) { + setError(t("chat.create.nameRequired")); + return; + } + if (tab === "DM" && selectedUsers.length === 0) { + setError(t("chat.create.recipientRequired")); + return; + } + + createChat({ + data: { + chat_type: tab === "DM" ? ChatTypeEnum.DM : ChatTypeEnum.GROUP, + name: tab === "GROUP" ? name.trim() : undefined, + members: selectedUsers.map((u) => u.id), + }, + }); + } + + // Close on Escape + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [open, onClose]); + + if (!open) return null; + + return ( +
+ {/* Backdrop */} +
+ + {/* Dialog */} +
+ {/* Header */} +
+

+ {t("chat.create.title")} +

+ +
+ + {/* Tab switcher */} +
+ {(["DM", "GROUP"] as ChatTab[]).map((type) => ( + + ))} +
+ + {/* Form */} +
+ {/* Group name */} + {tab === "GROUP" && ( + setName(e.target.value)} + maxLength={255} + autoFocus + /> + )} + + {/* User search */} +
+ + + {/* Selected user chips */} + {selectedUsers.length > 0 && ( +
+ {selectedUsers.map((u) => ( + + {u.username} + + + ))} +
+ )} + + {/* Search input */} + {(tab === "GROUP" || selectedUsers.length === 0) && ( +
+ + { + setUserQuery(e.target.value); + setShowDropdown(true); + }} + onFocus={() => userQuery.length >= 2 && setShowDropdown(true)} + placeholder={t("chat.create.searchPlaceholder")} + className="w-full rounded-xl border border-brand-lines/25 bg-brand-bgLight/40 py-2.5 pl-8 pr-3 text-sm text-brand-text placeholder:text-brand-text/40 focus:border-brand-accent focus:outline-none focus:ring-2 focus:ring-brand-accent/30" + /> +
+ )} + + {/* Dropdown */} + {showDropdown && debouncedQuery.length >= 2 && ( +
+ {usersLoading && ( +
+ +
+ )} + {!usersLoading && filteredUsers.length === 0 && ( +

+ {t("chat.create.noUsers")} +

+ )} + {filteredUsers.map((user) => ( + + ))} +
+ )} +
+ + + + {/* Footer */} +
+ + +
+ +
+
+ ); +} diff --git a/frontend/src/i18n/locales/cs/social.json b/frontend/src/i18n/locales/cs/social.json index fa36402..1d515bf 100644 --- a/frontend/src/i18n/locales/cs/social.json +++ b/frontend/src/i18n/locales/cs/social.json @@ -61,6 +61,23 @@ "empty": "Zatím žádné konverzace.", "new": "Nová konverzace" }, + "create": { + "title": "Nová konverzace", + "close": "Zavřít", + "tabDM": "Přímá zpráva", + "tabGroup": "Skupinový chat", + "nameLabel": "Název skupiny", + "namePlaceholder": "Zadejte název chatu...", + "nameRequired": "Název skupiny je povinný.", + "recipientLabel": "Příjemce", + "membersLabel": "Přidat členy", + "searchPlaceholder": "Hledat uživatele...", + "noUsers": "Žádní uživatelé nenalezeni.", + "recipientRequired": "Vyberte příjemce.", + "cancel": "Zrušit", + "submit": "Vytvořit", + "error": "Nepodařilo se vytvořit konverzaci." + }, "room": { "selectChat": "Vyberte konverzaci nalevo", "typing": "{{user}} píše...",