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 */}
+
+
+
+ );
+}
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...",