mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
fixes
This commit is contained in:
1 parent
2b2b4cf48f
commit
22fe0674dd
22 files changed
+620
-286
No files matched your search
Vendored
+50
-8
@@ -132,6 +132,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/auth/logout/': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations['auth_logout_create'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/auth/token/': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1639,6 +1655,24 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
auth_logout_create: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description No response body */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
auth_token_create: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -2226,7 +2260,8 @@ export interface operations {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
/** @description A unique integer value identifying this notification. */
|
||||
id: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
@@ -2247,7 +2282,8 @@ export interface operations {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
/** @description A unique integer value identifying this notification. */
|
||||
id: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
@@ -2704,7 +2740,8 @@ export interface operations {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
/** @description A unique integer value identifying this ticket. */
|
||||
id: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
@@ -2725,7 +2762,8 @@ export interface operations {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
/** @description A unique integer value identifying this ticket. */
|
||||
id: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
@@ -2752,7 +2790,8 @@ export interface operations {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
/** @description A unique integer value identifying this ticket. */
|
||||
id: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
@@ -2772,7 +2811,8 @@ export interface operations {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
/** @description A unique integer value identifying this ticket. */
|
||||
id: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
@@ -2799,7 +2839,8 @@ export interface operations {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
/** @description A unique integer value identifying this ticket. */
|
||||
id: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
@@ -2826,7 +2867,8 @@ export interface operations {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
/** @description A unique integer value identifying this ticket. */
|
||||
id: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createContext, useCallback, useEffect, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import api, { clearTokens } from '../lib/api';
|
||||
import api from '../lib/api';
|
||||
|
||||
export interface AuthUser {
|
||||
username: string;
|
||||
@@ -23,7 +23,7 @@ export interface AuthContextValue {
|
||||
following: FollowingEntry[];
|
||||
loadingUser: boolean;
|
||||
refetchUser: () => Promise<void>;
|
||||
logout: () => void;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
@@ -35,14 +35,10 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [loadingUser, setLoadingUser] = useState(true);
|
||||
|
||||
const refetchUser = useCallback(async () => {
|
||||
const token = localStorage.getItem('access');
|
||||
if (!token) {
|
||||
setUser(null);
|
||||
setLoadingUser(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await api.get('users/me/');
|
||||
const res = await api.get('users/me/', {
|
||||
...({ skipAuthRedirect: true } as object),
|
||||
});
|
||||
if (res.data?.username) {
|
||||
setUser(res.data);
|
||||
if (res.data.theme) localStorage.setItem('theme', res.data.theme);
|
||||
@@ -70,8 +66,12 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
refetchUser();
|
||||
}, [refetchUser]);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
clearTokens();
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await api.post('auth/logout/');
|
||||
} catch {
|
||||
/* ignore — cookies still get cleared on server when refresh expires */
|
||||
}
|
||||
setUser(null);
|
||||
setFollowing([]);
|
||||
i18n.changeLanguage(navigator.language.split('-')[0] || 'en');
|
||||
|
||||
@@ -1,32 +1,14 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { clearTokens, getAccessToken, setTokens } from './api';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { clearLegacyToken } from './api';
|
||||
|
||||
describe('token helpers', () => {
|
||||
beforeEach(() => {
|
||||
clearTokens();
|
||||
});
|
||||
|
||||
it('returns null when no access token is stored', () => {
|
||||
expect(getAccessToken()).toBeNull();
|
||||
});
|
||||
|
||||
it('stores and retrieves the access token', () => {
|
||||
setTokens('access-123', 'refresh-abc');
|
||||
expect(getAccessToken()).toBe('access-123');
|
||||
expect(localStorage.getItem('refresh')).toBe('refresh-abc');
|
||||
});
|
||||
|
||||
it('updates access without clobbering refresh when refresh is omitted', () => {
|
||||
setTokens('first-access', 'first-refresh');
|
||||
setTokens('second-access');
|
||||
expect(getAccessToken()).toBe('second-access');
|
||||
expect(localStorage.getItem('refresh')).toBe('first-refresh');
|
||||
});
|
||||
|
||||
it('clearTokens removes both access and refresh', () => {
|
||||
setTokens('a', 'b');
|
||||
clearTokens();
|
||||
expect(getAccessToken()).toBeNull();
|
||||
describe('clearLegacyToken', () => {
|
||||
it('removes legacy DRF and JWT localStorage tokens', () => {
|
||||
localStorage.setItem('token', 'old-drf');
|
||||
localStorage.setItem('access', 'old-jwt');
|
||||
localStorage.setItem('refresh', 'old-refresh');
|
||||
clearLegacyToken();
|
||||
expect(localStorage.getItem('token')).toBeNull();
|
||||
expect(localStorage.getItem('access')).toBeNull();
|
||||
expect(localStorage.getItem('refresh')).toBeNull();
|
||||
});
|
||||
});
|
||||
+16
-46
@@ -1,40 +1,21 @@
|
||||
import axios from 'axios';
|
||||
import type { AxiosError, AxiosRequestConfig } from 'axios';
|
||||
|
||||
const ACCESS_KEY = 'access';
|
||||
const REFRESH_KEY = 'refresh';
|
||||
/**
|
||||
* httpOnly-cookie auth: the backend sets `access_token` + `refresh_token` cookies
|
||||
* on login. We don't read or write them from JS — `withCredentials` is enough.
|
||||
*/
|
||||
|
||||
export function getAccessToken(): string | null {
|
||||
return localStorage.getItem(ACCESS_KEY);
|
||||
}
|
||||
|
||||
export function setTokens(access: string, refresh?: string): void {
|
||||
localStorage.setItem(ACCESS_KEY, access);
|
||||
if (refresh) {
|
||||
localStorage.setItem(REFRESH_KEY, refresh);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearTokens(): void {
|
||||
localStorage.removeItem(ACCESS_KEY);
|
||||
localStorage.removeItem(REFRESH_KEY);
|
||||
}
|
||||
|
||||
/** One-time migration: remove the legacy DRF TokenAuth key from localStorage. */
|
||||
/** One-time migration: remove the legacy localStorage tokens (DRF and old JWT). */
|
||||
export function clearLegacyToken(): void {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('access');
|
||||
localStorage.removeItem('refresh');
|
||||
}
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000/api/',
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const access = getAccessToken();
|
||||
if (access) {
|
||||
config.headers.Authorization = `Bearer ${access}`;
|
||||
}
|
||||
return config;
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
type RetriableConfig = AxiosRequestConfig & {
|
||||
@@ -45,21 +26,14 @@ type RetriableConfig = AxiosRequestConfig & {
|
||||
skipAuthRedirect?: boolean;
|
||||
};
|
||||
|
||||
let refreshPromise: Promise<string | null> | null = null;
|
||||
|
||||
async function refreshAccessToken(): Promise<string | null> {
|
||||
const refresh = localStorage.getItem(REFRESH_KEY);
|
||||
if (!refresh) return null;
|
||||
let refreshPromise: Promise<boolean> | null = null;
|
||||
|
||||
async function refreshAccessToken(): Promise<boolean> {
|
||||
try {
|
||||
const res = await axios.post(`${api.defaults.baseURL}auth/token/refresh/`, { refresh });
|
||||
const newAccess = res.data.access as string;
|
||||
const newRefresh = res.data.refresh as string | undefined;
|
||||
setTokens(newAccess, newRefresh);
|
||||
return newAccess;
|
||||
await axios.post(`${api.defaults.baseURL}auth/token/refresh/`, {}, { withCredentials: true });
|
||||
return true;
|
||||
} catch {
|
||||
clearTokens();
|
||||
return null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,16 +46,16 @@ api.interceptors.response.use(
|
||||
}
|
||||
|
||||
const url = original.url ?? '';
|
||||
if (url.includes('auth/token/')) {
|
||||
if (url.includes('auth/token/') || url.includes('auth/logout/')) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
original._retry = true;
|
||||
refreshPromise = refreshPromise ?? refreshAccessToken();
|
||||
const newAccess = await refreshPromise;
|
||||
const ok = await refreshPromise;
|
||||
refreshPromise = null;
|
||||
|
||||
if (!newAccess) {
|
||||
if (!ok) {
|
||||
if (
|
||||
!original.skipAuthRedirect &&
|
||||
typeof window !== 'undefined' &&
|
||||
@@ -92,10 +66,6 @@ api.interceptors.response.use(
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
original.headers = {
|
||||
...(original.headers ?? {}),
|
||||
Authorization: `Bearer ${newAccess}`,
|
||||
};
|
||||
return api(original);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
||||
import { Typography, TextField, Button, Tab, Tabs, Alert, Stack, Box } from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import { Link } from 'react-router-dom';
|
||||
import api, { setTokens } from '../lib/api';
|
||||
import api from '../lib/api';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PageShell, GlassCard, FormCard } from '../components/ui';
|
||||
@@ -31,8 +31,7 @@ const Login: React.FC = () => {
|
||||
e.preventDefault();
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await api.post('auth/token/', { username, password });
|
||||
setTokens(res.data.access, res.data.refresh);
|
||||
await api.post('auth/token/', { username, password });
|
||||
window.location.href = '/';
|
||||
} catch (err) {
|
||||
const data = readError(err).response?.data;
|
||||
|
||||
@@ -178,8 +178,6 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
if (!ok) return;
|
||||
try {
|
||||
await confirmDeletion.mutateAsync(deletionCode);
|
||||
localStorage.removeItem('access');
|
||||
localStorage.removeItem('refresh');
|
||||
window.location.href = '/';
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Invalid code. Deletion failed.' });
|
||||
|
||||
Reference in new issue
Block a user