From 4791bbc92cfa2bfdde03d8cc01982ce592cc493a Mon Sep 17 00:00:00 2001 From: David Bruno Vontor Date: Fri, 31 Oct 2025 13:32:39 +0100 Subject: [PATCH] websockets + chat app (django) --- ...ser_email_verification_sent_at_and_more.py | 23 ++++++ backend/account/tasks.py | 28 +++++++ backend/account/tests.py | 27 +++++- backend/account/views.py | 14 ++-- backend/social/chat/__init__.py | 0 backend/social/chat/admin.py | 3 + backend/social/chat/apps.py | 8 ++ backend/social/chat/consumers.py | 27 ++++++ backend/social/chat/migrations/__init__.py | 0 backend/social/chat/models.py | 3 + backend/social/chat/routing.py | 8 ++ backend/social/chat/tests.py | 3 + backend/social/chat/views.py | 3 + backend/vontor_cz/settings.py | 2 + frontend/src/App.tsx | 18 +++- frontend/src/api/Client.ts | 51 +++++++----- frontend/src/api/models/User.ts | 82 +++++++++++++++++++ .../src/api/websockets/WebSocketClient.ts | 19 +++++ frontend/src/components/navbar/HomeNav.tsx | 6 +- frontend/src/context/Context.md | 30 +++++++ frontend/src/context/SettingsContext.tsx | 0 frontend/src/context/UserContext.tsx | 74 +++++++++++++++++ 22 files changed, 398 insertions(+), 31 deletions(-) create mode 100644 backend/account/migrations/0002_customuser_email_verification_sent_at_and_more.py create mode 100644 backend/social/chat/__init__.py create mode 100644 backend/social/chat/admin.py create mode 100644 backend/social/chat/apps.py create mode 100644 backend/social/chat/consumers.py create mode 100644 backend/social/chat/migrations/__init__.py create mode 100644 backend/social/chat/models.py create mode 100644 backend/social/chat/routing.py create mode 100644 backend/social/chat/tests.py create mode 100644 backend/social/chat/views.py create mode 100644 frontend/src/api/models/User.ts create mode 100644 frontend/src/api/websockets/WebSocketClient.ts create mode 100644 frontend/src/context/Context.md create mode 100644 frontend/src/context/SettingsContext.tsx create mode 100644 frontend/src/context/UserContext.tsx diff --git a/backend/account/migrations/0002_customuser_email_verification_sent_at_and_more.py b/backend/account/migrations/0002_customuser_email_verification_sent_at_and_more.py new file mode 100644 index 0000000..56b21b1 --- /dev/null +++ b/backend/account/migrations/0002_customuser_email_verification_sent_at_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.7 on 2025-10-31 07:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='customuser', + name='email_verification_sent_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='customuser', + name='email_verification_token', + field=models.CharField(blank=True, db_index=True, max_length=128, null=True), + ), + ] diff --git a/backend/account/tasks.py b/backend/account/tasks.py index 038998a..75ff897 100644 --- a/backend/account/tasks.py +++ b/backend/account/tasks.py @@ -121,4 +121,32 @@ def send_email_test_task(email): template_name="email/test.txt", html_template_name="email/test.html", context=context, + ) + + +@shared_task +def send_password_reset_email_task(user_id): + try: + user = CustomUser.objects.get(pk=user_id) + except CustomUser.DoesNotExist: + logger.info(f"Task send_password_reset_email has failed. Invalid User ID was sent.") + return 0 + + uid = urlsafe_base64_encode(force_bytes(user.pk)) + token = password_reset_token.make_token(user) + reset_url = f"{settings.FRONTEND_URL}/reset-password/{uid}/{token}" + + context = { + "user": _build_user_template_ctx(user), + "action_url": reset_url, + "frontend_url": settings.FRONTEND_URL, + "cta_label": "Obnovit heslo", + } + + send_email_with_context( + recipients=user.email, + subject="Obnova hesla", + template_name="email/password_reset.txt", + html_template_name="email/password_reset.html", + context=context, ) \ No newline at end of file diff --git a/backend/account/tests.py b/backend/account/tests.py index 7ce503c..f124502 100644 --- a/backend/account/tests.py +++ b/backend/account/tests.py @@ -1,3 +1,28 @@ from django.test import TestCase +from django.contrib.auth import get_user_model +from rest_framework.test import APIClient -# Create your tests here. + +class UserViewAnonymousTests(TestCase): + def setUp(self): + self.client = APIClient() + User = get_user_model() + self.target_user = User.objects.create_user( + username="target", + email="target@example.com", + password="pass1234", + is_active=True, + ) + + def test_anonymous_update_user_is_forbidden_and_does_not_crash(self): + url = f"/api/account/users/{self.target_user.id}/" + payload = {"username": "newname", "email": self.target_user.email} + resp = self.client.put(url, data=payload, format="json") + # Expect 403 Forbidden (permission denied), but most importantly no 500 error + self.assertEqual(resp.status_code, 403, msg=f"Unexpected status: {resp.status_code}, body={getattr(resp, 'data', resp.content)}") + + def test_anonymous_retrieve_user_is_unauthorized(self): + url = f"/api/account/users/{self.target_user.id}/" + resp = self.client.get(url) + # Retrieve requires authentication per view; expect 401 Unauthorized + self.assertEqual(resp.status_code, 401, msg=f"Unexpected status: {resp.status_code}, body={getattr(resp, 'data', resp.content)}") diff --git a/backend/account/views.py b/backend/account/views.py index ae9d49a..8476edd 100644 --- a/backend/account/views.py +++ b/backend/account/views.py @@ -229,13 +229,17 @@ class UserView(viewsets.ModelViewSet): # Only admin or the user themselves can update or delete elif self.action in ['update', 'partial_update', 'destroy']: - if self.request.user.role == 'admin': + user = getattr(self, 'request', None) and getattr(self.request, 'user', None) + # Admins can modify any user + if user and getattr(user, 'is_authenticated', False) and getattr(user, 'role', None) == 'admin': return [OnlyRolesAllowed("admin")()] - elif self.kwargs.get('pk') and str(self.request.user.id) == self.kwargs['pk']: + + # Users can modify their own record + if user and getattr(user, 'is_authenticated', False) and self.kwargs.get('pk') and str(getattr(user, 'id', '')) == self.kwargs['pk']: return [IsAuthenticated()] - else: - # fallback - deny access - return [OnlyRolesAllowed("admin")()] + + # Fallback - deny access (prevents AttributeError for AnonymousUser) + return [OnlyRolesAllowed("admin")()] # Any authenticated user can retrieve (view) any user's profile elif self.action == 'retrieve': diff --git a/backend/social/chat/__init__.py b/backend/social/chat/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/social/chat/admin.py b/backend/social/chat/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/backend/social/chat/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/backend/social/chat/apps.py b/backend/social/chat/apps.py new file mode 100644 index 0000000..bdfbeb0 --- /dev/null +++ b/backend/social/chat/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class ChatConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'social.chat' + + label = "chat" diff --git a/backend/social/chat/consumers.py b/backend/social/chat/consumers.py new file mode 100644 index 0000000..3fa4c01 --- /dev/null +++ b/backend/social/chat/consumers.py @@ -0,0 +1,27 @@ +# chat/consumers.py +import json + +from account.models import UserProfile + +from channels.db import database_sync_to_async +from channels.generic.websocket import AsyncWebsocketConsumer + + +class ChatConsumer(AsyncWebsocketConsumer): + async def connect(self): + await self.accept() + + async def disconnect(self, close_code): + pass + + async def receive(self, text_data): + text_data_json = json.loads(text_data) + message = text_data_json["message"] + + await self.send(text_data=json.dumps({"message": message})) + + + +@database_sync_to_async +def get_user_profile(user_id): + return UserProfile.objects.get(pk=user_id) \ No newline at end of file diff --git a/backend/social/chat/migrations/__init__.py b/backend/social/chat/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/social/chat/models.py b/backend/social/chat/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/backend/social/chat/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/backend/social/chat/routing.py b/backend/social/chat/routing.py new file mode 100644 index 0000000..4c48d8f --- /dev/null +++ b/backend/social/chat/routing.py @@ -0,0 +1,8 @@ +# chat/routing.py +from django.urls import re_path + +from . import consumers + +websocket_urlpatterns = [ + re_path(r"ws/chat/(?P\w+)/$", consumers.ChatConsumer.as_asgi()), +] \ No newline at end of file diff --git a/backend/social/chat/tests.py b/backend/social/chat/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/social/chat/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/social/chat/views.py b/backend/social/chat/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/backend/social/chat/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/backend/vontor_cz/settings.py b/backend/vontor_cz/settings.py index f7ae721..de72bdc 100644 --- a/backend/vontor_cz/settings.py +++ b/backend/vontor_cz/settings.py @@ -326,6 +326,8 @@ REST_FRAMEWORK = { MY_CREATED_APPS = [ 'account', 'commerce', + + 'social.chat', 'thirdparty.downloader', 'thirdparty.stripe', # register Stripe app so its models are recognized diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4dbb4f0..bd140d0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,17 +3,31 @@ import Home from "./pages/home/home"; import HomeLayout from "./layouts/HomeLayout"; import Downloader from "./pages/downloader/Downloader"; +import PrivateRoute from "./routes/PrivateRoute"; + +import { UserContextProvider } from "./context/UserContext"; + export default function App() { return ( - + + {/* Layout route */} }> } /> } /> - + + }> + {/* Protected routes go here */} + } > + } /> + + + + + ) } \ No newline at end of file diff --git a/frontend/src/api/Client.ts b/frontend/src/api/Client.ts index 7fd36a2..94ecbd5 100644 --- a/frontend/src/api/Client.ts +++ b/frontend/src/api/Client.ts @@ -3,8 +3,7 @@ import axios from "axios"; // --- ENV CONFIG --- const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000"; -const REFRESH_URL = - import.meta.env.VITE_API_REFRESH_URL || "/api/token/refresh/"; + const LOGIN_PATH = import.meta.env.VITE_LOGIN_PATH || "/login"; @@ -30,6 +29,7 @@ function notifyError(detail: ApiErrorDetail) { function onError(handler: ApiErrorHandler) { const wrapped = handler as EventListener; window.addEventListener(ERROR_EVENT, wrapped as EventListener); + return () => window.removeEventListener(ERROR_EVENT, wrapped); } @@ -38,7 +38,7 @@ function onError(handler: ApiErrorHandler) { function createAxios(baseURL: string): any { const instance = axios.create({ baseURL, - withCredentials: true, // <-- always true + withCredentials: true, // cookies headers: { "Content-Type": "application/json", }, @@ -51,12 +51,14 @@ function createAxios(baseURL: string): any { const apiPublic = createAxios(API_BASE_URL); const apiAuth = createAxios(API_BASE_URL); + // --- REQUEST INTERCEPTOR (PUBLIC) --- // Ensure no Authorization header is ever sent by the public client apiPublic.interceptors.request.use(function (config: any) { if (config?.headers && (config.headers as any).Authorization) { delete (config.headers as any).Authorization; } + return config; }); @@ -64,6 +66,7 @@ apiPublic.interceptors.request.use(function (config: any) { // Do not attach Authorization header; rely on cookies set by Django. apiAuth.interceptors.request.use(function (config: any) { (config as any)._retryCount = (config as any)._retryCount || 0; + return config; }); @@ -76,16 +79,18 @@ apiAuth.interceptors.response.use( async function (error: any) { if (!error.response) { alert("Backend connection is unavailable. Please check your network."); + notifyError({ message: "Network error or backend unavailable", url: error.config?.url, }); + return Promise.reject(error); } const status = error.response.status; if (status === 401) { - clearTokens(); // optional: clear cookies client-side + ClearTokens(); window.location.assign(LOGIN_PATH); return Promise.reject(error); } @@ -130,35 +135,37 @@ apiPublic.interceptors.response.use( } ); -// --- TOKEN HELPERS (NO-OPS) --- -// Django sets/rotates cookies server-side. Keep API surface to avoid breaking imports. -function setTokens(_access?: string, _refresh?: string) { - // no-op: cookies are managed by Django -} -function clearTokens() { - // optional: try to clear auth cookies client-side; server should also clear on logout + + + +function Logout() { try { - document.cookie = "access_token=; Max-Age=0; path=/"; - document.cookie = "refresh_token=; Max-Age=0; path=/"; - } catch { - // ignore + const LogOutResponse = apiAuth.post("/api/logout/"); + + if (LogOutResponse.body.detail != "Logout successful") { + throw new Error("Logout failed"); + } + + ClearTokens(); + + } catch (error) { + console.error("Error during logout:", error); } } -function getAccessToken(): string | null { - // no Authorization header is used; rely purely on cookies - return null; + +function ClearTokens(){ + document.cookie = "access_token=; Max-Age=0; path=/"; + document.cookie = "refresh_token=; Max-Age=0; path=/"; } + // --- EXPORT DEFAULT API WRAPPER --- const Client = { // Axios instances auth: apiAuth, public: apiPublic, - // Token helpers (kept for compatibility; now no-ops) - setTokens, - clearTokens, - getAccessToken, + Logout, // Error subscription onError, diff --git a/frontend/src/api/models/User.ts b/frontend/src/api/models/User.ts new file mode 100644 index 0000000..41adbba --- /dev/null +++ b/frontend/src/api/models/User.ts @@ -0,0 +1,82 @@ +// frontend/src/api/model/user.js +// User API model for searching users by username +// Structure matches other model files (see order.js for reference) + +import Client from '../Client'; + +const API_BASE_URL = "/account/users"; + +const userAPI = { + /** + * Get current authenticated user + * @returns {Promise} + */ + async getCurrentUser() { + const response = await Client.auth.get(`${API_BASE_URL}/me/`); + return response.data; + }, + + /** + * Get all users + * @returns {Promise>} + */ + async getUsers(params: Object) { + const response = await Client.auth.get(`${API_BASE_URL}/`, { params }); + return response.data; + }, + + /** + * Get a single user by ID + * @param {number|string} id + * @returns {Promise} + */ + async getUser(id: number) { + const response = await Client.auth.get(`${API_BASE_URL}/${id}/`); + return response.data; + }, + + /** + * Update a user by ID + * @param {number|string} id + * @param {Object} data + * @returns {Promise} + */ + async updateUser(id: number, data: Object) { + const response = await Client.auth.patch(`${API_BASE_URL}/${id}/`, data); + return response.data; + }, + + /** + * Delete a user by ID + * @param {number|string} id + * @returns {Promise} + */ + async deleteUser(id: number) { + const response = await Client.auth.delete(`${API_BASE_URL}/${id}/`); + return response.data; + }, + + /** + * Create a new user + * @param {Object} data + * @returns {Promise} + */ + async createUser(data: Object) { + const response = await Client.auth.post(`${API_BASE_URL}/`, data); + return response.data; + }, + + /** + * Search users by username (partial match) + * @param {Object} params - { username: string } + * @returns {Promise>} + */ + async searchUsers(params: { username: string }) { + // Adjust the endpoint as needed for your backend + const response = await Client.auth.get(`${API_BASE_URL}/`, { params }); + console.log("User search response:", response.data); + return response.data; + }, +}; + +export default userAPI; diff --git a/frontend/src/api/websockets/WebSocketClient.ts b/frontend/src/api/websockets/WebSocketClient.ts new file mode 100644 index 0000000..2f499b7 --- /dev/null +++ b/frontend/src/api/websockets/WebSocketClient.ts @@ -0,0 +1,19 @@ +const wsUri = "ws://127.0.0.1/"; + +const websocket = new WebSocket(wsUri); + +websocket.onopen = function (event) { + console.log("WebSocket is open now.", event); +}; + +websocket.onmessage = function (event) { + console.log("WebSocket message received:", event.data); +}; + +websocket.onclose = function (event) { + console.log("WebSocket is closed now.", event.reason); +}; + +websocket.onerror = function (event) { + console.error("WebSocket error observed:", event); +}; \ No newline at end of file diff --git a/frontend/src/components/navbar/HomeNav.tsx b/frontend/src/components/navbar/HomeNav.tsx index 1d45f04..04124d6 100644 --- a/frontend/src/components/navbar/HomeNav.tsx +++ b/frontend/src/components/navbar/HomeNav.tsx @@ -1,12 +1,16 @@ -import React, { useState } from "react" +import React, { useState, useContext } from "react" import styles from "./HomeNav.module.css" import { FaBars, FaChevronDown } from "react-icons/fa"; +import { UserContext } from "../../context/UserContext"; + export default function HomeNav() { const [navOpen, setNavOpen] = useState(false) const toggleNav = () => setNavOpen((prev) => !prev) + const { user } = useContext(UserContext); + return (