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.
This commit is contained in:
2026-05-28 08:40:55 +02:00
parent d52af2c495
commit f19375254f
4 changed files with 375 additions and 12 deletions

View File

@@ -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.http import urlsafe_base64_encode, urlsafe_base64_decode
from django.utils.encoding import force_bytes, force_str 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.permissions import IsAuthenticated, AllowAny
from rest_framework_simplejwt.tokens import RefreshToken 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 django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiExample, OpenApiParameter from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiExample, OpenApiParameter
@@ -52,14 +52,19 @@ class CookieTokenObtainPairView(TokenObtainPairView):
def post(self, request, *args, **kwargs): 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 = serializer.validated_data.get("access")
access = response.data.get("access") refresh = serializer.validated_data.get("refresh")
refresh = response.data.get("refresh")
if not access or not refresh: # Create a Django session so AuthMiddlewareStack authenticates WebSocket connections
return response # Např. při chybě přihlášení 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 jwt_settings = settings.SIMPLE_JWT
@@ -155,12 +160,10 @@ class LogoutView(APIView):
permission_classes = [AllowAny] permission_classes = [AllowAny]
def post(self, request): def post(self, request):
django_logout(request) # destroy Django session (used for WebSocket auth)
response = Response({"detail": "Logout successful"}, status=status.HTTP_200_OK) response = Response({"detail": "Logout successful"}, status=status.HTTP_200_OK)
# Smazání cookies
response.delete_cookie("access_token", path="/") response.delete_cookie("access_token", path="/")
response.delete_cookie("refresh_token", path="/") response.delete_cookie("refresh_token", path="/")
return response return response
#-------------------------------------------------------------------------------------------------------------- #--------------------------------------------------------------------------------------------------------------

View File

@@ -7,10 +7,12 @@ import Avatar from "@/components/ui/Avatar";
import Spinner from "@/components/ui/Spinner"; import Spinner from "@/components/ui/Spinner";
import EmptyState from "@/components/ui/EmptyState"; import EmptyState from "@/components/ui/EmptyState";
import IconButton from "@/components/ui/IconButton"; import IconButton from "@/components/ui/IconButton";
import CreateChatModal from "./CreateChatModal";
export default function ChatSidebar() { export default function ChatSidebar() {
const { t } = useTranslation("social"); const { t } = useTranslation("social");
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const { data, isLoading } = useApiSocialChatsList( const { data, isLoading } = useApiSocialChatsList(
query ? { search: query } : undefined, query ? { search: query } : undefined,
@@ -24,7 +26,11 @@ export default function ChatSidebar() {
<h2 className="text-sm font-semibold text-brand-text"> <h2 className="text-sm font-semibold text-brand-text">
{t("chat.sidebar.title")} {t("chat.sidebar.title")}
</h2> </h2>
<IconButton icon={<FiPlus size={16} />} label={t("chat.sidebar.new")} /> <IconButton
icon={<FiPlus size={16} />}
label={t("chat.sidebar.new")}
onClick={() => setCreateOpen(true)}
/>
</header> </header>
<div className="relative px-3 py-2"> <div className="relative px-3 py-2">
@@ -86,6 +92,11 @@ export default function ChatSidebar() {
))} ))}
</ul> </ul>
</div> </div>
<CreateChatModal
open={createOpen}
onClose={() => setCreateOpen(false)}
/>
</aside> </aside>
); );
} }

View File

@@ -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<ChatTab>("DM");
const [name, setName] = useState("");
const [userQuery, setUserQuery] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState("");
const [selectedUsers, setSelectedUsers] = useState<CustomUser[]>([]);
const [showDropdown, setShowDropdown] = useState(false);
const [error, setError] = useState<string | null>(null);
const searchRef = useRef<HTMLDivElement>(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 (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
aria-label={t("chat.create.title")}
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Dialog */}
<div className="relative z-10 w-full max-w-md rounded-2xl border border-brand-lines/20 bg-brand-bgLight shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between border-b border-brand-lines/15 px-5 py-4">
<h2 className="text-base font-semibold text-brand-text">
{t("chat.create.title")}
</h2>
<button
type="button"
onClick={onClose}
className="inline-flex h-7 w-7 items-center justify-center rounded-full text-brand-text/60 hover:bg-brand-lines/15 hover:text-brand-text transition-colors"
aria-label={t("chat.create.close")}
>
<FiX size={16} />
</button>
</div>
{/* Tab switcher */}
<div className="flex border-b border-brand-lines/15 px-5 pt-4 gap-1">
{(["DM", "GROUP"] as ChatTab[]).map((type) => (
<button
key={type}
type="button"
onClick={() => switchTab(type)}
className={[
"px-4 py-2 text-sm font-medium rounded-t-lg transition-colors",
tab === type
? "border-b-2 border-brand-accent text-brand-accent"
: "text-brand-text/60 hover:text-brand-text",
].join(" ")}
>
{type === "DM"
? t("chat.create.tabDM")
: t("chat.create.tabGroup")}
</button>
))}
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="px-5 py-5 space-y-4">
{/* Group name */}
{tab === "GROUP" && (
<Input
label={t("chat.create.nameLabel")}
placeholder={t("chat.create.namePlaceholder")}
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={255}
autoFocus
/>
)}
{/* User search */}
<div ref={searchRef} className="relative">
<label className="mb-1.5 block text-sm font-medium text-brand-text/90">
{tab === "DM"
? t("chat.create.recipientLabel")
: t("chat.create.membersLabel")}
</label>
{/* Selected user chips */}
{selectedUsers.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{selectedUsers.map((u) => (
<span
key={u.id}
className="inline-flex items-center gap-1.5 rounded-full bg-brand-accent/15 border border-brand-accent/30 px-2.5 py-1 text-xs text-brand-accent"
>
{u.username}
<button
type="button"
onClick={() => toggleUser(u)}
className="hover:text-brand-text transition-colors"
aria-label={`Remove ${u.username}`}
>
<FiX size={11} />
</button>
</span>
))}
</div>
)}
{/* Search input */}
{(tab === "GROUP" || selectedUsers.length === 0) && (
<div className="relative">
<FiSearch
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-brand-text/40"
size={14}
/>
<input
value={userQuery}
onChange={(e) => {
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"
/>
</div>
)}
{/* Dropdown */}
{showDropdown && debouncedQuery.length >= 2 && (
<div className="absolute left-0 right-0 top-full z-20 mt-1 max-h-52 overflow-y-auto rounded-xl border border-brand-lines/20 bg-brand-bg shadow-xl">
{usersLoading && (
<div className="flex justify-center py-4">
<Spinner size={18} />
</div>
)}
{!usersLoading && filteredUsers.length === 0 && (
<p className="px-4 py-3 text-sm text-brand-text/50">
{t("chat.create.noUsers")}
</p>
)}
{filteredUsers.map((user) => (
<button
key={user.id}
type="button"
onClick={() => toggleUser(user)}
className="flex w-full items-center gap-3 px-4 py-2.5 text-left hover:bg-brand-lines/10 transition-colors"
>
<Avatar
name={user.username}
src={user.avatar ?? undefined}
size={28}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-brand-text">
{user.username}
</div>
{(user.first_name || user.last_name) && (
<div className="truncate text-xs text-brand-text/50">
{[user.first_name, user.last_name]
.filter(Boolean)
.join(" ")}
</div>
)}
</div>
{selectedUsers.some((s) => s.id === user.id) && (
<FiCheck size={14} className="shrink-0 text-brand-accent" />
)}
</button>
))}
</div>
)}
</div>
<FormErrorBanner message={error} />
{/* Footer */}
<div className="flex items-center justify-end gap-2 pt-1">
<Button
type="button"
variant="ghost"
size="sm"
onClick={onClose}
disabled={isPending}
>
{t("chat.create.cancel")}
</Button>
<Button
type="submit"
variant="primary"
size="sm"
loading={isPending}
>
{t("chat.create.submit")}
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -61,6 +61,23 @@
"empty": "Zatím žádné konverzace.", "empty": "Zatím žádné konverzace.",
"new": "Nová 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": { "room": {
"selectChat": "Vyberte konverzaci nalevo", "selectChat": "Vyberte konverzaci nalevo",
"typing": "{{user}} píše...", "typing": "{{user}} píše...",