fixed expiring login creds

This commit is contained in:
David Bruno Vontor
2026-05-19 16:52:55 +02:00
parent 6cec6fbb94
commit c7de2dbcdc
30 changed files with 163 additions and 69 deletions

View File

@@ -6,7 +6,8 @@
"Bash(xargs head -5)", "Bash(xargs head -5)",
"Bash(npm install *)", "Bash(npm install *)",
"Bash(npx tsc *)", "Bash(npx tsc *)",
"Bash(npx eslint *)" "Bash(npx eslint *)",
"Bash(python -c ' *)"
] ]
} }
} }

View File

@@ -77,6 +77,9 @@ class CustomTokenObtainPairSerializer(TokenObtainPairSerializer):
if user is None or not user.check_password(password): if user is None or not user.check_password(password):
raise serializers.ValidationError(_("No active account found with the given credentials")) 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 # Call the parent validation to create token
data = super().validate({ data = super().validate({
self.username_field: user.username, self.username_field: user.username,

View File

@@ -86,26 +86,7 @@ class CookieTokenObtainPairView(TokenObtainPairView):
) )
return response 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( @extend_schema(
tags=["account", "public"], tags=["account", "public"],
summary="Refresh JWT token using cookie", summary="Refresh JWT token using cookie",

View File

@@ -21,6 +21,7 @@ import type {
import type { import type {
ApiAccountUsersListParams, ApiAccountUsersListParams,
ChangePassword,
CustomUser, CustomUser,
PaginatedCustomUserList, PaginatedCustomUserList,
PatchedCustomUser, PatchedCustomUser,
@@ -56,6 +57,92 @@ type NonReadonly<T> = [T] extends [UnionToIntersection<T>]
} }
: DistributeReadOnlyOverUnions<T>; : DistributeReadOnlyOverUnions<T>;
/**
* @summary Change password for the authenticated user
*/
export const apiAccountPasswordChangeCreate = (
changePassword: ChangePassword,
signal?: AbortSignal,
) => {
return privateMutator<void>({
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<ReturnType<typeof apiAccountPasswordChangeCreate>>,
TError,
{ data: ChangePassword },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof apiAccountPasswordChangeCreate>>,
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<ReturnType<typeof apiAccountPasswordChangeCreate>>,
{ data: ChangePassword }
> = (props) => {
const { data } = props ?? {};
return apiAccountPasswordChangeCreate(data);
};
return { mutationFn, ...mutationOptions };
};
export type ApiAccountPasswordChangeCreateMutationResult = NonNullable<
Awaited<ReturnType<typeof apiAccountPasswordChangeCreate>>
>;
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<ReturnType<typeof apiAccountPasswordChangeCreate>>,
TError,
{ data: ChangePassword },
TContext
>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof apiAccountPasswordChangeCreate>>,
TError,
{ data: ChangePassword },
TContext
> => {
return useMutation(
getApiAccountPasswordChangeCreateMutationOptions(options),
queryClient,
);
};
/** /**
* Returns details of the currently authenticated user based on JWT token or session. * Returns details of the currently authenticated user based on JWT token or session.
* @summary Get current authenticated user * @summary Get current authenticated user

View File

@@ -20,4 +20,5 @@ export type ApiAccountUsersListParams = {
postal_code?: string; postal_code?: string;
role?: string; role?: string;
street?: string; street?: string;
username?: string;
}; };

View File

@@ -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;
}

View File

@@ -44,4 +44,6 @@ export interface CustomUser {
is_active?: boolean; is_active?: boolean;
/** @nullable */ /** @nullable */
avatar?: string | null; avatar?: string | null;
/** @nullable */
banner?: string | null;
} }

View File

@@ -56,6 +56,7 @@ export * from "./gopayGetStatus200";
export * from "./gopayRefundPayment200"; export * from "./gopayRefundPayment200";
export * from "./hub"; export * from "./hub";
export * from "./hubPermission"; export * from "./hubPermission";
export * from "./changePassword";
export * from "./chat"; export * from "./chat";
export * from "./chatMember"; export * from "./chatMember";
export * from "./chatTypeEnum"; export * from "./chatTypeEnum";

View File

@@ -44,4 +44,6 @@ export interface PatchedCustomUser {
is_active?: boolean; is_active?: boolean;
/** @nullable */ /** @nullable */
avatar?: string | null; avatar?: string | null;
/** @nullable */
banner?: string | null;
} }

View File

@@ -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;
}

View File

@@ -44,4 +44,6 @@ export interface CustomUser {
is_active?: boolean; is_active?: boolean;
/** @nullable */ /** @nullable */
avatar?: string | null; avatar?: string | null;
/** @nullable */
banner?: string | null;
} }

View File

@@ -38,6 +38,7 @@ export * from "./downloaderStats";
export * from "./errorResponse"; export * from "./errorResponse";
export * from "./hub"; export * from "./hub";
export * from "./hubPermission"; export * from "./hubPermission";
export * from "./changePassword";
export * from "./chat"; export * from "./chat";
export * from "./chatMember"; export * from "./chatMember";
export * from "./chatTypeEnum"; export * from "./chatTypeEnum";

View File

@@ -44,4 +44,6 @@ export interface PatchedCustomUser {
is_active?: boolean; is_active?: boolean;
/** @nullable */ /** @nullable */
avatar?: string | null; avatar?: string | null;
/** @nullable */
banner?: string | null;
} }

View File

@@ -1,5 +1,5 @@
import axios, { type AxiosRequestConfig } from "axios"; import axios, { type AxiosRequestConfig } from "axios";
import { AUTH_FLAG } from "@/context/AuthContext"; import { AUTH_FLAG } from "@/hooks/useAuth";
export const privateApi = axios.create({ export const privateApi = axios.create({
withCredentials: true, 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/")) { if (original.url?.includes("/api/account/logout/")) {
return Promise.reject(error); return Promise.reject(error);
} }
@@ -79,8 +79,11 @@ privateApi.interceptors.response.use(
await privateApi.post("/api/account/token/refresh/"); await privateApi.post("/api/account/token/refresh/");
processQueue(); processQueue();
return privateApi(original); return privateApi(original);
} catch (refreshError) { } catch (refreshError: any) {
processQueue(refreshError); 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); return Promise.reject(refreshError);
} finally { } finally {
isRefreshing = false; isRefreshing = false;

View File

@@ -1,5 +1,5 @@
import axios, { type AxiosRequestConfig } from "axios"; import axios, { type AxiosRequestConfig } from "axios";
import { AUTH_FLAG } from "@/context/AuthContext"; import { AUTH_FLAG } from "@/hooks/useAuth";
export const publicApi = axios.create({ export const publicApi = axios.create({
withCredentials: true, withCredentials: true,

View File

@@ -1,5 +1,5 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useAuth } from '@/context/AuthContext'; import { useAuth } from '@/hooks/useAuth';
export default function Ad() { export default function Ad() {
const { isAuthenticated, isLoading } = useAuth(); const { isAuthenticated, isLoading } = useAuth();

View File

@@ -14,7 +14,7 @@ import {
FaHandsHelping, FaHandsHelping,
} from "react-icons/fa"; } from "react-icons/fa";
import { FaClapperboard, FaCubes } from "react-icons/fa6"; import { FaClapperboard, FaCubes } from "react-icons/fa6";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/hooks/useAuth";
import Avatar from "@/components/ui/Avatar"; import Avatar from "@/components/ui/Avatar";
import styles from "./navbar.module.css"; import styles from "./navbar.module.css";

View File

@@ -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 type { Chat } from "@/api/generated/private/models/chat";
import Avatar from "@/components/ui/Avatar"; import Avatar from "@/components/ui/Avatar";
import IconButton from "@/components/ui/IconButton"; import IconButton from "@/components/ui/IconButton";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/hooks/useAuth";
import { canDeleteMessage } from "@/hooks/usePermissions"; import { canDeleteMessage } from "@/hooks/usePermissions";
import { formatRelative } from "@/utils/relativeTime"; import { formatRelative } from "@/utils/relativeTime";
import { apiSocialMessagesDestroy } from "@/api/generated/private/chat/chat"; import { apiSocialMessagesDestroy } from "@/api/generated/private/chat/chat";

View File

@@ -5,7 +5,7 @@ import { FiTrash2, FiMoreHorizontal } from "react-icons/fi";
import type { Post } from "@/api/generated/private/models/post"; import type { Post } from "@/api/generated/private/models/post";
import Avatar from "@/components/ui/Avatar"; import Avatar from "@/components/ui/Avatar";
import IconButton from "@/components/ui/IconButton"; import IconButton from "@/components/ui/IconButton";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/hooks/useAuth";
import { canDeletePost, canEditPost } from "@/hooks/usePermissions"; import { canDeletePost, canEditPost } from "@/hooks/usePermissions";
import { formatRelative } from "@/utils/relativeTime"; import { formatRelative } from "@/utils/relativeTime";
import { apiSocialPostsDestroy } from "@/api/generated/private/posts/posts"; import { apiSocialPostsDestroy } from "@/api/generated/private/posts/posts";

View File

@@ -1,16 +1,14 @@
import type { ReactNode } from "react"; 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 { 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 { CustomTokenObtainPair } from "@/api/generated/public/models/customTokenObtainPair";
import type { CustomUser } from "@/api/generated/private/models/customUser"; import type { CustomUser } from "@/api/generated/private/models/customUser";
export const AUTH_FLAG = "vontor_was_logged_in"; export interface AuthContextType {
interface AuthContextType {
user: CustomUser | null; user: CustomUser | null;
isAuthenticated: boolean; isAuthenticated: boolean;
isLoading: boolean; isLoading: boolean;
@@ -19,7 +17,7 @@ interface AuthContextType {
refreshUser: () => Promise<void>; refreshUser: () => Promise<void>;
} }
const AuthContext = createContext<AuthContextType | undefined>(undefined); export const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<CustomUser | null>(null); const [user, setUser] = useState<CustomUser | null>(null);
@@ -56,23 +54,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, []); }, []);
async function login(payload: CustomTokenObtainPair) { async function login(payload: CustomTokenObtainPair) {
setIsLoading(true); await apiAccountLoginCreate(payload);
try { localStorage.setItem(AUTH_FLAG, "true");
await privateApi.post("/api/account/login/", payload); await refreshUser();
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);
}
} }
async function logout() { async function logout() {
@@ -94,9 +78,3 @@ export function AuthProvider({ children }: { children: ReactNode }) {
</AuthContext.Provider> </AuthContext.Provider>
); );
} }
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used inside AuthProvider");
return ctx;
}

View File

@@ -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;
}

View File

@@ -8,7 +8,7 @@ import {
FiBookmark, FiBookmark,
FiLogOut, FiLogOut,
} from "react-icons/fi"; } from "react-icons/fi";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/hooks/useAuth";
import Avatar from "@/components/ui/Avatar"; import Avatar from "@/components/ui/Avatar";
interface NavItem { interface NavItem {

View File

@@ -4,7 +4,7 @@ import { useNavigate } from "react-router-dom";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { FiArrowLeft, FiUser, FiLock, FiCamera, FiImage } from "react-icons/fi"; 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 { privateApi } from "@/api/privateClient";
import { mediaUrl } from "@/utils/mediaUrl"; import { mediaUrl } from "@/utils/mediaUrl";
import Avatar from "@/components/ui/Avatar"; import Avatar from "@/components/ui/Avatar";
@@ -187,11 +187,11 @@ export default function AccountSettingsPage() {
{bannerSrc && ( {bannerSrc && (
<img src={bannerSrc} alt="" className="h-full w-full object-cover" /> <img src={bannerSrc} alt="" className="h-full w-full object-cover" />
)} )}
<div className="absolute inset-0 flex items-center justify-center bg-black/0 transition-colors group-hover:bg-black/40"> <div className="absolute inset-0 flex items-center justify-center bg-black/20 transition-colors group-hover:bg-black/40">
{bannerUploading ? ( {bannerUploading ? (
<Spinner size={22} /> <Spinner size={22} />
) : ( ) : (
<div className="flex flex-col items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100"> <div className="flex flex-col items-center gap-1 opacity-60 transition-opacity group-hover:opacity-100">
<FiImage size={20} className="text-white" /> <FiImage size={20} className="text-white" />
<span className="text-xs text-white/90">Změnit banner</span> <span className="text-xs text-white/90">Změnit banner</span>
</div> </div>

View File

@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FiLogOut, FiUser } from "react-icons/fi"; import { FiLogOut, FiUser } from "react-icons/fi";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/hooks/useAuth";
import Avatar from "@/components/ui/Avatar"; import Avatar from "@/components/ui/Avatar";
import Card from "@/components/ui/Card"; import Card from "@/components/ui/Card";
import EmptyState from "@/components/ui/EmptyState"; import EmptyState from "@/components/ui/EmptyState";

View File

@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { FiArrowLeft, FiLogOut, FiSettings, FiUser, FiCalendar, FiMapPin } from "react-icons/fi"; import { FiArrowLeft, FiLogOut, FiSettings, FiUser, FiCalendar, FiMapPin } from "react-icons/fi";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useApiSocialPostsList } from "@/api/generated/private/posts/posts"; import { useApiSocialPostsList } from "@/api/generated/private/posts/posts";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/hooks/useAuth";
import { privateApi } from "@/api/privateClient"; import { privateApi } from "@/api/privateClient";
import Avatar from "@/components/ui/Avatar"; import Avatar from "@/components/ui/Avatar";
import { mediaUrl } from "@/utils/mediaUrl"; import { mediaUrl } from "@/utils/mediaUrl";

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/hooks/useAuth";
import { Navigate } from "react-router-dom"; import { Navigate } from "react-router-dom";
import { import {
FiUser, FiUser,

View File

@@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/hooks/useAuth";
import { useNavigate, Link } from "react-router-dom"; import { useNavigate, Link } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FiAtSign, FiLock } from "react-icons/fi"; import { FiAtSign, FiLock } from "react-icons/fi";

View File

@@ -1,5 +1,5 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/hooks/useAuth";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import Spinner from "@/components/ui/Spinner"; import Spinner from "@/components/ui/Spinner";

View File

@@ -1,5 +1,5 @@
import { Navigate, Outlet, useLocation } from "react-router-dom"; import { Navigate, Outlet, useLocation } from "react-router-dom";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/hooks/useAuth";
import Spinner from "@/components/ui/Spinner"; import Spinner from "@/components/ui/Spinner";
export default function PrivateRoute() { export default function PrivateRoute() {

View File

@@ -1,5 +1,5 @@
import { Navigate, Outlet } from "react-router-dom"; import { Navigate, Outlet } from "react-router-dom";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/hooks/useAuth";
import Spinner from "@/components/ui/Spinner"; import Spinner from "@/components/ui/Spinner";
export default function PublicOnlyRoute() { export default function PublicOnlyRoute() {