This commit is contained in:
nvrl committed 2026-04-27 03:36:35 +02:00
1 parent 2b2b4cf48f
commit 22fe0674dd
22 files changed
+620 -286

No files matched your search

+50 -8
View File
@@ -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;
};
+11 -11
View File
@@ -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');
+10 -28
View File
@@ -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
View File
@@ -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 -3
View File
@@ -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;
-2
View File
@@ -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.' });