Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79aca01bbf | ||
|
|
a87ca648dc | ||
|
|
b5caabed20 | ||
|
|
8814596d6e |
No files matched your search
@@ -5,6 +5,7 @@ import axios from "axios";
|
|||||||
const initialState = {
|
const initialState = {
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
authToken: "",
|
authToken: "",
|
||||||
|
refreshToken: "",
|
||||||
user: "",
|
user: "",
|
||||||
success: null,
|
success: null,
|
||||||
msg: null,
|
msg: null,
|
||||||
@@ -138,6 +139,7 @@ const handleAuthFulfilled = (state, action) => {
|
|||||||
state.success = action.payload.success;
|
state.success = action.payload.success;
|
||||||
state.msg = action.payload.msg;
|
state.msg = action.payload.msg;
|
||||||
state.authToken = action.payload.data.token;
|
state.authToken = action.payload.data.token;
|
||||||
|
state.refreshToken = action.payload.data.refreshToken || "";
|
||||||
state.user = action.payload.data.user;
|
state.user = action.payload.data.user;
|
||||||
};
|
};
|
||||||
const handleAuthRejected = (state, action) => {
|
const handleAuthRejected = (state, action) => {
|
||||||
@@ -188,11 +190,15 @@ const authSlice = createSlice({
|
|||||||
reducers: {
|
reducers: {
|
||||||
clearAuthState: (state) => {
|
clearAuthState: (state) => {
|
||||||
state.authToken = "";
|
state.authToken = "";
|
||||||
|
state.refreshToken = "";
|
||||||
state.user = "";
|
state.user = "";
|
||||||
state.isLoading = false;
|
state.isLoading = false;
|
||||||
state.success = true;
|
state.success = true;
|
||||||
state.msg = "Logged out successfully";
|
state.msg = "Logged out successfully";
|
||||||
},
|
},
|
||||||
|
setAuthToken: (state, action) => {
|
||||||
|
state.authToken = action.payload;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
extraReducers: (builder) => {
|
extraReducers: (builder) => {
|
||||||
// Register thunk
|
// Register thunk
|
||||||
@@ -246,4 +252,4 @@ const authSlice = createSlice({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default authSlice.reducer;
|
export default authSlice.reducer;
|
||||||
export const { clearAuthState } = authSlice.actions;
|
export const { clearAuthState, setAuthToken } = authSlice.actions;
|
||||||
@@ -178,8 +178,7 @@ const PageSpeedSetup = () => {
|
|||||||
|
|
||||||
const handleRemove = async (event) => {
|
const handleRemove = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const TEMP_MONITOR = { id: monitor._id };
|
await deleteMonitor({ monitor: { id: monitorId }, redirect: "/pagespeed" });
|
||||||
await deleteMonitor({ monitor: TEMP_MONITOR, redirect: "/pagespeed" });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const isBusy = isLoading || isCreating || isDeleting || isUpdating || isPausing;
|
const isBusy = isLoading || isCreating || isDeleting || isUpdating || isPausing;
|
||||||
|
|||||||
@@ -101,47 +101,6 @@ const SettingsEmail = ({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Build email configuration object for code preview
|
|
||||||
* Constructs the config incrementally instead of using nested conditional spreads
|
|
||||||
*/
|
|
||||||
const buildEmailConfigPreview = () => {
|
|
||||||
const config = {
|
|
||||||
host: systemEmailHost,
|
|
||||||
port: systemEmailPort,
|
|
||||||
secure: systemEmailSecure,
|
|
||||||
auth: {
|
|
||||||
user: systemEmailUser || systemEmailAddress,
|
|
||||||
pass: "<your_password>",
|
|
||||||
},
|
|
||||||
name: systemEmailConnectionHost || "localhost",
|
|
||||||
pool: systemEmailPool,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (systemEmailSecure) {
|
|
||||||
if (systemEmailIgnoreTLS) {
|
|
||||||
config.ignoreTLS = systemEmailIgnoreTLS;
|
|
||||||
}
|
|
||||||
if (systemEmailRequireTLS) {
|
|
||||||
config.requireTLS = systemEmailRequireTLS;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tlsSettings = {};
|
|
||||||
if (systemEmailRejectUnauthorized !== undefined) {
|
|
||||||
tlsSettings.rejectUnauthorized = systemEmailRejectUnauthorized;
|
|
||||||
}
|
|
||||||
if (systemEmailTLSServername && systemEmailTLSServername !== "") {
|
|
||||||
tlsSettings.servername = systemEmailTLSServername;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(tlsSettings).length > 0) {
|
|
||||||
config.tls = tlsSettings;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfigBox>
|
<ConfigBox>
|
||||||
<Box>
|
<Box>
|
||||||
@@ -312,7 +271,29 @@ const SettingsEmail = ({
|
|||||||
overflow: "auto",
|
overflow: "auto",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<code>{JSON.stringify(buildEmailConfigPreview(), null, 2)}</code>
|
<code>
|
||||||
|
{JSON.stringify(
|
||||||
|
{
|
||||||
|
host: systemEmailHost,
|
||||||
|
port: systemEmailPort,
|
||||||
|
secure: systemEmailSecure,
|
||||||
|
auth: {
|
||||||
|
user: systemEmailUser || systemEmailAddress,
|
||||||
|
pass: "<your_password>",
|
||||||
|
},
|
||||||
|
name: systemEmailConnectionHost || "localhost",
|
||||||
|
pool: systemEmailPool,
|
||||||
|
tls: {
|
||||||
|
rejectUnauthorized: systemEmailRejectUnauthorized,
|
||||||
|
ignoreTLS: systemEmailIgnoreTLS,
|
||||||
|
requireTLS: systemEmailRequireTLS,
|
||||||
|
servername: systemEmailTLSServername,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}
|
||||||
|
</code>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import axios from "axios";
|
|||||||
import i18next from "i18next";
|
import i18next from "i18next";
|
||||||
const BASE_URL = import.meta.env.VITE_APP_API_BASE_URL;
|
const BASE_URL = import.meta.env.VITE_APP_API_BASE_URL;
|
||||||
const FALLBACK_BASE_URL = "http://localhost:5000/api/v1";
|
const FALLBACK_BASE_URL = "http://localhost:5000/api/v1";
|
||||||
import { clearAuthState } from "../Features/Auth/authSlice";
|
import { clearAuthState, setAuthToken } from "../Features/Auth/authSlice";
|
||||||
class NetworkService {
|
class NetworkService {
|
||||||
constructor(store, dispatch, navigate) {
|
constructor(store, dispatch, navigate) {
|
||||||
this.store = store;
|
this.store = store;
|
||||||
@@ -26,7 +26,7 @@ class NetworkService {
|
|||||||
(config) => {
|
(config) => {
|
||||||
const currentLanguage = i18next.language || "en";
|
const currentLanguage = i18next.language || "en";
|
||||||
|
|
||||||
const { authToken } = store.getState().auth;
|
const { authToken, refreshToken } = store.getState().auth;
|
||||||
|
|
||||||
config.headers = {
|
config.headers = {
|
||||||
Authorization: `Bearer ${authToken}`,
|
Authorization: `Bearer ${authToken}`,
|
||||||
@@ -34,15 +34,36 @@ class NetworkService {
|
|||||||
...config.headers,
|
...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;
|
return config;
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
return Promise.reject(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(
|
this.axiosInstance.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
async (error) => {
|
||||||
// Handle network errors (server unreachable)
|
// Handle network errors (server unreachable)
|
||||||
if (error.code === "ERR_NETWORK") {
|
if (error.code === "ERR_NETWORK") {
|
||||||
// Navigate to server unreachable page
|
// Navigate to server unreachable page
|
||||||
@@ -51,8 +72,66 @@ class NetworkService {
|
|||||||
return Promise.reject(error);
|
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) {
|
if (error.response && error.response.status === 401) {
|
||||||
dispatch(clearAuthState());
|
dispatch(clearAuthState());
|
||||||
navigate("/login");
|
navigate("/login");
|
||||||
|
|||||||
@@ -36,11 +36,11 @@ class AuthController {
|
|||||||
req.body.email = req.body.email?.toLowerCase();
|
req.body.email = req.body.email?.toLowerCase();
|
||||||
}
|
}
|
||||||
await registrationBodyValidation.validateAsync(req.body);
|
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({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
msg: "User registered successfully",
|
msg: "User registered successfully",
|
||||||
data: { user, token },
|
data: { user, token, refreshToken },
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
@@ -53,7 +53,7 @@ class AuthController {
|
|||||||
req.body.email = req.body.email?.toLowerCase();
|
req.body.email = req.body.email?.toLowerCase();
|
||||||
}
|
}
|
||||||
await loginValidation.validateAsync(req.body);
|
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({
|
return res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -61,6 +61,7 @@ class AuthController {
|
|||||||
data: {
|
data: {
|
||||||
user,
|
user,
|
||||||
token,
|
token,
|
||||||
|
refreshToken,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -128,11 +129,35 @@ class AuthController {
|
|||||||
resetPassword = async (req: Request, res: Response, next: NextFunction) => {
|
resetPassword = async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
await newPasswordValidation.validateAsync(req.body);
|
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({
|
return res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
msg: "Password has been reset successfully",
|
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) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ const verifyJWT = (req, res, next) => {
|
|||||||
if (err) {
|
if (err) {
|
||||||
const errorMessage = err.name === "TokenExpiredError" ? stringService.expiredAuthToken : stringService.invalidAuthToken;
|
const errorMessage = err.name === "TokenExpiredError" ? stringService.expiredAuthToken : stringService.invalidAuthToken;
|
||||||
err.details = { msg: errorMessage };
|
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.service = SERVICE_NAME;
|
||||||
err.method = "verifyJWT";
|
err.method = "verifyJWT";
|
||||||
next(err);
|
next(err);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class AuthRoutes {
|
|||||||
initRoutes() {
|
initRoutes() {
|
||||||
this.router.post("/register", upload.single("profileImage"), this.authController.registerUser);
|
this.router.post("/register", upload.single("profileImage"), this.authController.registerUser);
|
||||||
this.router.post("/login", this.authController.loginUser);
|
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/request", this.authController.requestRecovery);
|
||||||
this.router.post("/recovery/validate", this.authController.validateRecovery);
|
this.router.post("/recovery/validate", this.authController.validateRecovery);
|
||||||
|
|||||||
@@ -62,6 +62,17 @@ class UserService {
|
|||||||
return this.jwt.sign(payloadData, tokenSecret, { expiresIn: tokenTTL });
|
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) => {
|
registerUser = async (user: any, file: any) => {
|
||||||
// Create a new user
|
// Create a new user
|
||||||
// If superAdmin exists, a token should be attached to all further register requests
|
// 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 appSettings = await this.settingsService.getSettings();
|
||||||
|
|
||||||
const token = this.issueToken(userForToken, appSettings);
|
const token = this.issueToken(userForToken, appSettings);
|
||||||
|
const refreshToken = this.issueRefreshToken(userForToken, appSettings);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const html = await this.emailService.buildEmail("welcomeEmailTemplate", {
|
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) => {
|
loginUser = async (email: string, password: string) => {
|
||||||
@@ -134,9 +146,10 @@ class UserService {
|
|||||||
// Happy path, return token
|
// Happy path, return token
|
||||||
const appSettings = await this.settingsService.getSettings();
|
const appSettings = await this.settingsService.getSettings();
|
||||||
const token = this.issueToken(userWithoutPassword, appSettings);
|
const token = this.issueToken(userWithoutPassword, appSettings);
|
||||||
|
const refreshToken = this.issueRefreshToken(userWithoutPassword, appSettings);
|
||||||
// reset avatar image
|
// reset avatar image
|
||||||
userWithoutPassword.avatarImage = user.avatarImage;
|
userWithoutPassword.avatarImage = user.avatarImage;
|
||||||
return { user: userWithoutPassword, token };
|
return { user: userWithoutPassword, token, refreshToken };
|
||||||
};
|
};
|
||||||
|
|
||||||
editUser = async (updates: any, file: any, currentUser: any) => {
|
editUser = async (updates: any, file: any, currentUser: any) => {
|
||||||
@@ -191,7 +204,39 @@ class UserService {
|
|||||||
const user = await this.db.recoveryModule.resetPassword(password, recoveryToken);
|
const user = await this.db.recoveryModule.resetPassword(password, recoveryToken);
|
||||||
const appSettings = await this.settingsService.getSettings();
|
const appSettings = await this.settingsService.getSettings();
|
||||||
const token = this.issueToken(user._doc, appSettings);
|
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) => {
|
deleteUser = async (user: any) => {
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
const SERVICE_NAME = "EmailService";
|
const SERVICE_NAME = "EmailService";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,37 +46,16 @@ class EmailService {
|
|||||||
*/
|
*/
|
||||||
this.loadTemplate = (templateName) => {
|
this.loadTemplate = (templateName) => {
|
||||||
try {
|
try {
|
||||||
// Use EMAIL_TEMPLATE_PATH environment variable or default to src/templates/ in cwd
|
const templatePath = this.path.join(__dirname, `../../../templates/${templateName}.mjml`);
|
||||||
const templateBase = process.env.EMAIL_TEMPLATE_PATH || this.path.join(process.cwd(), "src", "templates");
|
|
||||||
const templatePath = this.path.join(templateBase, `${templateName}.mjml`);
|
|
||||||
|
|
||||||
this.logger.debug({
|
|
||||||
message: `Loading template: ${templateName}`,
|
|
||||||
service: SERVICE_NAME,
|
|
||||||
method: "loadTemplate",
|
|
||||||
templatePath: templatePath,
|
|
||||||
});
|
|
||||||
|
|
||||||
const templateContent = this.fs.readFileSync(templatePath, "utf8");
|
const templateContent = this.fs.readFileSync(templatePath, "utf8");
|
||||||
const compiled = this.compile(templateContent);
|
return this.compile(templateContent);
|
||||||
|
|
||||||
this.logger.debug({
|
|
||||||
message: `Template loaded successfully: ${templateName}`,
|
|
||||||
service: SERVICE_NAME,
|
|
||||||
method: "loadTemplate",
|
|
||||||
});
|
|
||||||
return compiled;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error({
|
this.logger.error({
|
||||||
message: `Failed to load template '${templateName}': ${error.message}`,
|
message: error.message,
|
||||||
service: SERVICE_NAME,
|
service: SERVICE_NAME,
|
||||||
method: "loadTemplate",
|
method: "loadTemplate",
|
||||||
templateName: templateName,
|
|
||||||
error: error.message,
|
|
||||||
stack: error.stack,
|
stack: error.stack,
|
||||||
});
|
});
|
||||||
// Fail fast - throw error instead of returning empty function
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -98,169 +83,20 @@ class EmailService {
|
|||||||
|
|
||||||
buildEmail = async (template, context) => {
|
buildEmail = async (template, context) => {
|
||||||
try {
|
try {
|
||||||
if (!this.templateLookup[template]) {
|
|
||||||
this.logger.error({
|
|
||||||
message: `Template '${template}' not found in templateLookup`,
|
|
||||||
service: SERVICE_NAME,
|
|
||||||
method: "buildEmail",
|
|
||||||
availableTemplates: Object.keys(this.templateLookup),
|
|
||||||
});
|
|
||||||
throw new Error(`Template '${template}' not found`);
|
|
||||||
}
|
|
||||||
if (typeof this.templateLookup[template] !== "function") {
|
|
||||||
this.logger.error({
|
|
||||||
message: `Template '${template}' is not a function. Type: ${typeof this.templateLookup[template]}`,
|
|
||||||
service: SERVICE_NAME,
|
|
||||||
method: "buildEmail",
|
|
||||||
templateValue: this.templateLookup[template],
|
|
||||||
});
|
|
||||||
throw new Error(`Template '${template}' is not a function`);
|
|
||||||
}
|
|
||||||
const mjml = this.templateLookup[template](context);
|
const mjml = this.templateLookup[template](context);
|
||||||
|
|
||||||
// Check if MJML is empty (template failed to load)
|
|
||||||
if (!mjml || mjml.trim() === "") {
|
|
||||||
const msg = `Template '${template}' returned empty MJML content. Template may have failed to load.`;
|
|
||||||
this.logger.error({
|
|
||||||
message: msg,
|
|
||||||
service: SERVICE_NAME,
|
|
||||||
method: "buildEmail",
|
|
||||||
template: template,
|
|
||||||
});
|
|
||||||
throw new Error(msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
const html = await this.mjml2html(mjml);
|
const html = await this.mjml2html(mjml);
|
||||||
|
|
||||||
// Check if HTML is empty
|
|
||||||
if (!html || !html.html) {
|
|
||||||
const msg = `MJML conversion failed for template '${template}'. No HTML output.`;
|
|
||||||
this.logger.error({
|
|
||||||
message: msg,
|
|
||||||
service: SERVICE_NAME,
|
|
||||||
method: "buildEmail",
|
|
||||||
template: template,
|
|
||||||
mjmlLength: mjml.length,
|
|
||||||
});
|
|
||||||
throw new Error(msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
return html.html;
|
return html.html;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error({
|
this.logger.error({
|
||||||
message: `Failed to build email for template '${template}': ${error.message}`,
|
message: error.message,
|
||||||
service: SERVICE_NAME,
|
service: SERVICE_NAME,
|
||||||
method: "buildEmail",
|
method: "buildEmail",
|
||||||
template: template,
|
|
||||||
error: error.message,
|
|
||||||
stack: error.stack,
|
stack: error.stack,
|
||||||
});
|
});
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates email parameters.
|
|
||||||
* @param {string} to - The recipient email address.
|
|
||||||
* @param {string} subject - The email subject.
|
|
||||||
* @param {string} html - The email HTML content.
|
|
||||||
* @returns {boolean} True if valid, false otherwise.
|
|
||||||
*/
|
|
||||||
validateEmailParams(to, subject, html) {
|
|
||||||
if (!to || !subject) {
|
|
||||||
this.logger.error({
|
|
||||||
message: "Invalid email parameters: missing 'to' or 'subject'",
|
|
||||||
service: SERVICE_NAME,
|
|
||||||
method: "validateEmailParams",
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!html || html.trim() === "") {
|
|
||||||
this.logger.error({
|
|
||||||
message: "Cannot send email: HTML content is empty",
|
|
||||||
service: SERVICE_NAME,
|
|
||||||
method: "validateEmailParams",
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the from email address.
|
|
||||||
* @param {string} systemEmailAddress - The system email address.
|
|
||||||
* @returns {boolean} True if valid, false otherwise.
|
|
||||||
*/
|
|
||||||
validateFromAddress(systemEmailAddress) {
|
|
||||||
const fromAddress = systemEmailAddress;
|
|
||||||
if (!fromAddress || !fromAddress.includes("@")) {
|
|
||||||
this.logger.error({
|
|
||||||
message: "Missing or invalid systemEmailAddress - a valid email address is required",
|
|
||||||
service: SERVICE_NAME,
|
|
||||||
method: "validateFromAddress",
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the TLS configuration for the email transporter.
|
|
||||||
* @param {Object} config - The email configuration object.
|
|
||||||
* @returns {Object} The TLS configuration object.
|
|
||||||
*/
|
|
||||||
buildTLSConfig(config) {
|
|
||||||
const { systemEmailSecure, systemEmailIgnoreTLS, systemEmailRequireTLS, systemEmailRejectUnauthorized, systemEmailTLSServername } = config;
|
|
||||||
|
|
||||||
const tlsConfig = {};
|
|
||||||
|
|
||||||
// Only apply TLS settings if secure is enabled
|
|
||||||
if (systemEmailSecure) {
|
|
||||||
// Top-level Nodemailer options (control STARTTLS behavior):
|
|
||||||
// - ignoreTLS: If true, the connection will not attempt to use STARTTLS
|
|
||||||
// - requireTLS: If true, connection will fail if STARTTLS is not available
|
|
||||||
if (systemEmailIgnoreTLS !== undefined) {
|
|
||||||
tlsConfig.ignoreTLS = systemEmailIgnoreTLS;
|
|
||||||
}
|
|
||||||
if (systemEmailRequireTLS !== undefined) {
|
|
||||||
tlsConfig.requireTLS = systemEmailRequireTLS;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Node.js TLS options (go inside the 'tls' object):
|
|
||||||
// - rejectUnauthorized: If false, accepts self-signed certificates
|
|
||||||
// - servername: Overrides the hostname for SNI (Server Name Indication)
|
|
||||||
const tlsSettings = {};
|
|
||||||
|
|
||||||
if (systemEmailRejectUnauthorized !== undefined) {
|
|
||||||
tlsSettings.rejectUnauthorized = systemEmailRejectUnauthorized;
|
|
||||||
}
|
|
||||||
if (systemEmailTLSServername !== undefined && systemEmailTLSServername !== null && systemEmailTLSServername !== "") {
|
|
||||||
tlsSettings.servername = systemEmailTLSServername;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only add tls property if we have TLS settings
|
|
||||||
if (Object.keys(tlsSettings).length > 0) {
|
|
||||||
tlsConfig.tls = tlsSettings;
|
|
||||||
this.logger.debug({
|
|
||||||
message: `TLS settings applied to email config`,
|
|
||||||
service: SERVICE_NAME,
|
|
||||||
method: "buildTLSConfig",
|
|
||||||
tlsSettings: Object.keys(tlsSettings),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return tlsConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
sendEmail = async (to, subject, html, transportConfig) => {
|
sendEmail = async (to, subject, html, transportConfig) => {
|
||||||
// Validate email parameters
|
|
||||||
if (!this.validateEmailParams(to, subject, html)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
let config;
|
let config;
|
||||||
if (typeof transportConfig !== "undefined") {
|
if (typeof transportConfig !== "undefined") {
|
||||||
config = transportConfig;
|
config = transportConfig;
|
||||||
@@ -271,35 +107,35 @@ class EmailService {
|
|||||||
systemEmailHost,
|
systemEmailHost,
|
||||||
systemEmailPort,
|
systemEmailPort,
|
||||||
systemEmailSecure,
|
systemEmailSecure,
|
||||||
|
systemEmailPool,
|
||||||
systemEmailUser,
|
systemEmailUser,
|
||||||
systemEmailAddress,
|
systemEmailAddress,
|
||||||
systemEmailPassword,
|
systemEmailPassword,
|
||||||
systemEmailConnectionHost,
|
systemEmailConnectionHost,
|
||||||
|
systemEmailTLSServername,
|
||||||
|
systemEmailIgnoreTLS,
|
||||||
|
systemEmailRequireTLS,
|
||||||
|
systemEmailRejectUnauthorized,
|
||||||
} = config;
|
} = config;
|
||||||
|
|
||||||
// Validate from address
|
|
||||||
if (!this.validateFromAddress(systemEmailAddress)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build base email config
|
|
||||||
const emailConfig = {
|
const emailConfig = {
|
||||||
host: systemEmailHost,
|
host: systemEmailHost,
|
||||||
port: Number(systemEmailPort),
|
port: Number(systemEmailPort),
|
||||||
secure: systemEmailSecure,
|
secure: systemEmailSecure,
|
||||||
pool: config.systemEmailPool ?? false,
|
|
||||||
auth: {
|
auth: {
|
||||||
user: systemEmailUser || systemEmailAddress,
|
user: systemEmailUser || systemEmailAddress,
|
||||||
pass: systemEmailPassword,
|
pass: systemEmailPassword,
|
||||||
},
|
},
|
||||||
name: systemEmailConnectionHost || "localhost",
|
name: systemEmailConnectionHost || "localhost",
|
||||||
connectionTimeout: 5000,
|
connectionTimeout: 5000,
|
||||||
|
pool: systemEmailPool,
|
||||||
|
tls: {
|
||||||
|
rejectUnauthorized: systemEmailRejectUnauthorized,
|
||||||
|
ignoreTLS: systemEmailIgnoreTLS,
|
||||||
|
requireTLS: systemEmailRequireTLS,
|
||||||
|
servername: systemEmailTLSServername,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Apply TLS configuration
|
|
||||||
const tlsConfig = this.buildTLSConfig(config);
|
|
||||||
Object.assign(emailConfig, tlsConfig);
|
|
||||||
|
|
||||||
this.transporter = this.nodemailer.createTransport(emailConfig);
|
this.transporter = this.nodemailer.createTransport(emailConfig);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -308,8 +144,7 @@ class EmailService {
|
|||||||
this.logger.warn({
|
this.logger.warn({
|
||||||
message: "Email transporter verification failed",
|
message: "Email transporter verification failed",
|
||||||
service: SERVICE_NAME,
|
service: SERVICE_NAME,
|
||||||
method: "sendEmail",
|
method: "verifyTransporter",
|
||||||
error: error.message,
|
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -329,7 +164,6 @@ class EmailService {
|
|||||||
method: "sendEmail",
|
method: "sendEmail",
|
||||||
stack: error.stack,
|
stack: error.stack,
|
||||||
});
|
});
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ const SERVICE_NAME = "SettingsService";
|
|||||||
const envConfig = {
|
const envConfig = {
|
||||||
jwtSecret: process.env.JWT_SECRET,
|
jwtSecret: process.env.JWT_SECRET,
|
||||||
jwtTTL: process.env.TOKEN_TTL,
|
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,
|
systemEmailHost: process.env.SYSTEM_EMAIL_HOST,
|
||||||
nodeEnv: process.env.NODE_ENV,
|
nodeEnv: process.env.NODE_ENV,
|
||||||
logLevel: process.env.LOG_LEVEL,
|
logLevel: process.env.LOG_LEVEL,
|
||||||
|
|||||||
Reference in new issue
Block a user