Merge pull request #890 from bluewave-labs/feat/fe/in-app-settings

Feat/fe/in app settings
This commit is contained in:
Alexander Holliday
2024-09-29 20:34:33 -07:00
committed by GitHub
13 changed files with 531 additions and 17 deletions

View File

@@ -19,6 +19,7 @@ import SetNewPassword from "./Pages/Auth/SetNewPassword";
import NewPasswordConfirmed from "./Pages/Auth/NewPasswordConfirmed";
import ProtectedRoute from "./Components/ProtectedRoute";
import Details from "./Pages/Monitors/Details";
import AdvancedSettings from "./Pages/AdvancedSettings";
// import Maintenance from "./Pages/Maintenance";
import withAdminCheck from "./HOC/withAdminCheck";
import withAdminProp from "./HOC/withAdminProp";
@@ -33,7 +34,11 @@ import lightTheme from "./Utils/Theme/lightTheme";
import darkTheme from "./Utils/Theme/darkTheme";
import { useSelector } from "react-redux";
import { CssBaseline } from "@mui/material";
import { useEffect } from "react";
import { useDispatch } from "react-redux";
import { getAppSettings } from "./Features/Settings/settingsSlice";
import { logger } from "./Utils/Logger"; // Import the logger
import { networkService } from "./main";
function App() {
const AdminCheckedRegister = withAdminCheck(Register);
const MonitorsWithAdminProp = withAdminProp(Monitors);
@@ -42,8 +47,24 @@ function App() {
const PageSpeedDetailsWithAdminProp = withAdminProp(PageSpeedDetails);
// const MaintenanceWithAdminProp = withAdminProp(Maintenance);
const SettingsWithAdminProp = withAdminProp(Settings);
const AdvancedSettingsWithAdminProp = withAdminProp(AdvancedSettings);
const mode = useSelector((state) => state.ui.mode);
const { authToken } = useSelector((state) => state.auth);
const dispatch = useDispatch();
useEffect(() => {
if (authToken) {
dispatch(getAppSettings({ authToken }));
}
}, [dispatch, authToken]);
// Cleanup
useEffect(() => {
return () => {
logger.cleanup();
networkService.cleanup();
};
}, []);
return (
<ThemeProvider theme={mode === "light" ? lightTheme : darkTheme}>
@@ -96,6 +117,12 @@ function App() {
path="settings"
element={<ProtectedRoute Component={SettingsWithAdminProp} />}
/>
<Route
path="advanced-settings"
element={
<ProtectedRoute Component={AdvancedSettingsWithAdminProp} />
}
/>
<Route
path="account/profile"
element={<ProtectedRoute Component={Account} open="profile" />}

View File

@@ -0,0 +1,117 @@
import { networkService } from "../../main";
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
const initialState = {
isLoading: false,
apiBaseUrl: "http://localhost:5000/api/v1",
logLevel: "debug",
};
export const getAppSettings = createAsyncThunk(
"settings/getSettings",
async (data, thunkApi) => {
try {
const res = await networkService.getAppSettings({
authToken: data.authToken,
});
return res.data;
} catch (error) {
if (error.response.data) {
return thunkApi.rejectWithValue(error.response.data);
}
const payload = {
status: false,
msg: error.message ? error.message : "Unknown error",
};
return thunkApi.rejectWithValue(payload);
}
}
);
export const updateAppSettings = createAsyncThunk(
"settings/updateSettings",
async ({ settings, authToken }, thunkApi) => {
networkService.setBaseUrl(settings.apiBaseUrl);
try {
const parsedSettings = {
apiBaseUrl: settings.apiBaseUrl,
logLevel: settings.logLevel,
clientHost: settings.clientHost,
jwtSecret: settings.jwtSecret,
dbType: settings.dbType,
dbConnectionString: settings.dbConnectionString,
redisHost: settings.redisHost,
redisPort: settings.redisPort,
jwtTTL: settings.jwtTTL,
pagespeedApiKey: settings.pagespeedApiKey,
systemEmailHost: settings.systemEmailHost,
systemEmailPort: settings.systemEmailPort,
systemEmailAddress: settings.systemEmailAddress,
systemEmailPassword: settings.systemEmailPassword,
};
const res = await networkService.updateAppSettings({
settings: parsedSettings,
authToken,
});
return res.data;
} catch (error) {
if (error.response && error.response.data) {
return thunkApi.rejectWithValue(error.response.data);
}
const payload = {
status: false,
msg: error.message ? error.message : "Unknown error",
};
return thunkApi.rejectWithValue(payload);
}
}
);
const handleGetSettingsFulfilled = (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
state.apiBaseUrl = action.payload.data.apiBaseUrl;
state.logLevel = action.payload.data.logLevel;
};
const handleGetSettingsRejected = (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload ? action.payload.msg : "Failed to get settings.";
};
const handleUpdateSettingsFulfilled = (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
state.apiBaseUrl = action.payload.data.apiBaseUrl;
state.logLevel = action.payload.data.logLevel;
};
const handleUpdateSettingsRejected = (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to update settings.";
};
const settingsSlice = createSlice({
name: "settings",
initialState,
extraReducers: (builder) => {
builder
.addCase(getAppSettings.pending, (state) => {
state.isLoading = true;
})
.addCase(getAppSettings.fulfilled, handleGetSettingsFulfilled)
.addCase(getAppSettings.rejected, handleGetSettingsRejected);
builder
.addCase(updateAppSettings.pending, (state) => {
state.isLoading = true;
})
.addCase(updateAppSettings.fulfilled, handleUpdateSettingsFulfilled)
.addCase(updateAppSettings.rejected, handleUpdateSettingsRejected);
},
});
export default settingsSlice.reducer;

View File

@@ -0,0 +1,266 @@
import { useTheme } from "@emotion/react";
import { Box, Stack, Typography } from "@mui/material";
import Field from "../../Components/Inputs/Field";
import Link from "../../Components/Link";
import "./index.css";
import { useDispatch, useSelector } from "react-redux";
import { createToast } from "../../Utils/toastUtils";
import PropTypes from "prop-types";
import LoadingButton from "@mui/lab/LoadingButton";
import { ConfigBox } from "../Settings/styled";
import { useNavigate } from "react-router";
import {
getAppSettings,
updateAppSettings,
} from "../../Features/Settings/settingsSlice";
import { useState, useEffect } from "react";
import Select from "../../Components/Inputs/Select";
const AdvancedSettings = ({ isAdmin }) => {
const navigate = useNavigate();
useEffect(() => {
if (!isAdmin) {
navigate("/");
}
}, [navigate, isAdmin]);
const theme = useTheme();
const { authToken } = useSelector((state) => state.auth);
const dispatch = useDispatch();
const settings = useSelector((state) => state.settings);
const [localSettings, setLocalSettings] = useState({
apiBaseUrl: "",
logLevel: "debug",
systemEmailHost: "",
systemEmailPort: "",
systemEmailAddress: "",
systemEmailPassword: "",
jwtTTL: "",
dbType: "",
redisHost: "",
redisPort: "",
pagespeedApiKey: "",
});
useEffect(() => {
const getSettings = async () => {
const action = await dispatch(getAppSettings({ authToken }));
if (action.payload.success) {
console.log(action.payload.data);
setLocalSettings(action.payload.data);
} else {
createToast({ body: "Failed to get settings" });
}
};
getSettings();
}, [authToken, dispatch]);
const logItems = [
{ _id: 1, name: "none" },
{ _id: 2, name: "debug" },
{ _id: 3, name: "error" },
{ _id: 4, name: "warn" },
];
const logItemLookup = {
none: 1,
debug: 2,
error: 3,
warn: 4,
};
const handleLogLevel = (e) => {
const id = e.target.value;
const newLogLevel = logItems.find((item) => item._id === id).name;
setLocalSettings({ ...localSettings, logLevel: newLogLevel });
};
const handleChange = (event) => {
const { value, id } = event.target;
setLocalSettings({ ...localSettings, [id]: value });
};
const handleSave = async () => {
const action = await dispatch(
updateAppSettings({ settings: localSettings, authToken })
);
let body = "";
if (action.payload.success) {
console.log(action.payload.data);
setLocalSettings(action.payload.data);
body = "Settings saved successfully";
} else {
body = "Failed to save settings";
}
createToast({ body });
};
return (
<Box
className="settings"
style={{
paddingBottom: 0,
}}
>
<Stack
component="form"
gap={theme.spacing(12)}
noValidate
spellCheck="false"
>
<ConfigBox>
<Box>
<Typography component="h1">Client Settings</Typography>
<Typography sx={{ mt: theme.spacing(2) }}>
Modify client settings here
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Field
id="apiBaseUrl"
label="API URL Host"
value={localSettings.apiBaseUrl}
onChange={handleChange}
/>
<Select
id="logLevel"
label="logLevel"
name="logLevel"
items={logItems}
value={logItemLookup[localSettings.logLevel]}
onChange={handleLogLevel}
/>
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h1">Email Settings</Typography>
<Typography sx={{ mt: theme.spacing(2) }}>
Set your host email settings here. These settings are used for
sending system emails
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Field
type="text"
id="systemEmailHost"
label="Email Host"
name="systemEmailHost"
value={localSettings.systemEmailHost}
onChange={handleChange}
/>
<Field
type="number"
id="systemEmailPort"
label="System Email Address"
name="systemEmailPort"
value={localSettings.systemEmailPort.toString()}
onChange={handleChange}
/>
<Field
type="email"
id="systemEmailAddress"
label="System Email Address"
name="systemEmailAddress"
value={localSettings.systemEmailAddress}
onChange={handleChange}
/>
<Field
type="text"
id="systemEmailPassword"
label="System Email Password"
name="systemEmailPassword"
value={localSettings.systemEmailPassword}
onChange={handleChange}
/>
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h1">Server Settings</Typography>
<Typography sx={{ mt: theme.spacing(2) }}>
Modify server settings here
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Field
type="text"
id="jwtTTL"
label="JWT Time To Live"
name="jwtTTL"
value={localSettings.jwtTTL}
onChange={handleChange}
/>
<Field
type="text"
id="dbType"
label="Database Type"
name="dbType"
value={localSettings.dbType}
onChange={handleChange}
/>
<Field
type="text"
id="redisHost"
label="Redis Host"
name="redisHost"
value={localSettings.redisHost}
onChange={handleChange}
/>
<Field
type="number"
id="redisPort"
label="Redis Port"
name="redisPort"
value={localSettings.redisPort.toString()}
onChange={handleChange}
/>
<Field
type="text"
id="pagespeedApiKey"
label="PageSpeed API Key"
name="pagespeedApiKey"
value={localSettings.pagespeedApiKey}
onChange={handleChange}
/>
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h1">About</Typography>
</Box>
<Box>
<Typography component="h2">BlueWave Uptime v1.0.0</Typography>
<Typography
sx={{ mt: theme.spacing(2), mb: theme.spacing(6), opacity: 0.6 }}
>
Developed by Bluewave Labs.
</Typography>
<Link
level="secondary"
url="https://github.com/bluewave-labs"
label="https://github.com/bluewave-labs"
/>
</Box>
</ConfigBox>
<Stack direction="row" justifyContent="flex-end">
<LoadingButton
loading={settings.isLoading || settings.authIsLoading}
variant="contained"
color="primary"
sx={{ px: theme.spacing(12), mt: theme.spacing(20) }}
onClick={handleSave}
>
Save
</LoadingButton>
</Stack>
</Stack>
</Box>
);
};
AdvancedSettings.propTypes = {
isAdmin: PropTypes.bool,
};
export default AdvancedSettings;

View File

@@ -1,5 +1,5 @@
import { useTheme } from "@emotion/react";
import { Box, Stack, Typography } from "@mui/material";
import { Box, Stack, Typography, Button } from "@mui/material";
import Field from "../../Components/Inputs/Field";
import Link from "../../Components/Link";
import Select from "../../Components/Inputs/Select";
@@ -21,6 +21,7 @@ import { useState } from "react";
import { ConfigBox } from "./styled";
import { networkService } from "../../main";
import { settingsValidation } from "../../Validation/validation";
import { useNavigate } from "react-router";
const SECONDS_PER_DAY = 86400;
@@ -37,6 +38,7 @@ const Settings = ({ isAdmin }) => {
});
const [errors, setErrors] = useState({});
const dispatch = useDispatch();
const navigate = useNavigate();
const handleChange = (event) => {
const { value, id } = event.target;
@@ -249,6 +251,28 @@ const Settings = ({ isAdmin }) => {
</Stack>
</ConfigBox>
)}
{isAdmin && (
<ConfigBox>
<Box>
<Typography component="h1">Advanced Settings</Typography>
<Typography sx={{ mt: theme.spacing(2) }}>
Click here to modify advanced settings
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Box>
<Button
variant="contained"
onClick={() => {
navigate("/advanced-settings");
}}
>
Advanced Settings
</Button>
</Box>
</Stack>
</ConfigBox>
)}
<ConfigBox>
<Box>
<Typography component="h1">About</Typography>

View File

@@ -1,6 +1,16 @@
const LOG_LEVEL = import.meta.env.VITE_APP_LOG_LEVEL;
import store from "../store";
const LOG_LEVEL = import.meta.env.VITE_APP_LOG_LEVEL || "debug";
class Logger {
constructor(logLevel) {
constructor() {
let logLevel = LOG_LEVEL;
this.unsubscribe = store.subscribe(() => {
const state = store.getState();
logLevel = state.settings.logLevel || "debug";
this.updateLogLevel(logLevel);
});
}
updateLogLevel(logLevel) {
const NO_OP = () => {};
if (logLevel === "none") {
@@ -25,6 +35,12 @@ class Logger {
}
this.log = console.log.bind(console);
}
cleanup() {
if (this.unsubscribe) {
this.unsubscribe();
}
}
}
export const logger = new Logger(LOG_LEVEL);
export const logger = new Logger();

View File

@@ -1,10 +1,18 @@
import axios from "axios";
const BASE_URL = import.meta.env.VITE_APP_API_BASE_URL;
const BASE_URL =
import.meta.env.VITE_APP_API_BASE_URL || "http://localhost:5000/api/v1";
import { logger } from "./Logger";
class NetworkService {
constructor(store) {
this.store = store;
this.axiosInstance = axios.create({ baseURL: BASE_URL });
let baseURL = BASE_URL;
this.axiosInstance = axios.create();
this.setBaseUrl(baseURL);
this.unsubscribe = store.subscribe(() => {
const state = store.getState();
baseURL = state.settings.apiBaseUrl || BASE_URL;
this.setBaseUrl(baseURL);
});
this.axiosInstance.interceptors.response.use(
(response) => response,
(error) => {
@@ -17,6 +25,16 @@ class NetworkService {
);
}
setBaseUrl = (url) => {
this.axiosInstance.defaults.baseURL = url;
};
cleanup() {
if (this.unsubscribe) {
this.unsubscribe();
}
}
/**
*
* ************************************
@@ -619,6 +637,48 @@ class NetworkService {
}
);
}
/**
* ************************************
* Get app settings
* ************************************
*
* @async
* @param {Object} config - The configuration object.
* @param {string} config.authToken - The authorization token to be used in the request header.
* @returns {Promise<AxiosResponse>} The response from the axios GET request.
*
*/
async getAppSettings(config) {
return this.axiosInstance.get("/settings", {
headers: {
Authorization: `Bearer ${config.authToken}`,
"Content-Type": "application/json",
},
});
}
/**
*
* ************************************
* Create a new monitor
* ************************************
*
* @async
* @param {Object} config - The configuration object.
* @param {string} config.authToken - The authorization token to be used in the request header.
* @param {Object} config.settings - The monitor object to be sent in the request body.
* @returns {Promise<AxiosResponse>} The response from the axios POST request.
*/
async updateAppSettings(config) {
return this.axiosInstance.put(`/settings`, config.settings, {
headers: {
Authorization: `Bearer ${config.authToken}`,
"Content-Type": "application/json",
},
});
}
}
export default NetworkService;

View File

@@ -4,6 +4,7 @@ import uptimeMonitorsReducer from "./Features/UptimeMonitors/uptimeMonitorsSlice
import pageSpeedMonitorReducer from "./Features/PageSpeedMonitor/pageSpeedMonitorSlice";
import authReducer from "./Features/Auth/authSlice";
import uiReducer from "./Features/UI/uiSlice";
import settingsReducer from "./Features/Settings/settingsSlice";
import storage from "redux-persist/lib/storage";
import { persistReducer, persistStore, createTransform } from "redux-persist";
@@ -21,7 +22,7 @@ const authTransform = createTransform(
const persistConfig = {
key: "root",
storage,
whitielist: ["auth", "monitors", "pageSpeed", "ui"],
whitelist: ["auth", "monitors", "pageSpeed", "ui", "settings"],
transforms: [authTransform],
};
@@ -30,6 +31,7 @@ const rootReducer = combineReducers({
auth: authReducer,
pageSpeedMonitors: pageSpeedMonitorReducer,
ui: uiReducer,
settings: settingsReducer,
});
const persistedReducer = persistReducer(persistConfig, rootReducer);
@@ -49,3 +51,4 @@ export const store = configureStore({
});
export const persistor = persistStore(store);
export default store;

View File

@@ -4,7 +4,8 @@ const { updateAppSettingsBodyValidation } = require("../validation/joi");
const getAppSettings = async (req, res, next) => {
try {
const settings = await req.settingsService.getSettings();
const settings = { ...(await req.settingsService.getSettings()) };
delete settings.jwtSecret;
return res.status(200).json({
success: true,
msg: successMessages.GET_APP_SETTINGS,
@@ -31,7 +32,8 @@ const updateAppSettings = async (req, res, next) => {
try {
await req.db.updateAppSettings(req.body);
const updatedSettings = await req.settingsService.reloadSettings();
const updatedSettings = { ...(await req.settingsService.reloadSettings()) };
delete updatedSettings.jwtSecret;
return res.status(200).json({
success: true,
msg: successMessages.UPDATE_APP_SETTINGS,

View File

@@ -36,7 +36,6 @@ const verifyJWT = (req, res, next) => {
const parsedToken = token.slice(TOKEN_PREFIX.length, token.length);
// Verify the token's authenticity
const { jwtSecret } = req.settingsService.getSettings();
jwt.verify(parsedToken, jwtSecret, (err, decoded) => {
if (err) {
return res

View File

@@ -48,6 +48,7 @@ const AppSettingsSchema = mongoose.Schema(
},
pagespeedApiKey: {
type: String,
default: "",
},
systemEmailHost: {
type: String,

View File

@@ -2,13 +2,15 @@ const { env } = require("process");
const AppSettings = require("../models/AppSettings");
const SERVICE_NAME = "SettingsService";
const envConfig = {
logLevel: undefined,
apiBaseUrl: undefined,
clientHost: process.env.CLIENT_HOST,
jwtSecret: process.env.JWT_SECRET,
dbType: process.env.DB_TYPE,
dbConnectionString: process.env.DB_CONNECTION_STRING,
redisHost: process.env.REDIS_HOST,
redisPort: process.env.REDIS_PORT,
tokenTTL: process.env.TOKEN_TTL,
jwtTTL: process.env.TOKEN_TTL,
pagespeedApiKey: process.env.PAGESPEED_API_KEY,
systemEmailHost: process.env.SYSTEM_EMAIL_HOST,
systemEmailPort: process.env.SYSTEM_EMAIL_PORT,
@@ -18,7 +20,7 @@ const envConfig = {
class SettingsService {
constructor() {
this.settings = envConfig;
this.settings = { ...envConfig };
}
async loadSettings() {
@@ -27,7 +29,6 @@ class SettingsService {
if (!this.settings) {
throw new Error("Settings not found");
}
// Try to load settings from env first, if not found, load from db
for (const key in envConfig) {
if (envConfig[key] === undefined && dbSettings[key] !== undefined) {
@@ -38,7 +39,6 @@ class SettingsService {
if (!this.settings) {
throw new Error("Settings not found");
}
return this.settings;
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;

View File

@@ -389,7 +389,6 @@ const updateAppSettingsBodyValidation = joi.object({
apiBaseUrl: joi.string().allow(""),
logLevel: joi.string().valid("debug", "none", "error", "warn").allow(""),
clientHost: joi.string().allow(""),
jwtSecret: joi.string().allow(""),
dbType: joi.string().allow(""),
dbConnectionString: joi.string().allow(""),
redisHost: joi.string().allow(""),