diff --git a/backend/config/settings.js b/backend/config/settings.js index 969951c7..34096507 100644 --- a/backend/config/settings.js +++ b/backend/config/settings.js @@ -20,6 +20,7 @@ module.exports = { update: [userRole.ADMIN], changeRole: [userRole.ADMIN], setOrg: [userRole.ADMIN], + serverUrl: [userRole.ADMIN], popups: [userRole.ADMIN], hints: [userRole.ADMIN], banners: [userRole.ADMIN], diff --git a/backend/migrations/20241216195934-server_url_col_team_rel.js b/backend/migrations/20241216195934-server_url_col_team_rel.js new file mode 100644 index 00000000..1bbad56f --- /dev/null +++ b/backend/migrations/20241216195934-server_url_col_team_rel.js @@ -0,0 +1,14 @@ +'use strict'; + +module.exports = { + async up (queryInterface, Sequelize) { + await queryInterface.addColumn('teams', 'serverUrl', { + type: Sequelize.STRING(255), + allowNull: true, + }) + }, + + async down (queryInterface, Sequelize) { + await queryInterface.removeColumn('teams', 'serverUrl') + } +}; diff --git a/backend/src/controllers/team.controller.js b/backend/src/controllers/team.controller.js index cb240e61..cfee88ca 100644 --- a/backend/src/controllers/team.controller.js +++ b/backend/src/controllers/team.controller.js @@ -3,6 +3,7 @@ const TeamService = require("../service/team.service"); const { internalServerError } = require("../utils/errors.helper"); const { MAX_ORG_NAME_LENGTH, ORG_NAME_REGEX } = require('../utils/constants.helper'); const db = require("../models"); +const { validationResult } = require('express-validator'); const Team = db.Team; const teamService = new TeamService(); @@ -61,6 +62,21 @@ const getTeamCount = async (req, res) => { } }; +const getServerUrl = async (req, res) => { + try { + let serverUrl = await teamService.fetchServerUrl(); + serverUrl = serverUrl === null ? "" : serverUrl; + + return res.status(200).json({ serverUrl }); + } catch (err) { + const { statusCode, payload } = internalServerError( + "GET_SERVER_URL_ERROR", + err.message + ); + res.status(statusCode).json(payload); + } +}; + const getTeamDetails = async (req, res) => { try { const data = await teamService.getTeam(); @@ -101,6 +117,30 @@ const updateTeamDetails = async (req, res) => { } }; +const setServerUrl = async (req, res) => { + const validationErrors = validationResult(req); + + if (!validationErrors.isEmpty()) { + const errors = []; + validationErrors.array().forEach(err => { + errors.push(err.msg); + }); + return res.status(400).json({ errors }); + } + + try { + const { serverUrl } = req.body; + await teamService.addServerUrl(serverUrl); + return res.status(200).json({ message: "Server URL Set Successfully" }); + } catch (err) { + const { statusCode, payload } = internalServerError( + "SET_SERVER_URL_ERROR", + err.message + ) + res.status(statusCode).json(payload); + } +} + const removeMember = async (req, res) => { const userId = req.user.id; const { memberId } = req.params; @@ -130,4 +170,4 @@ const changeRole = async (req, res) => { } } -module.exports = { setOrganisation, getTeamDetails, updateTeamDetails, removeMember, changeRole, getTeamCount, teamService }; +module.exports = { setOrganisation, getTeamDetails, updateTeamDetails, removeMember, changeRole, getTeamCount, getServerUrl, setServerUrl, teamService }; diff --git a/backend/src/models/Team.js b/backend/src/models/Team.js index 593e6a86..36602d4c 100644 --- a/backend/src/models/Team.js +++ b/backend/src/models/Team.js @@ -11,6 +11,10 @@ module.exports = (sequelize, DataTypes) => { type: DataTypes.STRING(50), allowNull: false, }, + serverUrl: { + type: DataTypes.STRING(255), + allowNull: true, + }, createdAt: { type: DataTypes.DATE, allowNull: false, diff --git a/backend/src/routes/team.routes.js b/backend/src/routes/team.routes.js index baa77e49..0aadc927 100644 --- a/backend/src/routes/team.routes.js +++ b/backend/src/routes/team.routes.js @@ -3,6 +3,8 @@ const { setOrganisation, getTeamDetails, getTeamCount, + getServerUrl, + setServerUrl, updateTeamDetails, removeMember, changeRole @@ -14,17 +16,20 @@ const { const authenticateJWT = require("../middleware/auth.middleware"); const accessGuard = require("../middleware/accessGuard.middleware"); const settings = require("../../config/settings"); +const { validateSetServerUrl } = require('../utils/team.helper'); const router = express.Router(); const teamPermissions = settings.team.permissions; router.get("/details", authenticateJWT, getTeamDetails); router.get("/count", getTeamCount); +router.get('/server-url', authenticateJWT, accessGuard(teamPermissions.serverUrl), getServerUrl); router.post("/set-organisation", authenticateJWT, accessGuard(teamPermissions.setOrg), setOrganisation); router.post("/invite", authenticateJWT, accessGuard(teamPermissions.invite), sendTeamInvite); router.put("/update", authenticateJWT, accessGuard(teamPermissions.update), updateTeamDetails); router.put("/change-role", authenticateJWT, accessGuard(teamPermissions.changeRole), changeRole); +router.put('/server-url', authenticateJWT, accessGuard(teamPermissions.serverUrl), validateSetServerUrl, setServerUrl); router.delete("/remove/:memberId", authenticateJWT, accessGuard(teamPermissions.removeUser), removeMember); router.get('/get-all-invites', authenticateJWT, accessGuard(teamPermissions.removeUser), getAllInvites); diff --git a/backend/src/service/team.service.js b/backend/src/service/team.service.js index 0d2a329f..910c8ff0 100644 --- a/backend/src/service/team.service.js +++ b/backend/src/service/team.service.js @@ -1,3 +1,4 @@ +const { where } = require("sequelize"); const settings = require("../../config/settings"); const db = require("../models"); const Team = db.Team; @@ -42,6 +43,28 @@ class TeamService { } }; + async fetchServerUrl() { + try { + const { serverUrl } = await Team.findOne(); + return serverUrl; + } catch (err) { + throw new Error("Failed to fetch server url"); + } + } + + async addServerUrl(serverUrl) { + const transaction = await sequelize.transaction(); + try { + await Team.update({ + serverUrl + }, { where: {} }, { transaction }); + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw new Error("Failed to add server url") + } + } + async updateTeam(name) { const transaction = await sequelize.transaction(); try { diff --git a/backend/src/utils/constants.helper.js b/backend/src/utils/constants.helper.js index a9f315cb..b3d04bbc 100644 --- a/backend/src/utils/constants.helper.js +++ b/backend/src/utils/constants.helper.js @@ -11,5 +11,7 @@ module.exports = Object.freeze({ }, MAX_ORG_NAME_LENGTH: 100, ORG_NAME_REGEX: /^[a-zA-Z0-9\s\-_&.]+$/, + URL_PROTOCOL_REGEX: /^(https?:\/\/)/, + URL_DOMAIN_REGEX: /^https?:\/\/([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/, }); \ No newline at end of file diff --git a/backend/src/utils/team.helper.js b/backend/src/utils/team.helper.js new file mode 100644 index 00000000..359abf78 --- /dev/null +++ b/backend/src/utils/team.helper.js @@ -0,0 +1,50 @@ +const { URL_PROTOCOL_REGEX, URL_DOMAIN_REGEX } = require('./constants.helper'); +const { check } = require('express-validator'); + +require('dotenv').config(); + +const validateServerUrl = url => { + if (url === "") { + return { valid: true, errors: null } + } + + const errors = []; + + if (!URL_PROTOCOL_REGEX.test(url)) { + errors.push("Invalid or missing protocol (must be 'http://' or 'https://').") + } + + const domainMatch = url.match(URL_DOMAIN_REGEX); + if (!domainMatch) { + errors.push("Invalid domain name (must include a valid top-level domain like '.com')."); + } else { + const domain = domainMatch[1]; + if (!/^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(domain)) { + errors.push(`Malformed domain: '${domain}'.`); + } + } + + if (errors.length === 0) { + return { valid: true, errors: null } + } + + return { valid: false, errors } +}; + +const validateSetServerUrl = [ + check('serverUrl') + .optional({ + values: ["", null, undefined] + }) + .isString().withMessage('Server URL must be a string') + .trim() + .custom(value => { + const result = validateServerUrl(value); + if (result.valid) { + return true; + } + throw new Error(result.errors); + }) +]; + +module.exports = { validateSetServerUrl }; \ No newline at end of file diff --git a/frontend/src/scenes/settings/CodeTab/CodeTab.jsx b/frontend/src/scenes/settings/CodeTab/CodeTab.jsx index aad20339..56e5e854 100644 --- a/frontend/src/scenes/settings/CodeTab/CodeTab.jsx +++ b/frontend/src/scenes/settings/CodeTab/CodeTab.jsx @@ -1,57 +1,101 @@ -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import styles from "./CodeTab.module.css"; import CustomTextField from "@components/TextFieldComponents/CustomTextField/CustomTextField"; import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; import Button from "@components/Button/Button"; import ContentCopyOutlinedIcon from '@mui/icons-material/ContentCopyOutlined'; -import { generateApiKey } from "../../../utils/generalHelper"; +import { emitToastError } from "../../../utils/guideHelper"; +import { getServerUrl, addServerUrl } from '../../../services/teamServices'; +import toastEmitter, { TOAST_EMITTER_KEY } from "../../../utils/toastEmitter"; +import { URL_REGEX } from "../../../utils/constants"; const CodeTab = () => { - const [apiKey, setApiKey] = useState('') const [serverUrl, setServerUrl] = useState('') + const [isLoading, setIsLoading] = useState(false); + + const validateServerUrl = url => { + const errors = []; + + if (url === "") { + return { valid: true, errors: null }; + } + + if (!URL_REGEX.PROTOCOL.test(url)) { + errors.push("Invalid or missing protocol (must be 'http://' or 'https://').") + } + + const domainMatch = url.match(URL_REGEX.DOMAIN); + if (!domainMatch) { + errors.push("Invalid domain name (must include a valid top-level domain like '.com')."); + } else { + const domain = domainMatch[1]; + if (!/^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(domain)) { + errors.push(`Malformed domain: '${domain}'.`); + } + } + + if (errors.length === 0) { + return { valid: true, errors: null } + } + + return { valid: false, errors } + }; + + useEffect(() => { + const fetchServerUrl = async () => { + try { + const { serverUrl } = await getServerUrl(); + setServerUrl(serverUrl); + } catch (err) { + console.error('Error fetching server url: ', err); + } + } + fetchServerUrl(); + }, []) const handleUrlChange = (e) => { setServerUrl(e.target.value); }; - const handleApiKeyChange = (e) => { - setApiKey(e.target.value); - }; + const onSave = async () => { + const { valid, errors } = validateServerUrl(serverUrl); + + if (!valid) { + errors.forEach(err => { + toastEmitter.emit(TOAST_EMITTER_KEY, err); + }); + return; + } - const deleteApiKey = () => { - setApiKey(''); - } + try { + setIsLoading(true); + const response = await addServerUrl(serverUrl); + toastEmitter.emit(TOAST_EMITTER_KEY, response.message); + } catch (err) { + emitToastError(err); + } finally { + setIsLoading(false); + } + }; return (

API key management

Manage the key that Onboarding app uses to authenticate the agent code.

- {/* api key */} -
-

API key:

- - -
{/* server url */}

Server URL:

- -
-

Code in your webpage

+

Code in your webpage

Code snippet to copy in your web page between {""} and {""}. Make sure you edit the API URL. @@ -64,14 +108,13 @@ const CodeTab = () => { {` diff --git a/frontend/src/services/teamServices.js b/frontend/src/services/teamServices.js index e527ed68..f0ee7be3 100644 --- a/frontend/src/services/teamServices.js +++ b/frontend/src/services/teamServices.js @@ -22,4 +22,24 @@ export const getTeamCount = async () => { console.error('Error getting team count: ', err); throw err; } -} \ No newline at end of file +} + +export const addServerUrl = async url => { + try { + const response = await apiClient.put(`${baseEndpoint}/server-url`, { serverUrl: url }); + return response.data; + } catch (err) { + console.error('Error setting server url: ', err); + throw err; + } +} + +export const getServerUrl = async () => { + try { + const response = await apiClient.get(`${baseEndpoint}/server-url`); + return response.data; + } catch (err) { + console.error('Error getting server url: ', err); + throw err; + } +}; \ No newline at end of file diff --git a/frontend/src/utils/constants.js b/frontend/src/utils/constants.js index cff18108..395dfcd2 100644 --- a/frontend/src/utils/constants.js +++ b/frontend/src/utils/constants.js @@ -1,11 +1,15 @@ // API constants //local environment -export const API_BASE_URL = 'http://localhost:3000/api/'; +//export const API_BASE_URL = 'http://localhost:3000/api/'; //staging environment -// export const API_BASE_URL = 'https://onboarding-demo.bluewavelabs.ca/api/'; +export const API_BASE_URL = 'https://onboarding-demo.bluewavelabs.ca/api/'; // Other constants export const APP_TITLE = 'Bluewave Onboarding'; export const SUPPORT_EMAIL = 'support@bluewave.com'; export const roles = Object.freeze(["admin", "member"]); +export const URL_REGEX = Object.freeze({ + PROTOCOL: /^(https?:\/\/)/, + DOMAIN: /^https?:\/\/([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/, +}); \ No newline at end of file