diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 69452485b..c9ef181bb 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -756,6 +756,11 @@ "infoBox": { "description": "Configure where you want to receive notifications when monitors detect issues. Connect email, Slack, Discord, webhooks, and other channels to stay informed.", "title": "Notification Channels" + }, + "table": { + "headers": { + "destination": "Destination" + } } }, "profile": { @@ -894,4 +899,4 @@ } } } -} \ No newline at end of file +} diff --git a/client/src/pages/notification-channels/NotificationChannels.tsx b/client/src/pages/notification-channels/NotificationChannels.tsx index 1ac887fcb..bf6dcdc35 100644 --- a/client/src/pages/notification-channels/NotificationChannels.tsx +++ b/client/src/pages/notification-channels/NotificationChannels.tsx @@ -10,7 +10,7 @@ import { useTheme } from "@mui/material/styles"; import { useNavigate } from "react-router"; import type { ActionMenuItem } from "@/components/actions-menu"; import type { Header } from "@/components/design-elements/Table"; -import type { INotificationChannel } from "@/types/notification-channel"; +import type { NotificationChannel } from "@/types/notification-channel"; import { useGet, usePatch, useDelete } from "@/hooks/UseApi"; import type { ApiResponse } from "@/types/api"; import { useTranslation } from "react-i18next"; @@ -21,11 +21,11 @@ const NotificationChannelsPage = () => { const navigate = useNavigate(); const theme = useTheme(); const [selectedChannel, setSelectedChannel] = - useState(null); + useState(null); const open = Boolean(selectedChannel); const { response, isValidating, error, refetch } = useGet< - ApiResponse + ApiResponse >( "/notification-channels", {}, @@ -37,15 +37,15 @@ const NotificationChannelsPage = () => { { useTeamIdAsKey: true } ); - const { patch, loading: pausing } = usePatch<{}, INotificationChannel>(); + const { patch, loading: pausing } = usePatch<{}, NotificationChannel>(); const { deleteFn, loading: isDeleting } = - useDelete>(); + useDelete>(); const notificationChannels = response?.data || []; const handleConfirm = async () => { if (!selectedChannel) return; - const res = await deleteFn(`/notification-channels/${selectedChannel._id}`); + const res = await deleteFn(`/notification-channels/${selectedChannel.id}`); if (res) { setSelectedChannel(null); refetch(); @@ -56,13 +56,13 @@ const NotificationChannelsPage = () => { setSelectedChannel(null); }; - const getActions = (channel: INotificationChannel): ActionMenuItem[] => { + const getActions = (channel: NotificationChannel): ActionMenuItem[] => { return [ { id: 1, label: t("monitors.common.actions.configure"), action: () => { - navigate(`/notification-channels/${channel._id}/configure`); + navigate(`/notification-channels/${channel.id}/configure`); }, closeMenu: true, }, @@ -73,7 +73,7 @@ const NotificationChannelsPage = () => { : t("common.buttons.enable"), action: async () => { const res = await patch( - `/notification-channels/${channel._id}/active`, + `/notification-channels/${channel.id}/active`, {} ); if (res) { @@ -98,7 +98,7 @@ const NotificationChannelsPage = () => { }; const getHeaders = () => { - const headers: Header[] = [ + const headers: Header[] = [ { id: "name", content: t("common.table.headers.name"), @@ -125,6 +125,17 @@ const NotificationChannelsPage = () => { ); }, }, + { + id: "destination", + content: t("notificationChannels.table.headers.destination"), + render: (row) => { + return ( + + {row?.config?.url || row?.config?.emailAddress} + + ); + }, + }, { id: "actions", content: t("common.table.headers.actions"), @@ -167,7 +178,7 @@ const NotificationChannelsPage = () => { headers={headers} data={notificationChannels} onRowClick={(row) => { - navigate(`/notification-channels/${row._id}/configure`); + navigate(`/notification-channels/${row.id}/configure`); }} /> diff --git a/client/src/pages/notification-channels/NotificationChannelsConfig.tsx b/client/src/pages/notification-channels/NotificationChannelsConfig.tsx index 33eac8818..f28730567 100644 --- a/client/src/pages/notification-channels/NotificationChannelsConfig.tsx +++ b/client/src/pages/notification-channels/NotificationChannelsConfig.tsx @@ -4,18 +4,18 @@ import { useParams } from "react-router"; import { useNavigate } from "react-router"; import { usePatch, useGet } from "@/hooks/UseApi"; import type { ApiResponse } from "@/types/api"; -import type { INotificationChannel } from "@/types/notification-channel"; +import type { NotificationChannel } from "@/types/notification-channel"; import type { FormValues } from "./NotificationChannelsForm"; const NotificationsChannelConfigPage = () => { const navigate = useNavigate(); const { id } = useParams<{ id: string }>(); - const { response, loading } = useGet>( + const { response, loading } = useGet>( `/notification-channels/${id}` ); const { patch, loading: updating } = usePatch< FormValues, - INotificationChannel + NotificationChannel >(); const notification = response?.data; diff --git a/client/src/pages/notification-channels/NotificationChannelsCreate.tsx b/client/src/pages/notification-channels/NotificationChannelsCreate.tsx index 7137aa170..d7d379c6a 100644 --- a/client/src/pages/notification-channels/NotificationChannelsCreate.tsx +++ b/client/src/pages/notification-channels/NotificationChannelsCreate.tsx @@ -2,11 +2,11 @@ import { NotificationChannelsForm } from "./NotificationChannelsForm"; import { useNavigate } from "react-router"; import { usePost } from "@/hooks/UseApi"; -import type { INotificationChannel } from "@/types/notification-channel"; +import type { NotificationChannel } from "@/types/notification-channel"; import type { FormValues } from "./NotificationChannelsForm"; const NotificationsChannelCreatePage = () => { - const { post, loading } = usePost(); + const { post, loading } = usePost(); const navigate = useNavigate(); const onSubmit = async (data: FormValues) => { diff --git a/client/src/types/notification-channel.ts b/client/src/types/notification-channel.ts index e31a3ed9b..e14bd207d 100644 --- a/client/src/types/notification-channel.ts +++ b/client/src/types/notification-channel.ts @@ -1,19 +1,19 @@ export const ChannelTypes = ["email", "slack", "discord", "webhook"] as const; export type ChannelType = (typeof ChannelTypes)[number]; -export interface INotificationChannelConfig { +export interface NotificationChannelConfig { url?: string; // For webhook, slack, discord emailAddress?: string; // For email } -export interface INotificationChannel { - _id: string; +export interface NotificationChannel { + id: string; orgId: string; teamId: string; name: string; type: ChannelType; - config: INotificationChannelConfig; - isActive: boolean; + config: NotificationChannelConfig; + isActive: boolean | unknown; createdBy: string; updatedBy: string; createdAt: Date; diff --git a/server/src/controllers/RecoveryController.ts b/server/src/controllers/RecoveryController.ts index 28254ccf8..f1716d6f3 100644 --- a/server/src/controllers/RecoveryController.ts +++ b/server/src/controllers/RecoveryController.ts @@ -46,7 +46,9 @@ class RecoveryController implements IRecoveryController { if (user) { try { - const recoveryToken = await this.recoveryService.create(user._id); + const recoveryToken = await this.recoveryService.create( + user._id.toString() + ); // Don't wait for email to be sent this.emailService .sendGeneric(user.email, "Password Recovery", { @@ -77,7 +79,7 @@ class RecoveryController implements IRecoveryController { recoveryToken.userId.toString(), password ); - await this.recoveryService.delete(recoveryToken._id); + await this.recoveryService.delete(recoveryToken.id); const user = await User.findById(recoveryToken.userId); if (!user) { diff --git a/server/src/init/services.ts b/server/src/init/services.ts index d9b5cbbd1..4de9dbbd3 100644 --- a/server/src/init/services.ts +++ b/server/src/init/services.ts @@ -47,6 +47,7 @@ import { MongoOrgMembershipRepository, MongoMaintenanceRepository, MongoNotificationChannelRepository, + MongoRecoveryTokenRepository, } from "@/repositories/index.js"; export const initServices = async () => { @@ -62,7 +63,9 @@ export const initServices = async () => { const orgRepository = new MongoOrgRepository(); const orgMembershipRepository = new MongoOrgMembershipRepository(); const maintenanceRepository = new MongoMaintenanceRepository(); - const notificationChannelRepository = new MongoNotificationChannelRepository(); + const notificationChannelRepository = + new MongoNotificationChannelRepository(); + const recoveryTokenRepository = new MongoRecoveryTokenRepository(); const checkService = new CheckService(checksRepository, monitorRepository); const inviteService = new InviteService( @@ -147,11 +150,11 @@ export const initServices = async () => { ); const queueService = new QueueService(jobQueue); const teamService = new TeamService(jobQueue, monitorRepository); - const roleService = new RoleService(); + const roleService = new RoleService(roleRepository); const teamMemberService = new TeamMemberService(); const statusPageService = new StatusPageService(); const diagnosticService = new DiagnosticService(jobQueue); - const recoveryService = new RecoveryService(); + const recoveryService = new RecoveryService(recoveryTokenRepository); const services = { checkService, inviteService, diff --git a/server/src/repositories/recovery-token/IRecoveryTokenRepistory.ts b/server/src/repositories/recovery-token/IRecoveryTokenRepistory.ts index de988e4d6..b9c84cf83 100644 --- a/server/src/repositories/recovery-token/IRecoveryTokenRepistory.ts +++ b/server/src/repositories/recovery-token/IRecoveryTokenRepistory.ts @@ -1 +1,12 @@ -export interface IRecoveryTokenRepistory {} +import type { RecoveryToken } from "@/types/domain/index.js"; + +export interface IRecoveryTokenRepistory { + // create + create(userId: string, tokenHash: string): Promise; + // single fetch + findByTokenHash(tokenHash: string): Promise; + // collection fetch + // update + // delete + deleteByid(tokenId: string): Promise; +} diff --git a/server/src/repositories/recovery-token/MongoRecoveryTokenRepository.ts b/server/src/repositories/recovery-token/MongoRecoveryTokenRepository.ts index bede6bff4..ff40ef601 100644 --- a/server/src/repositories/recovery-token/MongoRecoveryTokenRepository.ts +++ b/server/src/repositories/recovery-token/MongoRecoveryTokenRepository.ts @@ -1,5 +1,38 @@ +import { type IRecoveryToken, RecoveryToken } from "@/db/models/index.js"; +import type { RecoveryToken as RecoveryTokenEntity } from "@/types/domain/index.js"; + import { IRecoveryTokenRepistory } from "@/repositories/index.js"; -class MongoRecoveryTokenRepository implements IRecoveryTokenRepistory {} +class MongoRecoveryTokenRepository implements IRecoveryTokenRepistory { + private toEntity = (doc: IRecoveryToken): RecoveryTokenEntity => { + return { + id: doc._id.toString(), + userId: doc.userId.toString(), + tokenHash: doc.tokenHash, + expiry: doc.expiry, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + }; + }; + create = async (userId: string, tokenHash: string) => { + const recoveryToken = await RecoveryToken.create({ + userId, + tokenHash, + }); + if (!recoveryToken) return false; + return true; + }; + + findByTokenHash = async (tokenHash: string) => { + const recoveryToken = await RecoveryToken.findOne({ tokenHash }); + if (!recoveryToken) return null; + return this.toEntity(recoveryToken); + }; + + deleteByid = async (tokenId: string) => { + const result = await RecoveryToken.deleteOne({ _id: tokenId }); + return result.deletedCount === 1; + }; +} export default MongoRecoveryTokenRepository; diff --git a/server/src/services/business/RecoveryService.ts b/server/src/services/business/RecoveryService.ts index bdfe78536..4ad136d12 100644 --- a/server/src/services/business/RecoveryService.ts +++ b/server/src/services/business/RecoveryService.ts @@ -1,36 +1,45 @@ import crypto from "node:crypto"; -import { RecoveryToken, IRecoveryToken } from "@/db/models/index.js"; import ApiError from "@/utils/ApiError.js"; -import { Types } from "mongoose"; +import { IRecoveryTokenRepistory } from "@/repositories/index.js"; +import type { RecoveryToken } from "@/types/domain/index.js"; const SERVICE_NAME = "RecoveryService"; export interface IRecoveryService { - create: (userId: Types.ObjectId) => Promise; - get: (token: string) => Promise; + create: (userId: string) => Promise; + get: (token: string) => Promise; delete: (id: string) => Promise; } class RecoveryService implements IRecoveryService { public SERVICE_NAME: string; - constructor() { + + private recoveryTokenRepository: IRecoveryTokenRepistory; + constructor(recoveryTokenRepository: IRecoveryTokenRepistory) { this.SERVICE_NAME = SERVICE_NAME; + this.recoveryTokenRepository = recoveryTokenRepository; } - create = async (userId: Types.ObjectId) => { + create = async (userId: string) => { const token = crypto.randomBytes(32).toString("hex"); const tokenHash = crypto.createHash("sha256").update(token).digest("hex"); - await RecoveryToken.create({ + const didCreate = await this.recoveryTokenRepository.create( userId, - tokenHash, - }); + tokenHash + ); + if (!didCreate) { + throw new ApiError("Failed to create recovery token", 500); + } + return token; }; get = async (token: string) => { const tokenHash = crypto.createHash("sha256").update(token).digest("hex"); - const recoveryToken = await RecoveryToken.findOne({ tokenHash }); + const recoveryToken = await this.recoveryTokenRepository.findByTokenHash( + tokenHash + ); if (!recoveryToken) { throw new ApiError("Recovery token not found", 404); } @@ -43,11 +52,11 @@ class RecoveryService implements IRecoveryService { }; delete = async (id: string) => { - const result = await RecoveryToken.deleteOne({ _id: id }); - if (!result.deletedCount) { + const didDelete = await this.recoveryTokenRepository.deleteByid(id); + if (!didDelete) { throw new ApiError("Recovery token not found", 404); } - return result.deletedCount === 1; + return didDelete; }; } diff --git a/server/src/services/business/RoleService.ts b/server/src/services/business/RoleService.ts index 48097da5d..393e52f89 100644 --- a/server/src/services/business/RoleService.ts +++ b/server/src/services/business/RoleService.ts @@ -1,5 +1,5 @@ import { Role, IRole } from "@/db/models/index.js"; -import type { IJobQueue } from "../infrastructure/JobQueue.js"; +import type { IRoleRepository } from "@/repositories/index.js"; import ApiError from "@/utils/ApiError.js"; const SERVICE_NAME = "RoleService"; @@ -11,9 +11,11 @@ export interface IRoleService { class RoleService implements IRoleService { public SERVICE_NAME: string; + private roleRepository: IRoleRepository; - constructor() { + constructor(roleRepository: IRoleRepository) { this.SERVICE_NAME = SERVICE_NAME; + this.roleRepository = roleRepository; } getAll = async (orgId: string, type: string) => {