Compare commits

...
Author SHA1 Message Date
gorkem-bwl 79aca01bbf Fix formatting issues 2026-01-15 22:31:05 -05:00
gorkem-bwl a87ca648dc Restore refresh token functionality for seamless session renewal
## Summary
Restores refresh token functionality that was lost during a previous refactor.
This enables seamless session renewal without forcing users to re-login when
their access token expires.

## Server changes
- Add refresh token configuration to settingsService (7d TTL by default)
- Add issueRefreshToken method to userService
- Update login/register/resetPassword to return refreshToken
- Add refreshAuthToken method to userService for token refresh
- Add refreshAuthToken controller method
- Add POST /auth/refresh route
- Update verifyJWT middleware to return 403 for expired tokens (triggers refresh)
  and 401 for invalid tokens (triggers logout)

## Client changes
- Store refreshToken in Redux auth state
- Clear refreshToken on logout
- Add setAuthToken action for updating token after refresh
- Update NetworkService to handle 403 responses:
  - Call /auth/refresh endpoint to get new token
  - Retry failed request with new token
  - Queue concurrent requests during refresh
  - Redirect to login if refresh fails
2026-01-15 22:28:07 -05:00
Alexander Holliday b5caabed20 Merge pull request #3135 from bluewave-labs/fix/pagespeed-monitor-delete
Fix pagespeed monitor deletion failing with undefined ID
2026-01-15 18:29:34 -08:00
gorkem-bwl 8814596d6e Fix pagespeed monitor deletion failing with undefined ID
The delete handler was using monitor._id from state, which was undefined
because the fetched monitor object didn't have _id populated. Changed to
use monitorId directly from URL params, which is already available and
used by other handlers in the same component (e.g., handlePause).

Fixes "Failed to delete monitor" error when removing pagespeed monitors.
2026-01-15 21:27:37 -05:00
8 changed files with 175 additions and 16 deletions

No files matched your search

+7 -1
View File
@@ -5,6 +5,7 @@ import axios from "axios";
const initialState = {
isLoading: false,
authToken: "",
refreshToken: "",
user: "",
success: null,
msg: null,
@@ -138,6 +139,7 @@ const handleAuthFulfilled = (state, action) => {
state.success = action.payload.success;
state.msg = action.payload.msg;
state.authToken = action.payload.data.token;
state.refreshToken = action.payload.data.refreshToken || "";
state.user = action.payload.data.user;
};
const handleAuthRejected = (state, action) => {
@@ -188,11 +190,15 @@ const authSlice = createSlice({
reducers: {
clearAuthState: (state) => {
state.authToken = "";
state.refreshToken = "";
state.user = "";
state.isLoading = false;
state.success = true;
state.msg = "Logged out successfully";
},
setAuthToken: (state, action) => {
state.authToken = action.payload;
},
},
extraReducers: (builder) => {
// Register thunk
@@ -246,4 +252,4 @@ const authSlice = createSlice({
});
export default authSlice.reducer;
export const { clearAuthState } = authSlice.actions;
export const { clearAuthState, setAuthToken } = authSlice.actions;
+1 -2
View File
@@ -178,8 +178,7 @@ const PageSpeedSetup = () => {
const handleRemove = async (event) => {
event.preventDefault();
const TEMP_MONITOR = { id: monitor._id };
await deleteMonitor({ monitor: TEMP_MONITOR, redirect: "/pagespeed" });
await deleteMonitor({ monitor: { id: monitorId }, redirect: "/pagespeed" });
};
const isBusy = isLoading || isCreating || isDeleting || isUpdating || isPausing;
+83 -4
View File
@@ -2,7 +2,7 @@ import axios from "axios";
import i18next from "i18next";
const BASE_URL = import.meta.env.VITE_APP_API_BASE_URL;
const FALLBACK_BASE_URL = "http://localhost:5000/api/v1";
import { clearAuthState } from "../Features/Auth/authSlice";
import { clearAuthState, setAuthToken } from "../Features/Auth/authSlice";
class NetworkService {
constructor(store, dispatch, navigate) {
this.store = store;
@@ -26,7 +26,7 @@ class NetworkService {
(config) => {
const currentLanguage = i18next.language || "en";
const { authToken } = store.getState().auth;
const { authToken, refreshToken } = store.getState().auth;
config.headers = {
Authorization: `Bearer ${authToken}`,
@@ -34,15 +34,36 @@ class NetworkService {
...config.headers,
};
// Only send refresh token to the refresh endpoint
if (refreshToken && config.url?.includes("/auth/refresh")) {
config.headers["x-refresh-token"] = refreshToken;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Track if we're currently refreshing to prevent multiple refresh calls
this.isRefreshing = false;
this.failedQueue = [];
const processQueue = (error, token = null) => {
this.failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
this.failedQueue = [];
};
this.axiosInstance.interceptors.response.use(
(response) => response,
(error) => {
async (error) => {
// Handle network errors (server unreachable)
if (error.code === "ERR_NETWORK") {
// Navigate to server unreachable page
@@ -51,8 +72,66 @@ class NetworkService {
return Promise.reject(error);
}
// Handle authentication errors
const originalRequest = error.config;
// Handle 403 - server is telling us to request a new access token
// Skip refresh logic for the refresh endpoint itself to prevent circular dependency
const isRefreshEndpoint = originalRequest.url?.includes("/auth/refresh");
if (
error.response &&
error.response.status === 403 &&
!originalRequest._retry &&
!isRefreshEndpoint
) {
// Check if we have a refresh token before attempting refresh
const { refreshToken } = store.getState().auth;
if (!refreshToken) {
dispatch(clearAuthState());
navigate("/login");
return Promise.reject(error);
}
if (this.isRefreshing) {
// If already refreshing, queue this request
return new Promise((resolve, reject) => {
this.failedQueue.push({ resolve, reject });
})
.then((token) => {
originalRequest.headers["Authorization"] = `Bearer ${token}`;
return this.axiosInstance(originalRequest);
})
.catch((err) => Promise.reject(err));
}
originalRequest._retry = true;
this.isRefreshing = true;
try {
// Call refresh endpoint
const response = await this.axiosInstance.post("/auth/refresh");
const newToken = response.data.data.token;
// Update the auth token in store
dispatch(setAuthToken(newToken));
// Process queued requests
processQueue(null, newToken);
// Retry original request with new token
originalRequest.headers["Authorization"] = `Bearer ${newToken}`;
return this.axiosInstance(originalRequest);
} catch (refreshError) {
// Refresh failed, clear auth and redirect to login
processQueue(refreshError, null);
dispatch(clearAuthState());
navigate("/login");
return Promise.reject(refreshError);
} finally {
this.isRefreshing = false;
}
}
// Handle 401 - authentication failed (invalid/expired refresh token or no token)
if (error.response && error.response.status === 401) {
dispatch(clearAuthState());
navigate("/login");
+30 -5
View File
@@ -36,11 +36,11 @@ class AuthController {
req.body.email = req.body.email?.toLowerCase();
}
await registrationBodyValidation.validateAsync(req.body);
const { user, token } = await this.userService.registerUser(req.body, req.file);
const { user, token, refreshToken } = await this.userService.registerUser(req.body, req.file);
res.status(200).json({
success: true,
msg: "User registered successfully",
data: { user, token },
data: { user, token, refreshToken },
});
} catch (error) {
next(error);
@@ -53,7 +53,7 @@ class AuthController {
req.body.email = req.body.email?.toLowerCase();
}
await loginValidation.validateAsync(req.body);
const { user, token } = await this.userService.loginUser(req.body.email, req.body.password);
const { user, token, refreshToken } = await this.userService.loginUser(req.body.email, req.body.password);
return res.status(200).json({
success: true,
@@ -61,6 +61,7 @@ class AuthController {
data: {
user,
token,
refreshToken,
},
});
} catch (error) {
@@ -128,11 +129,35 @@ class AuthController {
resetPassword = async (req: Request, res: Response, next: NextFunction) => {
try {
await newPasswordValidation.validateAsync(req.body);
const { user, token } = await this.userService.resetPassword(req.body.password, req.body.recoveryToken);
const { user, token, refreshToken } = await this.userService.resetPassword(req.body.password, req.body.recoveryToken);
return res.status(200).json({
success: true,
msg: "Password has been reset successfully",
data: { user, token },
data: { user, token, refreshToken },
});
} catch (error) {
next(error);
}
};
refreshAuthToken = async (req: Request, res: Response, next: NextFunction) => {
try {
const authToken = req.headers.authorization?.split(" ")[1];
const refreshToken = req.headers["x-refresh-token"] as string;
if (!authToken || !refreshToken) {
return res.status(400).json({
success: false,
msg: "Auth token and refresh token are required",
});
}
const { user, token, refreshToken: newRefreshToken } = await this.userService.refreshAuthToken(authToken, refreshToken);
return res.status(200).json({
success: true,
msg: "Token refreshed successfully",
data: { user, token, refreshToken: newRefreshToken },
});
} catch (error) {
next(error);
+3 -1
View File
@@ -41,7 +41,9 @@ const verifyJWT = (req, res, next) => {
if (err) {
const errorMessage = err.name === "TokenExpiredError" ? stringService.expiredAuthToken : stringService.invalidAuthToken;
err.details = { msg: errorMessage };
err.status = 401;
// Return 403 for expired tokens to trigger client refresh flow
// Return 401 for invalid tokens to trigger logout
err.status = err.name === "TokenExpiredError" ? 403 : 401;
err.service = SERVICE_NAME;
err.method = "verifyJWT";
next(err);
+1
View File
@@ -15,6 +15,7 @@ class AuthRoutes {
initRoutes() {
this.router.post("/register", upload.single("profileImage"), this.authController.registerUser);
this.router.post("/login", this.authController.loginUser);
this.router.post("/refresh", this.authController.refreshAuthToken);
this.router.post("/recovery/request", this.authController.requestRecovery);
this.router.post("/recovery/validate", this.authController.validateRecovery);
+48 -3
View File
@@ -62,6 +62,17 @@ class UserService {
return this.jwt.sign(payloadData, tokenSecret, { expiresIn: tokenTTL });
};
issueRefreshToken = (payload: any, appSettings: any) => {
const refreshTokenTTL = appSettings?.refreshTokenTTL ?? "7d";
const refreshTokenSecret = appSettings?.refreshTokenSecret ?? appSettings?.jwtSecret;
const payloadData = { ...payload };
// Remove sensitive data from refresh token payload
delete payloadData.password;
delete payloadData.profileImage;
delete payloadData.avatarImage;
return this.jwt.sign(payloadData, refreshTokenSecret, { expiresIn: refreshTokenTTL });
};
registerUser = async (user: any, file: any) => {
// Create a new user
// If superAdmin exists, a token should be attached to all further register requests
@@ -92,6 +103,7 @@ class UserService {
const appSettings = await this.settingsService.getSettings();
const token = this.issueToken(userForToken, appSettings);
const refreshToken = this.issueRefreshToken(userForToken, appSettings);
try {
const html = await this.emailService.buildEmail("welcomeEmailTemplate", {
@@ -114,7 +126,7 @@ class UserService {
});
}
return { user: newUser, token };
return { user: newUser, token, refreshToken };
};
loginUser = async (email: string, password: string) => {
@@ -134,9 +146,10 @@ class UserService {
// Happy path, return token
const appSettings = await this.settingsService.getSettings();
const token = this.issueToken(userWithoutPassword, appSettings);
const refreshToken = this.issueRefreshToken(userWithoutPassword, appSettings);
// reset avatar image
userWithoutPassword.avatarImage = user.avatarImage;
return { user: userWithoutPassword, token };
return { user: userWithoutPassword, token, refreshToken };
};
editUser = async (updates: any, file: any, currentUser: any) => {
@@ -191,7 +204,39 @@ class UserService {
const user = await this.db.recoveryModule.resetPassword(password, recoveryToken);
const appSettings = await this.settingsService.getSettings();
const token = this.issueToken(user._doc, appSettings);
return { user, token };
const refreshToken = this.issueRefreshToken(user._doc, appSettings);
return { user, token, refreshToken };
};
refreshAuthToken = async (oldAuthToken: string, refreshTokenFromHeader: string) => {
const appSettings = await this.settingsService.getSettings();
const refreshTokenSecret = appSettings?.refreshTokenSecret ?? appSettings?.jwtSecret;
const jwtSecret = appSettings?.jwtSecret;
// Verify refresh token
try {
this.jwt.verify(refreshTokenFromHeader, refreshTokenSecret);
} catch (err: any) {
const errorMessage = err.name === "TokenExpiredError" ? "Refresh token has expired" : "Invalid refresh token";
throw this.errorService.createAuthenticationError(errorMessage);
}
// Refresh token is valid, get payload from old auth token (ignoring expiration)
let payloadData: any;
try {
payloadData = this.jwt.verify(oldAuthToken, jwtSecret, { ignoreExpiration: true });
} catch (err: any) {
throw this.errorService.createAuthenticationError("Invalid auth token");
}
// Remove old token metadata
delete payloadData.iat;
delete payloadData.exp;
// Issue new access token
const newAuthToken = this.issueToken(payloadData, appSettings);
return { user: payloadData, token: newAuthToken, refreshToken: refreshTokenFromHeader };
};
deleteUser = async (user: any) => {
@@ -3,6 +3,8 @@ const SERVICE_NAME = "SettingsService";
const envConfig = {
jwtSecret: process.env.JWT_SECRET,
jwtTTL: process.env.TOKEN_TTL,
refreshTokenSecret: process.env.REFRESH_TOKEN_SECRET || process.env.JWT_SECRET,
refreshTokenTTL: process.env.REFRESH_TOKEN_TTL || "7d",
systemEmailHost: process.env.SYSTEM_EMAIL_HOST,
nodeEnv: process.env.NODE_ENV,
logLevel: process.env.LOG_LEVEL,