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:
@@ -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
|
||||||
|
|
||||||
#--------------------------------------------------------------------------------------------------------------
|
#--------------------------------------------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
332
frontend/src/components/social/chat/CreateChatModal.tsx
Normal file
332
frontend/src/components/social/chat/CreateChatModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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...",
|
||||||
|
|||||||
Reference in New Issue
Block a user