diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 55e3565..fa3c279 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -6,7 +6,8 @@ "Bash(xargs head -5)", "Bash(npm install *)", "Bash(npx tsc *)", - "Bash(npx eslint *)" + "Bash(npx eslint *)", + "Bash(python -c ' *)" ] } } diff --git a/backend/account/serializers.py b/backend/account/serializers.py index de00bc5..c3100e8 100644 --- a/backend/account/serializers.py +++ b/backend/account/serializers.py @@ -77,6 +77,9 @@ class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): if user is None or not user.check_password(password): raise serializers.ValidationError(_("No active account found with the given credentials")) + if not user.is_active: + raise serializers.ValidationError(_("Tento účet není aktivní. Ověřte prosím svůj e-mail.")) + # Call the parent validation to create token data = super().validate({ self.username_field: user.username, diff --git a/backend/account/views.py b/backend/account/views.py index 9a0aed2..b8df7c2 100644 --- a/backend/account/views.py +++ b/backend/account/views.py @@ -86,26 +86,7 @@ class CookieTokenObtainPairView(TokenObtainPairView): ) return response - - def validate(self, attrs): - username = attrs.get("username") - password = attrs.get("password") - # Přihlaš uživatele ručně - user = authenticate(request=self.context.get('request'), username=username, password=password) - - if not user: - raise AuthenticationFailed("Špatné uživatelské jméno nebo heslo.") - - if not user.is_active: - raise AuthenticationFailed("Uživatel je deaktivován.") - - # Nastav validní uživatele (přebere další logiku ze SimpleJWT) - self.user = user - - # Vrátí access a refresh token jako obvykle - return super().validate(attrs) - @extend_schema( tags=["account", "public"], summary="Refresh JWT token using cookie", diff --git a/frontend/src/api/generated/private/account/account.ts b/frontend/src/api/generated/private/account/account.ts index a12a5c9..8eb0eba 100644 --- a/frontend/src/api/generated/private/account/account.ts +++ b/frontend/src/api/generated/private/account/account.ts @@ -21,6 +21,7 @@ import type { import type { ApiAccountUsersListParams, + ChangePassword, CustomUser, PaginatedCustomUserList, PatchedCustomUser, @@ -56,6 +57,92 @@ type NonReadonly = [T] extends [UnionToIntersection] } : DistributeReadOnlyOverUnions; +/** + * @summary Change password for the authenticated user + */ +export const apiAccountPasswordChangeCreate = ( + changePassword: ChangePassword, + signal?: AbortSignal, +) => { + return privateMutator({ + url: `/api/account/password-change/`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: changePassword, + signal, + }); +}; + +export const getApiAccountPasswordChangeCreateMutationOptions = < + TError = void, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: ChangePassword }, + TContext + >; +}): UseMutationOptions< + Awaited>, + TError, + { data: ChangePassword }, + TContext +> => { + const mutationKey = ["apiAccountPasswordChangeCreate"]; + const { mutation: mutationOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey } }; + + const mutationFn: MutationFunction< + Awaited>, + { data: ChangePassword } + > = (props) => { + const { data } = props ?? {}; + + return apiAccountPasswordChangeCreate(data); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type ApiAccountPasswordChangeCreateMutationResult = NonNullable< + Awaited> +>; +export type ApiAccountPasswordChangeCreateMutationBody = ChangePassword; +export type ApiAccountPasswordChangeCreateMutationError = void; + +/** + * @summary Change password for the authenticated user + */ +export const useApiAccountPasswordChangeCreate = < + TError = void, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: ChangePassword }, + TContext + >; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { data: ChangePassword }, + TContext +> => { + return useMutation( + getApiAccountPasswordChangeCreateMutationOptions(options), + queryClient, + ); +}; /** * Returns details of the currently authenticated user based on JWT token or session. * @summary Get current authenticated user diff --git a/frontend/src/api/generated/private/models/apiAccountUsersListParams.ts b/frontend/src/api/generated/private/models/apiAccountUsersListParams.ts index 8328544..29dd614 100644 --- a/frontend/src/api/generated/private/models/apiAccountUsersListParams.ts +++ b/frontend/src/api/generated/private/models/apiAccountUsersListParams.ts @@ -20,4 +20,5 @@ export type ApiAccountUsersListParams = { postal_code?: string; role?: string; street?: string; + username?: string; }; diff --git a/frontend/src/api/generated/private/models/changePassword.ts b/frontend/src/api/generated/private/models/changePassword.ts new file mode 100644 index 0000000..fc7fffe --- /dev/null +++ b/frontend/src/api/generated/private/models/changePassword.ts @@ -0,0 +1,10 @@ +/** + * Generated by orval v8.8.0 🍺 + * Do not edit manually. + * OpenAPI spec version: 0.0.0 + */ + +export interface ChangePassword { + current_password: string; + new_password: string; +} diff --git a/frontend/src/api/generated/private/models/customUser.ts b/frontend/src/api/generated/private/models/customUser.ts index 95aa012..8060072 100644 --- a/frontend/src/api/generated/private/models/customUser.ts +++ b/frontend/src/api/generated/private/models/customUser.ts @@ -44,4 +44,6 @@ export interface CustomUser { is_active?: boolean; /** @nullable */ avatar?: string | null; + /** @nullable */ + banner?: string | null; } diff --git a/frontend/src/api/generated/private/models/index.ts b/frontend/src/api/generated/private/models/index.ts index eaf2b9a..7ca62d1 100644 --- a/frontend/src/api/generated/private/models/index.ts +++ b/frontend/src/api/generated/private/models/index.ts @@ -56,6 +56,7 @@ export * from "./gopayGetStatus200"; export * from "./gopayRefundPayment200"; export * from "./hub"; export * from "./hubPermission"; +export * from "./changePassword"; export * from "./chat"; export * from "./chatMember"; export * from "./chatTypeEnum"; diff --git a/frontend/src/api/generated/private/models/patchedCustomUser.ts b/frontend/src/api/generated/private/models/patchedCustomUser.ts index becc8f4..59d29de 100644 --- a/frontend/src/api/generated/private/models/patchedCustomUser.ts +++ b/frontend/src/api/generated/private/models/patchedCustomUser.ts @@ -44,4 +44,6 @@ export interface PatchedCustomUser { is_active?: boolean; /** @nullable */ avatar?: string | null; + /** @nullable */ + banner?: string | null; } diff --git a/frontend/src/api/generated/public/models/changePassword.ts b/frontend/src/api/generated/public/models/changePassword.ts new file mode 100644 index 0000000..fc7fffe --- /dev/null +++ b/frontend/src/api/generated/public/models/changePassword.ts @@ -0,0 +1,10 @@ +/** + * Generated by orval v8.8.0 🍺 + * Do not edit manually. + * OpenAPI spec version: 0.0.0 + */ + +export interface ChangePassword { + current_password: string; + new_password: string; +} diff --git a/frontend/src/api/generated/public/models/customUser.ts b/frontend/src/api/generated/public/models/customUser.ts index 95aa012..8060072 100644 --- a/frontend/src/api/generated/public/models/customUser.ts +++ b/frontend/src/api/generated/public/models/customUser.ts @@ -44,4 +44,6 @@ export interface CustomUser { is_active?: boolean; /** @nullable */ avatar?: string | null; + /** @nullable */ + banner?: string | null; } diff --git a/frontend/src/api/generated/public/models/index.ts b/frontend/src/api/generated/public/models/index.ts index cbfe4d6..15699f1 100644 --- a/frontend/src/api/generated/public/models/index.ts +++ b/frontend/src/api/generated/public/models/index.ts @@ -38,6 +38,7 @@ export * from "./downloaderStats"; export * from "./errorResponse"; export * from "./hub"; export * from "./hubPermission"; +export * from "./changePassword"; export * from "./chat"; export * from "./chatMember"; export * from "./chatTypeEnum"; diff --git a/frontend/src/api/generated/public/models/patchedCustomUser.ts b/frontend/src/api/generated/public/models/patchedCustomUser.ts index becc8f4..59d29de 100644 --- a/frontend/src/api/generated/public/models/patchedCustomUser.ts +++ b/frontend/src/api/generated/public/models/patchedCustomUser.ts @@ -44,4 +44,6 @@ export interface PatchedCustomUser { is_active?: boolean; /** @nullable */ avatar?: string | null; + /** @nullable */ + banner?: string | null; } diff --git a/frontend/src/api/privateClient.ts b/frontend/src/api/privateClient.ts index 9a46173..5b50cf6 100644 --- a/frontend/src/api/privateClient.ts +++ b/frontend/src/api/privateClient.ts @@ -1,5 +1,5 @@ import axios, { type AxiosRequestConfig } from "axios"; -import { AUTH_FLAG } from "@/context/AuthContext"; +import { AUTH_FLAG } from "@/hooks/useAuth"; export const privateApi = axios.create({ withCredentials: true, @@ -38,7 +38,7 @@ privateApi.interceptors.response.use( } } - if (error.response?.status === 401 && !original._retry) { + if ((error.response?.status === 401 || error.response?.status === 403) && !original._retry) { if (original.url?.includes("/api/account/logout/")) { return Promise.reject(error); } @@ -79,8 +79,11 @@ privateApi.interceptors.response.use( await privateApi.post("/api/account/token/refresh/"); processQueue(); return privateApi(original); - } catch (refreshError) { + } catch (refreshError: any) { processQueue(refreshError); + // Refresh failed for any reason (400 missing cookie, 401 expired) — clear auth and redirect + localStorage.removeItem(AUTH_FLAG); + window.location.href = "/social/login"; return Promise.reject(refreshError); } finally { isRefreshing = false; diff --git a/frontend/src/api/publicClient.ts b/frontend/src/api/publicClient.ts index d18b350..0a26f48 100644 --- a/frontend/src/api/publicClient.ts +++ b/frontend/src/api/publicClient.ts @@ -1,5 +1,5 @@ import axios, { type AxiosRequestConfig } from "axios"; -import { AUTH_FLAG } from "@/context/AuthContext"; +import { AUTH_FLAG } from "@/hooks/useAuth"; export const publicApi = axios.create({ withCredentials: true, diff --git a/frontend/src/components/downloader/Ad.tsx b/frontend/src/components/downloader/Ad.tsx index c8b1c28..305d836 100644 --- a/frontend/src/components/downloader/Ad.tsx +++ b/frontend/src/components/downloader/Ad.tsx @@ -1,5 +1,5 @@ import { Link } from 'react-router-dom'; -import { useAuth } from '@/context/AuthContext'; +import { useAuth } from '@/hooks/useAuth'; export default function Ad() { const { isAuthenticated, isLoading } = useAuth(); diff --git a/frontend/src/components/home/navbar/SiteNav.tsx b/frontend/src/components/home/navbar/SiteNav.tsx index 3f03f00..5a77b5b 100644 --- a/frontend/src/components/home/navbar/SiteNav.tsx +++ b/frontend/src/components/home/navbar/SiteNav.tsx @@ -14,7 +14,7 @@ import { FaHandsHelping, } from "react-icons/fa"; import { FaClapperboard, FaCubes } from "react-icons/fa6"; -import { useAuth } from "@/context/AuthContext"; +import { useAuth } from "@/hooks/useAuth"; import Avatar from "@/components/ui/Avatar"; import styles from "./navbar.module.css"; diff --git a/frontend/src/components/social/chat/Message.tsx b/frontend/src/components/social/chat/Message.tsx index 7e721f9..853dbf1 100644 --- a/frontend/src/components/social/chat/Message.tsx +++ b/frontend/src/components/social/chat/Message.tsx @@ -4,7 +4,7 @@ import type { Message as MessageModel } from "@/api/generated/private/models/mes import type { Chat } from "@/api/generated/private/models/chat"; import Avatar from "@/components/ui/Avatar"; import IconButton from "@/components/ui/IconButton"; -import { useAuth } from "@/context/AuthContext"; +import { useAuth } from "@/hooks/useAuth"; import { canDeleteMessage } from "@/hooks/usePermissions"; import { formatRelative } from "@/utils/relativeTime"; import { apiSocialMessagesDestroy } from "@/api/generated/private/chat/chat"; diff --git a/frontend/src/components/social/posts/Post.tsx b/frontend/src/components/social/posts/Post.tsx index 1bf092c..c793aec 100644 --- a/frontend/src/components/social/posts/Post.tsx +++ b/frontend/src/components/social/posts/Post.tsx @@ -5,7 +5,7 @@ import { FiTrash2, FiMoreHorizontal } from "react-icons/fi"; import type { Post } from "@/api/generated/private/models/post"; import Avatar from "@/components/ui/Avatar"; import IconButton from "@/components/ui/IconButton"; -import { useAuth } from "@/context/AuthContext"; +import { useAuth } from "@/hooks/useAuth"; import { canDeletePost, canEditPost } from "@/hooks/usePermissions"; import { formatRelative } from "@/utils/relativeTime"; import { apiSocialPostsDestroy } from "@/api/generated/private/posts/posts"; diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 1d4a76a..eca10b4 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -1,16 +1,14 @@ import type { ReactNode } from "react"; -import { createContext, useContext, useState, useEffect, useRef } from "react"; +import { createContext, useState, useEffect, useRef } from "react"; -import { apiAccountLogoutCreate } from "@/api/generated/public/account"; +import { apiAccountLoginCreate, apiAccountLogoutCreate } from "@/api/generated/public/account"; import { apiAccountUserMeRetrieve } from "@/api/generated/private/account/account"; -import { privateApi } from "@/api/privateClient"; +import { AUTH_FLAG } from "@/hooks/useAuth"; import type { CustomTokenObtainPair } from "@/api/generated/public/models/customTokenObtainPair"; import type { CustomUser } from "@/api/generated/private/models/customUser"; -export const AUTH_FLAG = "vontor_was_logged_in"; - -interface AuthContextType { +export interface AuthContextType { user: CustomUser | null; isAuthenticated: boolean; isLoading: boolean; @@ -19,7 +17,7 @@ interface AuthContextType { refreshUser: () => Promise; } -const AuthContext = createContext(undefined); +export const AuthContext = createContext(undefined); export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); @@ -56,23 +54,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, []); async function login(payload: CustomTokenObtainPair) { - setIsLoading(true); - try { - await privateApi.post("/api/account/login/", payload); - localStorage.setItem(AUTH_FLAG, "true"); - await refreshUser(); - } catch (err: any) { - setIsLoading(false); - const data = err.response?.data; - const errorMessage = - data?.detail || - (typeof data === "object" && !Array.isArray(data) - ? Object.values(data).flat().filter(Boolean).join(" ") - : null) || - err.message || - "Login failed"; - throw new Error(errorMessage); - } + await apiAccountLoginCreate(payload); + localStorage.setItem(AUTH_FLAG, "true"); + await refreshUser(); } async function logout() { @@ -94,9 +78,3 @@ export function AuthProvider({ children }: { children: ReactNode }) { ); } - -export function useAuth() { - const ctx = useContext(AuthContext); - if (!ctx) throw new Error("useAuth must be used inside AuthProvider"); - return ctx; -} diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts new file mode 100644 index 0000000..ee6f1f6 --- /dev/null +++ b/frontend/src/hooks/useAuth.ts @@ -0,0 +1,10 @@ +import { useContext } from "react"; +import { AuthContext } from "@/context/AuthContext"; + +export const AUTH_FLAG = "vontor_was_logged_in"; + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("useAuth must be used inside AuthProvider"); + return ctx; +} diff --git a/frontend/src/layouts/social/SocialLayout.tsx b/frontend/src/layouts/social/SocialLayout.tsx index 8af9e46..0fb3421 100644 --- a/frontend/src/layouts/social/SocialLayout.tsx +++ b/frontend/src/layouts/social/SocialLayout.tsx @@ -8,7 +8,7 @@ import { FiBookmark, FiLogOut, } from "react-icons/fi"; -import { useAuth } from "@/context/AuthContext"; +import { useAuth } from "@/hooks/useAuth"; import Avatar from "@/components/ui/Avatar"; interface NavItem { diff --git a/frontend/src/pages/social/AccountSettingsPage.tsx b/frontend/src/pages/social/AccountSettingsPage.tsx index 4cff2ca..6842a53 100644 --- a/frontend/src/pages/social/AccountSettingsPage.tsx +++ b/frontend/src/pages/social/AccountSettingsPage.tsx @@ -4,7 +4,7 @@ import { useNavigate } from "react-router-dom"; import { useForm } from "react-hook-form"; import { useQueryClient } from "@tanstack/react-query"; import { FiArrowLeft, FiUser, FiLock, FiCamera, FiImage } from "react-icons/fi"; -import { useAuth } from "@/context/AuthContext"; +import { useAuth } from "@/hooks/useAuth"; import { privateApi } from "@/api/privateClient"; import { mediaUrl } from "@/utils/mediaUrl"; import Avatar from "@/components/ui/Avatar"; @@ -187,11 +187,11 @@ export default function AccountSettingsPage() { {bannerSrc && ( )} -
+
{bannerUploading ? ( ) : ( -
+
Změnit banner
diff --git a/frontend/src/pages/social/ProfilePage.tsx b/frontend/src/pages/social/ProfilePage.tsx index 1ff873e..5359989 100644 --- a/frontend/src/pages/social/ProfilePage.tsx +++ b/frontend/src/pages/social/ProfilePage.tsx @@ -1,7 +1,7 @@ import { useTranslation } from "react-i18next"; import { FiLogOut, FiUser } from "react-icons/fi"; import { Link } from "react-router-dom"; -import { useAuth } from "@/context/AuthContext"; +import { useAuth } from "@/hooks/useAuth"; import Avatar from "@/components/ui/Avatar"; import Card from "@/components/ui/Card"; import EmptyState from "@/components/ui/EmptyState"; diff --git a/frontend/src/pages/social/UserProfilePage.tsx b/frontend/src/pages/social/UserProfilePage.tsx index 69699be..e1bed3e 100644 --- a/frontend/src/pages/social/UserProfilePage.tsx +++ b/frontend/src/pages/social/UserProfilePage.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { FiArrowLeft, FiLogOut, FiSettings, FiUser, FiCalendar, FiMapPin } from "react-icons/fi"; import { useQuery } from "@tanstack/react-query"; import { useApiSocialPostsList } from "@/api/generated/private/posts/posts"; -import { useAuth } from "@/context/AuthContext"; +import { useAuth } from "@/hooks/useAuth"; import { privateApi } from "@/api/privateClient"; import Avatar from "@/components/ui/Avatar"; import { mediaUrl } from "@/utils/mediaUrl"; diff --git a/frontend/src/pages/social/account/AccountSettings.tsx b/frontend/src/pages/social/account/AccountSettings.tsx index 8aa44e8..c481eb8 100644 --- a/frontend/src/pages/social/account/AccountSettings.tsx +++ b/frontend/src/pages/social/account/AccountSettings.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; -import { useAuth } from "@/context/AuthContext"; +import { useAuth } from "@/hooks/useAuth"; import { Navigate } from "react-router-dom"; import { FiUser, diff --git a/frontend/src/pages/social/account/Login.tsx b/frontend/src/pages/social/account/Login.tsx index 58b21c1..b3be9a2 100644 --- a/frontend/src/pages/social/account/Login.tsx +++ b/frontend/src/pages/social/account/Login.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { useForm } from "react-hook-form"; -import { useAuth } from "@/context/AuthContext"; +import { useAuth } from "@/hooks/useAuth"; import { useNavigate, Link } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { FiAtSign, FiLock } from "react-icons/fi"; diff --git a/frontend/src/pages/social/account/Logout.tsx b/frontend/src/pages/social/account/Logout.tsx index 1270fff..c9af51d 100644 --- a/frontend/src/pages/social/account/Logout.tsx +++ b/frontend/src/pages/social/account/Logout.tsx @@ -1,5 +1,5 @@ import { useEffect } from "react"; -import { useAuth } from "@/context/AuthContext"; +import { useAuth } from "@/hooks/useAuth"; import { useNavigate } from "react-router-dom"; import Spinner from "@/components/ui/Spinner"; diff --git a/frontend/src/routes/PrivateRoute.tsx b/frontend/src/routes/PrivateRoute.tsx index a22c42f..1b52a9d 100644 --- a/frontend/src/routes/PrivateRoute.tsx +++ b/frontend/src/routes/PrivateRoute.tsx @@ -1,5 +1,5 @@ import { Navigate, Outlet, useLocation } from "react-router-dom"; -import { useAuth } from "@/context/AuthContext"; +import { useAuth } from "@/hooks/useAuth"; import Spinner from "@/components/ui/Spinner"; export default function PrivateRoute() { diff --git a/frontend/src/routes/PublicOnlyRoute.tsx b/frontend/src/routes/PublicOnlyRoute.tsx index a267d16..8c76e71 100644 --- a/frontend/src/routes/PublicOnlyRoute.tsx +++ b/frontend/src/routes/PublicOnlyRoute.tsx @@ -1,5 +1,5 @@ import { Navigate, Outlet } from "react-router-dom"; -import { useAuth } from "@/context/AuthContext"; +import { useAuth } from "@/hooks/useAuth"; import Spinner from "@/components/ui/Spinner"; export default function PublicOnlyRoute() {