google sign in with oauth 2.0. no more console errors or warnings
This commit is contained in:
@@ -146,11 +146,11 @@ const validateLogin = [
|
|||||||
|
|
||||||
// Google auth validation
|
// Google auth validation
|
||||||
const validateGoogleAuth = [
|
const validateGoogleAuth = [
|
||||||
body("idToken")
|
body("code")
|
||||||
.notEmpty()
|
.notEmpty()
|
||||||
.withMessage("Google ID token is required")
|
.withMessage("Authorization code is required")
|
||||||
.isLength({ max: 2048 })
|
.isLength({ max: 512 })
|
||||||
.withMessage("Invalid token format"),
|
.withMessage("Invalid authorization code format"),
|
||||||
|
|
||||||
handleValidationErrors,
|
handleValidationErrors,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ const { csrfProtection, getCSRFToken } = require("../middleware/csrf");
|
|||||||
const { loginLimiter, registerLimiter } = require("../middleware/rateLimiter");
|
const { loginLimiter, registerLimiter } = require("../middleware/rateLimiter");
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const googleClient = new OAuth2Client(process.env.GOOGLE_CLIENT_ID);
|
const googleClient = new OAuth2Client(
|
||||||
|
process.env.GOOGLE_CLIENT_ID,
|
||||||
|
process.env.GOOGLE_CLIENT_SECRET,
|
||||||
|
process.env.GOOGLE_REDIRECT_URI || "http://localhost:3000/auth/google/callback"
|
||||||
|
);
|
||||||
|
|
||||||
// Get CSRF token endpoint
|
// Get CSRF token endpoint
|
||||||
router.get("/csrf-token", (req, res) => {
|
router.get("/csrf-token", (req, res) => {
|
||||||
@@ -214,15 +218,21 @@ router.post(
|
|||||||
validateGoogleAuth,
|
validateGoogleAuth,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { idToken } = req.body;
|
const { code } = req.body;
|
||||||
|
|
||||||
if (!idToken) {
|
if (!code) {
|
||||||
return res.status(400).json({ error: "ID token is required" });
|
return res.status(400).json({ error: "Authorization code is required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the Google ID token
|
// Exchange authorization code for tokens
|
||||||
|
const { tokens } = await googleClient.getToken({
|
||||||
|
code,
|
||||||
|
redirect_uri: process.env.GOOGLE_REDIRECT_URI || "http://localhost:3000/auth/google/callback",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify the ID token from the token response
|
||||||
const ticket = await googleClient.verifyIdToken({
|
const ticket = await googleClient.verifyIdToken({
|
||||||
idToken,
|
idToken: tokens.id_token,
|
||||||
audience: process.env.GOOGLE_CLIENT_ID,
|
audience: process.env.GOOGLE_CLIENT_ID,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -315,26 +325,21 @@ router.post(
|
|||||||
// Don't send token in response body for security
|
// Don't send token in response body for security
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.message && error.message.includes("Token used too late")) {
|
if (error.message && error.message.includes("invalid_grant")) {
|
||||||
return res
|
return res
|
||||||
.status(401)
|
.status(401)
|
||||||
.json({ error: "Google token has expired. Please try again." });
|
.json({ error: "Invalid or expired authorization code. Please try again." });
|
||||||
}
|
}
|
||||||
if (error.message && error.message.includes("Invalid token")) {
|
if (error.message && error.message.includes("redirect_uri_mismatch")) {
|
||||||
return res
|
|
||||||
.status(401)
|
|
||||||
.json({ error: "Invalid Google token. Please try again." });
|
|
||||||
}
|
|
||||||
if (error.message && error.message.includes("Wrong number of segments")) {
|
|
||||||
return res
|
return res
|
||||||
.status(400)
|
.status(400)
|
||||||
.json({ error: "Malformed Google token. Please try again." });
|
.json({ error: "Redirect URI mismatch. Please contact support." });
|
||||||
}
|
}
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
reqLogger.error("Google auth error", {
|
reqLogger.error("Google OAuth error", {
|
||||||
error: error.message,
|
error: error.message,
|
||||||
stack: error.stack,
|
stack: error.stack,
|
||||||
tokenInfo: logger.sanitize({ idToken: req.body.idToken })
|
codePresent: !!req.body.code
|
||||||
});
|
});
|
||||||
res
|
res
|
||||||
.status(500)
|
.status(500)
|
||||||
|
|||||||
@@ -19,7 +19,6 @@
|
|||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css"
|
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css"
|
||||||
/>
|
/>
|
||||||
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import Footer from './components/Footer';
|
|||||||
import Home from './pages/Home';
|
import Home from './pages/Home';
|
||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import Register from './pages/Register';
|
import Register from './pages/Register';
|
||||||
|
import GoogleCallback from './pages/GoogleCallback';
|
||||||
import ItemList from './pages/ItemList';
|
import ItemList from './pages/ItemList';
|
||||||
import ItemDetail from './pages/ItemDetail';
|
import ItemDetail from './pages/ItemDetail';
|
||||||
import EditItem from './pages/EditItem';
|
import EditItem from './pages/EditItem';
|
||||||
@@ -36,6 +37,7 @@ function App() {
|
|||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/register" element={<Register />} />
|
<Route path="/register" element={<Register />} />
|
||||||
|
<Route path="/auth/google/callback" element={<GoogleCallback />} />
|
||||||
<Route path="/items" element={<ItemList />} />
|
<Route path="/items" element={<ItemList />} />
|
||||||
<Route path="/items/:id" element={<ItemDetail />} />
|
<Route path="/items/:id" element={<ItemDetail />} />
|
||||||
<Route path="/users/:id" element={<PublicProfile />} />
|
<Route path="/users/:id" element={<PublicProfile />} />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import PasswordStrengthMeter from "./PasswordStrengthMeter";
|
import PasswordStrengthMeter from "./PasswordStrengthMeter";
|
||||||
|
|
||||||
@@ -20,9 +20,8 @@ const AuthModal: React.FC<AuthModalProps> = ({
|
|||||||
const [lastName, setLastName] = useState("");
|
const [lastName, setLastName] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const googleButtonRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const { login, register, googleLogin, updateUser } = useAuth();
|
const { login, register } = useAuth();
|
||||||
|
|
||||||
// Update mode when modal is opened with different initialMode
|
// Update mode when modal is opened with different initialMode
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -31,43 +30,29 @@ const AuthModal: React.FC<AuthModalProps> = ({
|
|||||||
}
|
}
|
||||||
}, [show, initialMode]);
|
}, [show, initialMode]);
|
||||||
|
|
||||||
// Initialize Google Sign-In
|
const resetModal = () => {
|
||||||
useEffect(() => {
|
setError("");
|
||||||
if (show && window.google && process.env.REACT_APP_GOOGLE_CLIENT_ID) {
|
setEmail("");
|
||||||
try {
|
setPassword("");
|
||||||
window.google.accounts.id.initialize({
|
setFirstName("");
|
||||||
client_id: process.env.REACT_APP_GOOGLE_CLIENT_ID,
|
setLastName("");
|
||||||
callback: handleGoogleResponse,
|
};
|
||||||
auto_select: false,
|
|
||||||
cancel_on_tap_outside: false,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error initializing Google Sign-In:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [show]);
|
|
||||||
|
|
||||||
const handleGoogleResponse = async (response: any) => {
|
const handleGoogleLogin = () => {
|
||||||
try {
|
const clientId = process.env.REACT_APP_GOOGLE_CLIENT_ID;
|
||||||
setLoading(true);
|
const redirectUri = `${window.location.origin}/auth/google/callback`;
|
||||||
setError("");
|
const scope = 'email profile';
|
||||||
|
const responseType = 'code';
|
||||||
|
|
||||||
if (!response?.credential) {
|
const googleAuthUrl = `https://accounts.google.com/o/oauth2/v2/auth?` +
|
||||||
throw new Error("No credential received from Google");
|
`client_id=${encodeURIComponent(clientId || '')}` +
|
||||||
}
|
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
|
||||||
|
`&response_type=${responseType}` +
|
||||||
|
`&scope=${encodeURIComponent(scope)}` +
|
||||||
|
`&access_type=offline` +
|
||||||
|
`&prompt=consent`;
|
||||||
|
|
||||||
await googleLogin(response.credential);
|
window.location.href = googleAuthUrl;
|
||||||
onHide();
|
|
||||||
resetModal();
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(
|
|
||||||
err.response?.data?.error ||
|
|
||||||
err.message ||
|
|
||||||
"Failed to sign in with Google"
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEmailSubmit = async (e: React.FormEvent) => {
|
const handleEmailSubmit = async (e: React.FormEvent) => {
|
||||||
@@ -96,27 +81,6 @@ const AuthModal: React.FC<AuthModalProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSocialLogin = (provider: string) => {
|
|
||||||
if (provider === "google") {
|
|
||||||
if (window.google) {
|
|
||||||
try {
|
|
||||||
window.google.accounts.id.prompt();
|
|
||||||
} catch (error) {
|
|
||||||
setError("Failed to open Google Sign-In. Please try again.");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setError("Google Sign-In is not available. Please try again later.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetModal = () => {
|
|
||||||
setError("");
|
|
||||||
setEmail("");
|
|
||||||
setPassword("");
|
|
||||||
setFirstName("");
|
|
||||||
setLastName("");
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!show) return null;
|
if (!show) return null;
|
||||||
|
|
||||||
@@ -226,8 +190,9 @@ const AuthModal: React.FC<AuthModalProps> = ({
|
|||||||
{/* Social Login Options */}
|
{/* Social Login Options */}
|
||||||
<button
|
<button
|
||||||
className="btn btn-outline-dark w-100 mb-2 py-3 d-flex align-items-center justify-content-center"
|
className="btn btn-outline-dark w-100 mb-2 py-3 d-flex align-items-center justify-content-center"
|
||||||
onClick={() => handleSocialLogin("google")}
|
onClick={handleGoogleLogin}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<i className="bi bi-google me-2"></i>
|
<i className="bi bi-google me-2"></i>
|
||||||
Continue with Google
|
Continue with Google
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import React, {
|
|||||||
useState,
|
useState,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useRef,
|
||||||
ReactNode,
|
ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { User } from "../types";
|
import { User } from "../types";
|
||||||
@@ -18,7 +19,7 @@ interface AuthContextType {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
login: (email: string, password: string) => Promise<void>;
|
login: (email: string, password: string) => Promise<void>;
|
||||||
register: (data: any) => Promise<void>;
|
register: (data: any) => Promise<void>;
|
||||||
googleLogin: (idToken: string) => Promise<void>;
|
googleLogin: (code: string) => Promise<void>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
updateUser: (user: User) => void;
|
updateUser: (user: User) => void;
|
||||||
checkAuth: () => Promise<void>;
|
checkAuth: () => Promise<void>;
|
||||||
@@ -41,6 +42,7 @@ interface AuthProviderProps {
|
|||||||
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const isAuthenticating = useRef(false);
|
||||||
|
|
||||||
const checkAuth = async () => {
|
const checkAuth = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -62,9 +64,18 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
|||||||
// Initialize authentication
|
// Initialize authentication
|
||||||
const initializeAuth = async () => {
|
const initializeAuth = async () => {
|
||||||
try {
|
try {
|
||||||
await fetchCSRFToken();
|
// Skip CSRF token fetch and auth check if we're on the OAuth callback page
|
||||||
// Check if user is already authenticated
|
// The callback page will handle both CSRF token fetch and authentication
|
||||||
await checkAuth();
|
const isOAuthCallback = window.location.pathname === '/auth/google/callback';
|
||||||
|
|
||||||
|
if (!isOAuthCallback) {
|
||||||
|
await fetchCSRFToken();
|
||||||
|
|
||||||
|
// Skip auth check if we're in the middle of an authentication operation
|
||||||
|
if (!isAuthenticating.current) {
|
||||||
|
await checkAuth();
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to initialize authentication:", error);
|
console.error("Failed to initialize authentication:", error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -89,11 +100,16 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
|||||||
await fetchCSRFToken();
|
await fetchCSRFToken();
|
||||||
};
|
};
|
||||||
|
|
||||||
const googleLogin = async (idToken: string) => {
|
const googleLogin = async (code: string) => {
|
||||||
const response = await authAPI.googleLogin({ idToken });
|
isAuthenticating.current = true;
|
||||||
setUser(response.data.user);
|
try {
|
||||||
// Fetch new CSRF token after Google login
|
const response = await authAPI.googleLogin(code);
|
||||||
await fetchCSRFToken();
|
setUser(response.data.user);
|
||||||
|
// Fetch new CSRF token after Google login
|
||||||
|
await fetchCSRFToken();
|
||||||
|
} finally {
|
||||||
|
isAuthenticating.current = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
|
|||||||
98
frontend/src/pages/GoogleCallback.tsx
Normal file
98
frontend/src/pages/GoogleCallback.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import React, { useEffect, useState, useRef } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { fetchCSRFToken } from '../services/api';
|
||||||
|
|
||||||
|
const GoogleCallback: React.FC = () => {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { googleLogin } = useAuth();
|
||||||
|
const [error, setError] = useState<string>('');
|
||||||
|
const [processing, setProcessing] = useState(true);
|
||||||
|
const hasProcessed = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleCallback = async () => {
|
||||||
|
// Prevent double execution in React StrictMode
|
||||||
|
if (hasProcessed.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hasProcessed.current = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const code = searchParams.get('code');
|
||||||
|
const errorParam = searchParams.get('error');
|
||||||
|
|
||||||
|
if (errorParam) {
|
||||||
|
setError('Google Sign-In was cancelled or failed. Please try again.');
|
||||||
|
setProcessing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
setError('No authorization code received from Google.');
|
||||||
|
setProcessing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch CSRF token before making auth request
|
||||||
|
const csrfToken = await fetchCSRFToken();
|
||||||
|
|
||||||
|
if (!csrfToken) {
|
||||||
|
console.error('Failed to fetch CSRF token');
|
||||||
|
setError('Failed to initialize security token. Please try again.');
|
||||||
|
setProcessing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchange code for user session
|
||||||
|
await googleLogin(code);
|
||||||
|
|
||||||
|
// Redirect to home page on success
|
||||||
|
navigate('/', { replace: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Google OAuth callback error:', err);
|
||||||
|
setError(err.response?.data?.error || 'Failed to sign in with Google. Please try again.');
|
||||||
|
setProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleCallback();
|
||||||
|
}, [searchParams, googleLogin, navigate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<div className="row justify-content-center mt-5">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-body text-center py-5">
|
||||||
|
{processing ? (
|
||||||
|
<>
|
||||||
|
<div className="spinner-border text-primary mb-3" role="status">
|
||||||
|
<span className="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<h5>Completing Google Sign-In...</h5>
|
||||||
|
<p className="text-muted">Please wait while we log you in.</p>
|
||||||
|
</>
|
||||||
|
) : error ? (
|
||||||
|
<>
|
||||||
|
<i className="bi bi-exclamation-circle text-danger" style={{ fontSize: '3rem' }}></i>
|
||||||
|
<h5 className="mt-3">Sign-In Failed</h5>
|
||||||
|
<p className="text-danger">{error}</p>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary mt-3"
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
>
|
||||||
|
Return to Home
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GoogleCallback;
|
||||||
@@ -58,10 +58,12 @@ api.interceptors.request.use(async (config) => {
|
|||||||
if (["POST", "PUT", "DELETE", "PATCH"].includes(method)) {
|
if (["POST", "PUT", "DELETE", "PATCH"].includes(method)) {
|
||||||
// If we don't have a CSRF token yet, try to fetch it
|
// If we don't have a CSRF token yet, try to fetch it
|
||||||
if (!csrfToken) {
|
if (!csrfToken) {
|
||||||
// Skip fetching for auth endpoints to avoid circular dependency
|
// Skip fetching for most auth endpoints to avoid circular dependency
|
||||||
|
// Exception: /auth/google needs CSRF token and should auto-fetch as fallback
|
||||||
const isAuthEndpoint =
|
const isAuthEndpoint =
|
||||||
config.url?.includes("/auth/") &&
|
config.url?.includes("/auth/") &&
|
||||||
!config.url?.includes("/auth/refresh");
|
!config.url?.includes("/auth/refresh") &&
|
||||||
|
!config.url?.includes("/auth/google");
|
||||||
if (!isAuthEndpoint) {
|
if (!isAuthEndpoint) {
|
||||||
await fetchCSRFToken();
|
await fetchCSRFToken();
|
||||||
}
|
}
|
||||||
@@ -90,6 +92,12 @@ api.interceptors.response.use(
|
|||||||
errorData?.code === "CSRF_TOKEN_MISMATCH" &&
|
errorData?.code === "CSRF_TOKEN_MISMATCH" &&
|
||||||
!originalRequest._csrfRetry
|
!originalRequest._csrfRetry
|
||||||
) {
|
) {
|
||||||
|
// Don't retry OAuth endpoints - the authorization code is single-use
|
||||||
|
const isOAuthEndpoint = originalRequest.url?.includes("/auth/google");
|
||||||
|
if (isOAuthEndpoint) {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
originalRequest._csrfRetry = true;
|
originalRequest._csrfRetry = true;
|
||||||
|
|
||||||
// Try to fetch a new CSRF token and retry
|
// Try to fetch a new CSRF token and retry
|
||||||
@@ -153,7 +161,7 @@ api.interceptors.response.use(
|
|||||||
export const authAPI = {
|
export const authAPI = {
|
||||||
register: (data: any) => api.post("/auth/register", data),
|
register: (data: any) => api.post("/auth/register", data),
|
||||||
login: (data: any) => api.post("/auth/login", data),
|
login: (data: any) => api.post("/auth/login", data),
|
||||||
googleLogin: (data: any) => api.post("/auth/google", data),
|
googleLogin: (code: string) => api.post("/auth/google", { code }),
|
||||||
logout: () => api.post("/auth/logout"),
|
logout: () => api.post("/auth/logout"),
|
||||||
refresh: () => api.post("/auth/refresh"),
|
refresh: () => api.post("/auth/refresh"),
|
||||||
getCSRFToken: () => api.get("/auth/csrf-token"),
|
getCSRFToken: () => api.get("/auth/csrf-token"),
|
||||||
|
|||||||
Reference in New Issue
Block a user