mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 08:42:13 +02:00
Compare commits
152 Commits
telemetry-
...
ms-teams
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19941029e2 | ||
|
|
54545826d4 | ||
|
|
ba4093838b | ||
|
|
4bd7902afe | ||
|
|
5c300ed513 | ||
|
|
c4a50e853c | ||
|
|
20c1f13876 | ||
|
|
09426ed6be | ||
|
|
675a031ee6 | ||
|
|
92986ac1f8 | ||
|
|
2b95d608dc | ||
|
|
2696071933 | ||
|
|
684a61b599 | ||
|
|
633a89161e | ||
|
|
fd24781783 | ||
|
|
903b13d515 | ||
|
|
58a128a05e | ||
|
|
ec1d567813 | ||
|
|
0a53161eac | ||
|
|
83b91af708 | ||
|
|
3fefee8725 | ||
|
|
9b2cc7d377 | ||
|
|
1fb71ed2e3 | ||
|
|
94a5abdb31 | ||
|
|
73f4559943 | ||
|
|
9c3c6ee4e9 | ||
|
|
56743214a0 | ||
|
|
9136c6d40e | ||
|
|
920a9baee9 | ||
|
|
1c4aad2d81 | ||
|
|
8a4644922a | ||
|
|
c3f4b7d3d4 | ||
|
|
6f7c0814ee | ||
|
|
77cd3fc4c0 | ||
|
|
e7cbc3d739 | ||
|
|
6d14ea19b9 | ||
|
|
1290d3b946 | ||
|
|
c0c58546d0 | ||
|
|
6c5ef10606 | ||
|
|
ec4c6ff7c5 | ||
|
|
616e6e43ab | ||
|
|
aa08cd904b | ||
|
|
fef1c1055c | ||
|
|
22e33809f9 | ||
|
|
eb8324a3c2 | ||
|
|
fa6dedc9a1 | ||
|
|
099cd807bf | ||
|
|
5d0b010fc4 | ||
|
|
1fc421f92a | ||
|
|
14a14e2341 | ||
|
|
ab23cca264 | ||
|
|
678a961fb9 | ||
|
|
c2e458f035 | ||
|
|
4daf17dc8c | ||
|
|
842aa4b88d | ||
|
|
fd51142693 | ||
|
|
e0ddf80aa6 | ||
|
|
e8d55164c6 | ||
|
|
5cd8795e7a | ||
|
|
aebf7a4f2e | ||
|
|
3ef093eee1 | ||
|
|
b4c530a6a5 | ||
|
|
166228cad5 | ||
|
|
1eb95c71fe | ||
|
|
56f33f256b | ||
|
|
42afd164b7 | ||
|
|
0796166a55 | ||
|
|
170bfa8515 | ||
|
|
2f517d8dcc | ||
|
|
cb5c4dce45 | ||
|
|
d9abeda60d | ||
|
|
15c4c89310 | ||
|
|
8c1d5652f4 | ||
|
|
fbf87cf8d4 | ||
|
|
1c12ad94dd | ||
|
|
aa09bab7c9 | ||
|
|
f7d1975ab0 | ||
|
|
99c9a591cb | ||
|
|
c956d01789 | ||
|
|
17c829869b | ||
|
|
d65e91a912 | ||
|
|
39710ba9b0 | ||
|
|
8c70a4dfae | ||
|
|
ff99055594 | ||
|
|
f01cc2fd71 | ||
|
|
49b43593b1 | ||
|
|
e293ffd0eb | ||
|
|
b62a5e7722 | ||
|
|
8f8ba0abb8 | ||
|
|
5525556b54 | ||
|
|
669066b70a | ||
|
|
76d2abed08 | ||
|
|
a6c18b3f21 | ||
|
|
955ea7bc31 | ||
|
|
45719d4656 | ||
|
|
796c94a261 | ||
|
|
d2fe822cb7 | ||
|
|
289a369eab | ||
|
|
6f07e3e119 | ||
|
|
8cdc1e9faf | ||
|
|
d4609a84ef | ||
|
|
eb4a91a598 | ||
|
|
5bea404d6c | ||
|
|
df3f8b6a74 | ||
|
|
0c9d2c821a | ||
|
|
ba49aaf0c3 | ||
|
|
6ea5ad7fe8 | ||
|
|
962866d109 | ||
|
|
115216561c | ||
|
|
f709c90cc4 | ||
|
|
d7f01b0189 | ||
|
|
c3eaa8995c | ||
|
|
53b482b9f3 | ||
|
|
d52670f39c | ||
|
|
fdc1332b9e | ||
|
|
a937416663 | ||
|
|
546d41da81 | ||
|
|
c4c6793b29 | ||
|
|
c894b112e6 | ||
|
|
304baf1bb4 | ||
|
|
9adea6b1ba | ||
|
|
5498521e02 | ||
|
|
9e97c6ddbc | ||
|
|
63272e09f8 | ||
|
|
327c28afdc | ||
|
|
896020b93b | ||
|
|
15a68472b0 | ||
|
|
0210480d97 | ||
|
|
72fdc06687 | ||
|
|
3710b81b9a | ||
|
|
9fcb3dc2e0 | ||
|
|
43e2ccf51a | ||
|
|
48c3d8603a | ||
|
|
9cfc912161 | ||
|
|
29e3ee57ab | ||
|
|
be7e849822 | ||
|
|
59d76b601a | ||
|
|
b77ef336b8 | ||
|
|
7df21fe8e5 | ||
|
|
f39e1943c7 | ||
|
|
966a903646 | ||
|
|
1d9d37c6d1 | ||
|
|
7edcc4dbce | ||
|
|
0939294d22 | ||
|
|
dbcbfe5f79 | ||
|
|
a638972817 | ||
|
|
37c6310465 | ||
|
|
a7d38389fd | ||
|
|
2f55336db7 | ||
|
|
f99a15b95b | ||
|
|
de5bff2ffe | ||
|
|
cef2764499 |
3
.github/workflows/release.yml
vendored
3
.github/workflows/release.yml
vendored
@@ -70,7 +70,7 @@ jobs:
|
||||
|
||||
publish-mcp-server:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [generate-build-number]
|
||||
needs: [generate-build-number, publish-npm-packages]
|
||||
env:
|
||||
CI_PIPELINE_ID: ${{ github.run_number }}
|
||||
NPM_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }}
|
||||
@@ -138,6 +138,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd MCP
|
||||
npm update @oneuptime/common
|
||||
npm install
|
||||
|
||||
- name: Build MCP server
|
||||
|
||||
1
.github/workflows/test-release.yaml
vendored
1
.github/workflows/test-release.yaml
vendored
@@ -144,6 +144,7 @@ jobs:
|
||||
- name: Install dependencies and build
|
||||
run: |
|
||||
cd MCP
|
||||
npm update @oneuptime/common
|
||||
npm install
|
||||
npm run build
|
||||
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -127,3 +127,4 @@ MCP/build/
|
||||
MCP/.env
|
||||
MCP/node_modules
|
||||
Dashboard/public/sw.js
|
||||
.claude/settings.local.json
|
||||
|
||||
@@ -583,6 +583,18 @@ import StatusPageAnnouncementTemplateService, {
|
||||
Service as StatusPageAnnouncementTemplateServiceType,
|
||||
} from "Common/Server/Services/StatusPageAnnouncementTemplateService";
|
||||
|
||||
// ProjectSCIM
|
||||
import ProjectSCIM from "Common/Models/DatabaseModels/ProjectSCIM";
|
||||
import ProjectSCIMService, {
|
||||
Service as ProjectSCIMServiceType,
|
||||
} from "Common/Server/Services/ProjectSCIMService";
|
||||
|
||||
// StatusPageSCIM
|
||||
import StatusPageSCIM from "Common/Models/DatabaseModels/StatusPageSCIM";
|
||||
import StatusPageSCIMService, {
|
||||
Service as StatusPageSCIMServiceType,
|
||||
} from "Common/Server/Services/StatusPageSCIMService";
|
||||
|
||||
// Open API Spec
|
||||
import OpenAPI from "Common/Server/API/OpenAPI";
|
||||
|
||||
@@ -618,6 +630,24 @@ const BaseAPIFeatureSet: FeatureSet = {
|
||||
).getRouter(),
|
||||
);
|
||||
|
||||
// Project SCIM
|
||||
app.use(
|
||||
`/${APP_NAME.toLocaleLowerCase()}`,
|
||||
new BaseAPI<ProjectSCIM, ProjectSCIMServiceType>(
|
||||
ProjectSCIM,
|
||||
ProjectSCIMService,
|
||||
).getRouter(),
|
||||
);
|
||||
|
||||
// Status Page SCIM
|
||||
app.use(
|
||||
`/${APP_NAME.toLocaleLowerCase()}`,
|
||||
new BaseAPI<StatusPageSCIM, StatusPageSCIMServiceType>(
|
||||
StatusPageSCIM,
|
||||
StatusPageSCIMService,
|
||||
).getRouter(),
|
||||
);
|
||||
|
||||
// status page announcement templates
|
||||
app.use(
|
||||
`/${APP_NAME.toLocaleLowerCase()}`,
|
||||
@@ -1577,6 +1607,10 @@ const BaseAPIFeatureSet: FeatureSet = {
|
||||
`/${APP_NAME.toLocaleLowerCase()}`,
|
||||
new ResellerPlanAPI().getRouter(),
|
||||
);
|
||||
app.use(
|
||||
`/${APP_NAME.toLocaleLowerCase()}`,
|
||||
new (await import("../../../Common/Server/API/MicrosoftTeamsAPI")).default().getRouter(),
|
||||
);
|
||||
app.use(`/${APP_NAME.toLocaleLowerCase()}`, new SlackAPI().getRouter());
|
||||
app.use(
|
||||
`/${APP_NAME.toLocaleLowerCase()}`,
|
||||
|
||||
722
App/FeatureSet/Identity/API/SCIM.ts
Normal file
722
App/FeatureSet/Identity/API/SCIM.ts
Normal file
@@ -0,0 +1,722 @@
|
||||
import SCIMMiddleware from "Common/Server/Middleware/SCIMAuthorization";
|
||||
import UserService from "Common/Server/Services/UserService";
|
||||
import TeamMemberService from "Common/Server/Services/TeamMemberService";
|
||||
import Express, {
|
||||
ExpressRequest,
|
||||
ExpressResponse,
|
||||
ExpressRouter,
|
||||
OneUptimeRequest,
|
||||
} from "Common/Server/Utils/Express";
|
||||
import Response from "Common/Server/Utils/Response";
|
||||
import logger from "Common/Server/Utils/Logger";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import Email from "Common/Types/Email";
|
||||
import Name from "Common/Types/Name";
|
||||
import { JSONObject } from "Common/Types/JSON";
|
||||
import TeamMember from "Common/Models/DatabaseModels/TeamMember";
|
||||
import ProjectSCIM from "Common/Models/DatabaseModels/ProjectSCIM";
|
||||
import BadRequestException from "Common/Types/Exception/BadRequestException";
|
||||
import NotFoundException from "Common/Types/Exception/NotFoundException";
|
||||
import OneUptimeDate from "Common/Types/Date";
|
||||
import LIMIT_MAX, { LIMIT_PER_PROJECT } from "Common/Types/Database/LimitMax";
|
||||
import Query from "Common/Types/BaseDatabase/Query";
|
||||
import ProjectUser from "Common/Models/DatabaseModels/ProjectUser";
|
||||
import QueryHelper from "Common/Server/Types/Database/QueryHelper";
|
||||
import User from "Common/Models/DatabaseModels/User";
|
||||
import {
|
||||
parseNameFromSCIM,
|
||||
formatUserForSCIM,
|
||||
generateServiceProviderConfig,
|
||||
generateUsersListResponse,
|
||||
parseSCIMQueryParams,
|
||||
logSCIMOperation,
|
||||
} from "../Utils/SCIMUtils";
|
||||
import { DocsClientUrl } from "Common/Server/EnvironmentConfig";
|
||||
|
||||
const router: ExpressRouter = Express.getRouter();
|
||||
|
||||
const handleUserTeamOperations: (
|
||||
operation: "add" | "remove",
|
||||
projectId: ObjectID,
|
||||
userId: ObjectID,
|
||||
scimConfig: ProjectSCIM,
|
||||
) => Promise<void> = async (
|
||||
operation: "add" | "remove",
|
||||
projectId: ObjectID,
|
||||
userId: ObjectID,
|
||||
scimConfig: ProjectSCIM,
|
||||
): Promise<void> => {
|
||||
const teamsIds: Array<ObjectID> =
|
||||
scimConfig.teams?.map((team: any) => {
|
||||
return team.id;
|
||||
}) || [];
|
||||
|
||||
if (teamsIds.length === 0) {
|
||||
logger.debug(`SCIM Team operations - no teams configured for SCIM`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (operation === "add") {
|
||||
logger.debug(
|
||||
`SCIM Team operations - adding user to ${teamsIds.length} configured teams`,
|
||||
);
|
||||
|
||||
for (const team of scimConfig.teams || []) {
|
||||
const existingMember: TeamMember | null =
|
||||
await TeamMemberService.findOneBy({
|
||||
query: {
|
||||
projectId: projectId,
|
||||
userId: userId,
|
||||
teamId: team.id!,
|
||||
},
|
||||
select: { _id: true },
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
if (!existingMember) {
|
||||
logger.debug(`SCIM Team operations - adding user to team: ${team.id}`);
|
||||
const teamMember: TeamMember = new TeamMember();
|
||||
teamMember.projectId = projectId;
|
||||
teamMember.userId = userId;
|
||||
teamMember.teamId = team.id!;
|
||||
teamMember.hasAcceptedInvitation = true;
|
||||
teamMember.invitationAcceptedAt = OneUptimeDate.getCurrentDate();
|
||||
|
||||
await TeamMemberService.create({
|
||||
data: teamMember,
|
||||
props: {
|
||||
isRoot: true,
|
||||
ignoreHooks: true,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
logger.debug(
|
||||
`SCIM Team operations - user already member of team: ${team.id}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (operation === "remove") {
|
||||
logger.debug(
|
||||
`SCIM Team operations - removing user from ${teamsIds.length} configured teams`,
|
||||
);
|
||||
|
||||
await TeamMemberService.deleteBy({
|
||||
query: {
|
||||
projectId: projectId,
|
||||
userId: userId,
|
||||
teamId: QueryHelper.any(teamsIds),
|
||||
},
|
||||
skip: 0,
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
props: { isRoot: true },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// SCIM Service Provider Configuration - GET /scim/v2/ServiceProviderConfig
|
||||
router.get(
|
||||
"/scim/v2/:projectScimId/ServiceProviderConfig",
|
||||
SCIMMiddleware.isAuthorizedSCIMRequest,
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
logSCIMOperation(
|
||||
"ServiceProviderConfig",
|
||||
"project",
|
||||
req.params["projectScimId"]!,
|
||||
);
|
||||
|
||||
const serviceProviderConfig: JSONObject = generateServiceProviderConfig(
|
||||
req,
|
||||
req.params["projectScimId"]!,
|
||||
"project",
|
||||
DocsClientUrl.toString() + "/identity/scim",
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
"Project SCIM ServiceProviderConfig response prepared successfully",
|
||||
);
|
||||
return Response.sendJsonObjectResponse(req, res, serviceProviderConfig);
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
return Response.sendErrorResponse(req, res, err as BadRequestException);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Basic Users endpoint - GET /scim/v2/Users
|
||||
router.get(
|
||||
"/scim/v2/:projectScimId/Users",
|
||||
SCIMMiddleware.isAuthorizedSCIMRequest,
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
logSCIMOperation("Users list", "project", req.params["projectScimId"]!);
|
||||
|
||||
const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest;
|
||||
const bearerData: JSONObject =
|
||||
oneuptimeRequest.bearerTokenData as JSONObject;
|
||||
const projectId: ObjectID = bearerData["projectId"] as ObjectID;
|
||||
|
||||
// Parse query parameters
|
||||
const { startIndex, count } = parseSCIMQueryParams(req);
|
||||
const filter: string = req.query["filter"] as string;
|
||||
|
||||
logSCIMOperation(
|
||||
"Users list",
|
||||
"project",
|
||||
req.params["projectScimId"]!,
|
||||
`startIndex: ${startIndex}, count: ${count}, filter: ${filter || "none"}`,
|
||||
);
|
||||
|
||||
// Build query for team members in this project
|
||||
const query: Query<ProjectUser> = {
|
||||
projectId: projectId,
|
||||
};
|
||||
|
||||
// Handle SCIM filter for userName
|
||||
if (filter) {
|
||||
const emailMatch: RegExpMatchArray | null = filter.match(
|
||||
/userName eq "([^"]+)"/i,
|
||||
);
|
||||
if (emailMatch) {
|
||||
const email: string = emailMatch[1]!;
|
||||
logSCIMOperation(
|
||||
"Users list",
|
||||
"project",
|
||||
req.params["projectScimId"]!,
|
||||
`filter by email: ${email}`,
|
||||
);
|
||||
|
||||
if (email) {
|
||||
const user: User | null = await UserService.findOneBy({
|
||||
query: { email: new Email(email) },
|
||||
select: { _id: true },
|
||||
props: { isRoot: true },
|
||||
});
|
||||
if (user && user.id) {
|
||||
query.userId = user.id;
|
||||
logSCIMOperation(
|
||||
"Users list",
|
||||
"project",
|
||||
req.params["projectScimId"]!,
|
||||
`found user with id: ${user.id}`,
|
||||
);
|
||||
} else {
|
||||
logSCIMOperation(
|
||||
"Users list",
|
||||
"project",
|
||||
req.params["projectScimId"]!,
|
||||
`user not found for email: ${email}`,
|
||||
);
|
||||
return Response.sendJsonObjectResponse(
|
||||
req,
|
||||
res,
|
||||
generateUsersListResponse([], startIndex, 0),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logSCIMOperation(
|
||||
"Users list",
|
||||
"project",
|
||||
req.params["projectScimId"]!,
|
||||
`query built for projectId: ${projectId}`,
|
||||
);
|
||||
|
||||
// Get team members
|
||||
const teamMembers: Array<TeamMember> = await TeamMemberService.findBy({
|
||||
query: query,
|
||||
limit: LIMIT_MAX,
|
||||
skip: 0,
|
||||
props: { isRoot: true },
|
||||
select: {
|
||||
userId: true,
|
||||
user: {
|
||||
_id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// now get unique users.
|
||||
const usersInProjects: Array<JSONObject> = teamMembers
|
||||
.filter((tm: TeamMember) => {
|
||||
return tm.user && tm.user.id;
|
||||
})
|
||||
.map((tm: TeamMember) => {
|
||||
return formatUserForSCIM(
|
||||
tm.user!,
|
||||
req,
|
||||
req.params["projectScimId"]!,
|
||||
"project",
|
||||
);
|
||||
});
|
||||
|
||||
// remove duplicates
|
||||
const uniqueUserIds: Set<string> = new Set<string>();
|
||||
const users: Array<JSONObject> = usersInProjects.filter(
|
||||
(user: JSONObject) => {
|
||||
if (uniqueUserIds.has(user["id"]?.toString() || "")) {
|
||||
return false;
|
||||
}
|
||||
uniqueUserIds.add(user["id"]?.toString() || "");
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
// now paginate the results
|
||||
const paginatedUsers: Array<JSONObject> = users.slice(
|
||||
(startIndex - 1) * count,
|
||||
startIndex * count,
|
||||
);
|
||||
|
||||
logger.debug(`SCIM Users response prepared with ${users.length} users`);
|
||||
|
||||
return Response.sendJsonObjectResponse(
|
||||
req,
|
||||
res,
|
||||
generateUsersListResponse(paginatedUsers, startIndex, users.length),
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
return Response.sendErrorResponse(req, res, err as BadRequestException);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Get Individual User - GET /scim/v2/Users/{id}
|
||||
router.get(
|
||||
"/scim/v2/:projectScimId/Users/:userId",
|
||||
SCIMMiddleware.isAuthorizedSCIMRequest,
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
logger.debug(
|
||||
`SCIM Get individual user request for userId: ${req.params["userId"]}, projectScimId: ${req.params["projectScimId"]}`,
|
||||
);
|
||||
const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest;
|
||||
const bearerData: JSONObject =
|
||||
oneuptimeRequest.bearerTokenData as JSONObject;
|
||||
const projectId: ObjectID = bearerData["projectId"] as ObjectID;
|
||||
const userId: string = req.params["userId"]!;
|
||||
|
||||
logger.debug(
|
||||
`SCIM Get user - projectId: ${projectId}, userId: ${userId}`,
|
||||
);
|
||||
|
||||
if (!userId) {
|
||||
throw new BadRequestException("User ID is required");
|
||||
}
|
||||
|
||||
// Check if user exists and is part of the project
|
||||
const projectUser: TeamMember | null = await TeamMemberService.findOneBy({
|
||||
query: {
|
||||
projectId: projectId,
|
||||
userId: new ObjectID(userId),
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
user: {
|
||||
_id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
},
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
if (!projectUser || !projectUser.user) {
|
||||
logger.debug(
|
||||
`SCIM Get user - user not found or not part of project for userId: ${userId}`,
|
||||
);
|
||||
throw new NotFoundException(
|
||||
"User not found or not part of this project",
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug(`SCIM Get user - found user: ${projectUser.user.id}`);
|
||||
|
||||
const user: JSONObject = formatUserForSCIM(
|
||||
projectUser.user,
|
||||
req,
|
||||
req.params["projectScimId"]!,
|
||||
"project",
|
||||
);
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, user);
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
return Response.sendErrorResponse(req, res, err as BadRequestException);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Update User - PUT /scim/v2/Users/{id}
|
||||
router.put(
|
||||
"/scim/v2/:projectScimId/Users/:userId",
|
||||
SCIMMiddleware.isAuthorizedSCIMRequest,
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
logger.debug(
|
||||
`SCIM Update user request for userId: ${req.params["userId"]}, projectScimId: ${req.params["projectScimId"]}`,
|
||||
);
|
||||
const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest;
|
||||
const bearerData: JSONObject =
|
||||
oneuptimeRequest.bearerTokenData as JSONObject;
|
||||
const projectId: ObjectID = bearerData["projectId"] as ObjectID;
|
||||
const userId: string = req.params["userId"]!;
|
||||
const scimUser: JSONObject = req.body;
|
||||
|
||||
logger.debug(
|
||||
`SCIM Update user - projectId: ${projectId}, userId: ${userId}`,
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`Request body for SCIM Update user: ${JSON.stringify(scimUser, null, 2)}`,
|
||||
);
|
||||
|
||||
if (!userId) {
|
||||
throw new BadRequestException("User ID is required");
|
||||
}
|
||||
|
||||
// Check if user exists and is part of the project
|
||||
const projectUser: TeamMember | null = await TeamMemberService.findOneBy({
|
||||
query: {
|
||||
projectId: projectId,
|
||||
userId: new ObjectID(userId),
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
user: {
|
||||
_id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
},
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
if (!projectUser || !projectUser.user) {
|
||||
logger.debug(
|
||||
`SCIM Update user - user not found or not part of project for userId: ${userId}`,
|
||||
);
|
||||
throw new NotFoundException(
|
||||
"User not found or not part of this project",
|
||||
);
|
||||
}
|
||||
|
||||
// Update user information
|
||||
const email: string =
|
||||
(scimUser["userName"] as string) ||
|
||||
((scimUser["emails"] as JSONObject[])?.[0]?.["value"] as string);
|
||||
const name: string = parseNameFromSCIM(scimUser);
|
||||
const active: boolean = scimUser["active"] as boolean;
|
||||
|
||||
logger.debug(
|
||||
`SCIM Update user - email: ${email}, name: ${name}, active: ${active}`,
|
||||
);
|
||||
|
||||
// Handle user deactivation by removing from teams
|
||||
if (active === false) {
|
||||
logger.debug(
|
||||
`SCIM Update user - user marked as inactive, removing from teams`,
|
||||
);
|
||||
const scimConfig: ProjectSCIM = bearerData["scimConfig"] as ProjectSCIM;
|
||||
await handleUserTeamOperations(
|
||||
"remove",
|
||||
projectId,
|
||||
new ObjectID(userId),
|
||||
scimConfig,
|
||||
);
|
||||
logger.debug(
|
||||
`SCIM Update user - user successfully removed from teams due to deactivation`,
|
||||
);
|
||||
}
|
||||
|
||||
// Handle user activation by adding to teams
|
||||
if (active === true) {
|
||||
logger.debug(
|
||||
`SCIM Update user - user marked as active, adding to teams`,
|
||||
);
|
||||
const scimConfig: ProjectSCIM = bearerData["scimConfig"] as ProjectSCIM;
|
||||
await handleUserTeamOperations(
|
||||
"add",
|
||||
projectId,
|
||||
new ObjectID(userId),
|
||||
scimConfig,
|
||||
);
|
||||
logger.debug(
|
||||
`SCIM Update user - user successfully added to teams due to activation`,
|
||||
);
|
||||
}
|
||||
|
||||
if (email || name) {
|
||||
const updateData: any = {};
|
||||
if (email) {
|
||||
updateData.email = new Email(email);
|
||||
}
|
||||
if (name) {
|
||||
updateData.name = new Name(name);
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`SCIM Update user - updating user with data: ${JSON.stringify(updateData)}`,
|
||||
);
|
||||
|
||||
await UserService.updateOneById({
|
||||
id: new ObjectID(userId),
|
||||
data: updateData,
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
logger.debug(`SCIM Update user - user updated successfully`);
|
||||
|
||||
// Fetch updated user
|
||||
const updatedUser: User | null = await UserService.findOneById({
|
||||
id: new ObjectID(userId),
|
||||
select: {
|
||||
_id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
if (updatedUser) {
|
||||
const user: JSONObject = formatUserForSCIM(
|
||||
updatedUser,
|
||||
req,
|
||||
req.params["projectScimId"]!,
|
||||
"project",
|
||||
);
|
||||
return Response.sendJsonObjectResponse(req, res, user);
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`SCIM Update user - no updates made, returning existing user`,
|
||||
);
|
||||
|
||||
// If no updates were made, return the existing user
|
||||
const user: JSONObject = formatUserForSCIM(
|
||||
projectUser.user,
|
||||
req,
|
||||
req.params["projectScimId"]!,
|
||||
"project",
|
||||
);
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, user);
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
return Response.sendErrorResponse(req, res, err as BadRequestException);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Groups endpoint - GET /scim/v2/Groups
|
||||
router.get(
|
||||
"/scim/v2/:projectScimId/Groups",
|
||||
SCIMMiddleware.isAuthorizedSCIMRequest,
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
logger.debug(
|
||||
`SCIM Groups list request for projectScimId: ${req.params["projectScimId"]}`,
|
||||
);
|
||||
const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest;
|
||||
const bearerData: JSONObject =
|
||||
oneuptimeRequest.bearerTokenData as JSONObject;
|
||||
const scimConfig: ProjectSCIM = bearerData["scimConfig"] as ProjectSCIM;
|
||||
|
||||
logger.debug(
|
||||
`SCIM Groups - found ${scimConfig.teams?.length || 0} configured teams`,
|
||||
);
|
||||
|
||||
// Return configured teams as groups
|
||||
const groups: JSONObject[] = (scimConfig.teams || []).map((team: any) => {
|
||||
return {
|
||||
schemas: ["urn:ietf:params:scim:schemas:core:2.0:Group"],
|
||||
id: team.id?.toString(),
|
||||
displayName: team.name?.toString(),
|
||||
members: [],
|
||||
meta: {
|
||||
resourceType: "Group",
|
||||
location: `${req.protocol}://${req.get("host")}/scim/v2/${req.params["projectScimId"]}/Groups/${team.id?.toString()}`,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, {
|
||||
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
||||
totalResults: groups.length,
|
||||
startIndex: 1,
|
||||
itemsPerPage: groups.length,
|
||||
Resources: groups,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
return Response.sendErrorResponse(req, res, err as BadRequestException);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Create User - POST /scim/v2/Users
|
||||
router.post(
|
||||
"/scim/v2/:projectScimId/Users",
|
||||
SCIMMiddleware.isAuthorizedSCIMRequest,
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
logger.debug(
|
||||
`SCIM Create user request for projectScimId: ${req.params["projectScimId"]}`,
|
||||
);
|
||||
const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest;
|
||||
const bearerData: JSONObject =
|
||||
oneuptimeRequest.bearerTokenData as JSONObject;
|
||||
const projectId: ObjectID = bearerData["projectId"] as ObjectID;
|
||||
const scimConfig: ProjectSCIM = bearerData["scimConfig"] as ProjectSCIM;
|
||||
|
||||
if (!scimConfig.autoProvisionUsers) {
|
||||
throw new BadRequestException(
|
||||
"Auto-provisioning is disabled for this project",
|
||||
);
|
||||
}
|
||||
|
||||
const scimUser: JSONObject = req.body;
|
||||
const email: string =
|
||||
(scimUser["userName"] as string) ||
|
||||
((scimUser["emails"] as JSONObject[])?.[0]?.["value"] as string);
|
||||
const name: string = parseNameFromSCIM(scimUser);
|
||||
|
||||
logger.debug(`SCIM Create user - email: ${email}, name: ${name}`);
|
||||
|
||||
if (!email) {
|
||||
throw new BadRequestException("userName or email is required");
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
let user: User | null = await UserService.findOneBy({
|
||||
query: { email: new Email(email) },
|
||||
select: {
|
||||
_id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
// Create user if doesn't exist
|
||||
if (!user) {
|
||||
logger.debug(
|
||||
`SCIM Create user - creating new user for email: ${email}`,
|
||||
);
|
||||
user = await UserService.createByEmail({
|
||||
email: new Email(email),
|
||||
name: name ? new Name(name) : new Name("Unknown"),
|
||||
isEmailVerified: true,
|
||||
generateRandomPassword: true,
|
||||
props: { isRoot: true },
|
||||
});
|
||||
} else {
|
||||
logger.debug(
|
||||
`SCIM Create user - user already exists with id: ${user.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Add user to default teams if configured
|
||||
if (scimConfig.teams && scimConfig.teams.length > 0) {
|
||||
logger.debug(
|
||||
`SCIM Create user - adding user to ${scimConfig.teams.length} configured teams`,
|
||||
);
|
||||
await handleUserTeamOperations("add", projectId, user.id!, scimConfig);
|
||||
}
|
||||
|
||||
const createdUser: JSONObject = formatUserForSCIM(
|
||||
user,
|
||||
req,
|
||||
req.params["projectScimId"]!,
|
||||
"project",
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`SCIM Create user - returning created user with id: ${user.id}`,
|
||||
);
|
||||
|
||||
res.status(201);
|
||||
return Response.sendJsonObjectResponse(req, res, createdUser);
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
return Response.sendErrorResponse(req, res, err as BadRequestException);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Delete User - DELETE /scim/v2/Users/{id}
|
||||
router.delete(
|
||||
"/scim/v2/:projectScimId/Users/:userId",
|
||||
SCIMMiddleware.isAuthorizedSCIMRequest,
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
logger.debug(
|
||||
`SCIM Delete user request for userId: ${req.params["userId"]}, projectScimId: ${req.params["projectScimId"]}`,
|
||||
);
|
||||
const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest;
|
||||
const bearerData: JSONObject =
|
||||
oneuptimeRequest.bearerTokenData as JSONObject;
|
||||
const projectId: ObjectID = bearerData["projectId"] as ObjectID;
|
||||
const scimConfig: ProjectSCIM = bearerData["scimConfig"] as ProjectSCIM;
|
||||
const userId: string = req.params["userId"]!;
|
||||
|
||||
if (!scimConfig.autoDeprovisionUsers) {
|
||||
logger.debug("SCIM Delete user - auto-deprovisioning is disabled");
|
||||
throw new BadRequestException(
|
||||
"Auto-deprovisioning is disabled for this project",
|
||||
);
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
throw new BadRequestException("User ID is required");
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`SCIM Delete user - removing user from all teams in project: ${projectId}`,
|
||||
);
|
||||
|
||||
// Remove user from teams the SCIM configured
|
||||
if (!scimConfig.teams || scimConfig.teams.length === 0) {
|
||||
logger.debug("SCIM Delete user - no teams configured for SCIM");
|
||||
throw new BadRequestException("No teams configured for SCIM");
|
||||
}
|
||||
|
||||
await handleUserTeamOperations(
|
||||
"remove",
|
||||
projectId,
|
||||
new ObjectID(userId),
|
||||
scimConfig,
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`SCIM Delete user - user successfully deprovisioned from project`,
|
||||
);
|
||||
|
||||
res.status(204);
|
||||
return Response.sendJsonObjectResponse(req, res, {
|
||||
message: "User deprovisioned",
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
return Response.sendErrorResponse(req, res, err as BadRequestException);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
536
App/FeatureSet/Identity/API/StatusPageSCIM.ts
Normal file
536
App/FeatureSet/Identity/API/StatusPageSCIM.ts
Normal file
@@ -0,0 +1,536 @@
|
||||
import SCIMMiddleware from "Common/Server/Middleware/SCIMAuthorization";
|
||||
import StatusPagePrivateUserService from "Common/Server/Services/StatusPagePrivateUserService";
|
||||
import Express, {
|
||||
ExpressRequest,
|
||||
ExpressResponse,
|
||||
ExpressRouter,
|
||||
OneUptimeRequest,
|
||||
} from "Common/Server/Utils/Express";
|
||||
import Response from "Common/Server/Utils/Response";
|
||||
import logger from "Common/Server/Utils/Logger";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import Email from "Common/Types/Email";
|
||||
import { JSONObject } from "Common/Types/JSON";
|
||||
import StatusPagePrivateUser from "Common/Models/DatabaseModels/StatusPagePrivateUser";
|
||||
import StatusPageSCIM from "Common/Models/DatabaseModels/StatusPageSCIM";
|
||||
import BadRequestException from "Common/Types/Exception/BadRequestException";
|
||||
import NotFoundException from "Common/Types/Exception/NotFoundException";
|
||||
import LIMIT_MAX, { LIMIT_PER_PROJECT } from "Common/Types/Database/LimitMax";
|
||||
import {
|
||||
formatUserForSCIM,
|
||||
generateServiceProviderConfig,
|
||||
logSCIMOperation,
|
||||
} from "../Utils/SCIMUtils";
|
||||
import Text from "Common/Types/Text";
|
||||
import HashedString from "Common/Types/HashedString";
|
||||
|
||||
const router: ExpressRouter = Express.getRouter();
|
||||
|
||||
// SCIM Service Provider Configuration - GET /status-page-scim/v2/ServiceProviderConfig
|
||||
router.get(
|
||||
"/status-page-scim/v2/:statusPageScimId/ServiceProviderConfig",
|
||||
SCIMMiddleware.isAuthorizedSCIMRequest,
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
logSCIMOperation(
|
||||
"ServiceProviderConfig",
|
||||
"status-page",
|
||||
req.params["statusPageScimId"]!,
|
||||
);
|
||||
|
||||
const serviceProviderConfig: JSONObject = generateServiceProviderConfig(
|
||||
req,
|
||||
req.params["statusPageScimId"]!,
|
||||
"status-page",
|
||||
);
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, serviceProviderConfig);
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
return Response.sendErrorResponse(req, res, err as BadRequestException);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Status Page Users endpoint - GET /status-page-scim/v2/Users
|
||||
router.get(
|
||||
"/status-page-scim/v2/:statusPageScimId/Users",
|
||||
SCIMMiddleware.isAuthorizedSCIMRequest,
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
logger.debug(
|
||||
`Status Page SCIM Users list request for statusPageScimId: ${req.params["statusPageScimId"]}`,
|
||||
);
|
||||
const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest;
|
||||
const bearerData: JSONObject =
|
||||
oneuptimeRequest.bearerTokenData as JSONObject;
|
||||
const statusPageId: ObjectID = bearerData["statusPageId"] as ObjectID;
|
||||
|
||||
// Parse query parameters
|
||||
const startIndex: number =
|
||||
parseInt(req.query["startIndex"] as string) || 1;
|
||||
const count: number = Math.min(
|
||||
parseInt(req.query["count"] as string) || 100,
|
||||
LIMIT_PER_PROJECT,
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`Status Page SCIM Users - statusPageId: ${statusPageId}, startIndex: ${startIndex}, count: ${count}`,
|
||||
);
|
||||
|
||||
// Get all private users for this status page
|
||||
const statusPageUsers: Array<StatusPagePrivateUser> =
|
||||
await StatusPagePrivateUserService.findBy({
|
||||
query: {
|
||||
statusPageId: statusPageId,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
email: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
skip: 0,
|
||||
limit: LIMIT_MAX,
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
logger.debug(
|
||||
`Status Page SCIM Users - found ${statusPageUsers.length} users`,
|
||||
);
|
||||
|
||||
// Format users for SCIM
|
||||
const users: Array<JSONObject> = statusPageUsers.map(
|
||||
(user: StatusPagePrivateUser) => {
|
||||
return formatUserForSCIM(
|
||||
user,
|
||||
req,
|
||||
req.params["statusPageScimId"]!,
|
||||
"status-page",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Paginate the results
|
||||
const paginatedUsers: Array<JSONObject> = users.slice(
|
||||
(startIndex - 1) * count,
|
||||
startIndex * count,
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`Status Page SCIM Users response prepared with ${users.length} users`,
|
||||
);
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, {
|
||||
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
||||
totalResults: users.length,
|
||||
startIndex: startIndex,
|
||||
itemsPerPage: paginatedUsers.length,
|
||||
Resources: paginatedUsers,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
return Response.sendErrorResponse(req, res, err as BadRequestException);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Get Individual Status Page User - GET /status-page-scim/v2/Users/{id}
|
||||
router.get(
|
||||
"/status-page-scim/v2/:statusPageScimId/Users/:userId",
|
||||
SCIMMiddleware.isAuthorizedSCIMRequest,
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
logger.debug(
|
||||
`Status Page SCIM Get individual user request for userId: ${req.params["userId"]}, statusPageScimId: ${req.params["statusPageScimId"]}`,
|
||||
);
|
||||
const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest;
|
||||
const bearerData: JSONObject =
|
||||
oneuptimeRequest.bearerTokenData as JSONObject;
|
||||
const statusPageId: ObjectID = bearerData["statusPageId"] as ObjectID;
|
||||
const userId: string = req.params["userId"]!;
|
||||
|
||||
logger.debug(
|
||||
`Status Page SCIM Get user - statusPageId: ${statusPageId}, userId: ${userId}`,
|
||||
);
|
||||
|
||||
if (!userId) {
|
||||
throw new BadRequestException("User ID is required");
|
||||
}
|
||||
|
||||
// Check if user exists and belongs to this status page
|
||||
const statusPageUser: StatusPagePrivateUser | null =
|
||||
await StatusPagePrivateUserService.findOneBy({
|
||||
query: {
|
||||
statusPageId: statusPageId,
|
||||
_id: new ObjectID(userId),
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
email: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
if (!statusPageUser) {
|
||||
logger.debug(
|
||||
`Status Page SCIM Get user - user not found for userId: ${userId}`,
|
||||
);
|
||||
throw new NotFoundException(
|
||||
"User not found or not part of this status page",
|
||||
);
|
||||
}
|
||||
|
||||
const user: JSONObject = formatUserForSCIM(
|
||||
statusPageUser,
|
||||
req,
|
||||
req.params["statusPageScimId"]!,
|
||||
"status-page",
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`Status Page SCIM Get user - returning user with id: ${statusPageUser.id}`,
|
||||
);
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, user);
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
return Response.sendErrorResponse(req, res, err as BadRequestException);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Create Status Page User - POST /status-page-scim/v2/Users
|
||||
router.post(
|
||||
"/status-page-scim/v2/:statusPageScimId/Users",
|
||||
SCIMMiddleware.isAuthorizedSCIMRequest,
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
logger.debug(
|
||||
`Status Page SCIM Create user request for statusPageScimId: ${req.params["statusPageScimId"]}`,
|
||||
);
|
||||
const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest;
|
||||
const bearerData: JSONObject =
|
||||
oneuptimeRequest.bearerTokenData as JSONObject;
|
||||
const statusPageId: ObjectID = bearerData["statusPageId"] as ObjectID;
|
||||
const scimConfig: StatusPageSCIM = bearerData[
|
||||
"scimConfig"
|
||||
] as StatusPageSCIM;
|
||||
|
||||
if (!scimConfig.autoProvisionUsers) {
|
||||
throw new BadRequestException(
|
||||
"Auto-provisioning is disabled for this status page",
|
||||
);
|
||||
}
|
||||
|
||||
const scimUser: JSONObject = req.body;
|
||||
|
||||
logger.debug(
|
||||
`Status Page SCIM Create user - statusPageId: ${statusPageId}`,
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`Request body for Status Page SCIM Create user: ${JSON.stringify(scimUser, null, 2)}`,
|
||||
);
|
||||
|
||||
// Extract user data from SCIM payload
|
||||
const email: string =
|
||||
(scimUser["userName"] as string) ||
|
||||
((scimUser["emails"] as JSONObject[])?.[0]?.["value"] as string);
|
||||
|
||||
if (!email) {
|
||||
throw new BadRequestException("Email is required for user creation");
|
||||
}
|
||||
|
||||
logger.debug(`Status Page SCIM Create user - email: ${email}`);
|
||||
|
||||
// Check if user already exists for this status page
|
||||
let user: StatusPagePrivateUser | null =
|
||||
await StatusPagePrivateUserService.findOneBy({
|
||||
query: {
|
||||
statusPageId: statusPageId,
|
||||
email: new Email(email),
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
email: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
logger.debug(
|
||||
`Status Page SCIM Create user - creating new user with email: ${email}`,
|
||||
);
|
||||
|
||||
const privateUser: StatusPagePrivateUser = new StatusPagePrivateUser();
|
||||
privateUser.statusPageId = statusPageId;
|
||||
privateUser.email = new Email(email);
|
||||
privateUser.password = new HashedString(Text.generateRandomText(32));
|
||||
privateUser.projectId = bearerData["projectId"] as ObjectID;
|
||||
|
||||
// Create new status page private user
|
||||
user = await StatusPagePrivateUserService.create({
|
||||
data: privateUser as any,
|
||||
props: { isRoot: true },
|
||||
});
|
||||
} else {
|
||||
logger.debug(
|
||||
`Status Page SCIM Create user - user already exists with id: ${user.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
const createdUser: JSONObject = formatUserForSCIM(
|
||||
user,
|
||||
req,
|
||||
req.params["statusPageScimId"]!,
|
||||
"status-page",
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`Status Page SCIM Create user - returning created user with id: ${user.id}`,
|
||||
);
|
||||
|
||||
res.status(201);
|
||||
return Response.sendJsonObjectResponse(req, res, createdUser);
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
return Response.sendErrorResponse(req, res, err as BadRequestException);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Update Status Page User - PUT /status-page-scim/v2/Users/{id}
|
||||
router.put(
|
||||
"/status-page-scim/v2/:statusPageScimId/Users/:userId",
|
||||
SCIMMiddleware.isAuthorizedSCIMRequest,
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
logger.debug(
|
||||
`Status Page SCIM Update user request for userId: ${req.params["userId"]}, statusPageScimId: ${req.params["statusPageScimId"]}`,
|
||||
);
|
||||
const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest;
|
||||
const bearerData: JSONObject =
|
||||
oneuptimeRequest.bearerTokenData as JSONObject;
|
||||
const statusPageId: ObjectID = bearerData["statusPageId"] as ObjectID;
|
||||
const userId: string = req.params["userId"]!;
|
||||
const scimUser: JSONObject = req.body;
|
||||
|
||||
logger.debug(
|
||||
`Status Page SCIM Update user - statusPageId: ${statusPageId}, userId: ${userId}`,
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`Request body for Status Page SCIM Update user: ${JSON.stringify(scimUser, null, 2)}`,
|
||||
);
|
||||
|
||||
if (!userId) {
|
||||
throw new BadRequestException("User ID is required");
|
||||
}
|
||||
|
||||
// Check if user exists and belongs to this status page
|
||||
const statusPageUser: StatusPagePrivateUser | null =
|
||||
await StatusPagePrivateUserService.findOneBy({
|
||||
query: {
|
||||
statusPageId: statusPageId,
|
||||
_id: new ObjectID(userId),
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
email: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
if (!statusPageUser) {
|
||||
logger.debug(
|
||||
`Status Page SCIM Update user - user not found for userId: ${userId}`,
|
||||
);
|
||||
throw new NotFoundException(
|
||||
"User not found or not part of this status page",
|
||||
);
|
||||
}
|
||||
|
||||
// Update user information
|
||||
const email: string =
|
||||
(scimUser["userName"] as string) ||
|
||||
((scimUser["emails"] as JSONObject[])?.[0]?.["value"] as string);
|
||||
const active: boolean = scimUser["active"] as boolean;
|
||||
|
||||
logger.debug(
|
||||
`Status Page SCIM Update user - email: ${email}, active: ${active}`,
|
||||
);
|
||||
|
||||
// Handle user deactivation by deleting from status page
|
||||
if (active === false) {
|
||||
logger.debug(
|
||||
`Status Page SCIM Update user - user marked as inactive, removing from status page`,
|
||||
);
|
||||
|
||||
const scimConfig: StatusPageSCIM = bearerData[
|
||||
"scimConfig"
|
||||
] as StatusPageSCIM;
|
||||
if (scimConfig.autoDeprovisionUsers) {
|
||||
await StatusPagePrivateUserService.deleteOneById({
|
||||
id: new ObjectID(userId),
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
logger.debug(
|
||||
`Status Page SCIM Update user - user removed from status page`,
|
||||
);
|
||||
|
||||
// Return empty response for deleted user
|
||||
return Response.sendJsonObjectResponse(req, res, {});
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare update data
|
||||
const updateData: {
|
||||
email?: Email;
|
||||
} = {};
|
||||
|
||||
if (email && email !== statusPageUser.email?.toString()) {
|
||||
updateData.email = new Email(email);
|
||||
}
|
||||
|
||||
// Only update if there are changes
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
logger.debug(
|
||||
`Status Page SCIM Update user - updating user with data: ${JSON.stringify(updateData)}`,
|
||||
);
|
||||
|
||||
await StatusPagePrivateUserService.updateOneById({
|
||||
id: new ObjectID(userId),
|
||||
data: updateData,
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
logger.debug(
|
||||
`Status Page SCIM Update user - user updated successfully`,
|
||||
);
|
||||
|
||||
// Fetch updated user
|
||||
const updatedUser: StatusPagePrivateUser | null =
|
||||
await StatusPagePrivateUserService.findOneById({
|
||||
id: new ObjectID(userId),
|
||||
select: {
|
||||
_id: true,
|
||||
email: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
if (updatedUser) {
|
||||
const user: JSONObject = formatUserForSCIM(
|
||||
updatedUser,
|
||||
req,
|
||||
req.params["statusPageScimId"]!,
|
||||
"status-page",
|
||||
);
|
||||
return Response.sendJsonObjectResponse(req, res, user);
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Status Page SCIM Update user - no updates made, returning existing user`,
|
||||
);
|
||||
|
||||
// If no updates were made, return the existing user
|
||||
const user: JSONObject = formatUserForSCIM(
|
||||
statusPageUser,
|
||||
req,
|
||||
req.params["statusPageScimId"]!,
|
||||
"status-page",
|
||||
);
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, user);
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
return Response.sendErrorResponse(req, res, err as BadRequestException);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Delete Status Page User - DELETE /status-page-scim/v2/Users/{id}
|
||||
router.delete(
|
||||
"/status-page-scim/v2/:statusPageScimId/Users/:userId",
|
||||
SCIMMiddleware.isAuthorizedSCIMRequest,
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
logger.debug(
|
||||
`Status Page SCIM Delete user request for userId: ${req.params["userId"]}, statusPageScimId: ${req.params["statusPageScimId"]}`,
|
||||
);
|
||||
const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest;
|
||||
const bearerData: JSONObject =
|
||||
oneuptimeRequest.bearerTokenData as JSONObject;
|
||||
const statusPageId: ObjectID = bearerData["statusPageId"] as ObjectID;
|
||||
const scimConfig: StatusPageSCIM = bearerData[
|
||||
"scimConfig"
|
||||
] as StatusPageSCIM;
|
||||
const userId: string = req.params["userId"]!;
|
||||
|
||||
if (!scimConfig.autoDeprovisionUsers) {
|
||||
throw new BadRequestException(
|
||||
"Auto-deprovisioning is disabled for this status page",
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Status Page SCIM Delete user - statusPageId: ${statusPageId}, userId: ${userId}`,
|
||||
);
|
||||
|
||||
if (!userId) {
|
||||
throw new BadRequestException("User ID is required");
|
||||
}
|
||||
|
||||
// Check if user exists and belongs to this status page
|
||||
const statusPageUser: StatusPagePrivateUser | null =
|
||||
await StatusPagePrivateUserService.findOneBy({
|
||||
query: {
|
||||
statusPageId: statusPageId,
|
||||
_id: new ObjectID(userId),
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
},
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
if (!statusPageUser) {
|
||||
logger.debug(
|
||||
`Status Page SCIM Delete user - user not found for userId: ${userId}`,
|
||||
);
|
||||
// SCIM spec says to return 404 for non-existent resources
|
||||
throw new NotFoundException("User not found");
|
||||
}
|
||||
|
||||
// Delete the user from status page
|
||||
await StatusPagePrivateUserService.deleteOneById({
|
||||
id: new ObjectID(userId),
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
logger.debug(
|
||||
`Status Page SCIM Delete user - user deleted successfully for userId: ${userId}`,
|
||||
);
|
||||
|
||||
// Return 204 No Content for successful deletion
|
||||
res.status(204);
|
||||
return Response.sendEmptySuccessResponse(req, res);
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
return Response.sendErrorResponse(req, res, err as BadRequestException);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -1,8 +1,10 @@
|
||||
import AuthenticationAPI from "./API/Authentication";
|
||||
import ResellerAPI from "./API/Reseller";
|
||||
import SsoAPI from "./API/SSO";
|
||||
import SCIMAPI from "./API/SCIM";
|
||||
import StatusPageAuthenticationAPI from "./API/StatusPageAuthentication";
|
||||
import StatusPageSsoAPI from "./API/StatusPageSSO";
|
||||
import StatusPageSCIMAPI from "./API/StatusPageSCIM";
|
||||
import FeatureSet from "Common/Server/Types/FeatureSet";
|
||||
import Express, { ExpressApplication } from "Common/Server/Utils/Express";
|
||||
import "ejs";
|
||||
@@ -19,6 +21,10 @@ const IdentityFeatureSet: FeatureSet = {
|
||||
|
||||
app.use([`/${APP_NAME}`, "/"], SsoAPI);
|
||||
|
||||
app.use([`/${APP_NAME}`, "/"], SCIMAPI);
|
||||
|
||||
app.use([`/${APP_NAME}`, "/"], StatusPageSCIMAPI);
|
||||
|
||||
app.use([`/${APP_NAME}`, "/"], StatusPageSsoAPI);
|
||||
|
||||
app.use(
|
||||
|
||||
264
App/FeatureSet/Identity/Utils/SCIMUtils.ts
Normal file
264
App/FeatureSet/Identity/Utils/SCIMUtils.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { ExpressRequest } from "Common/Server/Utils/Express";
|
||||
import logger from "Common/Server/Utils/Logger";
|
||||
import { JSONObject } from "Common/Types/JSON";
|
||||
import Email from "Common/Types/Email";
|
||||
import Name from "Common/Types/Name";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
|
||||
/**
|
||||
* Shared SCIM utility functions for both Project SCIM and Status Page SCIM
|
||||
*/
|
||||
|
||||
// Base interface for SCIM user-like objects - compatible with User model
|
||||
export interface SCIMUser {
|
||||
id?: ObjectID | null;
|
||||
email?: Email;
|
||||
name?: Name | string;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse name information from SCIM user payload
|
||||
*/
|
||||
export const parseNameFromSCIM: (scimUser: JSONObject) => string = (
|
||||
scimUser: JSONObject,
|
||||
): string => {
|
||||
logger.debug(
|
||||
`SCIM - Parsing name from SCIM user: ${JSON.stringify(scimUser, null, 2)}`,
|
||||
);
|
||||
|
||||
const givenName: string =
|
||||
((scimUser["name"] as JSONObject)?.["givenName"] as string) || "";
|
||||
const familyName: string =
|
||||
((scimUser["name"] as JSONObject)?.["familyName"] as string) || "";
|
||||
const formattedName: string = (scimUser["name"] as JSONObject)?.[
|
||||
"formatted"
|
||||
] as string;
|
||||
|
||||
// Construct full name: prefer formatted, then combine given+family, then fallback to displayName
|
||||
if (formattedName) {
|
||||
return formattedName;
|
||||
} else if (givenName || familyName) {
|
||||
return `${givenName} ${familyName}`.trim();
|
||||
} else if (scimUser["displayName"]) {
|
||||
return scimUser["displayName"] as string;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse full name into SCIM name format
|
||||
*/
|
||||
export const parseNameToSCIMFormat: (fullName: string) => {
|
||||
givenName: string;
|
||||
familyName: string;
|
||||
formatted: string;
|
||||
} = (
|
||||
fullName: string,
|
||||
): { givenName: string; familyName: string; formatted: string } => {
|
||||
const nameParts: string[] = fullName.trim().split(/\s+/);
|
||||
const givenName: string = nameParts[0] || "";
|
||||
const familyName: string = nameParts.slice(1).join(" ") || "";
|
||||
|
||||
return {
|
||||
givenName,
|
||||
familyName,
|
||||
formatted: fullName,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Format user object for SCIM response
|
||||
*/
|
||||
export const formatUserForSCIM: (
|
||||
user: SCIMUser,
|
||||
req: ExpressRequest,
|
||||
scimId: string,
|
||||
scimType: "project" | "status-page",
|
||||
) => JSONObject = (
|
||||
user: SCIMUser,
|
||||
req: ExpressRequest,
|
||||
scimId: string,
|
||||
scimType: "project" | "status-page",
|
||||
): JSONObject => {
|
||||
const baseUrl: string = `${req.protocol}://${req.get("host")}`;
|
||||
const userName: string = user.email?.toString() || "";
|
||||
const fullName: string =
|
||||
user.name?.toString() || userName.split("@")[0] || "Unknown User";
|
||||
|
||||
const nameData: { givenName: string; familyName: string; formatted: string } =
|
||||
parseNameToSCIMFormat(fullName);
|
||||
|
||||
// Determine the correct endpoint path based on SCIM type
|
||||
const endpointPath: string =
|
||||
scimType === "project"
|
||||
? `/scim/v2/${scimId}/Users/${user.id?.toString()}`
|
||||
: `/status-page-scim/v2/${scimId}/Users/${user.id?.toString()}`;
|
||||
|
||||
return {
|
||||
schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
id: user.id?.toString(),
|
||||
userName: userName,
|
||||
displayName: nameData.formatted,
|
||||
name: {
|
||||
formatted: nameData.formatted,
|
||||
familyName: nameData.familyName,
|
||||
givenName: nameData.givenName,
|
||||
},
|
||||
emails: [
|
||||
{
|
||||
value: userName,
|
||||
type: "work",
|
||||
primary: true,
|
||||
},
|
||||
],
|
||||
active: true,
|
||||
meta: {
|
||||
resourceType: "User",
|
||||
created: user.createdAt?.toISOString(),
|
||||
lastModified: user.updatedAt?.toISOString(),
|
||||
location: `${baseUrl}${endpointPath}`,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract email from SCIM user payload
|
||||
*/
|
||||
export const extractEmailFromSCIM: (scimUser: JSONObject) => string = (
|
||||
scimUser: JSONObject,
|
||||
): string => {
|
||||
return (
|
||||
(scimUser["userName"] as string) ||
|
||||
((scimUser["emails"] as JSONObject[])?.[0]?.["value"] as string) ||
|
||||
""
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract active status from SCIM user payload
|
||||
*/
|
||||
export const extractActiveFromSCIM: (scimUser: JSONObject) => boolean = (
|
||||
scimUser: JSONObject,
|
||||
): boolean => {
|
||||
return scimUser["active"] !== false; // Default to true if not specified
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate SCIM ServiceProviderConfig response
|
||||
*/
|
||||
export const generateServiceProviderConfig: (
|
||||
req: ExpressRequest,
|
||||
scimId: string,
|
||||
scimType: "project" | "status-page",
|
||||
documentationUrl?: string,
|
||||
) => JSONObject = (
|
||||
req: ExpressRequest,
|
||||
scimId: string,
|
||||
scimType: "project" | "status-page",
|
||||
documentationUrl: string = "https://oneuptime.com/docs/identity/scim",
|
||||
): JSONObject => {
|
||||
const baseUrl: string = `${req.protocol}://${req.get("host")}`;
|
||||
const endpointPath: string =
|
||||
scimType === "project"
|
||||
? `/scim/v2/${scimId}`
|
||||
: `/status-page-scim/v2/${scimId}`;
|
||||
|
||||
return {
|
||||
schemas: ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
|
||||
documentationUri: documentationUrl,
|
||||
patch: {
|
||||
supported: true,
|
||||
},
|
||||
bulk: {
|
||||
supported: true,
|
||||
maxOperations: 1000,
|
||||
maxPayloadSize: 1048576,
|
||||
},
|
||||
filter: {
|
||||
supported: true,
|
||||
maxResults: 200,
|
||||
},
|
||||
changePassword: {
|
||||
supported: false,
|
||||
},
|
||||
sort: {
|
||||
supported: true,
|
||||
},
|
||||
etag: {
|
||||
supported: false,
|
||||
},
|
||||
authenticationSchemes: [
|
||||
{
|
||||
type: "httpbearer",
|
||||
name: "HTTP Bearer",
|
||||
description: "Authentication scheme using HTTP Bearer Token",
|
||||
primary: true,
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
location: `${baseUrl}${endpointPath}/ServiceProviderConfig`,
|
||||
resourceType: "ServiceProviderConfig",
|
||||
created: "2023-01-01T00:00:00Z",
|
||||
lastModified: "2023-01-01T00:00:00Z",
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate SCIM ListResponse for users
|
||||
*/
|
||||
export const generateUsersListResponse: (
|
||||
users: JSONObject[],
|
||||
startIndex: number,
|
||||
totalResults: number,
|
||||
) => JSONObject = (
|
||||
users: JSONObject[],
|
||||
startIndex: number,
|
||||
totalResults: number,
|
||||
): JSONObject => {
|
||||
return {
|
||||
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
||||
totalResults: totalResults,
|
||||
startIndex: startIndex,
|
||||
itemsPerPage: users.length,
|
||||
Resources: users,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse query parameters for SCIM list requests
|
||||
*/
|
||||
export const parseSCIMQueryParams: (req: ExpressRequest) => {
|
||||
startIndex: number;
|
||||
count: number;
|
||||
} = (req: ExpressRequest): { startIndex: number; count: number } => {
|
||||
const startIndex: number = parseInt(req.query["startIndex"] as string) || 1;
|
||||
const count: number = Math.min(
|
||||
parseInt(req.query["count"] as string) || 100,
|
||||
200, // SCIM recommended max
|
||||
);
|
||||
|
||||
return { startIndex, count };
|
||||
};
|
||||
|
||||
/**
|
||||
* Log SCIM operation with consistent format
|
||||
*/
|
||||
export const logSCIMOperation: (
|
||||
operation: string,
|
||||
scimType: "project" | "status-page",
|
||||
scimId: string,
|
||||
details?: string,
|
||||
) => void = (
|
||||
operation: string,
|
||||
scimType: "project" | "status-page",
|
||||
scimId: string,
|
||||
details?: string,
|
||||
): void => {
|
||||
const logPrefix: string =
|
||||
scimType === "project" ? "Project SCIM" : "Status Page SCIM";
|
||||
const message: string = `${logPrefix} ${operation} - scimId: ${scimId}${details ? `, ${details}` : ""}`;
|
||||
logger.debug(message);
|
||||
};
|
||||
@@ -729,6 +729,7 @@ export default class CopilotAction extends BaseModel {
|
||||
type: TableColumnType.Boolean,
|
||||
title: "Is Priority",
|
||||
description: "Is Priority",
|
||||
defaultValue: false,
|
||||
})
|
||||
@Column({
|
||||
nullable: false,
|
||||
|
||||
@@ -48,6 +48,7 @@ export default class GlobalConfig extends GlobalConfigModel {
|
||||
type: TableColumnType.Boolean,
|
||||
title: "Disable Signup",
|
||||
description: "Should we disable new user sign up to this server?",
|
||||
defaultValue: false,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
@@ -338,6 +339,7 @@ export default class GlobalConfig extends GlobalConfigModel {
|
||||
type: TableColumnType.Boolean,
|
||||
title: "Is Master API Key Enabled",
|
||||
description: "Is Master API Key Enabled?",
|
||||
defaultValue: false,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
|
||||
@@ -27,6 +27,7 @@ import IconProp from "../../Types/Icon/IconProp";
|
||||
import { JSONObject } from "../../Types/JSON";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import Permission from "../../Types/Permission";
|
||||
import StatusPageSubscriberNotificationStatus from "../../Types/StatusPage/StatusPageSubscriberNotificationStatus";
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
@@ -733,29 +734,74 @@ export default class Incident extends BaseModel {
|
||||
public changeMonitorStatusToId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateProjectIncident,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectIncident,
|
||||
],
|
||||
update: [],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.EditProjectIncident,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
isDefaultValueColumn: true,
|
||||
computed: true,
|
||||
hideColumnInDocumentation: true,
|
||||
type: TableColumnType.Boolean,
|
||||
title: "Are subscribers notified?",
|
||||
description: "Are subscribers notified about this incident?",
|
||||
defaultValue: false,
|
||||
type: TableColumnType.ShortText,
|
||||
title: "Subscriber Notification Status",
|
||||
description:
|
||||
"Status of notification sent to subscribers about this incident",
|
||||
defaultValue: StatusPageSubscriberNotificationStatus.Pending,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
default: false,
|
||||
type: ColumnType.ShortText,
|
||||
default: StatusPageSubscriberNotificationStatus.Pending,
|
||||
})
|
||||
public isStatusPageSubscribersNotifiedOnIncidentCreated?: boolean = undefined;
|
||||
public subscriberNotificationStatusOnIncidentCreated?: StatusPageSubscriberNotificationStatus =
|
||||
undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateProjectIncident,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectIncident,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.EditProjectIncident,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.VeryLongText,
|
||||
title: "Notification Status Message",
|
||||
description:
|
||||
"Status message for subscriber notifications - includes success messages, failure reasons, or skip reasons",
|
||||
required: false,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.VeryLongText,
|
||||
nullable: true,
|
||||
})
|
||||
public subscriberNotificationStatusMessage?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
|
||||
@@ -17,6 +17,7 @@ import TenantColumn from "../../Types/Database/TenantColumn";
|
||||
import IconProp from "../../Types/Icon/IconProp";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import Permission from "../../Types/Permission";
|
||||
import StatusPageSubscriberNotificationStatus from "../../Types/StatusPage/StatusPageSubscriberNotificationStatus";
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
|
||||
@EnableDocumentation()
|
||||
@@ -341,29 +342,73 @@ export default class IncidentPublicNote extends BaseModel {
|
||||
public note?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateIncidentPublicNote,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentPublicNote,
|
||||
],
|
||||
update: [],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.EditIncidentPublicNote,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
isDefaultValueColumn: true,
|
||||
computed: true,
|
||||
hideColumnInDocumentation: true,
|
||||
type: TableColumnType.Boolean,
|
||||
title: "Are subscribers notified?",
|
||||
description: "Are subscribers notified about this note?",
|
||||
defaultValue: false,
|
||||
type: TableColumnType.ShortText,
|
||||
title: "Subscriber Notification Status",
|
||||
description: "Status of notification sent to subscribers about this note",
|
||||
defaultValue: StatusPageSubscriberNotificationStatus.Pending,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
default: false,
|
||||
type: ColumnType.ShortText,
|
||||
default: StatusPageSubscriberNotificationStatus.Pending,
|
||||
})
|
||||
public isStatusPageSubscribersNotifiedOnNoteCreated?: boolean = undefined;
|
||||
public subscriberNotificationStatusOnNoteCreated?: StatusPageSubscriberNotificationStatus =
|
||||
undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateIncidentPublicNote,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentPublicNote,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.EditIncidentPublicNote,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.VeryLongText,
|
||||
title: "Notification Status Message",
|
||||
description:
|
||||
"Status message for subscriber notifications - includes success messages, failure reasons, or skip reasons",
|
||||
required: false,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.VeryLongText,
|
||||
nullable: true,
|
||||
})
|
||||
public subscriberNotificationStatusMessage?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
|
||||
@@ -19,6 +19,7 @@ import IconProp from "../../Types/Icon/IconProp";
|
||||
import { JSONObject } from "../../Types/JSON";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import Permission from "../../Types/Permission";
|
||||
import StatusPageSubscriberNotificationStatus from "../../Types/StatusPage/StatusPageSubscriberNotificationStatus";
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
|
||||
@EnableDocumentation()
|
||||
@@ -391,29 +392,74 @@ export default class IncidentStateTimeline extends BaseModel {
|
||||
public incidentStateId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateIncidentStateTimeline,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentStateTimeline,
|
||||
],
|
||||
update: [],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.EditIncidentStateTimeline,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
isDefaultValueColumn: true,
|
||||
computed: true,
|
||||
hideColumnInDocumentation: true,
|
||||
type: TableColumnType.Boolean,
|
||||
title: "Are subscribers notified?",
|
||||
description: "Are subscribers notified about this incident state change?",
|
||||
defaultValue: false,
|
||||
type: TableColumnType.ShortText,
|
||||
title: "Subscriber Notification Status",
|
||||
description:
|
||||
"Status of notification sent to subscribers about this incident state change",
|
||||
defaultValue: StatusPageSubscriberNotificationStatus.Pending,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
default: false,
|
||||
type: ColumnType.ShortText,
|
||||
default: StatusPageSubscriberNotificationStatus.Pending,
|
||||
})
|
||||
public isStatusPageSubscribersNotified?: boolean = undefined;
|
||||
public subscriberNotificationStatus?: StatusPageSubscriberNotificationStatus =
|
||||
undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateIncidentPublicNote,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentStateTimeline,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.EditIncidentStateTimeline,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.VeryLongText,
|
||||
title: "Notification Status Message",
|
||||
description:
|
||||
"Status message for subscriber notifications - includes success messages, failure reasons, or skip reasons",
|
||||
required: false,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.VeryLongText,
|
||||
nullable: true,
|
||||
})
|
||||
public subscriberNotificationStatusMessage?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
@@ -435,6 +481,7 @@ export default class IncidentStateTimeline extends BaseModel {
|
||||
type: TableColumnType.Boolean,
|
||||
title: "Should subscribers be notified?",
|
||||
description: "Should subscribers be notified about this state change?",
|
||||
defaultValue: true,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
@@ -461,6 +508,7 @@ export default class IncidentStateTimeline extends BaseModel {
|
||||
isDefaultValueColumn: true,
|
||||
title: "Are Owners Notified",
|
||||
description: "Are owners notified of state change?",
|
||||
defaultValue: false,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
|
||||
@@ -114,6 +114,7 @@ import StatusPageOwnerTeam from "./StatusPageOwnerTeam";
|
||||
import StatusPageOwnerUser from "./StatusPageOwnerUser";
|
||||
import StatusPagePrivateUser from "./StatusPagePrivateUser";
|
||||
import StatusPageResource from "./StatusPageResource";
|
||||
import StatusPageSCIM from "./StatusPageSCIM";
|
||||
import StatusPageSSO from "./StatusPageSso";
|
||||
import StatusPageSubscriber from "./StatusPageSubscriber";
|
||||
// Team
|
||||
@@ -179,6 +180,7 @@ import ProjectUser from "./ProjectUser";
|
||||
import OnCallDutyPolicyUserOverride from "./OnCallDutyPolicyUserOverride";
|
||||
import MonitorFeed from "./MonitorFeed";
|
||||
import MetricType from "./MetricType";
|
||||
import ProjectSCIM from "./ProjectSCIM";
|
||||
|
||||
const AllModelTypes: Array<{
|
||||
new (): BaseModel;
|
||||
@@ -276,6 +278,7 @@ const AllModelTypes: Array<{
|
||||
|
||||
ProjectSSO,
|
||||
StatusPageSSO,
|
||||
StatusPageSCIM,
|
||||
|
||||
MonitorProbe,
|
||||
|
||||
@@ -380,6 +383,8 @@ const AllModelTypes: Array<{
|
||||
MetricType,
|
||||
|
||||
OnCallDutyPolicyTimeLog,
|
||||
|
||||
ProjectSCIM,
|
||||
];
|
||||
|
||||
const modelTypeMap: { [key: string]: { new (): BaseModel } } = {};
|
||||
|
||||
@@ -456,6 +456,7 @@ export default class MonitorStatus extends BaseModel {
|
||||
canReadOnRelationQuery: true,
|
||||
title: "Is Offline State",
|
||||
description: "Is this monitor in offline state?",
|
||||
defaultValue: false,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
|
||||
451
Common/Models/DatabaseModels/ProjectSCIM.ts
Normal file
451
Common/Models/DatabaseModels/ProjectSCIM.ts
Normal file
@@ -0,0 +1,451 @@
|
||||
import Project from "./Project";
|
||||
import Team from "./Team";
|
||||
import User from "./User";
|
||||
import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel";
|
||||
import Route from "../../Types/API/Route";
|
||||
import { PlanType } from "../../Types/Billing/SubscriptionPlan";
|
||||
import ColumnAccessControl from "../../Types/Database/AccessControl/ColumnAccessControl";
|
||||
import TableAccessControl from "../../Types/Database/AccessControl/TableAccessControl";
|
||||
import TableBillingAccessControl from "../../Types/Database/AccessControl/TableBillingAccessControl";
|
||||
import ColumnLength from "../../Types/Database/ColumnLength";
|
||||
import ColumnType from "../../Types/Database/ColumnType";
|
||||
import CrudApiEndpoint from "../../Types/Database/CrudApiEndpoint";
|
||||
import TableColumn from "../../Types/Database/TableColumn";
|
||||
import TableColumnType from "../../Types/Database/TableColumnType";
|
||||
import TableMetadata from "../../Types/Database/TableMetadata";
|
||||
import TenantColumn from "../../Types/Database/TenantColumn";
|
||||
import UniqueColumnBy from "../../Types/Database/UniqueColumnBy";
|
||||
import IconProp from "../../Types/Icon/IconProp";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import Permission from "../../Types/Permission";
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
JoinTable,
|
||||
ManyToMany,
|
||||
ManyToOne,
|
||||
} from "typeorm";
|
||||
|
||||
@TableBillingAccessControl({
|
||||
create: PlanType.Scale,
|
||||
read: PlanType.Scale,
|
||||
update: PlanType.Scale,
|
||||
delete: PlanType.Scale,
|
||||
})
|
||||
@TenantColumn("projectId")
|
||||
@TableAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSSO,
|
||||
],
|
||||
delete: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.DeleteProjectSSO,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditProjectSSO,
|
||||
],
|
||||
})
|
||||
@CrudApiEndpoint(new Route("/project-scim"))
|
||||
@TableMetadata({
|
||||
tableName: "ProjectSCIM",
|
||||
singularName: "SCIM",
|
||||
pluralName: "SCIM",
|
||||
icon: IconProp.Lock,
|
||||
tableDescription: "Manage SCIM auto-provisioning for your project",
|
||||
})
|
||||
@Entity({
|
||||
name: "ProjectSCIM",
|
||||
})
|
||||
export default class ProjectSCIM extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSSO,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "projectId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: Project,
|
||||
title: "Project",
|
||||
description: "Relation to Project Resource in which this object belongs",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return Project;
|
||||
},
|
||||
{
|
||||
eager: false,
|
||||
nullable: true,
|
||||
onDelete: "CASCADE",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "projectId" })
|
||||
public project?: Project = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSSO,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@Index()
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
required: true,
|
||||
canReadOnRelationQuery: true,
|
||||
title: "Project ID",
|
||||
description: "ID of your OneUptime Project in which this object belongs",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: false,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public projectId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSSO,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditProjectSSO,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
required: true,
|
||||
type: TableColumnType.ShortText,
|
||||
canReadOnRelationQuery: true,
|
||||
title: "Name",
|
||||
description: "Any friendly name for this SCIM configuration",
|
||||
})
|
||||
@Column({
|
||||
nullable: false,
|
||||
type: ColumnType.ShortText,
|
||||
length: ColumnLength.ShortText,
|
||||
})
|
||||
@UniqueColumnBy("projectId")
|
||||
public name?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSSO,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditProjectSSO,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
required: false,
|
||||
type: TableColumnType.LongText,
|
||||
title: "Description",
|
||||
description: "Friendly description to help you remember",
|
||||
})
|
||||
@Column({
|
||||
nullable: true,
|
||||
type: ColumnType.LongText,
|
||||
length: ColumnLength.LongText,
|
||||
})
|
||||
public description?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ReadProjectSSO,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditProjectSSO,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
required: true,
|
||||
type: TableColumnType.LongText,
|
||||
title: "Bearer Token",
|
||||
description: "Bearer token for SCIM authentication. Keep this secure.",
|
||||
})
|
||||
@Column({
|
||||
nullable: false,
|
||||
type: ColumnType.LongText,
|
||||
length: ColumnLength.LongText,
|
||||
})
|
||||
public bearerToken?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSSO,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditProjectSSO,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
required: false,
|
||||
type: TableColumnType.EntityArray,
|
||||
modelType: Team,
|
||||
title: "Default Teams",
|
||||
description: "Default teams that new users will be added to via SCIM",
|
||||
})
|
||||
@ManyToMany(
|
||||
() => {
|
||||
return Team;
|
||||
},
|
||||
{ eager: false },
|
||||
)
|
||||
@JoinTable({
|
||||
name: "ProjectScimTeam",
|
||||
inverseJoinColumn: {
|
||||
name: "teamId",
|
||||
referencedColumnName: "_id",
|
||||
},
|
||||
joinColumn: {
|
||||
name: "projectScimId",
|
||||
referencedColumnName: "_id",
|
||||
},
|
||||
})
|
||||
public teams?: Array<Team> = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSSO,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditProjectSSO,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
isDefaultValueColumn: true,
|
||||
type: TableColumnType.Boolean,
|
||||
title: "Auto Provision Users",
|
||||
description: "Automatically create users when they are added via SCIM",
|
||||
defaultValue: true,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
default: true,
|
||||
})
|
||||
public autoProvisionUsers?: boolean = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSSO,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditProjectSSO,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
isDefaultValueColumn: true,
|
||||
type: TableColumnType.Boolean,
|
||||
title: "Auto Deprovision Users",
|
||||
description: "Automatically remove users when they are removed via SCIM",
|
||||
defaultValue: true,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
default: true,
|
||||
})
|
||||
public autoDeprovisionUsers?: boolean = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSSO,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "createdByUserId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: User,
|
||||
title: "Created by User",
|
||||
description:
|
||||
"Relation to User who created this object (if this object was created by a User)",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return User;
|
||||
},
|
||||
{
|
||||
eager: false,
|
||||
nullable: true,
|
||||
onDelete: "SET NULL",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "createdByUserId" })
|
||||
public createdByUser?: User = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSSO,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
title: "Created by User ID",
|
||||
description:
|
||||
"User ID who created this object (if this object was created by a User)",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: true,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public createdByUserId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSSO,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "deletedByUserId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: User,
|
||||
title: "Deleted by User",
|
||||
description:
|
||||
"Relation to User who deleted this object (if this object was deleted by a User)",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return User;
|
||||
},
|
||||
{
|
||||
cascade: false,
|
||||
eager: false,
|
||||
nullable: true,
|
||||
onDelete: "SET NULL",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "deletedByUserId" })
|
||||
public deletedByUser?: User = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSSO,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
title: "Deleted by User ID",
|
||||
description:
|
||||
"User ID who deleted this object (if this object was deleted by a User)",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: true,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public deletedByUserId?: ObjectID = undefined;
|
||||
}
|
||||
@@ -578,7 +578,11 @@ export default class ProjectSSO extends BaseModel {
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({ isDefaultValueColumn: true, type: TableColumnType.Boolean })
|
||||
@TableColumn({
|
||||
isDefaultValueColumn: true,
|
||||
type: TableColumnType.Boolean,
|
||||
defaultValue: false,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
default: false,
|
||||
|
||||
@@ -49,8 +49,8 @@ import {
|
||||
})
|
||||
@TableMetadata({
|
||||
tableName: "ProjectUser",
|
||||
singularName: "User",
|
||||
pluralName: "Users",
|
||||
singularName: "Project User",
|
||||
pluralName: "Project Users",
|
||||
icon: IconProp.User,
|
||||
tableDescription:
|
||||
"This model connects users and teams. This is an internal table. Its a view on TeamMembers table.",
|
||||
|
||||
@@ -269,6 +269,7 @@ export default class Reseller extends BaseModel {
|
||||
canReadOnRelationQuery: true,
|
||||
title: "Enable Telemetry Features",
|
||||
description: "Should we enable telemetry features for this reseller?",
|
||||
defaultValue: false,
|
||||
})
|
||||
@Column({
|
||||
nullable: true,
|
||||
|
||||
@@ -25,6 +25,7 @@ import IconProp from "../../Types/Icon/IconProp";
|
||||
import { JSONObject } from "../../Types/JSON";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import Permission from "../../Types/Permission";
|
||||
import StatusPageSubscriberNotificationStatus from "../../Types/StatusPage/StatusPageSubscriberNotificationStatus";
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
@@ -715,29 +716,74 @@ export default class ScheduledMaintenance extends BaseModel {
|
||||
public endsAt?: Date = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateProjectScheduledMaintenance,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectScheduledMaintenance,
|
||||
],
|
||||
update: [],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.EditProjectScheduledMaintenance,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
isDefaultValueColumn: true,
|
||||
computed: true,
|
||||
hideColumnInDocumentation: true,
|
||||
type: TableColumnType.Boolean,
|
||||
title: "Status Page Subscribers Notified On Event Scheduled",
|
||||
description: "Status Page Subscribers Notified On Event Scheduled",
|
||||
defaultValue: false,
|
||||
type: TableColumnType.ShortText,
|
||||
title: "Subscriber Notification Status On Event Scheduled",
|
||||
description:
|
||||
"Status of notification sent to subscribers when event was scheduled",
|
||||
defaultValue: StatusPageSubscriberNotificationStatus.Pending,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
default: false,
|
||||
type: ColumnType.ShortText,
|
||||
default: StatusPageSubscriberNotificationStatus.Pending,
|
||||
})
|
||||
public isStatusPageSubscribersNotifiedOnEventScheduled?: boolean = undefined;
|
||||
public subscriberNotificationStatusOnEventScheduled?: StatusPageSubscriberNotificationStatus =
|
||||
undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateIncidentPublicNote,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectScheduledMaintenance,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.EditProjectScheduledMaintenance,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.VeryLongText,
|
||||
title: "Notification Status Message On Event Scheduled",
|
||||
description:
|
||||
"Status message for subscriber notifications when event is scheduled - includes success messages, failure reasons, or skip reasons",
|
||||
required: false,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.VeryLongText,
|
||||
nullable: true,
|
||||
})
|
||||
public subscriberNotificationStatusMessage?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
|
||||
@@ -17,6 +17,7 @@ import TenantColumn from "../../Types/Database/TenantColumn";
|
||||
import IconProp from "../../Types/Icon/IconProp";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import Permission from "../../Types/Permission";
|
||||
import StatusPageSubscriberNotificationStatus from "../../Types/StatusPage/StatusPageSubscriberNotificationStatus";
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
|
||||
@EnableDocumentation()
|
||||
@@ -342,29 +343,73 @@ export default class ScheduledMaintenancePublicNote extends BaseModel {
|
||||
public note?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateScheduledMaintenancePublicNote,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadScheduledMaintenancePublicNote,
|
||||
],
|
||||
update: [],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.EditScheduledMaintenancePublicNote,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
isDefaultValueColumn: true,
|
||||
computed: true,
|
||||
hideColumnInDocumentation: true,
|
||||
type: TableColumnType.Boolean,
|
||||
title: "Are subscribers notified?",
|
||||
description: "Are subscribers notified about this note?",
|
||||
defaultValue: false,
|
||||
type: TableColumnType.ShortText,
|
||||
title: "Subscriber Notification Status",
|
||||
description: "Status of notification sent to subscribers about this note",
|
||||
defaultValue: StatusPageSubscriberNotificationStatus.Pending,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
default: false,
|
||||
type: ColumnType.ShortText,
|
||||
default: StatusPageSubscriberNotificationStatus.Pending,
|
||||
})
|
||||
public isStatusPageSubscribersNotifiedOnNoteCreated?: boolean = undefined;
|
||||
public subscriberNotificationStatusOnNoteCreated?: StatusPageSubscriberNotificationStatus =
|
||||
undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateScheduledMaintenancePublicNote,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadScheduledMaintenancePublicNote,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.EditScheduledMaintenancePublicNote,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.VeryLongText,
|
||||
title: "Notification Status Message",
|
||||
description:
|
||||
"Status message for subscriber notifications - includes success messages, failure reasons, or skip reasons",
|
||||
required: false,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.VeryLongText,
|
||||
nullable: true,
|
||||
})
|
||||
public subscriberNotificationStatusMessage?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
|
||||
@@ -423,6 +423,7 @@ export default class ScheduledMaintenanceState extends BaseModel {
|
||||
canReadOnRelationQuery: true,
|
||||
title: "Scheduled State",
|
||||
description: "Is this state a scheduled state?",
|
||||
defaultValue: false,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
@@ -456,6 +457,7 @@ export default class ScheduledMaintenanceState extends BaseModel {
|
||||
canReadOnRelationQuery: true,
|
||||
title: "Ongoing State",
|
||||
description: "Is this state a ongoing state?",
|
||||
defaultValue: false,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
@@ -489,6 +491,7 @@ export default class ScheduledMaintenanceState extends BaseModel {
|
||||
canReadOnRelationQuery: true,
|
||||
title: "Ended State",
|
||||
description: "Is this state a ended state?",
|
||||
defaultValue: false,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
@@ -522,6 +525,7 @@ export default class ScheduledMaintenanceState extends BaseModel {
|
||||
canReadOnRelationQuery: true,
|
||||
title: "Resolved State",
|
||||
description: "Is this state a resolved state?",
|
||||
defaultValue: false,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
|
||||
@@ -18,6 +18,7 @@ import TenantColumn from "../../Types/Database/TenantColumn";
|
||||
import IconProp from "../../Types/Icon/IconProp";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import Permission from "../../Types/Permission";
|
||||
import StatusPageSubscriberNotificationStatus from "../../Types/StatusPage/StatusPageSubscriberNotificationStatus";
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
|
||||
@EnableDocumentation()
|
||||
@@ -388,29 +389,74 @@ export default class ScheduledMaintenanceStateTimeline extends BaseModel {
|
||||
public scheduledMaintenanceStateId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateScheduledMaintenanceStateTimeline,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadScheduledMaintenanceStateTimeline,
|
||||
],
|
||||
update: [],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.EditScheduledMaintenanceStateTimeline,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
isDefaultValueColumn: true,
|
||||
computed: true,
|
||||
hideColumnInDocumentation: true,
|
||||
type: TableColumnType.Boolean,
|
||||
title: "Are subscribers notified?",
|
||||
description: "Are subscribers notified about this incident state change?",
|
||||
defaultValue: false,
|
||||
type: TableColumnType.ShortText,
|
||||
title: "Subscriber Notification Status",
|
||||
description:
|
||||
"Status of notification sent to subscribers about this scheduled maintenance state change",
|
||||
defaultValue: StatusPageSubscriberNotificationStatus.Pending,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
default: false,
|
||||
type: ColumnType.ShortText,
|
||||
default: StatusPageSubscriberNotificationStatus.Pending,
|
||||
})
|
||||
public isStatusPageSubscribersNotified?: boolean = undefined;
|
||||
public subscriberNotificationStatus?: StatusPageSubscriberNotificationStatus =
|
||||
undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateScheduledMaintenanceStateTimeline,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadScheduledMaintenanceStateTimeline,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.EditScheduledMaintenanceStateTimeline,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.VeryLongText,
|
||||
title: "Notification Status Message",
|
||||
description:
|
||||
"Status message for subscriber notifications - includes success messages, failure reasons, or skip reasons",
|
||||
required: false,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.VeryLongText,
|
||||
nullable: true,
|
||||
})
|
||||
public subscriberNotificationStatusMessage?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
|
||||
@@ -1438,7 +1438,12 @@ export default class StatusPage extends BaseModel {
|
||||
public callSmsConfigId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateProjectStatusPage,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
@@ -1490,6 +1495,7 @@ export default class StatusPage extends BaseModel {
|
||||
type: TableColumnType.Number,
|
||||
required: true,
|
||||
isDefaultValueColumn: true,
|
||||
defaultValue: 14,
|
||||
title: "Show incident history in days",
|
||||
description:
|
||||
"How many days of incident history should be shown on the status page (in days)?",
|
||||
@@ -1526,6 +1532,7 @@ export default class StatusPage extends BaseModel {
|
||||
type: TableColumnType.Number,
|
||||
required: true,
|
||||
isDefaultValueColumn: true,
|
||||
defaultValue: 14,
|
||||
title: "Show announcement history in days",
|
||||
description:
|
||||
"How many days of announcement history should be shown on the status page (in days)?",
|
||||
@@ -1562,6 +1569,7 @@ export default class StatusPage extends BaseModel {
|
||||
type: TableColumnType.Number,
|
||||
required: true,
|
||||
isDefaultValueColumn: true,
|
||||
defaultValue: 14,
|
||||
title: "Show scheduled event history in days",
|
||||
description:
|
||||
"How many days of scheduled event history should be shown on the status page (in days)?",
|
||||
@@ -1633,6 +1641,7 @@ export default class StatusPage extends BaseModel {
|
||||
type: TableColumnType.Boolean,
|
||||
title: "Hide Powered By OneUptime Branding",
|
||||
description: "Hide Powered By OneUptime Branding?",
|
||||
defaultValue: false,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
@@ -1706,6 +1715,7 @@ export default class StatusPage extends BaseModel {
|
||||
required: false,
|
||||
type: TableColumnType.EntityArray,
|
||||
modelType: MonitorStatus,
|
||||
isDefaultValueColumn: true,
|
||||
title: "Downtime Monitor Statuses",
|
||||
description:
|
||||
'List of monitors statuses that are considered as "down" for this status page.',
|
||||
@@ -1788,6 +1798,7 @@ export default class StatusPage extends BaseModel {
|
||||
type: TableColumnType.Boolean,
|
||||
title: "Is Report Enabled",
|
||||
description: "Is Report Enabled for this Status Page?",
|
||||
defaultValue: false,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
@@ -1931,6 +1942,7 @@ export default class StatusPage extends BaseModel {
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.Number,
|
||||
defaultValue: 30,
|
||||
title: "Report data for the last N days",
|
||||
description: "How many days of data should be included in the report?",
|
||||
})
|
||||
@@ -1971,6 +1983,7 @@ export default class StatusPage extends BaseModel {
|
||||
type: TableColumnType.Boolean,
|
||||
title: "Show Overall Uptime Percent on Status Page",
|
||||
description: "Show Overall Uptime Percent on Status Page?",
|
||||
defaultValue: false,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
@@ -2007,6 +2020,7 @@ export default class StatusPage extends BaseModel {
|
||||
type: TableColumnType.ShortText,
|
||||
title: "Overall Uptime Percent Precision",
|
||||
required: false,
|
||||
defaultValue: UptimePrecision.TWO_DECIMAL,
|
||||
description: "Overall Precision of uptime percent for this status page.",
|
||||
})
|
||||
@Column({
|
||||
@@ -2109,6 +2123,7 @@ export default class StatusPage extends BaseModel {
|
||||
type: TableColumnType.Boolean,
|
||||
title: "Show Incidents on Status Page",
|
||||
description: "Show Incidents on Status Page?",
|
||||
defaultValue: true,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
@@ -2147,6 +2162,7 @@ export default class StatusPage extends BaseModel {
|
||||
type: TableColumnType.Boolean,
|
||||
title: "Show Announcements on Status Page",
|
||||
description: "Show Announcements on Status Page?",
|
||||
defaultValue: true,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
@@ -2185,6 +2201,7 @@ export default class StatusPage extends BaseModel {
|
||||
type: TableColumnType.Boolean,
|
||||
title: "Show Scheduled Maintenance Events on Status Page",
|
||||
description: "Show Scheduled Maintenance Events on Status Page?",
|
||||
defaultValue: true,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
@@ -2223,6 +2240,7 @@ export default class StatusPage extends BaseModel {
|
||||
type: TableColumnType.Boolean,
|
||||
title: "Show Subscriber Page on Status Page",
|
||||
description: "Show Subscriber Page on Status Page?",
|
||||
defaultValue: true,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
|
||||
@@ -21,6 +21,7 @@ import TenantColumn from "../../Types/Database/TenantColumn";
|
||||
import IconProp from "../../Types/Icon/IconProp";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import Permission from "../../Types/Permission";
|
||||
import StatusPageSubscriberNotificationStatus from "../../Types/StatusPage/StatusPageSubscriberNotificationStatus";
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
@@ -443,27 +444,71 @@ export default class StatusPageAnnouncement extends BaseModel {
|
||||
public deletedByUserId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateStatusPageAnnouncement,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadStatusPageAnnouncement,
|
||||
],
|
||||
update: [],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.EditStatusPageAnnouncement,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
isDefaultValueColumn: true,
|
||||
computed: true,
|
||||
hideColumnInDocumentation: true,
|
||||
type: TableColumnType.Boolean,
|
||||
defaultValue: false,
|
||||
type: TableColumnType.ShortText,
|
||||
defaultValue: StatusPageSubscriberNotificationStatus.Pending,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
default: false,
|
||||
type: ColumnType.ShortText,
|
||||
default: StatusPageSubscriberNotificationStatus.Pending,
|
||||
})
|
||||
public isStatusPageSubscribersNotified?: boolean = undefined;
|
||||
public subscriberNotificationStatus?: StatusPageSubscriberNotificationStatus =
|
||||
undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateStatusPageAnnouncement,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadStatusPageAnnouncement,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.EditStatusPageAnnouncement,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.VeryLongText,
|
||||
title: "Notification Status Message",
|
||||
description:
|
||||
"Status message for subscriber notifications - includes success messages, failure reasons, or skip reasons",
|
||||
required: false,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.VeryLongText,
|
||||
nullable: true,
|
||||
})
|
||||
public subscriberNotificationStatusMessage?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
@@ -485,6 +530,7 @@ export default class StatusPageAnnouncement extends BaseModel {
|
||||
type: TableColumnType.Boolean,
|
||||
title: "Should subscribers be notified?",
|
||||
description: "Should subscribers be notified about this announcement?",
|
||||
defaultValue: true,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
@@ -493,7 +539,12 @@ export default class StatusPageAnnouncement extends BaseModel {
|
||||
public shouldStatusPageSubscribersBeNotified?: boolean = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateStatusPageAnnouncement,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
@@ -511,6 +562,7 @@ export default class StatusPageAnnouncement extends BaseModel {
|
||||
isDefaultValueColumn: true,
|
||||
title: "Are Owners Notified",
|
||||
description: "Are owners notified of this announcement?",
|
||||
defaultValue: false,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
|
||||
@@ -474,7 +474,12 @@ export default class StatusPageDomain extends BaseModel {
|
||||
public isCnameVerified?: boolean = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateStatusPageDomain,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
@@ -489,6 +494,7 @@ export default class StatusPageDomain extends BaseModel {
|
||||
type: TableColumnType.Boolean,
|
||||
title: "SSL Ordered",
|
||||
description: "Is SSL ordered?",
|
||||
defaultValue: false,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
@@ -499,7 +505,12 @@ export default class StatusPageDomain extends BaseModel {
|
||||
public isSslOrdered?: boolean = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateStatusPageDomain,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
@@ -514,6 +525,7 @@ export default class StatusPageDomain extends BaseModel {
|
||||
type: TableColumnType.Boolean,
|
||||
title: "SSL Provisioned",
|
||||
description: "Is SSL provisioned?",
|
||||
defaultValue: false,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
|
||||
469
Common/Models/DatabaseModels/StatusPageSCIM.ts
Normal file
469
Common/Models/DatabaseModels/StatusPageSCIM.ts
Normal file
@@ -0,0 +1,469 @@
|
||||
import Project from "./Project";
|
||||
import StatusPage from "./StatusPage";
|
||||
import User from "./User";
|
||||
import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel";
|
||||
import Route from "../../Types/API/Route";
|
||||
import { PlanType } from "../../Types/Billing/SubscriptionPlan";
|
||||
import ColumnAccessControl from "../../Types/Database/AccessControl/ColumnAccessControl";
|
||||
import TableAccessControl from "../../Types/Database/AccessControl/TableAccessControl";
|
||||
import TableBillingAccessControl from "../../Types/Database/AccessControl/TableBillingAccessControl";
|
||||
import CanAccessIfCanReadOn from "../../Types/Database/CanAccessIfCanReadOn";
|
||||
import ColumnLength from "../../Types/Database/ColumnLength";
|
||||
import ColumnType from "../../Types/Database/ColumnType";
|
||||
import CrudApiEndpoint from "../../Types/Database/CrudApiEndpoint";
|
||||
import EnableDocumentation from "../../Types/Database/EnableDocumentation";
|
||||
import TableColumn from "../../Types/Database/TableColumn";
|
||||
import TableColumnType from "../../Types/Database/TableColumnType";
|
||||
import TableMetadata from "../../Types/Database/TableMetadata";
|
||||
import TenantColumn from "../../Types/Database/TenantColumn";
|
||||
import UniqueColumnBy from "../../Types/Database/UniqueColumnBy";
|
||||
import IconProp from "../../Types/Icon/IconProp";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import Permission from "../../Types/Permission";
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
|
||||
@EnableDocumentation()
|
||||
@TableBillingAccessControl({
|
||||
create: PlanType.Scale,
|
||||
read: PlanType.Scale,
|
||||
update: PlanType.Scale,
|
||||
delete: PlanType.Scale,
|
||||
})
|
||||
@CanAccessIfCanReadOn("statusPage")
|
||||
@TenantColumn("projectId")
|
||||
@TableAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateStatusPageSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadStatusPageSSO,
|
||||
],
|
||||
delete: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.DeleteStatusPageSSO,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditStatusPageSSO,
|
||||
],
|
||||
})
|
||||
@CrudApiEndpoint(new Route("/status-page-scim"))
|
||||
@TableMetadata({
|
||||
tableName: "StatusPageSCIM",
|
||||
singularName: "Status Page SCIM",
|
||||
pluralName: "Status Page SCIM",
|
||||
icon: IconProp.Lock,
|
||||
tableDescription: "Manage SCIM auto-provisioning for your status page",
|
||||
})
|
||||
@Entity({
|
||||
name: "StatusPageSCIM",
|
||||
})
|
||||
export default class StatusPageSCIM extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateStatusPageSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadStatusPageSSO,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "projectId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: Project,
|
||||
title: "Project",
|
||||
description: "Relation to Project Resource in which this object belongs",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return Project;
|
||||
},
|
||||
{
|
||||
eager: false,
|
||||
nullable: true,
|
||||
onDelete: "CASCADE",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "projectId" })
|
||||
public project?: Project = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateStatusPageSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadStatusPageSSO,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@Index()
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
required: true,
|
||||
canReadOnRelationQuery: true,
|
||||
title: "Project ID",
|
||||
description: "ID of your OneUptime Project in which this object belongs",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: false,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public projectId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateStatusPageSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadStatusPageSSO,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "statusPageId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: StatusPage,
|
||||
title: "Status Page",
|
||||
description:
|
||||
"Relation to Status Page Resource in which this object belongs",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return StatusPage;
|
||||
},
|
||||
{
|
||||
eager: false,
|
||||
nullable: true,
|
||||
onDelete: "CASCADE",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "statusPageId" })
|
||||
public statusPage?: StatusPage = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateStatusPageSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadStatusPageSSO,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@Index()
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
required: true,
|
||||
title: "Status Page ID",
|
||||
description: "ID of your Status Page resource where this object belongs",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: false,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public statusPageId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateStatusPageSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadStatusPageSSO,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditStatusPageSSO,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
required: true,
|
||||
type: TableColumnType.ShortText,
|
||||
canReadOnRelationQuery: true,
|
||||
title: "Name",
|
||||
description: "Any friendly name for this SCIM configuration",
|
||||
})
|
||||
@Column({
|
||||
nullable: false,
|
||||
type: ColumnType.ShortText,
|
||||
length: ColumnLength.ShortText,
|
||||
})
|
||||
@UniqueColumnBy("statusPageId")
|
||||
public name?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateStatusPageSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadStatusPageSSO,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditStatusPageSSO,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
required: false,
|
||||
type: TableColumnType.LongText,
|
||||
title: "Description",
|
||||
description: "Friendly description to help you remember",
|
||||
})
|
||||
@Column({
|
||||
nullable: true,
|
||||
type: ColumnType.LongText,
|
||||
length: ColumnLength.LongText,
|
||||
})
|
||||
public description?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateStatusPageSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ReadStatusPageSSO,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditStatusPageSSO,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
required: true,
|
||||
type: TableColumnType.LongText,
|
||||
title: "Bearer Token",
|
||||
description: "Bearer token for SCIM authentication. Keep this secure.",
|
||||
})
|
||||
@Column({
|
||||
nullable: false,
|
||||
type: ColumnType.LongText,
|
||||
length: ColumnLength.LongText,
|
||||
})
|
||||
public bearerToken?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateStatusPageSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadStatusPageSSO,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditStatusPageSSO,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
isDefaultValueColumn: true,
|
||||
type: TableColumnType.Boolean,
|
||||
title: "Auto Provision Users",
|
||||
description:
|
||||
"Automatically create status page users when they are added via SCIM",
|
||||
defaultValue: true,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
default: true,
|
||||
})
|
||||
public autoProvisionUsers?: boolean = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateStatusPageSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadStatusPageSSO,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditStatusPageSSO,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
isDefaultValueColumn: true,
|
||||
type: TableColumnType.Boolean,
|
||||
title: "Auto Deprovision Users",
|
||||
description:
|
||||
"Automatically remove status page users when they are removed via SCIM",
|
||||
defaultValue: true,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
default: true,
|
||||
})
|
||||
public autoDeprovisionUsers?: boolean = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadStatusPageSSO,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "createdByUserId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: User,
|
||||
title: "Created by User",
|
||||
description:
|
||||
"Relation to User who created this object (if this object was created by a User)",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return User;
|
||||
},
|
||||
{
|
||||
eager: false,
|
||||
nullable: true,
|
||||
onDelete: "SET NULL",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "createdByUserId" })
|
||||
public createdByUser?: User = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateStatusPageSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadStatusPageSSO,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
title: "Created by User ID",
|
||||
description:
|
||||
"User ID who created this object (if this object was created by a User)",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: true,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public createdByUserId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadStatusPageSSO,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "deletedByUserId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: User,
|
||||
title: "Deleted by User",
|
||||
description:
|
||||
"Relation to User who deleted this object (if this object was deleted by a User)",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return User;
|
||||
},
|
||||
{
|
||||
cascade: false,
|
||||
eager: false,
|
||||
nullable: true,
|
||||
onDelete: "SET NULL",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "deletedByUserId" })
|
||||
public deletedByUser?: User = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadStatusPageSSO,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
title: "Deleted by User ID",
|
||||
description:
|
||||
"User ID who deleted this object (if this object was deleted by a User)",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: true,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public deletedByUserId?: ObjectID = undefined;
|
||||
}
|
||||
@@ -608,6 +608,7 @@ export default class StatusPageSubscriber extends BaseModel {
|
||||
type: TableColumnType.Boolean,
|
||||
title: "Send You Have Subscribed Message",
|
||||
description: "Send You Have Subscribed Message when subscriber is created?",
|
||||
defaultValue: true,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
@@ -642,6 +643,7 @@ export default class StatusPageSubscriber extends BaseModel {
|
||||
title: "Is Subscribed to All Resources",
|
||||
description:
|
||||
"Is Subscriber Subscribed to All Resources on this status page?",
|
||||
defaultValue: true,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
@@ -676,6 +678,7 @@ export default class StatusPageSubscriber extends BaseModel {
|
||||
title: "Is Subscribed to All Event Types",
|
||||
description:
|
||||
"Is Subscriber Subscribed to All Event Types (like Incidents, Scheduled Events, Announcements) on this status page?",
|
||||
defaultValue: true,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
|
||||
@@ -442,6 +442,7 @@ export default class TableView extends BaseModel {
|
||||
type: TableColumnType.Number,
|
||||
canReadOnRelationQuery: true,
|
||||
description: "Items on page",
|
||||
defaultValue: 10,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Number,
|
||||
|
||||
@@ -146,7 +146,11 @@ export default class TelemetryUsageBilling extends BaseModel {
|
||||
public productType?: ProductType = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ManageProjectBilling,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
@@ -158,6 +162,7 @@ export default class TelemetryUsageBilling extends BaseModel {
|
||||
type: TableColumnType.Number,
|
||||
title: "Retain Telemetry Data For Days",
|
||||
description: "Number of days to retain telemetry data for this service.",
|
||||
defaultValue: DEFAULT_RETENTION_IN_DAYS,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Number,
|
||||
|
||||
@@ -278,7 +278,11 @@ class UserNotificationSetting extends BaseModel {
|
||||
read: [Permission.CurrentUser],
|
||||
update: [Permission.CurrentUser],
|
||||
})
|
||||
@TableColumn({ isDefaultValueColumn: true, type: TableColumnType.Boolean })
|
||||
@TableColumn({
|
||||
isDefaultValueColumn: true,
|
||||
type: TableColumnType.Boolean,
|
||||
defaultValue: false,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
default: false,
|
||||
|
||||
172
Common/Server/API/MicrosoftTeamsAPI.ts
Normal file
172
Common/Server/API/MicrosoftTeamsAPI.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import Express, {
|
||||
ExpressRequest,
|
||||
ExpressResponse,
|
||||
ExpressRouter,
|
||||
} from "../Utils/Express";
|
||||
import Response from "../Utils/Response";
|
||||
import BadDataException from "../../Types/Exception/BadDataException";
|
||||
import { JSONObject } from "../../Types/JSON";
|
||||
import {
|
||||
AppApiClientUrl,
|
||||
DashboardClientUrl,
|
||||
MicrosoftTeamsAppClientId,
|
||||
MicrosoftTeamsAppClientSecret,
|
||||
} from "../EnvironmentConfig";
|
||||
import URL from "../../Types/API/URL";
|
||||
import HTTPErrorResponse from "../../Types/API/HTTPErrorResponse";
|
||||
import HTTPResponse from "../../Types/API/HTTPResponse";
|
||||
import API from "../../Utils/API";
|
||||
import WorkspaceProjectAuthTokenService from "../Services/WorkspaceProjectAuthTokenService";
|
||||
import WorkspaceUserAuthTokenService from "../Services/WorkspaceUserAuthTokenService";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import WorkspaceType from "../../Types/Workspace/WorkspaceType";
|
||||
|
||||
export default class MicrosoftTeamsAPI {
|
||||
public getRouter(): ExpressRouter {
|
||||
const router: ExpressRouter = Express.getRouter();
|
||||
|
||||
// Project-level OAuth callback
|
||||
router.get(
|
||||
"/microsoft-teams/auth/:projectId/:userId",
|
||||
async (req: ExpressRequest, res: ExpressResponse) => {
|
||||
if (!MicrosoftTeamsAppClientId) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadDataException("Microsoft Teams App Client ID is not set"),
|
||||
);
|
||||
}
|
||||
|
||||
if (!MicrosoftTeamsAppClientSecret) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadDataException(
|
||||
"Microsoft Teams App Client Secret is not set",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const projectId: string | undefined =
|
||||
req.params["projectId"]?.toString();
|
||||
const userId: string | undefined = req.params["userId"]?.toString();
|
||||
|
||||
if (!projectId) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadDataException("Invalid ProjectID in request"),
|
||||
);
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadDataException("Invalid UserID in request"),
|
||||
);
|
||||
}
|
||||
|
||||
const error: string | undefined = req.query["error"]?.toString();
|
||||
const integrationPageUrl: URL = URL.fromString(
|
||||
DashboardClientUrl.toString() +
|
||||
`/${projectId.toString()}/settings/microsoft-teams-integration`,
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return Response.redirect(
|
||||
req,
|
||||
res,
|
||||
integrationPageUrl.addQueryParam("error", error),
|
||||
);
|
||||
}
|
||||
|
||||
const code: string | undefined = req.query["code"]?.toString();
|
||||
const tenant: string | undefined = req.query["tenant"]?.toString();
|
||||
|
||||
if (!code) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadDataException("Invalid request"),
|
||||
);
|
||||
}
|
||||
|
||||
const tenantSegment: string = tenant || "common";
|
||||
const redirectUri: URL = URL.fromString(
|
||||
`${AppApiClientUrl.toString()}/microsoft-teams/auth/${projectId}/${userId}`,
|
||||
);
|
||||
|
||||
const tokenEndpoint: URL = URL.fromString(
|
||||
`https://login.microsoftonline.com/${tenantSegment}/oauth2/v2.0/token`,
|
||||
);
|
||||
|
||||
const requestBody: JSONObject = {
|
||||
client_id: MicrosoftTeamsAppClientId,
|
||||
client_secret: MicrosoftTeamsAppClientSecret,
|
||||
code: code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: redirectUri.toString(),
|
||||
};
|
||||
|
||||
const tokenResp: HTTPErrorResponse | HTTPResponse<JSONObject> =
|
||||
await API.post(tokenEndpoint, requestBody, {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
});
|
||||
|
||||
if (tokenResp instanceof HTTPErrorResponse) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadDataException(
|
||||
tokenResp.message || "Failed to exchange code for token",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const tokenJson: JSONObject = tokenResp.data;
|
||||
const accessToken: string = (tokenJson["access_token"] as string) || "";
|
||||
const idToken: string | undefined = tokenJson["id_token"] as string | undefined;
|
||||
|
||||
const tenantId: string | undefined = ((): string | undefined => {
|
||||
try {
|
||||
if (!idToken) {
|
||||
return undefined;
|
||||
}
|
||||
const payload: JSONObject = JSON.parse(
|
||||
Buffer.from(idToken.split(".")?.[1] || "", "base64").toString(
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
return (payload["tid"] as string) || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
})();
|
||||
|
||||
await WorkspaceProjectAuthTokenService.refreshAuthToken({
|
||||
projectId: new ObjectID(projectId),
|
||||
workspaceType: WorkspaceType.MicrosoftTeams,
|
||||
authToken: accessToken,
|
||||
workspaceProjectId: tenantId || "microsoft-teams",
|
||||
miscData: {
|
||||
tenantId: tenantId || "",
|
||||
},
|
||||
});
|
||||
|
||||
await WorkspaceUserAuthTokenService.refreshAuthToken({
|
||||
projectId: new ObjectID(projectId),
|
||||
userId: new ObjectID(userId),
|
||||
workspaceType: WorkspaceType.MicrosoftTeams,
|
||||
authToken: accessToken,
|
||||
workspaceUserId: "",
|
||||
miscData: {},
|
||||
});
|
||||
|
||||
return Response.redirect(req, res, integrationPageUrl);
|
||||
},
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
DashboardRoute,
|
||||
AppApiRoute,
|
||||
StatusPageApiRoute,
|
||||
DocsRoute,
|
||||
} from "../ServiceRoute";
|
||||
import BillingConfig from "./BillingConfig";
|
||||
import Protocol from "../Types/API/Protocol";
|
||||
@@ -150,6 +151,12 @@ export const AdminDashboardHostname: Hostname = Hostname.fromString(
|
||||
}`,
|
||||
);
|
||||
|
||||
export const DocsHostname: Hostname = Hostname.fromString(
|
||||
`${process.env["SERVER_DOCS_HOSTNAME"] || "localhost"}:${
|
||||
process.env["DOCS_PORT"] || 80
|
||||
}`,
|
||||
);
|
||||
|
||||
export const Env: string = process.env["NODE_ENV"] || "production";
|
||||
|
||||
// Redis does not require password.
|
||||
@@ -318,6 +325,8 @@ export const AccountsClientUrl: URL = new URL(
|
||||
AccountsRoute,
|
||||
);
|
||||
|
||||
export const DocsClientUrl: URL = new URL(HttpProtocol, Host, DocsRoute);
|
||||
|
||||
export const DisableTelemetry: boolean =
|
||||
process.env["DISABLE_TELEMETRY"] === "true";
|
||||
|
||||
@@ -328,6 +337,12 @@ export const SlackAppClientSecret: string | null =
|
||||
export const SlackAppSigningSecret: string | null =
|
||||
process.env["SLACK_APP_SIGNING_SECRET"] || null;
|
||||
|
||||
// Microsoft Teams OAuth App Config
|
||||
export const MicrosoftTeamsAppClientId: string | null =
|
||||
process.env["MICROSOFT_TEAMS_APP_CLIENT_ID"] || null;
|
||||
export const MicrosoftTeamsAppClientSecret: string | null =
|
||||
process.env["MICROSOFT_TEAMS_APP_CLIENT_SECRET"] || null;
|
||||
|
||||
// VAPID Configuration for Web Push Notifications
|
||||
export const VapidPublicKey: string | undefined =
|
||||
process.env["VAPID_PUBLIC_KEY"] || undefined;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class MigrationName1754304193228 implements MigrationInterface {
|
||||
public name = "MigrationName1754304193228";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "ProjectSCIM" ("_id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP WITH TIME ZONE, "version" integer NOT NULL, "projectId" uuid NOT NULL, "name" character varying(100) NOT NULL, "description" character varying(500), "bearerToken" character varying(500) NOT NULL, "autoProvisionUsers" boolean NOT NULL DEFAULT true, "autoDeprovisionUsers" boolean NOT NULL DEFAULT true, "isEnabled" boolean NOT NULL DEFAULT false, "createdByUserId" uuid, "deletedByUserId" uuid, CONSTRAINT "PK_51e71d70211675a5c918aee4e68" PRIMARY KEY ("_id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_f916360335859c26c4d7051239" ON "ProjectSCIM" ("projectId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "ProjectScimTeam" ("projectScimId" uuid NOT NULL, "teamId" uuid NOT NULL, CONSTRAINT "PK_db724b66b4fa8c880ce5ccf820b" PRIMARY KEY ("projectScimId", "teamId"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_b9a28efd66600267f0e9de0731" ON "ProjectScimTeam" ("projectScimId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_bb0eda2ef0c773f975e9ad8448" ON "ProjectScimTeam" ("teamId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ProjectSCIM" ADD CONSTRAINT "FK_f916360335859c26c4d7051239b" FOREIGN KEY ("projectId") REFERENCES "Project"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ProjectSCIM" ADD CONSTRAINT "FK_5d5d587984f156e5215d51daff7" FOREIGN KEY ("createdByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ProjectSCIM" ADD CONSTRAINT "FK_9cadda4fc2af268b5670d02bf76" FOREIGN KEY ("deletedByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ProjectScimTeam" ADD CONSTRAINT "FK_b9a28efd66600267f0e9de0731b" FOREIGN KEY ("projectScimId") REFERENCES "ProjectSCIM"("_id") ON DELETE CASCADE ON UPDATE CASCADE`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ProjectScimTeam" ADD CONSTRAINT "FK_bb0eda2ef0c773f975e9ad8448a" FOREIGN KEY ("teamId") REFERENCES "Team"("_id") ON DELETE CASCADE ON UPDATE CASCADE`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ProjectScimTeam" DROP CONSTRAINT "FK_bb0eda2ef0c773f975e9ad8448a"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ProjectScimTeam" DROP CONSTRAINT "FK_b9a28efd66600267f0e9de0731b"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ProjectSCIM" DROP CONSTRAINT "FK_9cadda4fc2af268b5670d02bf76"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ProjectSCIM" DROP CONSTRAINT "FK_5d5d587984f156e5215d51daff7"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ProjectSCIM" DROP CONSTRAINT "FK_f916360335859c26c4d7051239b"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_bb0eda2ef0c773f975e9ad8448"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_b9a28efd66600267f0e9de0731"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "ProjectScimTeam"`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_f916360335859c26c4d7051239"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "ProjectSCIM"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class MigrationName1754315774827 implements MigrationInterface {
|
||||
public name = "MigrationName1754315774827";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ProjectSCIM" DROP COLUMN "isEnabled"`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ProjectSCIM" ADD "isEnabled" boolean NOT NULL DEFAULT false`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class MigrationName1754384418632 implements MigrationInterface {
|
||||
public name = "MigrationName1754384418632";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "StatusPageSCIM" ("_id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP WITH TIME ZONE, "version" integer NOT NULL, "projectId" uuid NOT NULL, "statusPageId" uuid NOT NULL, "name" character varying(100) NOT NULL, "description" character varying(500), "bearerToken" character varying(500) NOT NULL, "autoProvisionUsers" boolean NOT NULL DEFAULT true, "autoDeprovisionUsers" boolean NOT NULL DEFAULT true, "createdByUserId" uuid, "deletedByUserId" uuid, CONSTRAINT "PK_9d65d486be515b9608347cf66d4" PRIMARY KEY ("_id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_0a241118fe6b4a8665deef444b" ON "StatusPageSCIM" ("projectId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7200e368657773fde2836c57eb" ON "StatusPageSCIM" ("statusPageId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type":"Recurring","value":{"intervalType":"Day","intervalCount":{"_type":"PositiveNumber","value":1}}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type":"RestrictionTimes","value":{"restictionType":"None","dayRestrictionTimes":null,"weeklyRestrictionTimes":[]}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "StatusPageSCIM" ADD CONSTRAINT "FK_0a241118fe6b4a8665deef444b2" FOREIGN KEY ("projectId") REFERENCES "Project"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "StatusPageSCIM" ADD CONSTRAINT "FK_7200e368657773fde2836c57ebe" FOREIGN KEY ("statusPageId") REFERENCES "StatusPage"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "StatusPageSCIM" ADD CONSTRAINT "FK_adb05dd1cbe0e734a76b3dbdcf1" FOREIGN KEY ("createdByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "StatusPageSCIM" ADD CONSTRAINT "FK_2fded7c784a5c2f56ad2553cb80" FOREIGN KEY ("deletedByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "StatusPageSCIM" DROP CONSTRAINT "FK_2fded7c784a5c2f56ad2553cb80"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "StatusPageSCIM" DROP CONSTRAINT "FK_adb05dd1cbe0e734a76b3dbdcf1"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "StatusPageSCIM" DROP CONSTRAINT "FK_7200e368657773fde2836c57ebe"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "StatusPageSCIM" DROP CONSTRAINT "FK_0a241118fe6b4a8665deef444b2"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type": "RestrictionTimes", "value": {"restictionType": "None", "dayRestrictionTimes": null, "weeklyRestrictionTimes": []}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type": "Recurring", "value": {"intervalType": "Day", "intervalCount": {"_type": "PositiveNumber", "value": 1}}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_7200e368657773fde2836c57eb"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_0a241118fe6b4a8665deef444b"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "StatusPageSCIM"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class MigrationName1754412708044 implements MigrationInterface {
|
||||
public name = "MigrationName1754412708044";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "Incident" ALTER COLUMN "subscriberNotificationStatusOnIncidentCreated" SET NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "Incident" DROP COLUMN "notificationFailureReasonOnIncidentCreated"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "Incident" ADD "notificationFailureReasonOnIncidentCreated" character varying`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "IncidentPublicNote" ALTER COLUMN "subscriberNotificationStatusOnNoteCreated" SET NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "IncidentPublicNote" DROP COLUMN "notificationFailureReasonOnNoteCreated"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "IncidentPublicNote" ADD "notificationFailureReasonOnNoteCreated" character varying`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "IncidentStateTimeline" ALTER COLUMN "subscriberNotificationStatus" SET NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "IncidentStateTimeline" DROP COLUMN "notificationFailureReason"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "IncidentStateTimeline" ADD "notificationFailureReason" character varying`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type":"Recurring","value":{"intervalType":"Day","intervalCount":{"_type":"PositiveNumber","value":1}}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type":"RestrictionTimes","value":{"restictionType":"None","dayRestrictionTimes":null,"weeklyRestrictionTimes":[]}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenance" ALTER COLUMN "subscriberNotificationStatusOnEventScheduled" SET NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenance" DROP COLUMN "notificationFailureReasonOnEventScheduled"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenance" ADD "notificationFailureReasonOnEventScheduled" character varying`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenancePublicNote" ALTER COLUMN "subscriberNotificationStatusOnNoteCreated" SET NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenancePublicNote" DROP COLUMN "notificationFailureReasonOnNoteCreated"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenancePublicNote" ADD "notificationFailureReasonOnNoteCreated" character varying`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenanceStateTimeline" ALTER COLUMN "subscriberNotificationStatus" SET NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenanceStateTimeline" DROP COLUMN "notificationFailureReason"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenanceStateTimeline" ADD "notificationFailureReason" character varying`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "StatusPageAnnouncement" ALTER COLUMN "subscriberNotificationStatus" SET NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "StatusPageAnnouncement" DROP COLUMN "notificationFailureReason"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "StatusPageAnnouncement" ADD "notificationFailureReason" character varying`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "StatusPageAnnouncement" DROP COLUMN "notificationFailureReason"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "StatusPageAnnouncement" ADD "notificationFailureReason" text`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "StatusPageAnnouncement" ALTER COLUMN "subscriberNotificationStatus" DROP NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenanceStateTimeline" DROP COLUMN "notificationFailureReason"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenanceStateTimeline" ADD "notificationFailureReason" text`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenanceStateTimeline" ALTER COLUMN "subscriberNotificationStatus" DROP NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenancePublicNote" DROP COLUMN "notificationFailureReasonOnNoteCreated"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenancePublicNote" ADD "notificationFailureReasonOnNoteCreated" text`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenancePublicNote" ALTER COLUMN "subscriberNotificationStatusOnNoteCreated" DROP NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenance" DROP COLUMN "notificationFailureReasonOnEventScheduled"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenance" ADD "notificationFailureReasonOnEventScheduled" text`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenance" ALTER COLUMN "subscriberNotificationStatusOnEventScheduled" DROP NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type": "RestrictionTimes", "value": {"restictionType": "None", "dayRestrictionTimes": null, "weeklyRestrictionTimes": []}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type": "Recurring", "value": {"intervalType": "Day", "intervalCount": {"_type": "PositiveNumber", "value": 1}}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "IncidentStateTimeline" DROP COLUMN "notificationFailureReason"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "IncidentStateTimeline" ADD "notificationFailureReason" text`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "IncidentStateTimeline" ALTER COLUMN "subscriberNotificationStatus" DROP NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "IncidentPublicNote" DROP COLUMN "notificationFailureReasonOnNoteCreated"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "IncidentPublicNote" ADD "notificationFailureReasonOnNoteCreated" text`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "IncidentPublicNote" ALTER COLUMN "subscriberNotificationStatusOnNoteCreated" DROP NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "Incident" DROP COLUMN "notificationFailureReasonOnIncidentCreated"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "Incident" ADD "notificationFailureReasonOnIncidentCreated" text`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "Incident" ALTER COLUMN "subscriberNotificationStatusOnIncidentCreated" DROP NOT NULL`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class MigrationName1754415281937 implements MigrationInterface {
|
||||
public name = "MigrationName1754415281937";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "Incident" RENAME COLUMN "notificationFailureReasonOnIncidentCreated" TO "subscriberNotificationFailedReason"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "IncidentPublicNote" RENAME COLUMN "notificationFailureReasonOnNoteCreated" TO "subscriberNotificationFailedReason"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "IncidentStateTimeline" RENAME COLUMN "notificationFailureReason" TO "subscriberNotificationFailedReason"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenance" RENAME COLUMN "notificationFailureReasonOnEventScheduled" TO "subscriberNotificationFailedReason"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenancePublicNote" RENAME COLUMN "notificationFailureReasonOnNoteCreated" TO "subscriberNotificationFailedReason"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenanceStateTimeline" RENAME COLUMN "notificationFailureReason" TO "subscriberNotificationFailedReason"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "StatusPageAnnouncement" RENAME COLUMN "notificationFailureReason" TO "subscriberNotificationFailedReason"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "Incident" DROP COLUMN "subscriberNotificationFailedReason"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "Incident" ADD "subscriberNotificationFailedReason" text`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "IncidentPublicNote" DROP COLUMN "subscriberNotificationFailedReason"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "IncidentPublicNote" ADD "subscriberNotificationFailedReason" text`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "IncidentStateTimeline" DROP COLUMN "subscriberNotificationFailedReason"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "IncidentStateTimeline" ADD "subscriberNotificationFailedReason" text`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type":"Recurring","value":{"intervalType":"Day","intervalCount":{"_type":"PositiveNumber","value":1}}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type":"RestrictionTimes","value":{"restictionType":"None","dayRestrictionTimes":null,"weeklyRestrictionTimes":[]}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenance" DROP COLUMN "subscriberNotificationFailedReason"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenance" ADD "subscriberNotificationFailedReason" text`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenancePublicNote" DROP COLUMN "subscriberNotificationFailedReason"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenancePublicNote" ADD "subscriberNotificationFailedReason" text`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenanceStateTimeline" DROP COLUMN "subscriberNotificationFailedReason"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenanceStateTimeline" ADD "subscriberNotificationFailedReason" text`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "StatusPageAnnouncement" DROP COLUMN "subscriberNotificationFailedReason"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "StatusPageAnnouncement" ADD "subscriberNotificationFailedReason" text`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "StatusPageAnnouncement" DROP COLUMN "subscriberNotificationFailedReason"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "StatusPageAnnouncement" ADD "subscriberNotificationFailedReason" character varying`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenanceStateTimeline" DROP COLUMN "subscriberNotificationFailedReason"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenanceStateTimeline" ADD "subscriberNotificationFailedReason" character varying`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenancePublicNote" DROP COLUMN "subscriberNotificationFailedReason"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenancePublicNote" ADD "subscriberNotificationFailedReason" character varying`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenance" DROP COLUMN "subscriberNotificationFailedReason"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenance" ADD "subscriberNotificationFailedReason" character varying`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type": "RestrictionTimes", "value": {"restictionType": "None", "dayRestrictionTimes": null, "weeklyRestrictionTimes": []}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type": "Recurring", "value": {"intervalType": "Day", "intervalCount": {"_type": "PositiveNumber", "value": 1}}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "IncidentStateTimeline" DROP COLUMN "subscriberNotificationFailedReason"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "IncidentStateTimeline" ADD "subscriberNotificationFailedReason" character varying`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "IncidentPublicNote" DROP COLUMN "subscriberNotificationFailedReason"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "IncidentPublicNote" ADD "subscriberNotificationFailedReason" character varying`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "Incident" DROP COLUMN "subscriberNotificationFailedReason"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "Incident" ADD "subscriberNotificationFailedReason" character varying`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "StatusPageAnnouncement" RENAME COLUMN "subscriberNotificationFailedReason" TO "notificationFailureReason"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenanceStateTimeline" RENAME COLUMN "subscriberNotificationFailedReason" TO "notificationFailureReason"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenancePublicNote" RENAME COLUMN "subscriberNotificationFailedReason" TO "notificationFailureReasonOnNoteCreated"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenance" RENAME COLUMN "subscriberNotificationFailedReason" TO "notificationFailureReasonOnEventScheduled"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "IncidentStateTimeline" RENAME COLUMN "subscriberNotificationFailedReason" TO "notificationFailureReason"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "IncidentPublicNote" RENAME COLUMN "subscriberNotificationFailedReason" TO "notificationFailureReasonOnNoteCreated"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "Incident" RENAME COLUMN "subscriberNotificationFailedReason" TO "notificationFailureReasonOnIncidentCreated"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class UpdateSubscriberNotificationStatusToEnum1754500000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
public name = "UpdateSubscriberNotificationStatusToEnum1754500000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// StatusPageAnnouncement changes
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "StatusPageAnnouncement" ADD "subscriberNotificationStatus" character varying DEFAULT 'Pending'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "StatusPageAnnouncement" ADD "notificationFailureReason" text`,
|
||||
);
|
||||
// Set Success status for existing records where notification was sent (backward compatibility)
|
||||
await queryRunner.query(
|
||||
`UPDATE "StatusPageAnnouncement" SET "subscriberNotificationStatus" = 'Success' WHERE "isStatusPageSubscribersNotified" = true`,
|
||||
);
|
||||
// Set Skipped status for records where notification should not be sent
|
||||
await queryRunner.query(
|
||||
`UPDATE "StatusPageAnnouncement" SET "subscriberNotificationStatus" = 'Skipped' WHERE "shouldStatusPageSubscribersBeNotified" = false`,
|
||||
);
|
||||
// Drop old boolean column
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "StatusPageAnnouncement" DROP COLUMN "isStatusPageSubscribersNotified"`,
|
||||
);
|
||||
|
||||
// Incident changes
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "Incident" ADD "subscriberNotificationStatusOnIncidentCreated" character varying DEFAULT 'Pending'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "Incident" ADD "notificationFailureReasonOnIncidentCreated" text`,
|
||||
);
|
||||
// Set Success status for existing records where notification was sent
|
||||
await queryRunner.query(
|
||||
`UPDATE "Incident" SET "subscriberNotificationStatusOnIncidentCreated" = 'Success' WHERE "isStatusPageSubscribersNotifiedOnIncidentCreated" = true`,
|
||||
);
|
||||
// Set Skipped status for records where notification should not be sent
|
||||
await queryRunner.query(
|
||||
`UPDATE "Incident" SET "subscriberNotificationStatusOnIncidentCreated" = 'Skipped' WHERE "shouldStatusPageSubscribersBeNotifiedOnIncidentCreated" = false`,
|
||||
);
|
||||
// Drop old boolean column
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "Incident" DROP COLUMN "isStatusPageSubscribersNotifiedOnIncidentCreated"`,
|
||||
);
|
||||
|
||||
// IncidentStateTimeline changes
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "IncidentStateTimeline" ADD "subscriberNotificationStatus" character varying DEFAULT 'Pending'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "IncidentStateTimeline" ADD "notificationFailureReason" text`,
|
||||
);
|
||||
// Set Success status for existing records where notification was sent
|
||||
await queryRunner.query(
|
||||
`UPDATE "IncidentStateTimeline" SET "subscriberNotificationStatus" = 'Success' WHERE "isStatusPageSubscribersNotified" = true`,
|
||||
);
|
||||
// Set Skipped status for records where notification should not be sent
|
||||
await queryRunner.query(
|
||||
`UPDATE "IncidentStateTimeline" SET "subscriberNotificationStatus" = 'Skipped' WHERE "shouldStatusPageSubscribersBeNotified" = false`,
|
||||
);
|
||||
// Drop old boolean column
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "IncidentStateTimeline" DROP COLUMN "isStatusPageSubscribersNotified"`,
|
||||
);
|
||||
|
||||
// IncidentPublicNote changes
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "IncidentPublicNote" ADD "subscriberNotificationStatusOnNoteCreated" character varying DEFAULT 'Pending'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "IncidentPublicNote" ADD "notificationFailureReasonOnNoteCreated" text`,
|
||||
);
|
||||
// Set Success status for existing records where notification was sent
|
||||
await queryRunner.query(
|
||||
`UPDATE "IncidentPublicNote" SET "subscriberNotificationStatusOnNoteCreated" = 'Success' WHERE "isStatusPageSubscribersNotifiedOnNoteCreated" = true`,
|
||||
);
|
||||
// Set Skipped status for records where notification should not be sent
|
||||
await queryRunner.query(
|
||||
`UPDATE "IncidentPublicNote" SET "subscriberNotificationStatusOnNoteCreated" = 'Skipped' WHERE "shouldStatusPageSubscribersBeNotifiedOnNoteCreated" = false`,
|
||||
);
|
||||
// Drop old boolean column
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "IncidentPublicNote" DROP COLUMN "isStatusPageSubscribersNotifiedOnNoteCreated"`,
|
||||
);
|
||||
|
||||
// ScheduledMaintenance changes
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenance" ADD "subscriberNotificationStatusOnEventScheduled" character varying DEFAULT 'Pending'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenance" ADD "notificationFailureReasonOnEventScheduled" text`,
|
||||
);
|
||||
// Set Success status for existing records where notification was sent
|
||||
await queryRunner.query(
|
||||
`UPDATE "ScheduledMaintenance" SET "subscriberNotificationStatusOnEventScheduled" = 'Success' WHERE "isStatusPageSubscribersNotifiedOnEventScheduled" = true`,
|
||||
);
|
||||
// Set Skipped status for records where notification should not be sent
|
||||
await queryRunner.query(
|
||||
`UPDATE "ScheduledMaintenance" SET "subscriberNotificationStatusOnEventScheduled" = 'Skipped' WHERE "shouldStatusPageSubscribersBeNotifiedOnEventCreated" = false`,
|
||||
);
|
||||
// Drop old boolean column
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenance" DROP COLUMN "isStatusPageSubscribersNotifiedOnEventScheduled"`,
|
||||
);
|
||||
|
||||
// ScheduledMaintenanceStateTimeline changes
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenanceStateTimeline" ADD "subscriberNotificationStatus" character varying DEFAULT 'Pending'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenanceStateTimeline" ADD "notificationFailureReason" text`,
|
||||
);
|
||||
// Set Success status for existing records where notification was sent
|
||||
await queryRunner.query(
|
||||
`UPDATE "ScheduledMaintenanceStateTimeline" SET "subscriberNotificationStatus" = 'Success' WHERE "isStatusPageSubscribersNotified" = true`,
|
||||
);
|
||||
// Set Skipped status for records where notification should not be sent
|
||||
await queryRunner.query(
|
||||
`UPDATE "ScheduledMaintenanceStateTimeline" SET "subscriberNotificationStatus" = 'Skipped' WHERE "shouldStatusPageSubscribersBeNotified" = false`,
|
||||
);
|
||||
// Drop old boolean column
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenanceStateTimeline" DROP COLUMN "isStatusPageSubscribersNotified"`,
|
||||
);
|
||||
|
||||
// ScheduledMaintenancePublicNote changes
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenancePublicNote" ADD "subscriberNotificationStatusOnNoteCreated" character varying DEFAULT 'Pending'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenancePublicNote" ADD "notificationFailureReasonOnNoteCreated" text`,
|
||||
);
|
||||
// Set Success status for existing records where notification was sent
|
||||
await queryRunner.query(
|
||||
`UPDATE "ScheduledMaintenancePublicNote" SET "subscriberNotificationStatusOnNoteCreated" = 'Success' WHERE "isStatusPageSubscribersNotifiedOnNoteCreated" = true`,
|
||||
);
|
||||
// Set Skipped status for records where notification should not be sent
|
||||
await queryRunner.query(
|
||||
`UPDATE "ScheduledMaintenancePublicNote" SET "subscriberNotificationStatusOnNoteCreated" = 'Skipped' WHERE "shouldStatusPageSubscribersBeNotifiedOnNoteCreated" = false`,
|
||||
);
|
||||
// Drop old boolean column
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenancePublicNote" DROP COLUMN "isStatusPageSubscribersNotifiedOnNoteCreated"`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Rollback StatusPageAnnouncement changes
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "StatusPageAnnouncement" ADD "isStatusPageSubscribersNotified" boolean DEFAULT false`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE "StatusPageAnnouncement" SET "isStatusPageSubscribersNotified" = true WHERE "subscriberNotificationStatus" = 'Success'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "StatusPageAnnouncement" DROP COLUMN "subscriberNotificationStatus"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "StatusPageAnnouncement" DROP COLUMN "notificationFailureReason"`,
|
||||
);
|
||||
|
||||
// Rollback Incident changes
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "Incident" ADD "isStatusPageSubscribersNotifiedOnIncidentCreated" boolean DEFAULT false`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE "Incident" SET "isStatusPageSubscribersNotifiedOnIncidentCreated" = true WHERE "subscriberNotificationStatusOnIncidentCreated" = 'Success'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "Incident" DROP COLUMN "subscriberNotificationStatusOnIncidentCreated"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "Incident" DROP COLUMN "notificationFailureReasonOnIncidentCreated"`,
|
||||
);
|
||||
|
||||
// Rollback IncidentStateTimeline changes
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "IncidentStateTimeline" ADD "isStatusPageSubscribersNotified" boolean DEFAULT false`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE "IncidentStateTimeline" SET "isStatusPageSubscribersNotified" = true WHERE "subscriberNotificationStatus" = 'Success'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "IncidentStateTimeline" DROP COLUMN "subscriberNotificationStatus"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "IncidentStateTimeline" DROP COLUMN "notificationFailureReason"`,
|
||||
);
|
||||
|
||||
// Rollback IncidentPublicNote changes
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "IncidentPublicNote" ADD "isStatusPageSubscribersNotifiedOnNoteCreated" boolean DEFAULT false`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE "IncidentPublicNote" SET "isStatusPageSubscribersNotifiedOnNoteCreated" = true WHERE "subscriberNotificationStatusOnNoteCreated" = 'Success'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "IncidentPublicNote" DROP COLUMN "subscriberNotificationStatusOnNoteCreated"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "IncidentPublicNote" DROP COLUMN "notificationFailureReasonOnNoteCreated"`,
|
||||
);
|
||||
|
||||
// Rollback ScheduledMaintenance changes
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenance" ADD "isStatusPageSubscribersNotifiedOnEventScheduled" boolean DEFAULT false`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE "ScheduledMaintenance" SET "isStatusPageSubscribersNotifiedOnEventScheduled" = true WHERE "subscriberNotificationStatusOnEventScheduled" = 'Success'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenance" DROP COLUMN "subscriberNotificationStatusOnEventScheduled"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenance" DROP COLUMN "notificationFailureReasonOnEventScheduled"`,
|
||||
);
|
||||
|
||||
// Rollback ScheduledMaintenanceStateTimeline changes
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenanceStateTimeline" ADD "isStatusPageSubscribersNotified" boolean DEFAULT false`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE "ScheduledMaintenanceStateTimeline" SET "isStatusPageSubscribersNotified" = true WHERE "subscriberNotificationStatus" = 'Success'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenanceStateTimeline" DROP COLUMN "subscriberNotificationStatus"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenanceStateTimeline" DROP COLUMN "notificationFailureReason"`,
|
||||
);
|
||||
|
||||
// Rollback ScheduledMaintenancePublicNote changes
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenancePublicNote" ADD "isStatusPageSubscribersNotifiedOnNoteCreated" boolean DEFAULT false`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE "ScheduledMaintenancePublicNote" SET "isStatusPageSubscribersNotifiedOnNoteCreated" = true WHERE "subscriberNotificationStatusOnNoteCreated" = 'Success'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenancePublicNote" DROP COLUMN "subscriberNotificationStatusOnNoteCreated"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenancePublicNote" DROP COLUMN "notificationFailureReasonOnNoteCreated"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class RenameSubscriberNotificationFailedReasonToStatusMessage1754484441976
|
||||
implements MigrationInterface
|
||||
{
|
||||
public name =
|
||||
"RenameSubscriberNotificationFailedReasonToStatusMessage1754484441976";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "Incident" RENAME COLUMN "subscriberNotificationFailedReason" TO "subscriberNotificationStatusMessage"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "IncidentPublicNote" RENAME COLUMN "subscriberNotificationFailedReason" TO "subscriberNotificationStatusMessage"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "IncidentStateTimeline" RENAME COLUMN "subscriberNotificationFailedReason" TO "subscriberNotificationStatusMessage"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenance" RENAME COLUMN "subscriberNotificationFailedReason" TO "subscriberNotificationStatusMessage"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenancePublicNote" RENAME COLUMN "subscriberNotificationFailedReason" TO "subscriberNotificationStatusMessage"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenanceStateTimeline" RENAME COLUMN "subscriberNotificationFailedReason" TO "subscriberNotificationStatusMessage"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "StatusPageAnnouncement" RENAME COLUMN "subscriberNotificationFailedReason" TO "subscriberNotificationStatusMessage"`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "StatusPageAnnouncement" RENAME COLUMN "subscriberNotificationStatusMessage" TO "subscriberNotificationFailedReason"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenanceStateTimeline" RENAME COLUMN "subscriberNotificationStatusMessage" TO "subscriberNotificationFailedReason"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenancePublicNote" RENAME COLUMN "subscriberNotificationStatusMessage" TO "subscriberNotificationFailedReason"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ScheduledMaintenance" RENAME COLUMN "subscriberNotificationStatusMessage" TO "subscriberNotificationFailedReason"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "IncidentStateTimeline" RENAME COLUMN "subscriberNotificationStatusMessage" TO "subscriberNotificationFailedReason"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "IncidentPublicNote" RENAME COLUMN "subscriberNotificationStatusMessage" TO "subscriberNotificationFailedReason"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "Incident" RENAME COLUMN "subscriberNotificationStatusMessage" TO "subscriberNotificationFailedReason"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -146,6 +146,13 @@ import { MigrationName1753343522987 } from "./1753343522987-MigrationName";
|
||||
import { MigrationName1753377161288 } from "./1753377161288-MigrationName";
|
||||
import { AddPerformanceIndexes1753378524062 } from "./1753378524062-AddPerformanceIndexes";
|
||||
import { MigrationName1753383711511 } from "./1753383711511-MigrationName";
|
||||
import { MigrationName1754304193228 } from "./1754304193228-MigrationName";
|
||||
import { MigrationName1754315774827 } from "./1754315774827-MigrationName";
|
||||
import { MigrationName1754384418632 } from "./1754384418632-MigrationName";
|
||||
import { MigrationName1754412708044 } from "./1754412708044-MigrationName";
|
||||
import { MigrationName1754415281937 } from "./1754415281937-MigrationName";
|
||||
import { RenameSubscriberNotificationFailedReasonToStatusMessage1754484441976 } from "./1754484441976-RenameSubscriberNotificationFailedReasonToStatusMessage";
|
||||
import { UpdateSubscriberNotificationStatusToEnum1754500000000 } from "./1754484441975-UpdateSubscriberNotificationStatusToEnum";
|
||||
|
||||
export default [
|
||||
InitialMigration,
|
||||
@@ -296,4 +303,11 @@ export default [
|
||||
MigrationName1753377161288,
|
||||
AddPerformanceIndexes1753378524062,
|
||||
MigrationName1753383711511,
|
||||
MigrationName1754304193228,
|
||||
MigrationName1754315774827,
|
||||
MigrationName1754384418632,
|
||||
UpdateSubscriberNotificationStatusToEnum1754500000000,
|
||||
MigrationName1754412708044,
|
||||
MigrationName1754415281937,
|
||||
RenameSubscriberNotificationFailedReasonToStatusMessage1754484441976,
|
||||
];
|
||||
|
||||
@@ -17,6 +17,10 @@ export enum QueueName {
|
||||
Workflow = "Workflow",
|
||||
Worker = "Worker",
|
||||
Telemetry = "Telemetry",
|
||||
FluentIngest = "FluentIngest",
|
||||
IncomingRequestIngest = "IncomingRequestIngest",
|
||||
ServerMonitorIngest = "ServerMonitorIngest",
|
||||
ProbeIngest = "ProbeIngest",
|
||||
}
|
||||
|
||||
export type QueueJob = Job;
|
||||
@@ -137,12 +141,12 @@ export default class Queue {
|
||||
|
||||
@CaptureSpan()
|
||||
public static async getQueueSize(queueName: QueueName): Promise<number> {
|
||||
const queue = this.getQueue(queueName);
|
||||
const waiting = await queue.getWaiting();
|
||||
const active = await queue.getActive();
|
||||
const delayed = await queue.getDelayed();
|
||||
|
||||
return waiting.length + active.length + delayed.length;
|
||||
const queue: BullQueue = this.getQueue(queueName);
|
||||
const waitingCount: number = await queue.getWaitingCount();
|
||||
const activeCount: number = await queue.getActiveCount();
|
||||
const delayedCount: number = await queue.getDelayedCount();
|
||||
|
||||
return waitingCount + activeCount + delayedCount;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
@@ -154,20 +158,77 @@ export default class Queue {
|
||||
delayed: number;
|
||||
total: number;
|
||||
}> {
|
||||
const queue = this.getQueue(queueName);
|
||||
const waiting = await queue.getWaiting();
|
||||
const active = await queue.getActive();
|
||||
const completed = await queue.getCompleted();
|
||||
const failed = await queue.getFailed();
|
||||
const delayed = await queue.getDelayed();
|
||||
|
||||
const queue: BullQueue = this.getQueue(queueName);
|
||||
const waitingCount: number = await queue.getWaitingCount();
|
||||
const activeCount: number = await queue.getActiveCount();
|
||||
const completedCount: number = await queue.getCompletedCount();
|
||||
const failedCount: number = await queue.getFailedCount();
|
||||
const delayedCount: number = await queue.getDelayedCount();
|
||||
|
||||
return {
|
||||
waiting: waiting.length,
|
||||
active: active.length,
|
||||
completed: completed.length,
|
||||
failed: failed.length,
|
||||
delayed: delayed.length,
|
||||
total: waiting.length + active.length + completed.length + failed.length + delayed.length,
|
||||
waiting: waitingCount,
|
||||
active: activeCount,
|
||||
completed: completedCount,
|
||||
failed: failedCount,
|
||||
delayed: delayedCount,
|
||||
total:
|
||||
waitingCount +
|
||||
activeCount +
|
||||
completedCount +
|
||||
failedCount +
|
||||
delayedCount,
|
||||
};
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public static async getFailedJobs(
|
||||
queueName: QueueName,
|
||||
options?: {
|
||||
start?: number;
|
||||
end?: number;
|
||||
},
|
||||
): Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
data: JSONObject;
|
||||
failedReason: string;
|
||||
stackTrace?: string;
|
||||
processedOn: Date | null;
|
||||
finishedOn: Date | null;
|
||||
attemptsMade: number;
|
||||
}>
|
||||
> {
|
||||
const queue: BullQueue = this.getQueue(queueName);
|
||||
const start: number = options?.start || 0;
|
||||
const end: number = options?.end || 100;
|
||||
const failed: Job[] = await queue.getFailed(start, end);
|
||||
|
||||
return failed.map((job: Job) => {
|
||||
const result: {
|
||||
id: string;
|
||||
name: string;
|
||||
data: JSONObject;
|
||||
failedReason: string;
|
||||
stackTrace?: string;
|
||||
processedOn: Date | null;
|
||||
finishedOn: Date | null;
|
||||
attemptsMade: number;
|
||||
} = {
|
||||
id: job.id || "unknown",
|
||||
name: job.name || "unknown",
|
||||
data: job.data as JSONObject,
|
||||
failedReason: job.failedReason || "No reason provided",
|
||||
processedOn: job.processedOn ? new Date(job.processedOn) : null,
|
||||
finishedOn: job.finishedOn ? new Date(job.finishedOn) : null,
|
||||
attemptsMade: job.attemptsMade || 0,
|
||||
};
|
||||
|
||||
if (job.stacktrace && job.stacktrace.length > 0) {
|
||||
result.stackTrace = job.stacktrace.join("\n");
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@ export default class ClusterKeyAuthorization {
|
||||
} else if (req.headers && req.headers["clusterkey"]) {
|
||||
// Header keys are automatically transformed to lowercase
|
||||
clusterKey = req.headers["clusterkey"] as string;
|
||||
} else if (req.headers && req.headers["x-clusterkey"]) {
|
||||
// KEDA TriggerAuthentication sends headers with X- prefix
|
||||
clusterKey = req.headers["x-clusterkey"] as string;
|
||||
} else if (req.body && req.body.clusterKey) {
|
||||
clusterKey = req.body.clusterKey;
|
||||
} else {
|
||||
|
||||
127
Common/Server/Middleware/SCIMAuthorization.ts
Normal file
127
Common/Server/Middleware/SCIMAuthorization.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import ProjectSCIMService from "../Services/ProjectSCIMService";
|
||||
import StatusPageSCIMService from "../Services/StatusPageSCIMService";
|
||||
import {
|
||||
ExpressRequest,
|
||||
ExpressResponse,
|
||||
NextFunction,
|
||||
OneUptimeRequest,
|
||||
} from "../Utils/Express";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import ProjectSCIM from "../../Models/DatabaseModels/ProjectSCIM";
|
||||
import StatusPageSCIM from "../../Models/DatabaseModels/StatusPageSCIM";
|
||||
import NotAuthorizedException from "../../Types/Exception/NotAuthorizedException";
|
||||
import BadRequestException from "../../Types/Exception/BadRequestException";
|
||||
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
|
||||
import logger from "../Utils/Logger";
|
||||
|
||||
export default class SCIMMiddleware {
|
||||
@CaptureSpan()
|
||||
public static async isAuthorizedSCIMRequest(
|
||||
req: ExpressRequest,
|
||||
_res: ExpressResponse,
|
||||
next: NextFunction,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest;
|
||||
|
||||
// Extract SCIM ID from URL path (could be project or status page)
|
||||
const scimId: string | undefined =
|
||||
req.params["projectScimId"] || req.params["statusPageScimId"];
|
||||
if (!scimId) {
|
||||
throw new BadRequestException("SCIM ID is required");
|
||||
}
|
||||
|
||||
// Extract bearer token from Authorization header
|
||||
let bearerToken: string | undefined;
|
||||
if (req.headers?.["authorization"]) {
|
||||
const authHeader: string = req.headers["authorization"] as string;
|
||||
if (authHeader.startsWith("Bearer ")) {
|
||||
bearerToken = authHeader.substring(7);
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`SCIM Authorization: scimId=${scimId}, bearerToken=${
|
||||
bearerToken ? "***" : "missing"
|
||||
}`,
|
||||
);
|
||||
|
||||
if (!bearerToken) {
|
||||
throw new NotAuthorizedException(
|
||||
"Bearer token is required for SCIM authentication",
|
||||
);
|
||||
}
|
||||
|
||||
// Try to find Project SCIM configuration first
|
||||
const projectScimConfig: ProjectSCIM | null =
|
||||
await ProjectSCIMService.findOneBy({
|
||||
query: {
|
||||
_id: new ObjectID(scimId),
|
||||
bearerToken: bearerToken,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
projectId: true,
|
||||
autoProvisionUsers: true,
|
||||
autoDeprovisionUsers: true,
|
||||
teams: {
|
||||
_id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (projectScimConfig) {
|
||||
// Store Project SCIM configuration
|
||||
oneuptimeRequest.bearerTokenData = {
|
||||
scimConfig: projectScimConfig,
|
||||
projectId: projectScimConfig.projectId,
|
||||
projectScimId: new ObjectID(scimId),
|
||||
type: "project-scim",
|
||||
};
|
||||
return next();
|
||||
}
|
||||
|
||||
// If not found, try Status Page SCIM configuration
|
||||
const statusPageScimConfig: StatusPageSCIM | null =
|
||||
await StatusPageSCIMService.findOneBy({
|
||||
query: {
|
||||
_id: new ObjectID(scimId),
|
||||
bearerToken: bearerToken,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
projectId: true,
|
||||
statusPageId: true,
|
||||
autoProvisionUsers: true,
|
||||
autoDeprovisionUsers: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (statusPageScimConfig) {
|
||||
// Store Status Page SCIM configuration
|
||||
oneuptimeRequest.bearerTokenData = {
|
||||
scimConfig: statusPageScimConfig,
|
||||
projectId: statusPageScimConfig.projectId,
|
||||
statusPageId: statusPageScimConfig.statusPageId,
|
||||
statusPageScimId: new ObjectID(scimId),
|
||||
type: "status-page-scim",
|
||||
};
|
||||
return next();
|
||||
}
|
||||
|
||||
// If neither found, throw error
|
||||
throw new NotAuthorizedException(
|
||||
"Invalid bearer token or SCIM configuration not found",
|
||||
);
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -228,6 +228,11 @@ class DatabaseService<TBaseModel extends BaseModel> extends BaseService {
|
||||
}
|
||||
|
||||
throw new BadDataException(`${requiredField} is required`);
|
||||
} else if (
|
||||
(data as any)[requiredField] === null &&
|
||||
data.isDefaultValueColumn(requiredField)
|
||||
) {
|
||||
delete (data as any)[requiredField];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { LIMIT_PER_PROJECT } from "../../Types/Database/LimitMax";
|
||||
import IncidentService from "./IncidentService";
|
||||
import Incident from "../../Models/DatabaseModels/Incident";
|
||||
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
|
||||
import StatusPageSubscriberNotificationStatus from "../../Types/StatusPage/StatusPageSubscriberNotificationStatus";
|
||||
|
||||
export class Service extends DatabaseService<Model> {
|
||||
public constructor() {
|
||||
@@ -47,6 +48,21 @@ export class Service extends DatabaseService<Model> {
|
||||
createBy.data.postedAt = OneUptimeDate.getCurrentDate();
|
||||
}
|
||||
|
||||
// Set notification status based on shouldStatusPageSubscribersBeNotifiedOnNoteCreated
|
||||
if (
|
||||
createBy.data.shouldStatusPageSubscribersBeNotifiedOnNoteCreated === false
|
||||
) {
|
||||
createBy.data.subscriberNotificationStatusOnNoteCreated =
|
||||
StatusPageSubscriberNotificationStatus.Skipped;
|
||||
createBy.data.subscriberNotificationStatusMessage =
|
||||
"Notifications skipped as subscribers are not to be notified for this incident note.";
|
||||
} else if (
|
||||
createBy.data.shouldStatusPageSubscribersBeNotifiedOnNoteCreated === true
|
||||
) {
|
||||
createBy.data.subscriberNotificationStatusOnNoteCreated =
|
||||
StatusPageSubscriberNotificationStatus.Pending;
|
||||
}
|
||||
|
||||
return {
|
||||
createBy: createBy,
|
||||
carryForward: null,
|
||||
|
||||
@@ -24,6 +24,7 @@ import ObjectID from "../../Types/ObjectID";
|
||||
import PositiveNumber from "../../Types/PositiveNumber";
|
||||
import Typeof from "../../Types/Typeof";
|
||||
import UserNotificationEventType from "../../Types/UserNotification/UserNotificationEventType";
|
||||
import StatusPageSubscriberNotificationStatus from "../../Types/StatusPage/StatusPageSubscriberNotificationStatus";
|
||||
import Model from "../../Models/DatabaseModels/Incident";
|
||||
import IncidentOwnerTeam from "../../Models/DatabaseModels/IncidentOwnerTeam";
|
||||
import IncidentOwnerUser from "../../Models/DatabaseModels/IncidentOwnerUser";
|
||||
@@ -426,6 +427,28 @@ export class Service extends DatabaseService<Model> {
|
||||
}
|
||||
}
|
||||
|
||||
// Set notification status based on shouldStatusPageSubscribersBeNotifiedOnIncidentCreated if it's being updated
|
||||
if (
|
||||
updateBy.data.shouldStatusPageSubscribersBeNotifiedOnIncidentCreated !==
|
||||
undefined
|
||||
) {
|
||||
if (
|
||||
updateBy.data.shouldStatusPageSubscribersBeNotifiedOnIncidentCreated ===
|
||||
false
|
||||
) {
|
||||
updateBy.data.subscriberNotificationStatusOnIncidentCreated =
|
||||
StatusPageSubscriberNotificationStatus.Skipped;
|
||||
updateBy.data.subscriberNotificationStatusMessage =
|
||||
"Notifications skipped as subscribers are not to be notified for this incident.";
|
||||
} else if (
|
||||
updateBy.data.shouldStatusPageSubscribersBeNotifiedOnIncidentCreated ===
|
||||
true
|
||||
) {
|
||||
updateBy.data.subscriberNotificationStatusOnIncidentCreated =
|
||||
StatusPageSubscriberNotificationStatus.Pending;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
updateBy: updateBy,
|
||||
carryForward: carryForward,
|
||||
@@ -523,6 +546,23 @@ export class Service extends DatabaseService<Model> {
|
||||
}
|
||||
}
|
||||
|
||||
// Set notification status based on shouldStatusPageSubscribersBeNotifiedOnIncidentCreated
|
||||
if (
|
||||
createBy.data.shouldStatusPageSubscribersBeNotifiedOnIncidentCreated ===
|
||||
false
|
||||
) {
|
||||
createBy.data.subscriberNotificationStatusOnIncidentCreated =
|
||||
StatusPageSubscriberNotificationStatus.Skipped;
|
||||
createBy.data.subscriberNotificationStatusMessage =
|
||||
"Notifications skipped as subscribers are not to be notified for this incident.";
|
||||
} else if (
|
||||
createBy.data.shouldStatusPageSubscribersBeNotifiedOnIncidentCreated ===
|
||||
true
|
||||
) {
|
||||
createBy.data.subscriberNotificationStatusOnIncidentCreated =
|
||||
StatusPageSubscriberNotificationStatus.Pending;
|
||||
}
|
||||
|
||||
return {
|
||||
createBy,
|
||||
carryForward: {
|
||||
@@ -1685,7 +1725,10 @@ ${incidentSeverity.name}
|
||||
statusTimeline.shouldStatusPageSubscribersBeNotified =
|
||||
shouldNotifyStatusPageSubscribers;
|
||||
|
||||
statusTimeline.isStatusPageSubscribersNotified = isSubscribersNotified;
|
||||
// Map boolean to enum value
|
||||
statusTimeline.subscriberNotificationStatus = isSubscribersNotified
|
||||
? StatusPageSubscriberNotificationStatus.Success
|
||||
: StatusPageSubscriberNotificationStatus.Pending;
|
||||
|
||||
if (stateChangeLog) {
|
||||
statusTimeline.stateChangeLog = stateChangeLog;
|
||||
|
||||
@@ -13,6 +13,7 @@ import BadDataException from "../../Types/Exception/BadDataException";
|
||||
import { JSONObject } from "../../Types/JSON";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import PositiveNumber from "../../Types/PositiveNumber";
|
||||
import StatusPageSubscriberNotificationStatus from "../../Types/StatusPage/StatusPageSubscriberNotificationStatus";
|
||||
import Incident from "../../Models/DatabaseModels/Incident";
|
||||
import IncidentPublicNote from "../../Models/DatabaseModels/IncidentPublicNote";
|
||||
import IncidentState from "../../Models/DatabaseModels/IncidentState";
|
||||
@@ -245,10 +246,26 @@ export class Service extends DatabaseService<IncidentStateTimeline> {
|
||||
if (publicNote) {
|
||||
// mark status page subscribers as notified for this state change because we dont want to send duplicate (two) emails one for public note and one for state change.
|
||||
if (createBy.data.shouldStatusPageSubscribersBeNotified) {
|
||||
createBy.data.isStatusPageSubscribersNotified = true;
|
||||
createBy.data.subscriberNotificationStatus =
|
||||
StatusPageSubscriberNotificationStatus.Success;
|
||||
}
|
||||
}
|
||||
|
||||
// Set notification status based on shouldStatusPageSubscribersBeNotified
|
||||
if (createBy.data.shouldStatusPageSubscribersBeNotified === false) {
|
||||
createBy.data.subscriberNotificationStatus =
|
||||
StatusPageSubscriberNotificationStatus.Skipped;
|
||||
createBy.data.subscriberNotificationStatusMessage =
|
||||
"Notifications skipped as subscribers are not to be notified for this incident state change.";
|
||||
} else if (
|
||||
createBy.data.shouldStatusPageSubscribersBeNotified === true &&
|
||||
!publicNote
|
||||
) {
|
||||
// Only set to Pending if there's no public note (public note handling sets it to Success)
|
||||
createBy.data.subscriberNotificationStatus =
|
||||
StatusPageSubscriberNotificationStatus.Pending;
|
||||
}
|
||||
|
||||
return {
|
||||
createBy,
|
||||
carryForward: {
|
||||
|
||||
27
Common/Server/Services/ProjectSCIMService.ts
Normal file
27
Common/Server/Services/ProjectSCIMService.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import CreateBy from "../Types/Database/CreateBy";
|
||||
import { OnCreate } from "../Types/Database/Hooks";
|
||||
import DatabaseService from "./DatabaseService";
|
||||
import Model from "../../Models/DatabaseModels/ProjectSCIM";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
|
||||
export class Service extends DatabaseService<Model> {
|
||||
public constructor() {
|
||||
super(Model);
|
||||
}
|
||||
|
||||
protected override async onBeforeCreate(
|
||||
createBy: CreateBy<Model>,
|
||||
): Promise<OnCreate<Model>> {
|
||||
if (!createBy.data.bearerToken) {
|
||||
// Generate a secure bearer token if not provided
|
||||
createBy.data.bearerToken = ObjectID.generate().toString();
|
||||
}
|
||||
|
||||
return {
|
||||
createBy: createBy,
|
||||
carryForward: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default new Service();
|
||||
@@ -11,6 +11,7 @@ import { LIMIT_PER_PROJECT } from "../../Types/Database/LimitMax";
|
||||
import ScheduledMaintenanceService from "./ScheduledMaintenanceService";
|
||||
import ScheduledMaintenance from "../../Models/DatabaseModels/ScheduledMaintenance";
|
||||
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
|
||||
import StatusPageSubscriberNotificationStatus from "../../Types/StatusPage/StatusPageSubscriberNotificationStatus";
|
||||
|
||||
export class Service extends DatabaseService<Model> {
|
||||
public constructor() {
|
||||
@@ -25,6 +26,21 @@ export class Service extends DatabaseService<Model> {
|
||||
createBy.data.postedAt = OneUptimeDate.getCurrentDate();
|
||||
}
|
||||
|
||||
// Set notification status based on shouldStatusPageSubscribersBeNotifiedOnNoteCreated
|
||||
if (
|
||||
createBy.data.shouldStatusPageSubscribersBeNotifiedOnNoteCreated === false
|
||||
) {
|
||||
createBy.data.subscriberNotificationStatusOnNoteCreated =
|
||||
StatusPageSubscriberNotificationStatus.Skipped;
|
||||
createBy.data.subscriberNotificationStatusMessage =
|
||||
"Notifications skipped as subscribers are not to be notified for this scheduled maintenance note.";
|
||||
} else if (
|
||||
createBy.data.shouldStatusPageSubscribersBeNotifiedOnNoteCreated === true
|
||||
) {
|
||||
createBy.data.subscriberNotificationStatusOnNoteCreated =
|
||||
StatusPageSubscriberNotificationStatus.Pending;
|
||||
}
|
||||
|
||||
return {
|
||||
createBy: createBy,
|
||||
carryForward: null,
|
||||
|
||||
@@ -16,6 +16,7 @@ import LIMIT_MAX, { LIMIT_PER_PROJECT } from "../../Types/Database/LimitMax";
|
||||
import BadDataException from "../../Types/Exception/BadDataException";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import Typeof from "../../Types/Typeof";
|
||||
import StatusPageSubscriberNotificationStatus from "../../Types/StatusPage/StatusPageSubscriberNotificationStatus";
|
||||
import Monitor from "../../Models/DatabaseModels/Monitor";
|
||||
import Model from "../../Models/DatabaseModels/ScheduledMaintenance";
|
||||
import ScheduledMaintenanceOwnerTeam from "../../Models/DatabaseModels/ScheduledMaintenanceOwnerTeam";
|
||||
@@ -369,6 +370,28 @@ ${resourcesAffected ? `**Resources Affected:** ${resourcesAffected}` : ""}
|
||||
nextTimeToNotifyBeforeTheEvent;
|
||||
}
|
||||
|
||||
// Set notification status based on shouldStatusPageSubscribersBeNotifiedOnEventCreated if it's being updated
|
||||
if (
|
||||
updateBy.data.shouldStatusPageSubscribersBeNotifiedOnEventCreated !==
|
||||
undefined
|
||||
) {
|
||||
if (
|
||||
updateBy.data.shouldStatusPageSubscribersBeNotifiedOnEventCreated ===
|
||||
false
|
||||
) {
|
||||
updateBy.data.subscriberNotificationStatusOnEventScheduled =
|
||||
StatusPageSubscriberNotificationStatus.Skipped;
|
||||
updateBy.data.subscriberNotificationStatusMessage =
|
||||
"Notifications skipped as subscribers are not to be notified for this scheduled maintenance.";
|
||||
} else if (
|
||||
updateBy.data.shouldStatusPageSubscribersBeNotifiedOnEventCreated ===
|
||||
true
|
||||
) {
|
||||
updateBy.data.subscriberNotificationStatusOnEventScheduled =
|
||||
StatusPageSubscriberNotificationStatus.Pending;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
updateBy,
|
||||
carryForward: null,
|
||||
@@ -539,6 +562,22 @@ ${resourcesAffected ? `**Resources Affected:** ${resourcesAffected}` : ""}
|
||||
}
|
||||
}
|
||||
|
||||
// Set notification status based on shouldStatusPageSubscribersBeNotifiedOnEventCreated
|
||||
if (
|
||||
createBy.data.shouldStatusPageSubscribersBeNotifiedOnEventCreated ===
|
||||
false
|
||||
) {
|
||||
createBy.data.subscriberNotificationStatusOnEventScheduled =
|
||||
StatusPageSubscriberNotificationStatus.Skipped;
|
||||
createBy.data.subscriberNotificationStatusMessage =
|
||||
"Notifications skipped as subscribers are not to be notified for this scheduled maintenance.";
|
||||
} else if (
|
||||
createBy.data.shouldStatusPageSubscribersBeNotifiedOnEventCreated === true
|
||||
) {
|
||||
createBy.data.subscriberNotificationStatusOnEventScheduled =
|
||||
StatusPageSubscriberNotificationStatus.Pending;
|
||||
}
|
||||
|
||||
return { createBy, carryForward: null };
|
||||
}
|
||||
|
||||
@@ -775,9 +814,11 @@ ${createdItem.description || "No description provided."}
|
||||
timeline.shouldStatusPageSubscribersBeNotified = Boolean(
|
||||
createdItem.shouldStatusPageSubscribersBeNotifiedOnEventCreated,
|
||||
);
|
||||
timeline.isStatusPageSubscribersNotified = Boolean(
|
||||
createdItem.shouldStatusPageSubscribersBeNotifiedOnEventCreated,
|
||||
); // ignore notifying subscribers because you already notify for Scheduled Event, no need to notify them for timeline event.
|
||||
// Map boolean to enum value - ignore notifying subscribers because you already notify for Scheduled Event, no need to notify them for timeline event.
|
||||
timeline.subscriberNotificationStatus =
|
||||
createdItem.shouldStatusPageSubscribersBeNotifiedOnEventCreated
|
||||
? StatusPageSubscriberNotificationStatus.Success
|
||||
: StatusPageSubscriberNotificationStatus.Pending;
|
||||
timeline.scheduledMaintenanceStateId =
|
||||
createdItem.currentScheduledMaintenanceStateId!;
|
||||
|
||||
@@ -1275,7 +1316,10 @@ ${labels
|
||||
statusTimeline.scheduledMaintenanceStateId = scheduledMaintenanceStateId;
|
||||
statusTimeline.projectId = projectId;
|
||||
statusTimeline.isOwnerNotified = !notifyOwners;
|
||||
statusTimeline.isStatusPageSubscribersNotified = isSubscribersNotified;
|
||||
// Map boolean to enum value
|
||||
statusTimeline.subscriberNotificationStatus = isSubscribersNotified
|
||||
? StatusPageSubscriberNotificationStatus.Success
|
||||
: StatusPageSubscriberNotificationStatus.Pending;
|
||||
statusTimeline.shouldStatusPageSubscribersBeNotified =
|
||||
shouldNotifyStatusPageSubscribers;
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import BadDataException from "../../Types/Exception/BadDataException";
|
||||
import { JSONObject } from "../../Types/JSON";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import PositiveNumber from "../../Types/PositiveNumber";
|
||||
import StatusPageSubscriberNotificationStatus from "../../Types/StatusPage/StatusPageSubscriberNotificationStatus";
|
||||
import Monitor from "../../Models/DatabaseModels/Monitor";
|
||||
import MonitorStatus from "../../Models/DatabaseModels/MonitorStatus";
|
||||
import MonitorStatusTimeline from "../../Models/DatabaseModels/MonitorStatusTimeline";
|
||||
@@ -255,7 +256,8 @@ export class Service extends DatabaseService<ScheduledMaintenanceStateTimeline>
|
||||
if (
|
||||
scheduledMaintenancePublicNote.shouldStatusPageSubscribersBeNotifiedOnNoteCreated
|
||||
) {
|
||||
createBy.data.isStatusPageSubscribersNotified = true;
|
||||
createBy.data.subscriberNotificationStatus =
|
||||
StatusPageSubscriberNotificationStatus.Success;
|
||||
}
|
||||
|
||||
await ScheduledMaintenancePublicNoteService.create({
|
||||
@@ -264,6 +266,17 @@ export class Service extends DatabaseService<ScheduledMaintenanceStateTimeline>
|
||||
});
|
||||
}
|
||||
|
||||
// Set notification status based on shouldStatusPageSubscribersBeNotified
|
||||
if (createBy.data.shouldStatusPageSubscribersBeNotified === false) {
|
||||
createBy.data.subscriberNotificationStatus =
|
||||
StatusPageSubscriberNotificationStatus.Skipped;
|
||||
createBy.data.subscriberNotificationStatusMessage =
|
||||
"Notifications skipped as subscribers are not to be notified for this scheduled maintenance state change.";
|
||||
} else if (createBy.data.shouldStatusPageSubscribersBeNotified === true) {
|
||||
createBy.data.subscriberNotificationStatus =
|
||||
StatusPageSubscriberNotificationStatus.Pending;
|
||||
}
|
||||
|
||||
return {
|
||||
createBy,
|
||||
carryForward: {
|
||||
|
||||
@@ -1,10 +1,56 @@
|
||||
import DatabaseService from "./DatabaseService";
|
||||
import Model from "../../Models/DatabaseModels/StatusPageAnnouncement";
|
||||
import CreateBy from "../Types/Database/CreateBy";
|
||||
import UpdateBy from "../Types/Database/UpdateBy";
|
||||
import { OnCreate, OnUpdate } from "../Types/Database/Hooks";
|
||||
import StatusPageSubscriberNotificationStatus from "../../Types/StatusPage/StatusPageSubscriberNotificationStatus";
|
||||
|
||||
export class Service extends DatabaseService<Model> {
|
||||
public constructor() {
|
||||
super(Model);
|
||||
}
|
||||
|
||||
protected override async onBeforeCreate(
|
||||
createBy: CreateBy<Model>,
|
||||
): Promise<OnCreate<Model>> {
|
||||
// Set notification status based on shouldStatusPageSubscribersBeNotified
|
||||
if (createBy.data.shouldStatusPageSubscribersBeNotified === false) {
|
||||
createBy.data.subscriberNotificationStatus =
|
||||
StatusPageSubscriberNotificationStatus.Skipped;
|
||||
createBy.data.subscriberNotificationStatusMessage =
|
||||
"Notifications skipped as subscribers are not to be notified for this announcement.";
|
||||
} else if (createBy.data.shouldStatusPageSubscribersBeNotified === true) {
|
||||
createBy.data.subscriberNotificationStatus =
|
||||
StatusPageSubscriberNotificationStatus.Pending;
|
||||
}
|
||||
|
||||
return {
|
||||
createBy,
|
||||
carryForward: null,
|
||||
};
|
||||
}
|
||||
|
||||
protected override async onBeforeUpdate(
|
||||
updateBy: UpdateBy<Model>,
|
||||
): Promise<OnUpdate<Model>> {
|
||||
// Set notification status based on shouldStatusPageSubscribersBeNotified if it's being updated
|
||||
if (updateBy.data.shouldStatusPageSubscribersBeNotified !== undefined) {
|
||||
if (updateBy.data.shouldStatusPageSubscribersBeNotified === false) {
|
||||
updateBy.data.subscriberNotificationStatus =
|
||||
StatusPageSubscriberNotificationStatus.Skipped;
|
||||
updateBy.data.subscriberNotificationStatusMessage =
|
||||
"Notifications skipped as subscribers are not to be notified for this announcement.";
|
||||
} else if (updateBy.data.shouldStatusPageSubscribersBeNotified === true) {
|
||||
updateBy.data.subscriberNotificationStatus =
|
||||
StatusPageSubscriberNotificationStatus.Pending;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
updateBy,
|
||||
carryForward: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default new Service();
|
||||
|
||||
27
Common/Server/Services/StatusPageSCIMService.ts
Normal file
27
Common/Server/Services/StatusPageSCIMService.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import CreateBy from "../Types/Database/CreateBy";
|
||||
import { OnCreate } from "../Types/Database/Hooks";
|
||||
import DatabaseService from "./DatabaseService";
|
||||
import Model from "../../Models/DatabaseModels/StatusPageSCIM";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
|
||||
export class Service extends DatabaseService<Model> {
|
||||
public constructor() {
|
||||
super(Model);
|
||||
}
|
||||
|
||||
protected override async onBeforeCreate(
|
||||
createBy: CreateBy<Model>,
|
||||
): Promise<OnCreate<Model>> {
|
||||
if (!createBy.data.bearerToken) {
|
||||
// Generate a secure bearer token if not provided
|
||||
createBy.data.bearerToken = ObjectID.generate().toString();
|
||||
}
|
||||
|
||||
return {
|
||||
createBy: createBy,
|
||||
carryForward: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default new Service();
|
||||
@@ -1,9 +1,7 @@
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import WorkspaceType from "../../Types/Workspace/WorkspaceType";
|
||||
import DatabaseService from "./DatabaseService";
|
||||
import Model, {
|
||||
SlackMiscData,
|
||||
} from "../../Models/DatabaseModels/WorkspaceProjectAuthToken";
|
||||
import Model from "../../Models/DatabaseModels/WorkspaceProjectAuthToken";
|
||||
import { LIMIT_PER_PROJECT } from "../../Types/Database/LimitMax";
|
||||
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
|
||||
|
||||
@@ -70,7 +68,7 @@ export class Service extends DatabaseService<Model> {
|
||||
workspaceType: WorkspaceType;
|
||||
authToken: string;
|
||||
workspaceProjectId: string;
|
||||
miscData: SlackMiscData;
|
||||
miscData: NonNullable<Model["miscData"]>;
|
||||
}): Promise<void> {
|
||||
let projectAuth: Model | null = await this.findOneBy({
|
||||
query: {
|
||||
@@ -92,7 +90,7 @@ export class Service extends DatabaseService<Model> {
|
||||
projectAuth.authToken = data.authToken;
|
||||
projectAuth.workspaceType = data.workspaceType;
|
||||
projectAuth.workspaceProjectId = data.workspaceProjectId;
|
||||
projectAuth.miscData = data.miscData;
|
||||
projectAuth.miscData = data.miscData || {};
|
||||
|
||||
await this.create({
|
||||
data: projectAuth,
|
||||
@@ -106,7 +104,7 @@ export class Service extends DatabaseService<Model> {
|
||||
data: {
|
||||
authToken: data.authToken,
|
||||
workspaceProjectId: data.workspaceProjectId,
|
||||
miscData: data.miscData,
|
||||
miscData: (data.miscData as unknown as object) || {},
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import WorkspaceType from "../../Types/Workspace/WorkspaceType";
|
||||
import DatabaseService from "./DatabaseService";
|
||||
import Model, {
|
||||
SlackMiscData,
|
||||
} from "../../Models/DatabaseModels/WorkspaceUserAuthToken";
|
||||
import Model from "../../Models/DatabaseModels/WorkspaceUserAuthToken";
|
||||
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
|
||||
|
||||
export class Service extends DatabaseService<Model> {
|
||||
@@ -66,7 +64,7 @@ export class Service extends DatabaseService<Model> {
|
||||
workspaceType: WorkspaceType;
|
||||
authToken: string;
|
||||
workspaceUserId: string;
|
||||
miscData: SlackMiscData;
|
||||
miscData: NonNullable<Model["miscData"]>;
|
||||
}): Promise<void> {
|
||||
let userAuth: Model | null = await this.findOneBy({
|
||||
query: {
|
||||
@@ -90,7 +88,7 @@ export class Service extends DatabaseService<Model> {
|
||||
userAuth.authToken = data.authToken;
|
||||
userAuth.workspaceType = data.workspaceType;
|
||||
userAuth.workspaceUserId = data.workspaceUserId;
|
||||
userAuth.miscData = data.miscData;
|
||||
userAuth.miscData = data.miscData || {};
|
||||
|
||||
await this.create({
|
||||
data: userAuth,
|
||||
@@ -104,7 +102,7 @@ export class Service extends DatabaseService<Model> {
|
||||
data: {
|
||||
authToken: data.authToken,
|
||||
workspaceUserId: data.workspaceUserId,
|
||||
miscData: data.miscData,
|
||||
miscData: (data.miscData as unknown as object) || {},
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
|
||||
@@ -24,6 +24,7 @@ import Permission, {
|
||||
} from "../../../../Types/Permission";
|
||||
|
||||
import CaptureSpan from "../../../Utils/Telemetry/CaptureSpan";
|
||||
import logger from "../../../Utils/Logger";
|
||||
|
||||
export default class ColumnPermissions {
|
||||
@CaptureSpan()
|
||||
@@ -220,6 +221,10 @@ export default class ColumnPermissions {
|
||||
getAllEnvVars(),
|
||||
)
|
||||
) {
|
||||
logger.debug(
|
||||
`User does not have access to update ${key} column of ${model.singularName}`,
|
||||
);
|
||||
|
||||
throw new PaymentRequiredException(
|
||||
"Please upgrade your plan to " +
|
||||
billingAccessControl.update +
|
||||
|
||||
@@ -137,7 +137,7 @@ export default class IncomingRequestCriteria {
|
||||
input.dataToProcess.monitorId.toString() +
|
||||
" is true",
|
||||
);
|
||||
return `Incoming request / heartbeat received in ${value} minutes.`;
|
||||
return `Incoming request / heartbeat received in ${value} minutes. It was received ${differenceInMinutes} minutes ago.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -153,7 +153,7 @@ export default class IncomingRequestCriteria {
|
||||
input.dataToProcess.monitorId.toString() +
|
||||
" is true",
|
||||
);
|
||||
return `Incoming request / heartbeat not received in ${value} minutes.`;
|
||||
return `Incoming request / heartbeat not received in ${value} minutes. It was received ${differenceInMinutes} minutes ago.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -228,6 +228,8 @@ export default class MonitorResourceUtil {
|
||||
await MonitorService.updateOneById({
|
||||
id: monitor.id!,
|
||||
data: {
|
||||
incomingRequestMonitorHeartbeatCheckedAt:
|
||||
OneUptimeDate.getCurrentDate(),
|
||||
incomingMonitorRequest: {
|
||||
...dataToProcess,
|
||||
} as any,
|
||||
|
||||
@@ -89,6 +89,16 @@ app.set("view engine", "ejs");
|
||||
* https://stackoverflow.com/questions/19917401/error-request-entity-too-large
|
||||
*/
|
||||
|
||||
// Handle SCIM content type before JSON middleware
|
||||
app.use((req: ExpressRequest, _res: ExpressResponse, next: NextFunction) => {
|
||||
const contentType: string | undefined = req.headers["content-type"];
|
||||
if (contentType && contentType.includes("application/scim+json")) {
|
||||
// Set content type to application/json so express.json() can parse it
|
||||
req.headers["content-type"] = "application/json";
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
app.use((req: OneUptimeRequest, res: ExpressResponse, next: NextFunction) => {
|
||||
if (req.headers["content-encoding"] === "gzip") {
|
||||
const buffers: any = [];
|
||||
|
||||
@@ -3,7 +3,12 @@ import HTTPResponse from "../../../../Types/API/HTTPResponse";
|
||||
import URL from "../../../../Types/API/URL";
|
||||
import { JSONObject } from "../../../../Types/JSON";
|
||||
import API from "../../../../Utils/API";
|
||||
import WorkspaceMessagePayload from "../../../../Types/Workspace/WorkspaceMessagePayload";
|
||||
import WorkspaceMessagePayload, {
|
||||
WorkspacePayloadButtons,
|
||||
WorkspacePayloadHeader,
|
||||
WorkspacePayloadMarkdown,
|
||||
WorkspaceMessagePayloadButton,
|
||||
} from "../../../../Types/Workspace/WorkspaceMessagePayload";
|
||||
import logger from "../../Logger";
|
||||
import Dictionary from "../../../../Types/Dictionary";
|
||||
import WorkspaceBase, {
|
||||
@@ -49,7 +54,9 @@ export default class MicrosoftTeams extends WorkspaceBase {
|
||||
continue;
|
||||
}
|
||||
|
||||
channels[team["displayName"].toString()] = {
|
||||
const key: string = (team["displayName"] as string).toLowerCase();
|
||||
|
||||
channels[key] = {
|
||||
id: team["id"] as string,
|
||||
name: team["displayName"] as string,
|
||||
workspaceType: WorkspaceType.MicrosoftTeams,
|
||||
@@ -124,6 +131,55 @@ export default class MicrosoftTeams extends WorkspaceBase {
|
||||
return values;
|
||||
}
|
||||
|
||||
// ---------- Basic block builders (Teams uses simple HTML content) ----------
|
||||
|
||||
@CaptureSpan()
|
||||
public static override getHeaderBlock(data: {
|
||||
payloadHeaderBlock: WorkspacePayloadHeader;
|
||||
}): JSONObject {
|
||||
return {
|
||||
type: "header",
|
||||
text: data.payloadHeaderBlock.text,
|
||||
} as JSONObject;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public static override getMarkdownBlock(data: {
|
||||
payloadMarkdownBlock: WorkspacePayloadMarkdown;
|
||||
}): JSONObject {
|
||||
return {
|
||||
type: "markdown",
|
||||
text: data.payloadMarkdownBlock.text,
|
||||
} as JSONObject;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public static override getButtonsBlock(data: {
|
||||
payloadButtonsBlock: WorkspacePayloadButtons;
|
||||
}): JSONObject {
|
||||
return {
|
||||
type: "buttons",
|
||||
buttons: data.payloadButtonsBlock.buttons?.map(
|
||||
(b: WorkspaceMessagePayloadButton) =>
|
||||
this.getButtonBlock({ payloadButtonBlock: b }),
|
||||
),
|
||||
} as JSONObject;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public static override getButtonBlock(data: {
|
||||
payloadButtonBlock: WorkspaceMessagePayloadButton;
|
||||
}): JSONObject {
|
||||
return {
|
||||
type: "button",
|
||||
title: data.payloadButtonBlock.title,
|
||||
url: data.payloadButtonBlock.url
|
||||
? data.payloadButtonBlock.url.toString()
|
||||
: undefined,
|
||||
value: data.payloadButtonBlock.value,
|
||||
} as JSONObject;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public static override async inviteUserToChannelByChannelName(data: {
|
||||
authToken: string;
|
||||
@@ -147,6 +203,20 @@ export default class MicrosoftTeams extends WorkspaceBase {
|
||||
});
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public static override async inviteUserToChannelByChannelId(_data: {
|
||||
authToken: string;
|
||||
channelId: string; // team id where we want to add member
|
||||
workspaceUserId: string; // AAD user id
|
||||
}): Promise<void> {
|
||||
// Teams membership management requires specific permissions and often admin consent.
|
||||
// We no-op here to avoid failures and rely on existing membership.
|
||||
logger.debug(
|
||||
"MicrosoftTeams.inviteUserToChannelByChannelId is not implemented. Skipping.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public static override async createChannelsIfDoesNotExist(data: {
|
||||
authToken: string;
|
||||
@@ -182,16 +252,11 @@ export default class MicrosoftTeams extends WorkspaceBase {
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.debug(`Channel ${channelName} does not exist. Creating channel.`);
|
||||
const channel: WorkspaceChannel = await this.createChannel({
|
||||
authToken: data.authToken,
|
||||
channelName: channelName,
|
||||
});
|
||||
|
||||
if (channel) {
|
||||
logger.debug(`Channel ${channelName} created successfully.`);
|
||||
workspaceChannels.push(channel);
|
||||
}
|
||||
// Creating new Microsoft Teams (teams) programmatically is not supported in this initial integration.
|
||||
// We will skip creation and simply log the absence.
|
||||
logger.debug(
|
||||
`Channel/Team ${channelName} does not exist in Microsoft Teams. Skipping creation.`,
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug("Channels created or found:");
|
||||
@@ -215,15 +280,15 @@ export default class MicrosoftTeams extends WorkspaceBase {
|
||||
logger.debug("All workspace channels:");
|
||||
logger.debug(channels);
|
||||
|
||||
if (!channels[data.channelName]) {
|
||||
if (!channels[data.channelName.toLowerCase()]) {
|
||||
logger.error("Channel not found.");
|
||||
throw new BadDataException("Channel not found.");
|
||||
}
|
||||
|
||||
logger.debug("Workspace channel ID obtained:");
|
||||
logger.debug(channels[data.channelName]!.id);
|
||||
logger.debug(channels[data.channelName.toLowerCase()]!.id);
|
||||
|
||||
return channels[data.channelName]!;
|
||||
return channels[data.channelName.toLowerCase()]!;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
@@ -284,13 +349,13 @@ export default class MicrosoftTeams extends WorkspaceBase {
|
||||
data.channelName = data.channelName.toLowerCase();
|
||||
|
||||
// get channel id from channel name
|
||||
const channels: Dictionary<WorkspaceChannel> =
|
||||
const channels: Dictionary<WorkspaceChannel> =
|
||||
await this.getAllWorkspaceChannels({
|
||||
authToken: data.authToken,
|
||||
});
|
||||
|
||||
// if this channel exists
|
||||
if (channels[data.channelName]) {
|
||||
if (channels[data.channelName]) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -361,22 +426,9 @@ export default class MicrosoftTeams extends WorkspaceBase {
|
||||
workspaceType: WorkspaceType.MicrosoftTeams,
|
||||
};
|
||||
|
||||
for (const channel of workspaceChannelsToPostTo) {
|
||||
for (const channel of workspaceChannelsToPostTo) {
|
||||
try {
|
||||
// check if the user is in the channel.
|
||||
const isUserInChannel: boolean = await this.isUserInChannel({
|
||||
authToken: data.authToken,
|
||||
channelId: channel.id,
|
||||
userId: data.userId,
|
||||
});
|
||||
|
||||
if (!isUserInChannel) {
|
||||
// add user to the channel
|
||||
await this.joinChannel({
|
||||
authToken: data.authToken,
|
||||
channelId: channel.id,
|
||||
});
|
||||
}
|
||||
// For Teams, skip membership checks in initial integration.
|
||||
|
||||
const thread: WorkspaceThread = await this.sendPayloadBlocksToChannel({
|
||||
authToken: data.authToken,
|
||||
@@ -398,4 +450,182 @@ export default class MicrosoftTeams extends WorkspaceBase {
|
||||
|
||||
return workspaceMessageResponse;
|
||||
}
|
||||
|
||||
// ---------- Teams specific implementations ----------
|
||||
|
||||
@CaptureSpan()
|
||||
public static override async getUsernameFromUserId(data: {
|
||||
authToken: string;
|
||||
userId: string;
|
||||
}): Promise<string | null> {
|
||||
try {
|
||||
const response: HTTPErrorResponse | HTTPResponse<JSONObject> =
|
||||
await API.get(
|
||||
URL.fromString(
|
||||
`https://graph.microsoft.com/v1.0/users/${encodeURIComponent(
|
||||
data.userId,
|
||||
)}`,
|
||||
),
|
||||
{
|
||||
Authorization: `Bearer ${data.authToken}`,
|
||||
},
|
||||
);
|
||||
|
||||
if (response instanceof HTTPErrorResponse) {
|
||||
logger.error("Error fetching Teams user details:");
|
||||
logger.error(response);
|
||||
return null;
|
||||
}
|
||||
|
||||
const displayName: string | undefined = (response.jsonData as JSONObject)[
|
||||
"displayName"
|
||||
] as string | undefined;
|
||||
return displayName || null;
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public static override async isUserInChannel(_data: {
|
||||
authToken: string;
|
||||
channelId: string;
|
||||
userId: string;
|
||||
}): Promise<boolean> {
|
||||
// Skipping membership verification for initial integration.
|
||||
return true;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public static override async joinChannel(_data: {
|
||||
authToken: string;
|
||||
channelId: string;
|
||||
}): Promise<void> {
|
||||
// No-op in initial integration.
|
||||
return;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public static override async sendPayloadBlocksToChannel(data: {
|
||||
authToken: string;
|
||||
workspaceChannel: WorkspaceChannel; // here, id is Team ID
|
||||
blocks: Array<JSONObject>;
|
||||
}): Promise<WorkspaceThread> {
|
||||
logger.debug("Posting message to Microsoft Teams channel (General)...");
|
||||
|
||||
// Build simple HTML content from blocks
|
||||
const toHtml = (bs: Array<JSONObject>): string => {
|
||||
const htmlParts: Array<string> = [];
|
||||
for (const b of bs) {
|
||||
const type: string = (b["type"] as string) || "";
|
||||
if (type === "header") {
|
||||
htmlParts.push(`<h3>${(b["text"] as string) || ""}</h3>`);
|
||||
} else if (type === "markdown") {
|
||||
const text: string = (b["text"] as string) || "";
|
||||
// very light markdown to HTML: **bold** -> <b>bold</b>
|
||||
const html: string = text
|
||||
.replace(/\*\*(.*?)\*\*/g, "<b>$1</b>")
|
||||
.replace(/\*(.*?)\*/g, "<i>$1</i>")
|
||||
.replace(/\n/g, "<br/>");
|
||||
htmlParts.push(`<p>${html}</p>`);
|
||||
} else if (type === "divider") {
|
||||
htmlParts.push("<hr/>");
|
||||
} else if (type === "buttons") {
|
||||
const btns: Array<JSONObject> = (b["buttons"] as Array<JSONObject>) || [];
|
||||
if (btns.length > 0) {
|
||||
htmlParts.push(
|
||||
btns
|
||||
.map((btn: JSONObject) => {
|
||||
const title: string = (btn["title"] as string) || "Button";
|
||||
const url: string | undefined = btn["url"] as string | undefined;
|
||||
return url
|
||||
? `<a href="${url}" target="_blank" rel="noopener noreferrer">${title}</a>`
|
||||
: `<span>${title}</span>`;
|
||||
})
|
||||
.join(" "),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return htmlParts.join("\n");
|
||||
};
|
||||
|
||||
const htmlContent: string = toHtml(data.blocks);
|
||||
|
||||
// Resolve General channel for the given Team (workspaceChannel.id)
|
||||
const channelsResp: HTTPErrorResponse | HTTPResponse<JSONObject> =
|
||||
await API.get(
|
||||
URL.fromString(
|
||||
`https://graph.microsoft.com/v1.0/teams/${encodeURIComponent(
|
||||
data.workspaceChannel.id,
|
||||
)}/channels`,
|
||||
),
|
||||
{
|
||||
Authorization: `Bearer ${data.authToken}`,
|
||||
},
|
||||
);
|
||||
|
||||
if (channelsResp instanceof HTTPErrorResponse) {
|
||||
logger.error("Error fetching Teams channels:");
|
||||
logger.error(channelsResp);
|
||||
throw channelsResp;
|
||||
}
|
||||
|
||||
const channels: Array<JSONObject> = ((channelsResp.jsonData as JSONObject)[
|
||||
"value"
|
||||
] || []) as Array<JSONObject>;
|
||||
|
||||
const generalChannel: JSONObject | undefined = channels.find(
|
||||
(c: JSONObject) =>
|
||||
(c["displayName"] as string)?.toLowerCase() === "general",
|
||||
);
|
||||
|
||||
if (!generalChannel || !generalChannel["id"]) {
|
||||
throw new BadDataException(
|
||||
"Could not find General channel for the selected Microsoft Team.",
|
||||
);
|
||||
}
|
||||
|
||||
const channelId: string = generalChannel["id"] as string;
|
||||
|
||||
const postResp: HTTPErrorResponse | HTTPResponse<JSONObject> =
|
||||
await API.post(
|
||||
URL.fromString(
|
||||
`https://graph.microsoft.com/v1.0/teams/${encodeURIComponent(
|
||||
data.workspaceChannel.id,
|
||||
)}/channels/${encodeURIComponent(channelId)}/messages`,
|
||||
),
|
||||
{
|
||||
body: {
|
||||
contentType: "html",
|
||||
content: htmlContent,
|
||||
},
|
||||
},
|
||||
{
|
||||
Authorization: `Bearer ${data.authToken}`,
|
||||
["Content-Type"]: "application/json",
|
||||
},
|
||||
{
|
||||
retries: 3,
|
||||
exponentialBackoff: true,
|
||||
},
|
||||
);
|
||||
|
||||
if (postResp instanceof HTTPErrorResponse) {
|
||||
logger.error("Error posting Teams message:");
|
||||
logger.error(postResp);
|
||||
throw postResp;
|
||||
}
|
||||
|
||||
const messageId: string = ((postResp.jsonData as JSONObject)["id"] ||
|
||||
"") as string;
|
||||
|
||||
const thread: WorkspaceThread = {
|
||||
channel: data.workspaceChannel,
|
||||
threadId: messageId,
|
||||
};
|
||||
|
||||
return thread;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,7 +174,8 @@ export default class WorkspaceUtil {
|
||||
botUserId = (projectAuthToken.miscData as SlackMiscData).botUserId;
|
||||
}
|
||||
|
||||
if (!botUserId) {
|
||||
// For Microsoft Teams initial integration, we don't require a bot user id
|
||||
if (!botUserId && workspaceType !== WorkspaceType.MicrosoftTeams) {
|
||||
responses.push({
|
||||
workspaceType: workspaceType,
|
||||
threads: [],
|
||||
@@ -192,7 +193,7 @@ export default class WorkspaceUtil {
|
||||
|
||||
const result: WorkspaceSendMessageResponse =
|
||||
await WorkspaceUtil.getWorkspaceTypeUtil(workspaceType).sendMessage({
|
||||
userId: botUserId,
|
||||
userId: botUserId || "",
|
||||
authToken: projectAuthToken.authToken,
|
||||
workspaceMessagePayload: messagePayloadByWorkspace,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
enum StatusPageSubscriberNotificationStatus {
|
||||
Skipped = "Skipped",
|
||||
Pending = "Pending",
|
||||
InProgress = "InProgress",
|
||||
Success = "Success",
|
||||
Failed = "Failed",
|
||||
}
|
||||
|
||||
export default StatusPageSubscriberNotificationStatus;
|
||||
991
Common/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx
Normal file
991
Common/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx
Normal file
@@ -0,0 +1,991 @@
|
||||
// Tremor BarChart [v1.0.0]
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { RiArrowLeftSLine, RiArrowRightSLine } from "@remixicon/react";
|
||||
import {
|
||||
Bar,
|
||||
CartesianGrid,
|
||||
Label,
|
||||
BarChart as RechartsBarChart,
|
||||
Legend as RechartsLegend,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import type { AxisDomain } from "recharts/types/util/types";
|
||||
|
||||
import {
|
||||
AvailableChartColors,
|
||||
type AvailableChartColorsKeys,
|
||||
constructCategoryColors,
|
||||
getColorClassName,
|
||||
} from "../Utils/ChartColors";
|
||||
import { cx } from "../Utils/Cx";
|
||||
import { getYAxisDomain } from "../Utils/GetYAxisDomain";
|
||||
import { useOnWindowResize } from "../Utils/UseWindowOnResize";
|
||||
|
||||
//#region Shape
|
||||
|
||||
function deepEqual<T>(obj1: T, obj2: T): boolean {
|
||||
if (obj1 === obj2) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof obj1 !== "object" ||
|
||||
typeof obj2 !== "object" ||
|
||||
obj1 === null ||
|
||||
obj2 === null
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const keys1: Array<keyof T> = Object.keys(obj1) as Array<keyof T>;
|
||||
const keys2: Array<keyof T> = Object.keys(obj2) as Array<keyof T>;
|
||||
|
||||
if (keys1.length !== keys2.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const key of keys1) {
|
||||
if (!keys2.includes(key) || !deepEqual(obj1[key], obj2[key])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const renderShape: (
|
||||
props: any,
|
||||
activeBar: any | undefined,
|
||||
activeLegend: string | undefined,
|
||||
layout: string,
|
||||
) => React.ReactElement = (
|
||||
props: any,
|
||||
activeBar: any | undefined,
|
||||
activeLegend: string | undefined,
|
||||
layout: string,
|
||||
): React.ReactElement => {
|
||||
const { fillOpacity, name, payload, value } = props;
|
||||
let { x, width, y, height } = props;
|
||||
|
||||
if (layout === "horizontal" && height < 0) {
|
||||
y += height;
|
||||
height = Math.abs(height); // height must be a positive number
|
||||
} else if (layout === "vertical" && width < 0) {
|
||||
x += width;
|
||||
width = Math.abs(width); // width must be a positive number
|
||||
}
|
||||
|
||||
return (
|
||||
<rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={width}
|
||||
height={height}
|
||||
opacity={
|
||||
activeBar || (activeLegend && activeLegend !== name)
|
||||
? deepEqual(activeBar, { ...payload, value })
|
||||
? fillOpacity
|
||||
: 0.3
|
||||
: fillOpacity
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
//#region Legend
|
||||
|
||||
interface LegendItemProps {
|
||||
name: string;
|
||||
color: AvailableChartColorsKeys;
|
||||
onClick?: (name: string, color: string) => void;
|
||||
activeLegend?: string;
|
||||
}
|
||||
|
||||
const LegendItem: React.FunctionComponent<LegendItemProps> = ({
|
||||
name,
|
||||
color,
|
||||
onClick,
|
||||
activeLegend,
|
||||
}: LegendItemProps): React.ReactElement => {
|
||||
const hasOnValueChange: boolean = Boolean(onClick);
|
||||
return (
|
||||
<li
|
||||
className={cx(
|
||||
// base
|
||||
"group inline-flex flex-nowrap items-center gap-1.5 rounded-sm px-2 py-1 whitespace-nowrap transition",
|
||||
hasOnValueChange
|
||||
? "cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
: "cursor-default",
|
||||
)}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClick?.(name, color as string);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={cx(
|
||||
"size-2 shrink-0 rounded-xs",
|
||||
getColorClassName(color, "bg"),
|
||||
activeLegend && activeLegend !== name ? "opacity-40" : "opacity-100",
|
||||
)}
|
||||
aria-hidden={true}
|
||||
/>
|
||||
<p
|
||||
className={cx(
|
||||
// base
|
||||
"truncate text-xs whitespace-nowrap",
|
||||
// text color
|
||||
"text-gray-700 dark:text-gray-300",
|
||||
hasOnValueChange &&
|
||||
"group-hover:text-gray-900 dark:group-hover:text-gray-50",
|
||||
activeLegend && activeLegend !== name ? "opacity-40" : "opacity-100",
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
interface ScrollButtonProps {
|
||||
icon: React.ElementType;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ScrollButton: React.FunctionComponent<ScrollButtonProps> = ({
|
||||
icon,
|
||||
onClick,
|
||||
disabled,
|
||||
}: ScrollButtonProps): React.ReactElement => {
|
||||
const Icon: React.ElementType = icon;
|
||||
const [isPressed, setIsPressed] = React.useState(false);
|
||||
const intervalRef: React.MutableRefObject<NodeJS.Timeout | null> =
|
||||
React.useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isPressed) {
|
||||
intervalRef.current = setInterval(() => {
|
||||
onClick?.();
|
||||
}, 300);
|
||||
} else {
|
||||
clearInterval(intervalRef.current as NodeJS.Timeout);
|
||||
}
|
||||
return () => {
|
||||
return clearInterval(intervalRef.current as NodeJS.Timeout);
|
||||
};
|
||||
}, [isPressed, onClick]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (disabled) {
|
||||
clearInterval(intervalRef.current as NodeJS.Timeout);
|
||||
setIsPressed(false);
|
||||
}
|
||||
}, [disabled]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cx(
|
||||
// base
|
||||
"group inline-flex size-5 items-center truncate rounded-sm transition",
|
||||
disabled
|
||||
? "cursor-not-allowed text-gray-400 dark:text-gray-600"
|
||||
: "cursor-pointer text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-gray-50",
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClick?.();
|
||||
}}
|
||||
onMouseDown={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setIsPressed(true);
|
||||
}}
|
||||
onMouseUp={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setIsPressed(false);
|
||||
}}
|
||||
>
|
||||
<Icon className="size-full" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
interface LegendProps extends React.OlHTMLAttributes<HTMLOListElement> {
|
||||
categories: string[];
|
||||
colors?: AvailableChartColorsKeys[];
|
||||
onClickLegendItem?: (category: string, color: string) => void;
|
||||
activeLegend?: string;
|
||||
enableLegendSlider?: boolean;
|
||||
}
|
||||
|
||||
type HasScrollProps = {
|
||||
left: boolean;
|
||||
right: boolean;
|
||||
};
|
||||
|
||||
const Legend: React.ForwardRefExoticComponent<
|
||||
LegendProps & React.RefAttributes<HTMLOListElement>
|
||||
> = React.forwardRef<HTMLOListElement, LegendProps>(
|
||||
(
|
||||
props: LegendProps,
|
||||
ref: React.Ref<HTMLOListElement>,
|
||||
): React.ReactElement => {
|
||||
const {
|
||||
categories,
|
||||
colors = AvailableChartColors,
|
||||
className,
|
||||
onClickLegendItem,
|
||||
activeLegend,
|
||||
enableLegendSlider = false,
|
||||
...other
|
||||
} = props;
|
||||
const scrollableRef: React.RefObject<HTMLInputElement> =
|
||||
React.useRef<HTMLInputElement>(null);
|
||||
const scrollButtonsRef: React.RefObject<HTMLDivElement> =
|
||||
React.useRef<HTMLDivElement>(null);
|
||||
const [hasScroll, setHasScroll] = React.useState<HasScrollProps | null>(
|
||||
null,
|
||||
);
|
||||
const [isKeyDowned, setIsKeyDowned] = React.useState<string | null>(null);
|
||||
const intervalRef: React.MutableRefObject<NodeJS.Timeout | null> =
|
||||
React.useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const checkScroll: () => void = React.useCallback(() => {
|
||||
const scrollable: HTMLInputElement | null = scrollableRef?.current;
|
||||
if (!scrollable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasLeftScroll: boolean = scrollable.scrollLeft > 0;
|
||||
const hasRightScroll: boolean =
|
||||
scrollable.scrollWidth - scrollable.clientWidth > scrollable.scrollLeft;
|
||||
|
||||
setHasScroll({ left: hasLeftScroll, right: hasRightScroll });
|
||||
}, [setHasScroll]);
|
||||
|
||||
const scrollToTest: (direction: "left" | "right") => void =
|
||||
React.useCallback(
|
||||
(direction: "left" | "right") => {
|
||||
const element: HTMLInputElement | null = scrollableRef?.current;
|
||||
const scrollButtons: HTMLDivElement | null =
|
||||
scrollButtonsRef?.current;
|
||||
const scrollButtonsWith: number = scrollButtons?.clientWidth ?? 0;
|
||||
const width: number = element?.clientWidth ?? 0;
|
||||
|
||||
if (element && enableLegendSlider) {
|
||||
element.scrollTo({
|
||||
left:
|
||||
direction === "left"
|
||||
? element.scrollLeft - width + scrollButtonsWith
|
||||
: element.scrollLeft + width - scrollButtonsWith,
|
||||
behavior: "smooth",
|
||||
});
|
||||
setTimeout(() => {
|
||||
checkScroll();
|
||||
}, 400);
|
||||
}
|
||||
},
|
||||
[enableLegendSlider, checkScroll],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const keyDownHandler: (key: string) => void = (key: string): void => {
|
||||
if (key === "ArrowLeft") {
|
||||
scrollToTest("left");
|
||||
} else if (key === "ArrowRight") {
|
||||
scrollToTest("right");
|
||||
}
|
||||
};
|
||||
if (isKeyDowned) {
|
||||
keyDownHandler(isKeyDowned);
|
||||
intervalRef.current = setInterval(() => {
|
||||
keyDownHandler(isKeyDowned);
|
||||
}, 300);
|
||||
} else {
|
||||
clearInterval(intervalRef.current as NodeJS.Timeout);
|
||||
}
|
||||
return () => {
|
||||
return clearInterval(intervalRef.current as NodeJS.Timeout);
|
||||
};
|
||||
}, [isKeyDowned, scrollToTest]);
|
||||
|
||||
const keyDown: (e: KeyboardEvent) => void = (e: KeyboardEvent): void => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
setIsKeyDowned(e.key);
|
||||
}
|
||||
};
|
||||
const keyUp: (e: KeyboardEvent) => void = (e: KeyboardEvent): void => {
|
||||
e.stopPropagation();
|
||||
setIsKeyDowned(null);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const scrollable: HTMLInputElement | null = scrollableRef?.current;
|
||||
if (enableLegendSlider) {
|
||||
checkScroll();
|
||||
scrollable?.addEventListener("keydown", keyDown);
|
||||
scrollable?.addEventListener("keyup", keyUp);
|
||||
}
|
||||
|
||||
return () => {
|
||||
scrollable?.removeEventListener("keydown", keyDown);
|
||||
scrollable?.removeEventListener("keyup", keyUp);
|
||||
};
|
||||
}, [checkScroll, enableLegendSlider]);
|
||||
|
||||
return (
|
||||
<ol
|
||||
ref={ref}
|
||||
className={cx("relative overflow-hidden", className)}
|
||||
{...other}
|
||||
>
|
||||
<div
|
||||
ref={scrollableRef}
|
||||
tabIndex={0}
|
||||
className={cx(
|
||||
"flex h-full",
|
||||
enableLegendSlider
|
||||
? hasScroll?.right || hasScroll?.left
|
||||
? "snap-mandatory items-center overflow-auto pr-12 pl-4 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
: ""
|
||||
: "flex-wrap",
|
||||
)}
|
||||
>
|
||||
{categories.map((category: string, index: number) => {
|
||||
return (
|
||||
<LegendItem
|
||||
key={`item-${index}`}
|
||||
name={category}
|
||||
color={colors[index] as AvailableChartColorsKeys}
|
||||
{...(onClickLegendItem ? { onClick: onClickLegendItem } : {})}
|
||||
{...(activeLegend ? { activeLegend: activeLegend } : {})}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{enableLegendSlider && (hasScroll?.right || hasScroll?.left) ? (
|
||||
<>
|
||||
<div
|
||||
className={cx(
|
||||
// base
|
||||
"absolute top-0 right-0 bottom-0 flex h-full items-center justify-center pr-1",
|
||||
// background color
|
||||
"bg-white dark:bg-gray-950",
|
||||
)}
|
||||
>
|
||||
<ScrollButton
|
||||
icon={RiArrowLeftSLine}
|
||||
onClick={() => {
|
||||
setIsKeyDowned(null);
|
||||
scrollToTest("left");
|
||||
}}
|
||||
disabled={!hasScroll?.left}
|
||||
/>
|
||||
<ScrollButton
|
||||
icon={RiArrowRightSLine}
|
||||
onClick={() => {
|
||||
setIsKeyDowned(null);
|
||||
scrollToTest("right");
|
||||
}}
|
||||
disabled={!hasScroll?.right}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</ol>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Legend.displayName = "Legend";
|
||||
|
||||
const ChartLegend: (
|
||||
payload: any,
|
||||
categoryColors: Map<string, AvailableChartColorsKeys>,
|
||||
setLegendHeight: React.Dispatch<React.SetStateAction<number>>,
|
||||
activeLegend: string | undefined,
|
||||
onClick?: (category: string, color: string) => void,
|
||||
enableLegendSlider?: boolean,
|
||||
legendPosition?: "left" | "center" | "right",
|
||||
yAxisWidth?: number,
|
||||
) => React.ReactElement = (
|
||||
{ payload }: any,
|
||||
categoryColors: Map<string, AvailableChartColorsKeys>,
|
||||
setLegendHeight: React.Dispatch<React.SetStateAction<number>>,
|
||||
activeLegend: string | undefined,
|
||||
onClick?: (category: string, color: string) => void,
|
||||
enableLegendSlider?: boolean,
|
||||
legendPosition?: "left" | "center" | "right",
|
||||
yAxisWidth?: number,
|
||||
) => {
|
||||
const legendRef: React.RefObject<HTMLDivElement> =
|
||||
React.useRef<HTMLDivElement>(null);
|
||||
|
||||
useOnWindowResize(() => {
|
||||
const calculateHeight: (height: number | undefined) => number = (
|
||||
height: number | undefined,
|
||||
): number => {
|
||||
return height ? Number(height) + 15 : 60;
|
||||
};
|
||||
setLegendHeight(calculateHeight(legendRef.current?.clientHeight));
|
||||
});
|
||||
|
||||
const filteredPayload: any[] = payload.filter((item: any) => {
|
||||
return item.type !== "none";
|
||||
});
|
||||
|
||||
const paddingLeft: number =
|
||||
legendPosition === "left" && yAxisWidth ? yAxisWidth - 8 : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ paddingLeft: paddingLeft }}
|
||||
ref={legendRef}
|
||||
className={cx(
|
||||
"flex items-center",
|
||||
{ "justify-center": legendPosition === "center" },
|
||||
{
|
||||
"justify-start": legendPosition === "left",
|
||||
},
|
||||
{ "justify-end": legendPosition === "right" },
|
||||
)}
|
||||
>
|
||||
<Legend
|
||||
categories={filteredPayload.map((entry: any) => {
|
||||
return entry.value;
|
||||
})}
|
||||
colors={filteredPayload.map((entry: any) => {
|
||||
return categoryColors.get(entry.value) as AvailableChartColorsKeys;
|
||||
})}
|
||||
{...(onClick ? { onClickLegendItem: onClick } : {})}
|
||||
{...(activeLegend ? { activeLegend: activeLegend } : {})}
|
||||
{...(enableLegendSlider
|
||||
? { enableLegendSlider: enableLegendSlider }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
//#region Tooltip
|
||||
|
||||
type TooltipProps = Pick<ChartTooltipProps, "active" | "payload" | "label">;
|
||||
|
||||
// eslint-disable-next-line react/no-unused-prop-types
|
||||
type PayloadItem = {
|
||||
category: string; // eslint-disable-line react/no-unused-prop-types
|
||||
value: number; // eslint-disable-line react/no-unused-prop-types
|
||||
index: string; // eslint-disable-line react/no-unused-prop-types
|
||||
color: AvailableChartColorsKeys; // eslint-disable-line react/no-unused-prop-types
|
||||
type?: string; // eslint-disable-line react/no-unused-prop-types
|
||||
payload: any;
|
||||
};
|
||||
|
||||
interface ChartTooltipProps {
|
||||
active: boolean | undefined;
|
||||
payload: PayloadItem[];
|
||||
label: string;
|
||||
valueFormatter: (value: number) => string;
|
||||
}
|
||||
|
||||
const ChartTooltip: React.FunctionComponent<ChartTooltipProps> = ({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
valueFormatter,
|
||||
}: ChartTooltipProps): React.ReactElement | null => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
// base
|
||||
"rounded-md border text-sm shadow-md",
|
||||
// border color
|
||||
"border-gray-200 dark:border-gray-800",
|
||||
// background color
|
||||
"bg-white dark:bg-gray-950",
|
||||
)}
|
||||
>
|
||||
<div className={cx("border-b border-inherit px-4 py-2")}>
|
||||
<p
|
||||
className={cx(
|
||||
// base
|
||||
"font-medium",
|
||||
// text color
|
||||
"text-gray-900 dark:text-gray-50",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</p>
|
||||
</div>
|
||||
<div className={cx("space-y-1 px-4 py-2")}>
|
||||
{payload.map(
|
||||
({ value, category, color }: PayloadItem, index: number) => {
|
||||
return (
|
||||
<div
|
||||
key={`id-${index}`}
|
||||
className="flex items-center justify-between space-x-8"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cx(
|
||||
"size-2 shrink-0 rounded-xs",
|
||||
getColorClassName(color, "bg"),
|
||||
)}
|
||||
/>
|
||||
<p
|
||||
className={cx(
|
||||
// base
|
||||
"text-right whitespace-nowrap",
|
||||
// text color
|
||||
"text-gray-700 dark:text-gray-300",
|
||||
)}
|
||||
>
|
||||
{category}
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
className={cx(
|
||||
// base
|
||||
"text-right font-medium whitespace-nowrap tabular-nums",
|
||||
// text color
|
||||
"text-gray-900 dark:text-gray-50",
|
||||
)}
|
||||
>
|
||||
{valueFormatter(value)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
//#region BarChart
|
||||
|
||||
type BaseEventProps = {
|
||||
eventType: "category" | "bar";
|
||||
categoryClicked: string;
|
||||
[key: string]: number | string;
|
||||
};
|
||||
|
||||
type BarChartEventProps = BaseEventProps | null | undefined;
|
||||
|
||||
interface BarChartProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
data: Record<string, any>[];
|
||||
index: string;
|
||||
categories: string[];
|
||||
colors?: AvailableChartColorsKeys[];
|
||||
valueFormatter?: (value: number) => string;
|
||||
startEndOnly?: boolean;
|
||||
showXAxis?: boolean;
|
||||
showYAxis?: boolean;
|
||||
showGridLines?: boolean;
|
||||
yAxisWidth?: number;
|
||||
intervalType?: "preserveStartEnd" | "equidistantPreserveStart";
|
||||
showTooltip?: boolean;
|
||||
showLegend?: boolean;
|
||||
autoMinValue?: boolean;
|
||||
minValue?: number;
|
||||
maxValue?: number;
|
||||
allowDecimals?: boolean;
|
||||
onValueChange?: (value: BarChartEventProps) => void;
|
||||
enableLegendSlider?: boolean;
|
||||
tickGap?: number;
|
||||
barCategoryGap?: string | number;
|
||||
xAxisLabel?: string;
|
||||
yAxisLabel?: string;
|
||||
layout?: "vertical" | "horizontal";
|
||||
type?: "default" | "stacked" | "percent";
|
||||
legendPosition?: "left" | "center" | "right";
|
||||
tooltipCallback?: (tooltipCallbackContent: TooltipProps) => void;
|
||||
customTooltip?: React.ComponentType<TooltipProps>;
|
||||
}
|
||||
|
||||
const BarChart: React.ForwardRefExoticComponent<
|
||||
BarChartProps & React.RefAttributes<HTMLDivElement>
|
||||
> = React.forwardRef<HTMLDivElement, BarChartProps>(
|
||||
(
|
||||
props: BarChartProps,
|
||||
forwardedRef: React.Ref<HTMLDivElement>,
|
||||
): React.ReactElement => {
|
||||
const {
|
||||
data = [],
|
||||
categories = [],
|
||||
index,
|
||||
colors = AvailableChartColors,
|
||||
valueFormatter = (value: number) => {
|
||||
return value.toString();
|
||||
},
|
||||
startEndOnly = false,
|
||||
showXAxis = true,
|
||||
showYAxis = true,
|
||||
showGridLines = true,
|
||||
yAxisWidth = 56,
|
||||
intervalType = "equidistantPreserveStart",
|
||||
showTooltip = true,
|
||||
showLegend = true,
|
||||
autoMinValue = false,
|
||||
minValue,
|
||||
maxValue,
|
||||
allowDecimals = true,
|
||||
className,
|
||||
onValueChange,
|
||||
enableLegendSlider = false,
|
||||
barCategoryGap,
|
||||
tickGap = 5,
|
||||
xAxisLabel,
|
||||
yAxisLabel,
|
||||
layout = "horizontal",
|
||||
type = "default",
|
||||
legendPosition = "right",
|
||||
tooltipCallback,
|
||||
customTooltip,
|
||||
...other
|
||||
} = props;
|
||||
const CustomTooltip: React.ComponentType<any> | undefined = customTooltip;
|
||||
const paddingValue: number =
|
||||
(!showXAxis && !showYAxis) || (startEndOnly && !showYAxis) ? 0 : 20;
|
||||
const [legendHeight, setLegendHeight] = React.useState(60);
|
||||
const [activeLegend, setActiveLegend] = React.useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const categoryColors: Map<string, AvailableChartColorsKeys> =
|
||||
constructCategoryColors(categories, colors);
|
||||
const [activeBar, setActiveBar] = React.useState<any | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const yAxisDomain: AxisDomain = getYAxisDomain(
|
||||
autoMinValue,
|
||||
minValue,
|
||||
maxValue,
|
||||
);
|
||||
const hasOnValueChange: boolean = Boolean(onValueChange);
|
||||
const stacked: boolean = type === "stacked" || type === "percent";
|
||||
|
||||
const prevActiveRef: React.MutableRefObject<boolean | undefined> =
|
||||
React.useRef<boolean | undefined>(undefined);
|
||||
const prevLabelRef: React.MutableRefObject<string | undefined> =
|
||||
React.useRef<string | undefined>(undefined);
|
||||
|
||||
function valueToPercent(value: number): string {
|
||||
return `${(value * 100).toFixed(0)}%`;
|
||||
}
|
||||
|
||||
const onBarClick: (data: any, _: any, event: React.MouseEvent) => void =
|
||||
React.useCallback(
|
||||
(data: any, _: any, event: React.MouseEvent): void => {
|
||||
event.stopPropagation();
|
||||
if (!onValueChange) {
|
||||
return;
|
||||
}
|
||||
if (deepEqual(activeBar, { ...data.payload, value: data.value })) {
|
||||
setActiveLegend(undefined);
|
||||
setActiveBar(undefined);
|
||||
onValueChange?.(null);
|
||||
} else {
|
||||
setActiveLegend(data.tooltipPayload?.[0]?.dataKey);
|
||||
setActiveBar({
|
||||
...data.payload,
|
||||
value: data.value,
|
||||
});
|
||||
onValueChange?.({
|
||||
eventType: "bar",
|
||||
categoryClicked: data.tooltipPayload?.[0]?.dataKey,
|
||||
...data.payload,
|
||||
});
|
||||
}
|
||||
},
|
||||
[activeBar, onValueChange, setActiveLegend, setActiveBar],
|
||||
);
|
||||
|
||||
function onCategoryClick(dataKey: string): void {
|
||||
if (!hasOnValueChange) {
|
||||
return;
|
||||
}
|
||||
if (dataKey === activeLegend && !activeBar) {
|
||||
setActiveLegend(undefined);
|
||||
onValueChange?.(null);
|
||||
} else {
|
||||
setActiveLegend(dataKey);
|
||||
onValueChange?.({
|
||||
eventType: "category",
|
||||
categoryClicked: dataKey,
|
||||
});
|
||||
}
|
||||
setActiveBar(undefined);
|
||||
}
|
||||
|
||||
const shapeRenderer: (props: any) => React.ReactElement = (
|
||||
props: any,
|
||||
): React.ReactElement => {
|
||||
return renderShape(props, activeBar, activeLegend, layout);
|
||||
};
|
||||
|
||||
const handleChartClick: () => void = (): void => {
|
||||
setActiveBar(undefined);
|
||||
setActiveLegend(undefined);
|
||||
onValueChange?.(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={forwardedRef}
|
||||
className={cx("h-80 w-full", className)}
|
||||
data-tremor-id="tremor-raw"
|
||||
{...other}
|
||||
>
|
||||
<ResponsiveContainer>
|
||||
<RechartsBarChart
|
||||
data={data}
|
||||
{...(hasOnValueChange && (activeLegend || activeBar)
|
||||
? {
|
||||
onClick: handleChartClick,
|
||||
}
|
||||
: {})}
|
||||
margin={{
|
||||
bottom: xAxisLabel ? 30 : 0,
|
||||
left: yAxisLabel ? 20 : 0,
|
||||
right: yAxisLabel ? 5 : 0,
|
||||
top: 5,
|
||||
}}
|
||||
stackOffset={type === "percent" ? "expand" : "none"}
|
||||
layout={layout}
|
||||
barCategoryGap={barCategoryGap ?? "10%"}
|
||||
>
|
||||
{showGridLines ? (
|
||||
<CartesianGrid
|
||||
className={cx("stroke-gray-200 stroke-1 dark:stroke-gray-800")}
|
||||
horizontal={layout !== "vertical"}
|
||||
vertical={layout === "vertical"}
|
||||
/>
|
||||
) : null}
|
||||
<XAxis
|
||||
hide={!showXAxis}
|
||||
tick={{
|
||||
transform:
|
||||
layout !== "vertical" ? "translate(0, 6)" : undefined,
|
||||
}}
|
||||
fill=""
|
||||
stroke=""
|
||||
className={cx(
|
||||
// base
|
||||
"text-xs",
|
||||
// text fill
|
||||
"fill-gray-500 dark:fill-gray-500",
|
||||
{ "mt-4": layout !== "vertical" },
|
||||
)}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
minTickGap={tickGap}
|
||||
{...(layout !== "vertical"
|
||||
? {
|
||||
padding: {
|
||||
left: paddingValue,
|
||||
right: paddingValue,
|
||||
},
|
||||
dataKey: index,
|
||||
interval: startEndOnly ? "preserveStartEnd" : intervalType,
|
||||
...(startEndOnly && data.length > 0
|
||||
? {
|
||||
ticks: [
|
||||
data[0]?.[index],
|
||||
data[data.length - 1]?.[index],
|
||||
].filter(Boolean),
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: {
|
||||
type: "number" as const,
|
||||
domain: yAxisDomain as AxisDomain,
|
||||
tickFormatter:
|
||||
type === "percent" ? valueToPercent : valueFormatter,
|
||||
allowDecimals: allowDecimals,
|
||||
})}
|
||||
>
|
||||
{xAxisLabel && (
|
||||
<Label
|
||||
position="insideBottom"
|
||||
offset={-20}
|
||||
className="fill-gray-800 text-sm font-medium dark:fill-gray-200"
|
||||
>
|
||||
{xAxisLabel}
|
||||
</Label>
|
||||
)}
|
||||
</XAxis>
|
||||
<YAxis
|
||||
width={yAxisWidth}
|
||||
hide={!showYAxis}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
fill=""
|
||||
stroke=""
|
||||
className={cx(
|
||||
// base
|
||||
"text-xs",
|
||||
// text fill
|
||||
"fill-gray-500 dark:fill-gray-500",
|
||||
)}
|
||||
tick={{
|
||||
transform:
|
||||
layout !== "vertical"
|
||||
? "translate(-3, 0)"
|
||||
: "translate(0, 0)",
|
||||
}}
|
||||
{...(layout !== "vertical"
|
||||
? {
|
||||
type: "number" as const,
|
||||
domain: yAxisDomain as AxisDomain,
|
||||
tickFormatter:
|
||||
type === "percent" ? valueToPercent : valueFormatter,
|
||||
allowDecimals: allowDecimals,
|
||||
}
|
||||
: {
|
||||
dataKey: index,
|
||||
...(startEndOnly && data.length > 0
|
||||
? {
|
||||
ticks: [
|
||||
data[0]?.[index],
|
||||
data[data.length - 1]?.[index],
|
||||
].filter(Boolean),
|
||||
}
|
||||
: {}),
|
||||
type: "category" as const,
|
||||
interval: "equidistantPreserveStart" as const,
|
||||
})}
|
||||
>
|
||||
{yAxisLabel && (
|
||||
<Label
|
||||
position="insideLeft"
|
||||
style={{ textAnchor: "middle" }}
|
||||
angle={-90}
|
||||
offset={-15}
|
||||
className="fill-gray-800 text-sm font-medium dark:fill-gray-200"
|
||||
>
|
||||
{yAxisLabel}
|
||||
</Label>
|
||||
)}
|
||||
</YAxis>
|
||||
<Tooltip
|
||||
wrapperStyle={{ outline: "none" }}
|
||||
isAnimationActive={true}
|
||||
animationDuration={100}
|
||||
cursor={{ fill: "#d1d5db", opacity: "0.15" }}
|
||||
offset={20}
|
||||
{...(layout === "horizontal"
|
||||
? { position: { y: 0 } }
|
||||
: { position: { x: yAxisWidth + 20 } })}
|
||||
content={({ active, payload, label }: any) => {
|
||||
const cleanPayload: TooltipProps["payload"] = payload
|
||||
? payload.map((item: any) => {
|
||||
return {
|
||||
category: item.dataKey,
|
||||
value: item.value,
|
||||
index: item.payload[index],
|
||||
color: categoryColors.get(
|
||||
item.dataKey,
|
||||
) as AvailableChartColorsKeys,
|
||||
type: item.type,
|
||||
payload: item.payload,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
if (
|
||||
tooltipCallback &&
|
||||
(active !== prevActiveRef.current ||
|
||||
label !== prevLabelRef.current)
|
||||
) {
|
||||
tooltipCallback({ active, payload: cleanPayload, label });
|
||||
prevActiveRef.current = active;
|
||||
prevLabelRef.current = label;
|
||||
}
|
||||
|
||||
return showTooltip && active ? (
|
||||
CustomTooltip ? (
|
||||
<CustomTooltip
|
||||
active={active}
|
||||
payload={cleanPayload}
|
||||
label={label}
|
||||
/>
|
||||
) : (
|
||||
<ChartTooltip
|
||||
active={active}
|
||||
payload={cleanPayload}
|
||||
label={label}
|
||||
valueFormatter={valueFormatter}
|
||||
/>
|
||||
)
|
||||
) : null;
|
||||
}}
|
||||
/>
|
||||
{showLegend ? (
|
||||
<RechartsLegend
|
||||
verticalAlign="top"
|
||||
height={legendHeight}
|
||||
content={({ payload }: any) => {
|
||||
return ChartLegend(
|
||||
{ payload },
|
||||
categoryColors,
|
||||
setLegendHeight,
|
||||
activeLegend,
|
||||
hasOnValueChange
|
||||
? (clickedLegendItem: string): void => {
|
||||
return onCategoryClick(clickedLegendItem);
|
||||
}
|
||||
: undefined,
|
||||
enableLegendSlider,
|
||||
legendPosition,
|
||||
yAxisWidth,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{categories.map((category: string) => {
|
||||
return (
|
||||
<Bar
|
||||
className={cx(
|
||||
getColorClassName(
|
||||
categoryColors.get(category) as AvailableChartColorsKeys,
|
||||
"fill",
|
||||
),
|
||||
onValueChange ? "cursor-pointer" : "",
|
||||
)}
|
||||
key={category}
|
||||
name={category}
|
||||
type="linear"
|
||||
dataKey={category}
|
||||
{...(stacked ? { stackId: "stack" } : {})}
|
||||
isAnimationActive={false}
|
||||
fill=""
|
||||
shape={shapeRenderer}
|
||||
onClick={onBarClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</RechartsBarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
BarChart.displayName = "BarChart";
|
||||
|
||||
export { BarChart, type BarChartEventProps, type TooltipProps };
|
||||
@@ -0,0 +1,367 @@
|
||||
// Tremor Spark Chart [v1.0.0]
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Area,
|
||||
Bar,
|
||||
Line,
|
||||
AreaChart as RechartsAreaChart,
|
||||
BarChart as RechartsBarChart,
|
||||
LineChart as RechartsLineChart,
|
||||
ResponsiveContainer,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import type { AxisDomain } from "recharts/types/util/types";
|
||||
|
||||
import {
|
||||
AvailableChartColors,
|
||||
type AvailableChartColorsKeys,
|
||||
constructCategoryColors,
|
||||
getColorClassName,
|
||||
} from "../Utils/ChartColors";
|
||||
import { cx } from "../Utils/Cx";
|
||||
import { getYAxisDomain } from "../Utils/GetYAxisDomain";
|
||||
|
||||
//#region SparkAreaChart
|
||||
|
||||
interface SparkAreaChartProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
data: Record<string, any>[];
|
||||
categories: string[];
|
||||
index: string;
|
||||
colors?: AvailableChartColorsKeys[];
|
||||
autoMinValue?: boolean;
|
||||
minValue?: number;
|
||||
maxValue?: number;
|
||||
connectNulls?: boolean;
|
||||
type?: "default" | "stacked" | "percent";
|
||||
fill?: "gradient" | "solid" | "none";
|
||||
}
|
||||
|
||||
const SparkAreaChart: React.ForwardRefExoticComponent<
|
||||
SparkAreaChartProps & React.RefAttributes<HTMLDivElement>
|
||||
> = React.forwardRef<HTMLDivElement, SparkAreaChartProps>(
|
||||
(
|
||||
props: SparkAreaChartProps,
|
||||
forwardedRef: React.Ref<HTMLDivElement>,
|
||||
): React.ReactElement => {
|
||||
const {
|
||||
data = [],
|
||||
categories = [],
|
||||
index,
|
||||
colors = AvailableChartColors,
|
||||
autoMinValue = false,
|
||||
minValue,
|
||||
maxValue,
|
||||
connectNulls = false,
|
||||
type = "default",
|
||||
className,
|
||||
fill = "gradient",
|
||||
...other
|
||||
} = props;
|
||||
|
||||
const categoryColors: Map<string, AvailableChartColorsKeys> =
|
||||
constructCategoryColors(categories, colors);
|
||||
const yAxisDomain: AxisDomain = getYAxisDomain(
|
||||
autoMinValue,
|
||||
minValue,
|
||||
maxValue,
|
||||
);
|
||||
const stacked: boolean = type === "stacked" || type === "percent";
|
||||
const areaId: string = React.useId();
|
||||
|
||||
const getFillContent: (
|
||||
fillType: SparkAreaChartProps["fill"],
|
||||
) => React.ReactElement = (
|
||||
fillType: SparkAreaChartProps["fill"],
|
||||
): React.ReactElement => {
|
||||
switch (fillType) {
|
||||
case "none":
|
||||
return <stop stopColor="currentColor" stopOpacity={0} />;
|
||||
case "gradient":
|
||||
return (
|
||||
<>
|
||||
<stop offset="5%" stopColor="currentColor" stopOpacity={0.4} />
|
||||
<stop offset="95%" stopColor="currentColor" stopOpacity={0} />
|
||||
</>
|
||||
);
|
||||
case "solid":
|
||||
return <stop stopColor="currentColor" stopOpacity={0.3} />;
|
||||
default:
|
||||
return <stop stopColor="currentColor" stopOpacity={0.3} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={forwardedRef}
|
||||
className={cx("h-12 w-28", className)}
|
||||
data-tremor-id="tremor-raw"
|
||||
{...other}
|
||||
>
|
||||
<ResponsiveContainer>
|
||||
<RechartsAreaChart
|
||||
data={data}
|
||||
margin={{
|
||||
bottom: 1,
|
||||
left: 1,
|
||||
right: 1,
|
||||
top: 1,
|
||||
}}
|
||||
{...(type === "percent" && { stackOffset: "expand" })}
|
||||
>
|
||||
<XAxis hide dataKey={index} />
|
||||
<YAxis hide={true} domain={yAxisDomain as AxisDomain} />
|
||||
|
||||
{categories.map((category: string) => {
|
||||
const categoryId: string = `${areaId}-${category.replace(/[^a-zA-Z0-9]/g, "")}`;
|
||||
return (
|
||||
<React.Fragment key={category}>
|
||||
<defs>
|
||||
<linearGradient
|
||||
key={category}
|
||||
className={cx(
|
||||
getColorClassName(
|
||||
categoryColors.get(
|
||||
category,
|
||||
) as AvailableChartColorsKeys,
|
||||
"text",
|
||||
),
|
||||
)}
|
||||
id={categoryId}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
{getFillContent(fill)}
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Area
|
||||
className={cx(
|
||||
getColorClassName(
|
||||
categoryColors.get(
|
||||
category,
|
||||
) as AvailableChartColorsKeys,
|
||||
"stroke",
|
||||
),
|
||||
)}
|
||||
dot={false}
|
||||
strokeOpacity={1}
|
||||
name={category}
|
||||
type="linear"
|
||||
dataKey={category}
|
||||
stroke=""
|
||||
strokeWidth={2}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
isAnimationActive={false}
|
||||
connectNulls={connectNulls}
|
||||
{...(stacked && { stackId: "stack" })}
|
||||
fill={`url(#${categoryId})`}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</RechartsAreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
SparkAreaChart.displayName = "SparkAreaChart";
|
||||
|
||||
//#region SparkLineChart
|
||||
|
||||
interface SparkLineChartProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
data: Record<string, any>[];
|
||||
categories: string[];
|
||||
index: string;
|
||||
colors?: AvailableChartColorsKeys[];
|
||||
autoMinValue?: boolean;
|
||||
minValue?: number;
|
||||
maxValue?: number;
|
||||
connectNulls?: boolean;
|
||||
}
|
||||
|
||||
const SparkLineChart: React.ForwardRefExoticComponent<
|
||||
SparkLineChartProps & React.RefAttributes<HTMLDivElement>
|
||||
> = React.forwardRef<HTMLDivElement, SparkLineChartProps>(
|
||||
(
|
||||
props: SparkLineChartProps,
|
||||
forwardedRef: React.Ref<HTMLDivElement>,
|
||||
): React.ReactElement => {
|
||||
const {
|
||||
data = [],
|
||||
categories = [],
|
||||
index,
|
||||
colors = AvailableChartColors,
|
||||
autoMinValue = false,
|
||||
minValue,
|
||||
maxValue,
|
||||
connectNulls = false,
|
||||
className,
|
||||
...other
|
||||
} = props;
|
||||
|
||||
const categoryColors: Map<string, AvailableChartColorsKeys> =
|
||||
constructCategoryColors(categories, colors);
|
||||
const yAxisDomain: AxisDomain = getYAxisDomain(
|
||||
autoMinValue,
|
||||
minValue,
|
||||
maxValue,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={forwardedRef}
|
||||
className={cx("h-12 w-28", className)}
|
||||
data-tremor-id="tremor-raw"
|
||||
{...other}
|
||||
>
|
||||
<ResponsiveContainer>
|
||||
<RechartsLineChart
|
||||
data={data}
|
||||
margin={{
|
||||
bottom: 1,
|
||||
left: 1,
|
||||
right: 1,
|
||||
top: 1,
|
||||
}}
|
||||
>
|
||||
<XAxis hide dataKey={index} />
|
||||
<YAxis hide={true} domain={yAxisDomain as AxisDomain} />
|
||||
{categories.map((category: string) => {
|
||||
return (
|
||||
<Line
|
||||
className={cx(
|
||||
getColorClassName(
|
||||
categoryColors.get(category) as AvailableChartColorsKeys,
|
||||
"stroke",
|
||||
),
|
||||
)}
|
||||
dot={false}
|
||||
strokeOpacity={1}
|
||||
key={category}
|
||||
name={category}
|
||||
type="linear"
|
||||
dataKey={category}
|
||||
stroke=""
|
||||
strokeWidth={2}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
isAnimationActive={false}
|
||||
connectNulls={connectNulls}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</RechartsLineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
SparkLineChart.displayName = "SparkLineChart";
|
||||
|
||||
//#region SparkBarChart
|
||||
|
||||
interface BarChartProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
data: Record<string, any>[];
|
||||
index: string;
|
||||
categories: string[];
|
||||
colors?: AvailableChartColorsKeys[];
|
||||
autoMinValue?: boolean;
|
||||
minValue?: number;
|
||||
maxValue?: number;
|
||||
barCategoryGap?: string | number;
|
||||
type?: "default" | "stacked" | "percent";
|
||||
}
|
||||
|
||||
const SparkBarChart: React.ForwardRefExoticComponent<
|
||||
BarChartProps & React.RefAttributes<HTMLDivElement>
|
||||
> = React.forwardRef<HTMLDivElement, BarChartProps>(
|
||||
(
|
||||
props: BarChartProps,
|
||||
forwardedRef: React.Ref<HTMLDivElement>,
|
||||
): React.ReactElement => {
|
||||
const {
|
||||
data = [],
|
||||
categories = [],
|
||||
index,
|
||||
colors = AvailableChartColors,
|
||||
autoMinValue = false,
|
||||
minValue,
|
||||
maxValue,
|
||||
barCategoryGap,
|
||||
type = "default",
|
||||
className,
|
||||
...other
|
||||
} = props;
|
||||
|
||||
const categoryColors: Map<string, AvailableChartColorsKeys> =
|
||||
constructCategoryColors(categories, colors);
|
||||
|
||||
const yAxisDomain: AxisDomain = getYAxisDomain(
|
||||
autoMinValue,
|
||||
minValue,
|
||||
maxValue,
|
||||
);
|
||||
const stacked: boolean = type === "stacked" || type === "percent";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={forwardedRef}
|
||||
className={cx("h-12 w-28", className)}
|
||||
data-tremor-id="tremor-raw"
|
||||
{...other}
|
||||
>
|
||||
<ResponsiveContainer>
|
||||
<RechartsBarChart
|
||||
data={data}
|
||||
margin={{
|
||||
bottom: 1,
|
||||
left: 1,
|
||||
right: 1,
|
||||
top: 1,
|
||||
}}
|
||||
{...(type === "percent" && { stackOffset: "expand" })}
|
||||
{...(barCategoryGap !== undefined && { barCategoryGap })}
|
||||
>
|
||||
<XAxis hide dataKey={index} />
|
||||
<YAxis hide={true} domain={yAxisDomain as AxisDomain} />
|
||||
|
||||
{categories.map((category: string) => {
|
||||
return (
|
||||
<Bar
|
||||
className={cx(
|
||||
getColorClassName(
|
||||
categoryColors.get(category) as AvailableChartColorsKeys,
|
||||
"fill",
|
||||
),
|
||||
)}
|
||||
key={category}
|
||||
name={category}
|
||||
type="linear"
|
||||
dataKey={category}
|
||||
{...(stacked && { stackId: "stack" })}
|
||||
isAnimationActive={false}
|
||||
fill=""
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</RechartsBarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
SparkBarChart.displayName = "SparkBarChart";
|
||||
|
||||
export { SparkAreaChart, SparkLineChart, SparkBarChart };
|
||||
@@ -4,11 +4,11 @@ export const getYAxisDomain: (
|
||||
autoMinValue: boolean,
|
||||
minValue: number | undefined,
|
||||
maxValue: number | undefined,
|
||||
) => (number | "auto")[] = (
|
||||
) => [number | "auto", number | "auto"] = (
|
||||
autoMinValue: boolean,
|
||||
minValue: number | undefined,
|
||||
maxValue: number | undefined,
|
||||
): (number | "auto")[] => {
|
||||
): [number | "auto", number | "auto"] => {
|
||||
const minDomain: number | "auto" = autoMinValue ? "auto" : minValue ?? 0;
|
||||
const maxDomain: number | "auto" = maxValue ?? "auto";
|
||||
return [minDomain, maxDomain];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Icon from "../Icon/Icon";
|
||||
import IconText from "../IconText/IconText";
|
||||
import { Green, Red } from "../../../Types/BrandColors";
|
||||
import IconProp from "../../../Types/Icon/IconProp";
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
@@ -12,23 +12,12 @@ const CheckboxViewer: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex">
|
||||
<div className="h-6 w-6">
|
||||
{props.isChecked ? (
|
||||
<Icon
|
||||
className="h-5 w-5"
|
||||
icon={IconProp.CheckCircle}
|
||||
color={Green}
|
||||
/>
|
||||
) : (
|
||||
<Icon className="h-5 w-5" icon={IconProp.CircleClose} color={Red} />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-900 flex justify-left">
|
||||
{props.text}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-6">
|
||||
<IconText
|
||||
text={props.text}
|
||||
icon={props.isChecked ? IconProp.CheckCircle : IconProp.CircleClose}
|
||||
iconColor={props.isChecked ? Green : Red}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
91
Common/UI/Components/IconText/IconText.tsx
Normal file
91
Common/UI/Components/IconText/IconText.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import Icon, { SizeProp } from "../Icon/Icon";
|
||||
import Color from "../../../Types/Color";
|
||||
import IconProp from "../../../Types/Icon/IconProp";
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
|
||||
export interface ComponentProps {
|
||||
text: string;
|
||||
icon: IconProp;
|
||||
iconColor?: Color | null;
|
||||
textColor?: Color | null;
|
||||
iconSize?: SizeProp;
|
||||
iconClassName?: string;
|
||||
textClassName?: string;
|
||||
containerClassName?: string;
|
||||
spacing?: "sm" | "md" | "lg";
|
||||
alignment?: "left" | "center" | "right";
|
||||
onClick?: (() => void) | undefined;
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
const IconText: FunctionComponent<ComponentProps> = ({
|
||||
text,
|
||||
icon,
|
||||
iconColor = null,
|
||||
textColor = null,
|
||||
iconSize = SizeProp.Regular,
|
||||
iconClassName = "h-5 w-5",
|
||||
textClassName = "text-sm text-gray-900",
|
||||
containerClassName = "",
|
||||
spacing = "sm",
|
||||
alignment = "left",
|
||||
onClick,
|
||||
"data-testid": dataTestId,
|
||||
}: ComponentProps): ReactElement => {
|
||||
const getSpacingClass: () => string = (): string => {
|
||||
switch (spacing) {
|
||||
case "sm":
|
||||
return "gap-1";
|
||||
case "md":
|
||||
return "gap-2";
|
||||
case "lg":
|
||||
return "gap-3";
|
||||
default:
|
||||
return "gap-1";
|
||||
}
|
||||
};
|
||||
|
||||
const getAlignmentClass: () => string = (): string => {
|
||||
switch (alignment) {
|
||||
case "center":
|
||||
return "justify-center";
|
||||
case "right":
|
||||
return "justify-end";
|
||||
case "left":
|
||||
default:
|
||||
return "justify-start";
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick: () => void = (): void => {
|
||||
if (onClick) {
|
||||
onClick();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center ${getSpacingClass()} ${getAlignmentClass()} ${containerClassName}`}
|
||||
onClick={handleClick}
|
||||
data-testid={dataTestId}
|
||||
style={{ cursor: onClick ? "pointer" : "default" }}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<Icon
|
||||
className={iconClassName}
|
||||
icon={icon}
|
||||
color={iconColor}
|
||||
size={iconSize}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`flex ${getAlignmentClass()} ${textClassName}`}
|
||||
style={{ color: textColor?.toString() || "inherit" }}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IconText;
|
||||
3
Common/UI/Components/IconText/Index.ts
Normal file
3
Common/UI/Components/IconText/Index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import IconText from "./IconText";
|
||||
|
||||
export default IconText;
|
||||
@@ -44,7 +44,7 @@ const ConfirmModal: FunctionComponent<ComponentProps> = (
|
||||
>
|
||||
<div
|
||||
data-testid="confirm-modal-description"
|
||||
className="text-gray-500 mt-5 text-sm"
|
||||
className="text-gray-500 mt-5 text-sm text-wrap"
|
||||
>
|
||||
{props.description}
|
||||
</div>
|
||||
|
||||
@@ -246,3 +246,6 @@ export const DisableTelemetry: boolean = env("DISABLE_TELEMETRY") === "true";
|
||||
|
||||
export const SlackAppClientId: string | null =
|
||||
env("SLACK_APP_CLIENT_ID") || null;
|
||||
|
||||
export const MicrosoftTeamsAppClientId: string | null =
|
||||
env("MICROSOFT_TEAMS_APP_CLIENT_ID") || null;
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import React, { FunctionComponent, ReactElement, useEffect } from "react";
|
||||
import Card, { CardButtonSchema } from "Common/UI/Components/Card/Card";
|
||||
import { ButtonStyleType } from "Common/UI/Components/Button/Button";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import ProjectUtil from "Common/UI/Utils/Project";
|
||||
import UserUtil from "Common/UI/Utils/User";
|
||||
import API from "Common/Utils/API";
|
||||
import Exception from "Common/Types/Exception/Exception";
|
||||
import PageLoader from "Common/UI/Components/Loader/PageLoader";
|
||||
import WorkspaceProjectAuthToken from "Common/Models/DatabaseModels/WorkspaceProjectAuthToken";
|
||||
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
|
||||
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
|
||||
import ListResult from "Common/Types/BaseDatabase/ListResult";
|
||||
import WorkspaceUserAuthToken from "Common/Models/DatabaseModels/WorkspaceUserAuthToken";
|
||||
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
|
||||
import WorkspaceType from "Common/Types/Workspace/WorkspaceType";
|
||||
import URL from "Common/Types/API/URL";
|
||||
import { APP_API_URL, MicrosoftTeamsAppClientId } from "Common/UI/Config";
|
||||
|
||||
export interface ComponentProps {
|
||||
onConnected: VoidFunction;
|
||||
onDisconnected: VoidFunction;
|
||||
}
|
||||
|
||||
const MicrosoftTeamsIntegration: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(true);
|
||||
const [isUserAccountConnected, setIsUserAccountConnected] =
|
||||
React.useState<boolean>(false);
|
||||
const [userAuthTokenId, setWorkspaceUserAuthTokenId] =
|
||||
React.useState<ObjectID | null>(null);
|
||||
const [projectAuthTokenId, setWorkspaceProjectAuthTokenId] =
|
||||
React.useState<ObjectID | null>(null);
|
||||
const [isProjectAccountConnected, setIsProjectAccountConnected] =
|
||||
React.useState<boolean>(false);
|
||||
const [isButtonLoading, setIsButtonLoading] = React.useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isProjectAccountConnected) {
|
||||
props.onConnected();
|
||||
} else {
|
||||
props.onDisconnected();
|
||||
}
|
||||
}, [isProjectAccountConnected]);
|
||||
|
||||
const loadItems: PromiseVoidFunction = async (): Promise<void> => {
|
||||
try {
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
const projectAuth: ListResult<WorkspaceProjectAuthToken> =
|
||||
await ModelAPI.getList<WorkspaceProjectAuthToken>({
|
||||
modelType: WorkspaceProjectAuthToken,
|
||||
query: {
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
workspaceType: WorkspaceType.MicrosoftTeams,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
miscData: true,
|
||||
},
|
||||
limit: 1,
|
||||
skip: 0,
|
||||
sort: {
|
||||
createdAt: SortOrder.Descending,
|
||||
},
|
||||
});
|
||||
|
||||
if (projectAuth.data.length > 0) {
|
||||
setIsProjectAccountConnected(true);
|
||||
setWorkspaceProjectAuthTokenId(projectAuth.data[0]!.id);
|
||||
}
|
||||
|
||||
const userAuth: ListResult<WorkspaceUserAuthToken> =
|
||||
await ModelAPI.getList<WorkspaceUserAuthToken>({
|
||||
modelType: WorkspaceUserAuthToken,
|
||||
query: {
|
||||
userId: UserUtil.getUserId()!,
|
||||
workspaceType: WorkspaceType.MicrosoftTeams,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
},
|
||||
limit: 1,
|
||||
skip: 0,
|
||||
sort: {
|
||||
createdAt: SortOrder.Descending,
|
||||
},
|
||||
});
|
||||
|
||||
if (userAuth.data.length > 0) {
|
||||
setIsUserAccountConnected(true);
|
||||
setWorkspaceUserAuthTokenId(userAuth.data[0]!.id);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
setIsLoading(false);
|
||||
setError(API.getFriendlyErrorMessage(error as Exception));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadItems().catch(() => {
|
||||
// ignore
|
||||
});
|
||||
}, []);
|
||||
|
||||
const connectWithTeams: VoidFunction = (): void => {
|
||||
const projectId: ObjectID | null = ProjectUtil.getCurrentProjectId();
|
||||
const userId: ObjectID | null = UserUtil.getUserId();
|
||||
|
||||
if (!projectId || !userId) {
|
||||
setError("Please select a project and sign in.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!MicrosoftTeamsAppClientId) {
|
||||
setError(
|
||||
"Microsoft Teams client id is not configured. Please set MICROSOFT_TEAMS_APP_CLIENT_ID.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const redirectUri: URL = URL.fromString(APP_API_URL.toString()).addRoute(
|
||||
`/microsoft-teams/auth/${projectId.toString()}/${userId.toString()}`,
|
||||
);
|
||||
|
||||
const authorizeUrl: URL = URL.fromString(
|
||||
`https://login.microsoftonline.com/common/oauth2/v2.0/authorize`,
|
||||
)
|
||||
.addQueryParam("client_id", MicrosoftTeamsAppClientId)
|
||||
.addQueryParam("response_type", "code")
|
||||
.addQueryParam("response_mode", "query")
|
||||
.addQueryParam(
|
||||
"scope",
|
||||
[
|
||||
"openid",
|
||||
"profile",
|
||||
"User.Read",
|
||||
"Channel.ReadBasic.All",
|
||||
"ChannelMessage.Send",
|
||||
"Team.ReadBasic.All",
|
||||
"offline_access",
|
||||
].join(" "),
|
||||
)
|
||||
.addQueryParam("redirect_uri", redirectUri.toString());
|
||||
|
||||
window.location.href = authorizeUrl.toString();
|
||||
};
|
||||
|
||||
const disconnect: VoidFunction = async (): Promise<void> => {
|
||||
try {
|
||||
setIsButtonLoading(true);
|
||||
setError(null);
|
||||
if (userAuthTokenId) {
|
||||
await ModelAPI.deleteItem({
|
||||
modelType: WorkspaceUserAuthToken,
|
||||
id: userAuthTokenId!,
|
||||
});
|
||||
setIsUserAccountConnected(false);
|
||||
setWorkspaceUserAuthTokenId(null);
|
||||
}
|
||||
|
||||
if (projectAuthTokenId) {
|
||||
await ModelAPI.deleteItem({
|
||||
modelType: WorkspaceProjectAuthToken,
|
||||
id: projectAuthTokenId!,
|
||||
});
|
||||
setIsProjectAccountConnected(false);
|
||||
setWorkspaceProjectAuthTokenId(null);
|
||||
}
|
||||
} catch (error) {
|
||||
setError(API.getFriendlyErrorMessage(error as Exception));
|
||||
}
|
||||
setIsButtonLoading(false);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <PageLoader isVisible={true} />;
|
||||
}
|
||||
|
||||
const buttons: Array<CardButtonSchema> = [];
|
||||
|
||||
if (!isProjectAccountConnected || !isUserAccountConnected) {
|
||||
buttons.push({
|
||||
title: `Connect Microsoft Teams`,
|
||||
buttonStyle: ButtonStyleType.NORMAL,
|
||||
onClick: connectWithTeams,
|
||||
icon: IconProp.MicrosoftTeams,
|
||||
isLoading: isButtonLoading,
|
||||
});
|
||||
} else {
|
||||
buttons.push({
|
||||
title: `Disconnect`,
|
||||
buttonStyle: ButtonStyleType.DANGER,
|
||||
onClick: disconnect,
|
||||
icon: IconProp.Close,
|
||||
isLoading: isButtonLoading,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="Connect Microsoft Teams"
|
||||
description="Connect your project and user account to Microsoft Teams to receive notifications."
|
||||
buttons={buttons}
|
||||
>
|
||||
{error ? <ErrorMessage message={error} /> : undefined}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default MicrosoftTeamsIntegration;
|
||||
@@ -0,0 +1,221 @@
|
||||
import {
|
||||
Blue500,
|
||||
Gray500,
|
||||
Green500,
|
||||
Red500,
|
||||
Yellow500,
|
||||
} from "Common/Types/BrandColors";
|
||||
import Color from "Common/Types/Color";
|
||||
import StatusPageSubscriberNotificationStatus from "Common/Types/StatusPage/StatusPageSubscriberNotificationStatus";
|
||||
import IconText from "Common/UI/Components/IconText/IconText";
|
||||
import Button, {
|
||||
ButtonStyleType,
|
||||
ButtonSize,
|
||||
} from "Common/UI/Components/Button/Button";
|
||||
import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import React, { FunctionComponent, ReactElement, useState } from "react";
|
||||
|
||||
export interface ComponentProps {
|
||||
status?: StatusPageSubscriberNotificationStatus | undefined | null;
|
||||
subscriberNotificationStatusMessage?: string | undefined | null;
|
||||
className?: string;
|
||||
onResendNotification?: (() => void) | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to get status info for notification status
|
||||
* @param status - The notification status
|
||||
* @returns Object with color, tailwindColor, text, and icon for the status
|
||||
*/
|
||||
export const getNotificationStatusInfo: (
|
||||
status?: StatusPageSubscriberNotificationStatus | undefined | null,
|
||||
) => {
|
||||
color: string;
|
||||
tailwindColor: string;
|
||||
text: string;
|
||||
icon: IconProp;
|
||||
} = (
|
||||
status?: StatusPageSubscriberNotificationStatus | undefined | null,
|
||||
): {
|
||||
color: string;
|
||||
tailwindColor: string;
|
||||
text: string;
|
||||
icon: IconProp;
|
||||
} => {
|
||||
if (!status || status === StatusPageSubscriberNotificationStatus.Skipped) {
|
||||
return {
|
||||
color: "gray",
|
||||
tailwindColor: "gray",
|
||||
text: "Notifications skipped.",
|
||||
icon: IconProp.CircleClose,
|
||||
};
|
||||
}
|
||||
|
||||
if (status === StatusPageSubscriberNotificationStatus.Pending) {
|
||||
return {
|
||||
color: "yellow",
|
||||
tailwindColor: "yellow",
|
||||
text: "Sending Soon",
|
||||
icon: IconProp.Clock,
|
||||
};
|
||||
}
|
||||
|
||||
if (status === StatusPageSubscriberNotificationStatus.InProgress) {
|
||||
return {
|
||||
color: "blue",
|
||||
tailwindColor: "blue",
|
||||
text: "Notifications Being Sent",
|
||||
icon: IconProp.Info,
|
||||
};
|
||||
}
|
||||
|
||||
if (status === StatusPageSubscriberNotificationStatus.Success) {
|
||||
return {
|
||||
color: "green",
|
||||
tailwindColor: "green",
|
||||
text: "Notificaitons Sent",
|
||||
icon: IconProp.CheckCircle,
|
||||
};
|
||||
}
|
||||
|
||||
if (status === StatusPageSubscriberNotificationStatus.Failed) {
|
||||
return {
|
||||
color: "red",
|
||||
tailwindColor: "red",
|
||||
text: "Failed",
|
||||
icon: IconProp.Error,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
color: "gray",
|
||||
tailwindColor: "gray",
|
||||
text: "Unknown",
|
||||
icon: IconProp.Info,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* SubscriberNotificationStatus Component
|
||||
*
|
||||
* A reusable component for displaying notification status with consistent styling.
|
||||
* Uses IconText component for status display and provides a "more" button for detailed messages.
|
||||
* Shows ConfirmModal with message details and retry button for failed notifications.
|
||||
*
|
||||
* @param status - The notification status to display
|
||||
* @param subscriberNotificationStatusMessage - The detailed status message
|
||||
* @param className - Additional CSS classes to apply
|
||||
* @param onResendNotification - Callback function to handle resend notification action
|
||||
*
|
||||
* Usage Examples:
|
||||
*
|
||||
* // Basic usage
|
||||
* <SubscriberNotificationStatus status={item.subscriberNotificationStatus} />
|
||||
*
|
||||
* // With message and resend callback
|
||||
* <SubscriberNotificationStatus
|
||||
* status={item.subscriberNotificationStatus}
|
||||
* subscriberNotificationStatusMessage={item.subscriberNotificationStatusMessage}
|
||||
* onResendNotification={() => handleResend(item)}
|
||||
* />
|
||||
*/
|
||||
const SubscriberNotificationStatus: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const {
|
||||
status,
|
||||
subscriberNotificationStatusMessage,
|
||||
className = "",
|
||||
onResendNotification,
|
||||
} = props;
|
||||
|
||||
const [showModal, setShowModal] = useState<boolean>(false);
|
||||
|
||||
const statusInfo: {
|
||||
color: string;
|
||||
tailwindColor: string;
|
||||
text: string;
|
||||
icon: IconProp;
|
||||
} = getNotificationStatusInfo(status);
|
||||
|
||||
const showResendButton: boolean =
|
||||
status === StatusPageSubscriberNotificationStatus.Failed &&
|
||||
Boolean(onResendNotification);
|
||||
|
||||
const showMoreButton: boolean = Boolean(
|
||||
subscriberNotificationStatusMessage &&
|
||||
(status === StatusPageSubscriberNotificationStatus.Failed ||
|
||||
status === StatusPageSubscriberNotificationStatus.Skipped),
|
||||
);
|
||||
|
||||
// Color mapping for IconText
|
||||
const colorMap: Record<string, Color> = {
|
||||
gray: Gray500,
|
||||
yellow: Yellow500,
|
||||
blue: Blue500,
|
||||
green: Green500,
|
||||
red: Red500,
|
||||
};
|
||||
|
||||
const iconColor: Color =
|
||||
colorMap[statusInfo.color as keyof typeof colorMap] || Gray500;
|
||||
|
||||
const handleModalConfirm: () => void = (): void => {
|
||||
if (showResendButton && onResendNotification) {
|
||||
onResendNotification();
|
||||
}
|
||||
setShowModal(false);
|
||||
};
|
||||
|
||||
const handleModalClose: () => void = (): void => {
|
||||
setShowModal(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-2 ${className}`}>
|
||||
<IconText
|
||||
text={statusInfo.text}
|
||||
icon={statusInfo.icon}
|
||||
iconColor={iconColor}
|
||||
textColor={iconColor}
|
||||
iconClassName="h-4 w-4"
|
||||
textClassName="text-sm font-medium"
|
||||
spacing="sm"
|
||||
alignment="left"
|
||||
/>
|
||||
|
||||
{showMoreButton && (
|
||||
<div className="-ml-2 text-gray-500">
|
||||
<Button
|
||||
title="more details"
|
||||
buttonStyle={ButtonStyleType.SECONDARY_LINK}
|
||||
buttonSize={ButtonSize.Small}
|
||||
onClick={() => {
|
||||
return setShowModal(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showModal && (
|
||||
<ConfirmModal
|
||||
title="Notification Status Details"
|
||||
description={
|
||||
subscriberNotificationStatusMessage ||
|
||||
"No additional information available."
|
||||
}
|
||||
onClose={showResendButton ? handleModalClose : undefined}
|
||||
onSubmit={handleModalConfirm}
|
||||
submitButtonText={showResendButton ? "Retry" : "Close"}
|
||||
closeButtonText={showResendButton ? "Close" : undefined}
|
||||
submitButtonType={
|
||||
showResendButton ? ButtonStyleType.PRIMARY : ButtonStyleType.NORMAL
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriberNotificationStatus;
|
||||
@@ -11,7 +11,6 @@ import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
|
||||
import EmptyState from "Common/UI/Components/EmptyState/EmptyState";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
|
||||
import ComingSoon from "Common/UI/Components/ComingSoon/ComingSoon";
|
||||
|
||||
const IncidentsPage: FunctionComponent<
|
||||
PageComponentProps
|
||||
@@ -21,10 +20,8 @@ const IncidentsPage: FunctionComponent<
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const [showComingSoon, setShowComingSoon] = React.useState<boolean>(false);
|
||||
|
||||
const loadItems: PromiseVoidFunction = async (): Promise<void> => {
|
||||
setShowComingSoon(true);
|
||||
try {
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
@@ -53,14 +50,7 @@ const IncidentsPage: FunctionComponent<
|
||||
return <ErrorMessage message={error} />;
|
||||
}
|
||||
|
||||
if (showComingSoon) {
|
||||
return (
|
||||
<ComingSoon
|
||||
title="Microsoft Teams Integration is coming soon, but you can still integrate Teams with Workflows!"
|
||||
description="We are working hard to bring you the Microsoft Teams integration. In the meantime, you can still integrate with Workflows to receive alerts in Microsoft Teams. Please click on Workflows in the top navigation to get started."
|
||||
/>
|
||||
);
|
||||
}
|
||||
// No coming soon; show rules if connected or an empty state if not.
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import ChangeIncidentState from "../../../Components/Incident/ChangeState";
|
||||
import LabelsElement from "../../../Components/Label/Labels";
|
||||
import MonitorsElement from "../../../Components/Monitor/Monitors";
|
||||
import OnCallDutyPoliciesView from "../../../Components/OnCallPolicy/OnCallPolicies";
|
||||
import SubscriberNotificationStatus from "../../../Components/StatusPageSubscribers/SubscriberNotificationStatus";
|
||||
import PageComponentProps from "../../PageComponentProps";
|
||||
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
|
||||
import { Black } from "Common/Types/BrandColors";
|
||||
@@ -11,7 +12,6 @@ import BadDataException from "Common/Types/Exception/BadDataException";
|
||||
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
|
||||
import { JSONObject } from "Common/Types/JSON";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import CheckboxViewer from "Common/UI/Components/Checkbox/CheckboxViewer";
|
||||
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
|
||||
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
|
||||
import InfoCard from "Common/UI/Components/InfoCard/InfoCard";
|
||||
@@ -52,6 +52,7 @@ import ColorSwatch from "Common/Types/ColorSwatch";
|
||||
import IncidentFeedElement from "../../../Components/Incident/IncidentFeed";
|
||||
import Monitor from "Common/Models/DatabaseModels/Monitor";
|
||||
import MonitorStatus from "Common/Models/DatabaseModels/MonitorStatus";
|
||||
import StatusPageSubscriberNotificationStatus from "Common/Types/StatusPage/StatusPageSubscriberNotificationStatus";
|
||||
|
||||
const IncidentView: FunctionComponent<
|
||||
PageComponentProps
|
||||
@@ -140,6 +141,32 @@ const IncidentView: FunctionComponent<
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const handleResendNotification: () => Promise<void> =
|
||||
async (): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// Reset the notification status to Pending so the worker can pick it up again
|
||||
await ModelAPI.updateById({
|
||||
id: modelId,
|
||||
modelType: Incident,
|
||||
data: {
|
||||
subscriberNotificationStatusOnIncidentCreated:
|
||||
StatusPageSubscriberNotificationStatus.Pending,
|
||||
subscriberNotificationStatusMessage:
|
||||
"Notification queued for resending",
|
||||
},
|
||||
});
|
||||
|
||||
// Refresh the data to show updated status
|
||||
await fetchData();
|
||||
} catch (err) {
|
||||
setError(BaseAPI.getFriendlyMessage(err));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData().catch((err: Error) => {
|
||||
setError(BaseAPI.getFriendlyMessage(err));
|
||||
@@ -360,6 +387,7 @@ const IncidentView: FunctionComponent<
|
||||
email: true,
|
||||
profilePictureId: true,
|
||||
},
|
||||
subscriberNotificationStatusMessage: true,
|
||||
},
|
||||
onBeforeFetch: async (): Promise<JSONObject> => {
|
||||
// get ack incident.
|
||||
@@ -530,28 +558,19 @@ const IncidentView: FunctionComponent<
|
||||
},
|
||||
{
|
||||
field: {
|
||||
shouldStatusPageSubscribersBeNotifiedOnIncidentCreated: true,
|
||||
subscriberNotificationStatusOnIncidentCreated: true,
|
||||
},
|
||||
title: "Notify Status Page Subscribers",
|
||||
fieldType: FieldType.Boolean,
|
||||
title: "Subscriber Notification Status",
|
||||
fieldType: FieldType.Element,
|
||||
getElement: (item: Incident): ReactElement => {
|
||||
return (
|
||||
<div className="">
|
||||
<CheckboxViewer
|
||||
isChecked={
|
||||
item[
|
||||
"shouldStatusPageSubscribersBeNotifiedOnIncidentCreated"
|
||||
] as boolean
|
||||
}
|
||||
text={
|
||||
item[
|
||||
"shouldStatusPageSubscribersBeNotifiedOnIncidentCreated"
|
||||
]
|
||||
? "Subscribers Notified"
|
||||
: "Subscribers Not Notified"
|
||||
}
|
||||
/>{" "}
|
||||
</div>
|
||||
<SubscriberNotificationStatus
|
||||
status={item.subscriberNotificationStatusOnIncidentCreated}
|
||||
subscriberNotificationStatusMessage={
|
||||
item.subscriberNotificationStatusMessage
|
||||
}
|
||||
onResendNotification={handleResendNotification}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -12,7 +12,6 @@ import { JSONObject } from "Common/Types/JSON";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import ProjectUtil from "Common/UI/Utils/Project";
|
||||
import { ButtonStyleType } from "Common/UI/Components/Button/Button";
|
||||
import CheckboxViewer from "Common/UI/Components/Checkbox/CheckboxViewer";
|
||||
import BasicFormModal from "Common/UI/Components/FormModal/BasicFormModal";
|
||||
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
|
||||
import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal";
|
||||
@@ -28,6 +27,8 @@ import Navigation from "Common/UI/Utils/Navigation";
|
||||
import IncidentNoteTemplate from "Common/Models/DatabaseModels/IncidentNoteTemplate";
|
||||
import IncidentPublicNote from "Common/Models/DatabaseModels/IncidentPublicNote";
|
||||
import User from "Common/Models/DatabaseModels/User";
|
||||
import StatusPageSubscriberNotificationStatus from "Common/Types/StatusPage/StatusPageSubscriberNotificationStatus";
|
||||
import SubscriberNotificationStatus from "../../../Components/StatusPageSubscribers/SubscriberNotificationStatus";
|
||||
import React, {
|
||||
Fragment,
|
||||
FunctionComponent,
|
||||
@@ -49,6 +50,26 @@ const PublicNote: FunctionComponent<PageComponentProps> = (
|
||||
useState<boolean>(false);
|
||||
const [initialValuesForIncident, setInitialValuesForIncident] =
|
||||
useState<JSONObject>({});
|
||||
const [refreshToggle, setRefreshToggle] = useState<boolean>(false);
|
||||
|
||||
const handleResendNotification: (
|
||||
item: IncidentPublicNote,
|
||||
) => Promise<void> = async (item: IncidentPublicNote): Promise<void> => {
|
||||
try {
|
||||
await ModelAPI.updateById({
|
||||
modelType: IncidentPublicNote,
|
||||
id: item.id!,
|
||||
data: {
|
||||
subscriberNotificationStatusOnNoteCreated:
|
||||
StatusPageSubscriberNotificationStatus.Pending,
|
||||
subscriberNotificationStatusMessage: null,
|
||||
},
|
||||
});
|
||||
setRefreshToggle(!refreshToggle);
|
||||
} catch (err) {
|
||||
setError(API.getFriendlyMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
const fetchIncidentNoteTemplate: (id: ObjectID) => Promise<void> = async (
|
||||
id: ObjectID,
|
||||
@@ -126,6 +147,7 @@ const PublicNote: FunctionComponent<PageComponentProps> = (
|
||||
isEditable={true}
|
||||
createEditModalWidth={ModalWidth.Large}
|
||||
isViewable={false}
|
||||
refreshToggle={refreshToggle.toString()}
|
||||
query={{
|
||||
incidentId: modelId,
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
@@ -200,6 +222,9 @@ const PublicNote: FunctionComponent<PageComponentProps> = (
|
||||
showAs={ShowAs.List}
|
||||
showRefreshButton={true}
|
||||
viewPageRoute={Navigation.getCurrentRoute()}
|
||||
selectMoreFields={{
|
||||
subscriberNotificationStatusMessage: true,
|
||||
}}
|
||||
filters={[
|
||||
{
|
||||
field: {
|
||||
@@ -281,27 +306,22 @@ const PublicNote: FunctionComponent<PageComponentProps> = (
|
||||
},
|
||||
{
|
||||
field: {
|
||||
shouldStatusPageSubscribersBeNotifiedOnNoteCreated: true,
|
||||
subscriberNotificationStatusOnNoteCreated: true,
|
||||
},
|
||||
title: "",
|
||||
type: FieldType.Boolean,
|
||||
colSpan: 2,
|
||||
title: "Subscriber Notification Status",
|
||||
type: FieldType.Element,
|
||||
colSpan: 1,
|
||||
getElement: (item: IncidentPublicNote): ReactElement => {
|
||||
return (
|
||||
<div className="-mt-5">
|
||||
<CheckboxViewer
|
||||
isChecked={
|
||||
item[
|
||||
"shouldStatusPageSubscribersBeNotifiedOnNoteCreated"
|
||||
] as boolean
|
||||
}
|
||||
text={
|
||||
item["shouldStatusPageSubscribersBeNotifiedOnNoteCreated"]
|
||||
? "Status Page Subscribers Notified"
|
||||
: "Status Page Subscribers Not Notified"
|
||||
}
|
||||
/>{" "}
|
||||
</div>
|
||||
<SubscriberNotificationStatus
|
||||
status={item.subscriberNotificationStatusOnNoteCreated}
|
||||
subscriberNotificationStatusMessage={
|
||||
item.subscriberNotificationStatusMessage
|
||||
}
|
||||
onResendNotification={() => {
|
||||
return handleResendNotification(item);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -16,6 +16,9 @@ import FieldType from "Common/UI/Components/Types/FieldType";
|
||||
import Navigation from "Common/UI/Utils/Navigation";
|
||||
import IncidentState from "Common/Models/DatabaseModels/IncidentState";
|
||||
import IncidentStateTimeline from "Common/Models/DatabaseModels/IncidentStateTimeline";
|
||||
import StatusPageSubscriberNotificationStatus from "Common/Types/StatusPage/StatusPageSubscriberNotificationStatus";
|
||||
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
|
||||
import SubscriberNotificationStatus from "../../../Components/StatusPageSubscribers/SubscriberNotificationStatus";
|
||||
import React, {
|
||||
Fragment,
|
||||
FunctionComponent,
|
||||
@@ -31,9 +34,28 @@ const IncidentViewStateTimeline: FunctionComponent<PageComponentProps> = (
|
||||
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
|
||||
const [showViewLogsModal, setShowViewLogsModal] = useState<boolean>(false);
|
||||
const [logs, setLogs] = useState<string>("");
|
||||
|
||||
const [showRootCause, setShowRootCause] = useState<boolean>(false);
|
||||
const [rootCause, setRootCause] = useState<string>("");
|
||||
const [refreshToggle, setRefreshToggle] = useState<boolean>(false);
|
||||
|
||||
const handleResendNotification: (
|
||||
item: IncidentStateTimeline,
|
||||
) => Promise<void> = async (item: IncidentStateTimeline): Promise<void> => {
|
||||
try {
|
||||
await ModelAPI.updateById({
|
||||
modelType: IncidentStateTimeline,
|
||||
id: item.id!,
|
||||
data: {
|
||||
subscriberNotificationStatus:
|
||||
StatusPageSubscriberNotificationStatus.Pending,
|
||||
subscriberNotificationStatusMessage: null,
|
||||
},
|
||||
});
|
||||
setRefreshToggle(!refreshToggle);
|
||||
} catch {
|
||||
// Error resending notification: handle appropriately
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
@@ -47,6 +69,7 @@ const IncidentViewStateTimeline: FunctionComponent<PageComponentProps> = (
|
||||
isCreateable={true}
|
||||
isViewable={false}
|
||||
showViewIdButton={true}
|
||||
refreshToggle={refreshToggle.toString()}
|
||||
query={{
|
||||
incidentId: modelId,
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
@@ -54,6 +77,7 @@ const IncidentViewStateTimeline: FunctionComponent<PageComponentProps> = (
|
||||
selectMoreFields={{
|
||||
stateChangeLog: true,
|
||||
rootCause: true,
|
||||
subscriberNotificationStatusMessage: true,
|
||||
}}
|
||||
sortBy="startsAt"
|
||||
sortOrder={SortOrder.Descending}
|
||||
@@ -242,10 +266,23 @@ const IncidentViewStateTimeline: FunctionComponent<PageComponentProps> = (
|
||||
},
|
||||
{
|
||||
field: {
|
||||
shouldStatusPageSubscribersBeNotified: true,
|
||||
subscriberNotificationStatus: true,
|
||||
},
|
||||
title: "Subscriber Notification Status",
|
||||
type: FieldType.Text,
|
||||
getElement: (item: IncidentStateTimeline): ReactElement => {
|
||||
return (
|
||||
<SubscriberNotificationStatus
|
||||
status={item.subscriberNotificationStatus}
|
||||
subscriberNotificationStatusMessage={
|
||||
item.subscriberNotificationStatusMessage
|
||||
}
|
||||
onResendNotification={() => {
|
||||
return handleResendNotification(item);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
title: "Subscribers Notified",
|
||||
type: FieldType.Boolean,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -11,7 +11,6 @@ import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
|
||||
import EmptyState from "Common/UI/Components/EmptyState/EmptyState";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
|
||||
import ComingSoon from "Common/UI/Components/ComingSoon/ComingSoon";
|
||||
|
||||
const IncidentsPage: FunctionComponent<
|
||||
PageComponentProps
|
||||
@@ -21,10 +20,8 @@ const IncidentsPage: FunctionComponent<
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const [showComingSoon, setShowComingSoon] = React.useState<boolean>(false);
|
||||
|
||||
const loadItems: PromiseVoidFunction = async (): Promise<void> => {
|
||||
setShowComingSoon(true);
|
||||
try {
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
@@ -53,14 +50,6 @@ const IncidentsPage: FunctionComponent<
|
||||
return <ErrorMessage message={error} />;
|
||||
}
|
||||
|
||||
if (showComingSoon) {
|
||||
return (
|
||||
<ComingSoon
|
||||
title="Microsoft Teams Integration is coming soon, but you can still integrate Teams with Workflows!"
|
||||
description="We are working hard to bring you the Microsoft Teams integration. In the meantime, you can still integrate with Workflows to receive incidents in Microsoft Teams. Please click on Workflows in the top navigation to get started."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -11,7 +11,6 @@ import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
|
||||
import EmptyState from "Common/UI/Components/EmptyState/EmptyState";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
|
||||
import ComingSoon from "Common/UI/Components/ComingSoon/ComingSoon";
|
||||
|
||||
const MonitorsPage: FunctionComponent<
|
||||
PageComponentProps
|
||||
@@ -21,10 +20,9 @@ const MonitorsPage: FunctionComponent<
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const [showComingSoon, setShowComingSoon] = React.useState<boolean>(false);
|
||||
// Coming soon removed; show rules if connected
|
||||
|
||||
const loadItems: PromiseVoidFunction = async (): Promise<void> => {
|
||||
setShowComingSoon(true);
|
||||
try {
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
@@ -53,14 +51,7 @@ const MonitorsPage: FunctionComponent<
|
||||
return <ErrorMessage message={error} />;
|
||||
}
|
||||
|
||||
if (showComingSoon) {
|
||||
return (
|
||||
<ComingSoon
|
||||
title="Microsoft Teams Integration is coming soon, but you can still integrate Teams with Workflows!"
|
||||
description="We are working hard to bring you the Microsoft Teams integration. In the meantime, you can still integrate with Workflows to receive monitors in Microsoft Teams. Please click on Workflows in the top navigation to get started."
|
||||
/>
|
||||
);
|
||||
}
|
||||
// No coming soon
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -11,7 +11,7 @@ import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
|
||||
import EmptyState from "Common/UI/Components/EmptyState/EmptyState";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
|
||||
import ComingSoon from "Common/UI/Components/ComingSoon/ComingSoon";
|
||||
// Coming soon removed; Teams integration is available
|
||||
|
||||
const MonitorsPage: FunctionComponent<
|
||||
PageComponentProps
|
||||
@@ -21,10 +21,7 @@ const MonitorsPage: FunctionComponent<
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const [showComingSoon, setShowComingSoon] = React.useState<boolean>(false);
|
||||
|
||||
const loadItems: PromiseVoidFunction = async (): Promise<void> => {
|
||||
setShowComingSoon(true);
|
||||
try {
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
@@ -53,15 +50,6 @@ const MonitorsPage: FunctionComponent<
|
||||
return <ErrorMessage message={error} />;
|
||||
}
|
||||
|
||||
if (showComingSoon) {
|
||||
return (
|
||||
<ComingSoon
|
||||
title="Microsoft Teams Integration is coming soon, but you can still integrate Teams with Workflows!"
|
||||
description="We are working hard to bring you the Microsoft Teams integration. In the meantime, you can still integrate with Workflows to receive on call duty alerts in Microsoft Teams. Please click on Workflows in the top navigation to get started."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isMicrosoftTeamsConnected && (
|
||||
|
||||
@@ -2,13 +2,13 @@ import LabelsElement from "../../../Components/Label/Labels";
|
||||
import MonitorsElement from "../../../Components/Monitor/Monitors";
|
||||
import ChangeScheduledMaintenanceState from "../../../Components/ScheduledMaintenance/ChangeState";
|
||||
import StatusPagesElement from "../../../Components/StatusPage/StatusPagesElement";
|
||||
import SubscriberNotificationStatus from "../../../Components/StatusPageSubscribers/SubscriberNotificationStatus";
|
||||
import PageComponentProps from "../../PageComponentProps";
|
||||
import { Black } from "Common/Types/BrandColors";
|
||||
import { LIMIT_PER_PROJECT } from "Common/Types/Database/LimitMax";
|
||||
import BadDataException from "Common/Types/Exception/BadDataException";
|
||||
import { JSONObject } from "Common/Types/JSON";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import CheckboxViewer from "Common/UI/Components/Checkbox/CheckboxViewer";
|
||||
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
|
||||
import CardModelDetail from "Common/UI/Components/ModelDetail/CardModelDetail";
|
||||
import Pill from "Common/UI/Components/Pill/Pill";
|
||||
@@ -20,7 +20,13 @@ import Monitor from "Common/Models/DatabaseModels/Monitor";
|
||||
import ScheduledMaintenance from "Common/Models/DatabaseModels/ScheduledMaintenance";
|
||||
import ScheduledMaintenanceStateTimeline from "Common/Models/DatabaseModels/ScheduledMaintenanceStateTimeline";
|
||||
import StatusPage from "Common/Models/DatabaseModels/StatusPage";
|
||||
import React, { Fragment, FunctionComponent, ReactElement } from "react";
|
||||
import StatusPageSubscriberNotificationStatus from "Common/Types/StatusPage/StatusPageSubscriberNotificationStatus";
|
||||
import React, {
|
||||
Fragment,
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useState,
|
||||
} from "react";
|
||||
import FormValues from "Common/UI/Components/Forms/Types/FormValues";
|
||||
import { CustomElementProps } from "Common/UI/Components/Forms/Types/Field";
|
||||
import RecurringArrayFieldElement from "Common/UI/Components/Events/RecurringArrayFieldElement";
|
||||
@@ -33,6 +39,29 @@ const ScheduledMaintenanceView: FunctionComponent<
|
||||
PageComponentProps
|
||||
> = (): ReactElement => {
|
||||
const modelId: ObjectID = Navigation.getLastParamAsObjectID();
|
||||
const [refreshToggle, setRefreshToggle] = useState<boolean>(false);
|
||||
|
||||
const handleResendNotification: () => Promise<void> =
|
||||
async (): Promise<void> => {
|
||||
try {
|
||||
// Reset the notification status to Pending so the worker can pick it up again
|
||||
await ModelAPI.updateById({
|
||||
id: modelId,
|
||||
modelType: ScheduledMaintenance,
|
||||
data: {
|
||||
subscriberNotificationStatusOnEventScheduled:
|
||||
StatusPageSubscriberNotificationStatus.Pending,
|
||||
subscriberNotificationStatusMessage:
|
||||
"Notification queued for resending",
|
||||
},
|
||||
});
|
||||
|
||||
// Trigger a refresh by toggling the refresh state
|
||||
setRefreshToggle(!refreshToggle);
|
||||
} catch {
|
||||
// Error resending notification: handle appropriately
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
@@ -260,6 +289,7 @@ const ScheduledMaintenanceView: FunctionComponent<
|
||||
true,
|
||||
shouldStatusPageSubscribersBeNotifiedWhenEventChangedToEnded: true,
|
||||
nextSubscriberNotificationBeforeTheEventAt: true,
|
||||
subscriberNotificationStatusMessage: true,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
@@ -396,62 +426,19 @@ const ScheduledMaintenanceView: FunctionComponent<
|
||||
},
|
||||
{
|
||||
field: {
|
||||
shouldStatusPageSubscribersBeNotifiedOnEventCreated: true,
|
||||
subscriberNotificationStatusOnEventScheduled: true,
|
||||
},
|
||||
title: "Notify Status Page Subscribers",
|
||||
fieldType: FieldType.Boolean,
|
||||
title: "Subscriber Notification Status",
|
||||
fieldType: FieldType.Element,
|
||||
getElement: (item: ScheduledMaintenance): ReactElement => {
|
||||
return (
|
||||
<div>
|
||||
<div className="">
|
||||
<CheckboxViewer
|
||||
isChecked={
|
||||
item[
|
||||
"shouldStatusPageSubscribersBeNotifiedOnEventCreated"
|
||||
] as boolean
|
||||
}
|
||||
text={
|
||||
item[
|
||||
"shouldStatusPageSubscribersBeNotifiedOnEventCreated"
|
||||
]
|
||||
? "Event Created: Notify Subscribers"
|
||||
: "Event Created: Do Not Notify Subscribers"
|
||||
}
|
||||
/>{" "}
|
||||
</div>
|
||||
<div className="">
|
||||
<CheckboxViewer
|
||||
isChecked={
|
||||
item[
|
||||
"shouldStatusPageSubscribersBeNotifiedWhenEventChangedToOngoing"
|
||||
] as boolean
|
||||
}
|
||||
text={
|
||||
item[
|
||||
"shouldStatusPageSubscribersBeNotifiedWhenEventChangedToOngoing"
|
||||
]
|
||||
? "Event Ongoing: Notify Subscribers"
|
||||
: "Event Ongoing: Do Not Notify Subscribers"
|
||||
}
|
||||
/>{" "}
|
||||
</div>
|
||||
<div className="">
|
||||
<CheckboxViewer
|
||||
isChecked={
|
||||
item[
|
||||
"shouldStatusPageSubscribersBeNotifiedWhenEventChangedToEnded"
|
||||
] as boolean
|
||||
}
|
||||
text={
|
||||
item[
|
||||
"shouldStatusPageSubscribersBeNotifiedWhenEventChangedToEnded"
|
||||
]
|
||||
? "Event Ended: Notify Subscribers"
|
||||
: "Event Ended: Do Not Notify Subscribers"
|
||||
}
|
||||
/>{" "}
|
||||
</div>
|
||||
</div>
|
||||
<SubscriberNotificationStatus
|
||||
status={item.subscriberNotificationStatusOnEventScheduled}
|
||||
subscriberNotificationStatusMessage={
|
||||
item.subscriberNotificationStatusMessage
|
||||
}
|
||||
onResendNotification={handleResendNotification}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -10,7 +10,6 @@ import IconProp from "Common/Types/Icon/IconProp";
|
||||
import { JSONObject } from "Common/Types/JSON";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import { ButtonStyleType } from "Common/UI/Components/Button/Button";
|
||||
import CheckboxViewer from "Common/UI/Components/Checkbox/CheckboxViewer";
|
||||
import BasicFormModal from "Common/UI/Components/FormModal/BasicFormModal";
|
||||
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
|
||||
import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal";
|
||||
@@ -27,6 +26,8 @@ import ScheduledMaintenanceNoteTemplate from "Common/Models/DatabaseModels/Sched
|
||||
import ScheduledMaintenancePublicNote from "Common/Models/DatabaseModels/ScheduledMaintenancePublicNote";
|
||||
import User from "Common/Models/DatabaseModels/User";
|
||||
import ProjectUtil from "Common/UI/Utils/Project";
|
||||
import StatusPageSubscriberNotificationStatus from "Common/Types/StatusPage/StatusPageSubscriberNotificationStatus";
|
||||
import SubscriberNotificationStatus from "../../../Components/StatusPageSubscribers/SubscriberNotificationStatus";
|
||||
import React, {
|
||||
Fragment,
|
||||
FunctionComponent,
|
||||
@@ -53,6 +54,28 @@ const PublicNote: FunctionComponent<PageComponentProps> = (
|
||||
initialValuesForScheduledMaintenance,
|
||||
setInitialValuesForScheduledMaintenance,
|
||||
] = useState<JSONObject>({});
|
||||
const [refreshToggle, setRefreshToggle] = useState<boolean>(false);
|
||||
|
||||
const handleResendNotification: (
|
||||
item: ScheduledMaintenancePublicNote,
|
||||
) => Promise<void> = async (
|
||||
item: ScheduledMaintenancePublicNote,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await ModelAPI.updateById({
|
||||
modelType: ScheduledMaintenancePublicNote,
|
||||
id: item.id!,
|
||||
data: {
|
||||
subscriberNotificationStatusOnNoteCreated:
|
||||
StatusPageSubscriberNotificationStatus.Pending,
|
||||
subscriberNotificationStatusMessage: null,
|
||||
},
|
||||
});
|
||||
setRefreshToggle(!refreshToggle);
|
||||
} catch (err) {
|
||||
setError(API.getFriendlyMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
const fetchScheduledMaintenanceNoteTemplate: (
|
||||
id: ObjectID,
|
||||
@@ -135,6 +158,7 @@ const PublicNote: FunctionComponent<PageComponentProps> = (
|
||||
}
|
||||
createInitialValues={initialValuesForScheduledMaintenance}
|
||||
isViewable={false}
|
||||
refreshToggle={refreshToggle.toString()}
|
||||
query={{
|
||||
scheduledMaintenanceId: modelId,
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
@@ -211,6 +235,9 @@ const PublicNote: FunctionComponent<PageComponentProps> = (
|
||||
showRefreshButton={true}
|
||||
showAs={ShowAs.List}
|
||||
viewPageRoute={Navigation.getCurrentRoute()}
|
||||
selectMoreFields={{
|
||||
subscriberNotificationStatusMessage: true,
|
||||
}}
|
||||
filters={[
|
||||
{
|
||||
field: {
|
||||
@@ -293,29 +320,24 @@ const PublicNote: FunctionComponent<PageComponentProps> = (
|
||||
},
|
||||
{
|
||||
field: {
|
||||
shouldStatusPageSubscribersBeNotifiedOnNoteCreated: true,
|
||||
subscriberNotificationStatusOnNoteCreated: true,
|
||||
},
|
||||
title: "",
|
||||
type: FieldType.Boolean,
|
||||
colSpan: 2,
|
||||
title: "Subscriber Notification Status",
|
||||
type: FieldType.Text,
|
||||
colSpan: 1,
|
||||
getElement: (
|
||||
item: ScheduledMaintenancePublicNote,
|
||||
): ReactElement => {
|
||||
return (
|
||||
<div className="-mt-5">
|
||||
<CheckboxViewer
|
||||
isChecked={
|
||||
item[
|
||||
"shouldStatusPageSubscribersBeNotifiedOnNoteCreated"
|
||||
] as boolean
|
||||
}
|
||||
text={
|
||||
item["shouldStatusPageSubscribersBeNotifiedOnNoteCreated"]
|
||||
? "Status Page Subscribers Notified"
|
||||
: "Status Page Subscribers Not Notified"
|
||||
}
|
||||
/>{" "}
|
||||
</div>
|
||||
<SubscriberNotificationStatus
|
||||
status={item.subscriberNotificationStatusOnNoteCreated}
|
||||
subscriberNotificationStatusMessage={
|
||||
item.subscriberNotificationStatusMessage
|
||||
}
|
||||
onResendNotification={() => {
|
||||
return handleResendNotification(item);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -10,7 +10,16 @@ import FieldType from "Common/UI/Components/Types/FieldType";
|
||||
import Navigation from "Common/UI/Utils/Navigation";
|
||||
import ScheduledMaintenanceState from "Common/Models/DatabaseModels/ScheduledMaintenanceState";
|
||||
import ScheduledMaintenanceStateTimeline from "Common/Models/DatabaseModels/ScheduledMaintenanceStateTimeline";
|
||||
import React, { Fragment, FunctionComponent, ReactElement } from "react";
|
||||
import StatusPageSubscriberNotificationStatus from "Common/Types/StatusPage/StatusPageSubscriberNotificationStatus";
|
||||
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
|
||||
import CheckboxViewer from "Common/UI/Components/Checkbox/CheckboxViewer";
|
||||
import SubscriberNotificationStatus from "../../../Components/StatusPageSubscribers/SubscriberNotificationStatus";
|
||||
import React, {
|
||||
Fragment,
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useState,
|
||||
} from "react";
|
||||
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
|
||||
import ProjectUtil from "Common/UI/Utils/Project";
|
||||
|
||||
@@ -18,6 +27,28 @@ const ScheduledMaintenanceDelete: FunctionComponent<PageComponentProps> = (
|
||||
props: PageComponentProps,
|
||||
): ReactElement => {
|
||||
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
|
||||
const [refreshToggle, setRefreshToggle] = useState<boolean>(false);
|
||||
|
||||
const handleResendNotification: (
|
||||
item: ScheduledMaintenanceStateTimeline,
|
||||
) => Promise<void> = async (
|
||||
item: ScheduledMaintenanceStateTimeline,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await ModelAPI.updateById({
|
||||
modelType: ScheduledMaintenanceStateTimeline,
|
||||
id: item.id!,
|
||||
data: {
|
||||
subscriberNotificationStatus:
|
||||
StatusPageSubscriberNotificationStatus.Pending,
|
||||
subscriberNotificationStatusMessage: null,
|
||||
},
|
||||
});
|
||||
setRefreshToggle(!refreshToggle);
|
||||
} catch {
|
||||
// Error resending notification: handle appropriately
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
@@ -30,6 +61,7 @@ const ScheduledMaintenanceDelete: FunctionComponent<PageComponentProps> = (
|
||||
isCreateable={true}
|
||||
showViewIdButton={true}
|
||||
isViewable={false}
|
||||
refreshToggle={refreshToggle.toString()}
|
||||
query={{
|
||||
scheduledMaintenanceId: modelId,
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
@@ -127,6 +159,9 @@ const ScheduledMaintenanceDelete: FunctionComponent<PageComponentProps> = (
|
||||
type: FieldType.Boolean,
|
||||
},
|
||||
]}
|
||||
selectMoreFields={{
|
||||
subscriberNotificationStatusMessage: true,
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
field: {
|
||||
@@ -193,8 +228,44 @@ const ScheduledMaintenanceDelete: FunctionComponent<PageComponentProps> = (
|
||||
field: {
|
||||
shouldStatusPageSubscribersBeNotified: true,
|
||||
},
|
||||
title: "Subscribers Notified",
|
||||
title: "Notification Enabled",
|
||||
type: FieldType.Boolean,
|
||||
getElement: (
|
||||
item: ScheduledMaintenanceStateTimeline,
|
||||
): ReactElement => {
|
||||
return (
|
||||
<CheckboxViewer
|
||||
isChecked={
|
||||
item.shouldStatusPageSubscribersBeNotified as boolean
|
||||
}
|
||||
text={
|
||||
item.shouldStatusPageSubscribersBeNotified ? "Yes" : "No"
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: {
|
||||
subscriberNotificationStatus: true,
|
||||
},
|
||||
title: "Subscriber Notification Status",
|
||||
type: FieldType.Text,
|
||||
getElement: (
|
||||
item: ScheduledMaintenanceStateTimeline,
|
||||
): ReactElement => {
|
||||
return (
|
||||
<SubscriberNotificationStatus
|
||||
status={item.subscriberNotificationStatus}
|
||||
subscriberNotificationStatusMessage={
|
||||
item.subscriberNotificationStatusMessage
|
||||
}
|
||||
onResendNotification={() => {
|
||||
return handleResendNotification(item);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -11,7 +11,6 @@ import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
|
||||
import EmptyState from "Common/UI/Components/EmptyState/EmptyState";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
|
||||
import ComingSoon from "Common/UI/Components/ComingSoon/ComingSoon";
|
||||
|
||||
const IncidentsPage: FunctionComponent<
|
||||
PageComponentProps
|
||||
@@ -21,10 +20,9 @@ const IncidentsPage: FunctionComponent<
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const [showComingSoon, setShowComingSoon] = React.useState<boolean>(false);
|
||||
// Remove coming soon and use connectivity check
|
||||
|
||||
const loadItems: PromiseVoidFunction = async (): Promise<void> => {
|
||||
setShowComingSoon(true);
|
||||
try {
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
@@ -53,14 +51,7 @@ const IncidentsPage: FunctionComponent<
|
||||
return <ErrorMessage message={error} />;
|
||||
}
|
||||
|
||||
if (showComingSoon) {
|
||||
return (
|
||||
<ComingSoon
|
||||
title="Microsoft Teams Integration is coming soon, but you can still integrate Teams with Workflows!"
|
||||
description="We are working hard to bring you the Microsoft Teams integration. In the meantime, you can still integrate with Workflows to receive incidents in Microsoft Teams. Please click on Workflows in the top navigation to get started."
|
||||
/>
|
||||
);
|
||||
}
|
||||
// No coming soon
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import PageComponentProps from "../PageComponentProps";
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
import ComingSoon from "Common/UI/Components/ComingSoon/ComingSoon";
|
||||
import MicrosoftTeamsIntegration from "../../Components/MicrosoftTeams/MicrosoftTeamsIntegration";
|
||||
|
||||
const SlackIntegrationPage: FunctionComponent<PageComponentProps> = (
|
||||
const MicrosoftTeamsIntegrationPage: FunctionComponent<PageComponentProps> = (
|
||||
_props: PageComponentProps,
|
||||
): ReactElement => {
|
||||
return (
|
||||
<div>
|
||||
<ComingSoon
|
||||
title="Microsoft Teams Integration is coming soon, but you can still integrate Teams with Workflows!"
|
||||
description="We are working hard to bring you the Microsoft Teams integration. In the meantime, you can still integrate with Workflows to receive alerts in Microsoft Teams. Please click on Workflows in the top navigation to get started."
|
||||
<MicrosoftTeamsIntegration
|
||||
onConnected={() => {}}
|
||||
onDisconnected={() => {}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SlackIntegrationPage;
|
||||
export default MicrosoftTeamsIntegrationPage;
|
||||
|
||||
393
Dashboard/src/Pages/Settings/SCIM.tsx
Normal file
393
Dashboard/src/Pages/Settings/SCIM.tsx
Normal file
@@ -0,0 +1,393 @@
|
||||
import ProjectUtil from "Common/UI/Utils/Project";
|
||||
import PageComponentProps from "../PageComponentProps";
|
||||
import Banner from "Common/UI/Components/Banner/Banner";
|
||||
import { ButtonStyleType } from "Common/UI/Components/Button/Button";
|
||||
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
|
||||
import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal";
|
||||
import ModelTable from "Common/UI/Components/ModelTable/ModelTable";
|
||||
import FieldType from "Common/UI/Components/Types/FieldType";
|
||||
import HiddenText from "Common/UI/Components/HiddenText/HiddenText";
|
||||
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
|
||||
import API from "Common/UI/Utils/API/API";
|
||||
import { IDENTITY_URL } from "Common/UI/Config";
|
||||
import Navigation from "Common/UI/Utils/Navigation";
|
||||
import ProjectSCIM from "Common/Models/DatabaseModels/ProjectSCIM";
|
||||
import Team from "Common/Models/DatabaseModels/Team";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import React, {
|
||||
Fragment,
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useState,
|
||||
} from "react";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import Route from "Common/Types/API/Route";
|
||||
|
||||
const SCIMPage: FunctionComponent<PageComponentProps> = (
|
||||
_props: PageComponentProps,
|
||||
): ReactElement => {
|
||||
const [showSCIMUrlId, setShowSCIMUrlId] = useState<string>("");
|
||||
const [currentSCIMConfig, setCurrentSCIMConfig] =
|
||||
useState<ProjectSCIM | null>(null);
|
||||
const [refresher, setRefresher] = useState<boolean>(false);
|
||||
const [resetSCIMId, setResetSCIMId] = useState<string>("");
|
||||
const [showResetModal, setShowResetModal] = useState<boolean>(false);
|
||||
const [isResetLoading, setIsResetLoading] = useState<boolean>(false);
|
||||
const [resetError, setResetError] = useState<string>("");
|
||||
const [showResetErrorModal, setShowResetErrorModal] =
|
||||
useState<boolean>(false);
|
||||
const [showResetSuccessModal, setShowResetSuccessModal] =
|
||||
useState<boolean>(false);
|
||||
const [newBearerToken, setNewBearerToken] = useState<string>("");
|
||||
|
||||
const resetBearerToken: () => Promise<void> = async (): Promise<void> => {
|
||||
setIsResetLoading(true);
|
||||
try {
|
||||
const newToken: ObjectID = ObjectID.generate();
|
||||
await ModelAPI.updateById<ProjectSCIM>({
|
||||
modelType: ProjectSCIM,
|
||||
id: new ObjectID(resetSCIMId),
|
||||
data: {
|
||||
bearerToken: newToken.toString(),
|
||||
},
|
||||
});
|
||||
setNewBearerToken(newToken.toString());
|
||||
setShowResetModal(false);
|
||||
setShowResetSuccessModal(true);
|
||||
setRefresher(!refresher);
|
||||
} catch (err) {
|
||||
setResetError(API.getFriendlyMessage(err));
|
||||
setShowResetErrorModal(true);
|
||||
setShowResetModal(false);
|
||||
}
|
||||
setIsResetLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<>
|
||||
<Banner
|
||||
openInNewTab={true}
|
||||
title="Need help with configuring SCIM?"
|
||||
description="Learn more about SCIM (System for Cross-domain Identity Management) setup and configuration"
|
||||
link={Route.fromString("/docs/identity/scim")}
|
||||
hideOnMobile={true}
|
||||
/>
|
||||
|
||||
<ModelTable<ProjectSCIM>
|
||||
key={refresher.toString()}
|
||||
modelType={ProjectSCIM}
|
||||
userPreferencesKey={"project-scim-table"}
|
||||
query={{
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
}}
|
||||
id="scim-table"
|
||||
name="Settings > Project SCIM"
|
||||
isDeleteable={true}
|
||||
isEditable={true}
|
||||
isCreateable={true}
|
||||
cardProps={{
|
||||
title: "SCIM (System for Cross-domain Identity Management)",
|
||||
description:
|
||||
"SCIM is an open standard for automating the exchange of user identity information between identity domains, or IT systems. Use SCIM to automatically provision and deprovision users from your identity provider.",
|
||||
}}
|
||||
formSteps={[
|
||||
{
|
||||
title: "Basic Info",
|
||||
id: "basic",
|
||||
},
|
||||
{
|
||||
title: "Configuration",
|
||||
id: "configuration",
|
||||
},
|
||||
{
|
||||
title: "Teams",
|
||||
id: "teams",
|
||||
},
|
||||
]}
|
||||
noItemsMessage={"No SCIM configuration found."}
|
||||
viewPageRoute={Navigation.getCurrentRoute()}
|
||||
formFields={[
|
||||
{
|
||||
field: {
|
||||
name: true,
|
||||
},
|
||||
title: "Name",
|
||||
fieldType: FormFieldSchemaType.Text,
|
||||
required: true,
|
||||
description:
|
||||
"Friendly name to help you remember this SCIM configuration.",
|
||||
placeholder: "Okta SCIM",
|
||||
validation: {
|
||||
minLength: 2,
|
||||
},
|
||||
stepId: "basic",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
description: true,
|
||||
},
|
||||
title: "Description",
|
||||
fieldType: FormFieldSchemaType.LongText,
|
||||
required: false,
|
||||
description: "Optional description for this SCIM configuration.",
|
||||
placeholder:
|
||||
"SCIM configuration for automatic user provisioning from Okta",
|
||||
stepId: "basic",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
autoProvisionUsers: true,
|
||||
},
|
||||
title: "Auto Provision Users",
|
||||
fieldType: FormFieldSchemaType.Checkbox,
|
||||
required: false,
|
||||
description:
|
||||
"Automatically create users when they are added in your identity provider.",
|
||||
stepId: "configuration",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
autoDeprovisionUsers: true,
|
||||
},
|
||||
title: "Auto Deprovision Users",
|
||||
fieldType: FormFieldSchemaType.Checkbox,
|
||||
required: false,
|
||||
description:
|
||||
"Automatically remove users from teams when they are removed from your identity provider.",
|
||||
stepId: "configuration",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
teams: true,
|
||||
},
|
||||
title: "Default Teams",
|
||||
fieldType: FormFieldSchemaType.MultiSelectDropdown,
|
||||
dropdownModal: {
|
||||
type: Team,
|
||||
labelField: "name",
|
||||
valueField: "_id",
|
||||
},
|
||||
required: false,
|
||||
description:
|
||||
"New users will be automatically added to these teams.",
|
||||
stepId: "teams",
|
||||
},
|
||||
]}
|
||||
columns={[
|
||||
{
|
||||
field: {
|
||||
name: true,
|
||||
},
|
||||
title: "Name",
|
||||
type: FieldType.Text,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
autoProvisionUsers: true,
|
||||
},
|
||||
title: "Auto Provision",
|
||||
type: FieldType.Boolean,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
autoDeprovisionUsers: true,
|
||||
},
|
||||
title: "Auto Deprovision",
|
||||
type: FieldType.Boolean,
|
||||
},
|
||||
]}
|
||||
selectMoreFields={{
|
||||
bearerToken: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
teams: {
|
||||
name: true,
|
||||
_id: true,
|
||||
},
|
||||
}}
|
||||
filters={[
|
||||
{
|
||||
field: {
|
||||
name: true,
|
||||
},
|
||||
title: "Name",
|
||||
type: FieldType.Text,
|
||||
},
|
||||
]}
|
||||
actionButtons={[
|
||||
{
|
||||
title: "View SCIM URLs",
|
||||
buttonStyleType: ButtonStyleType.NORMAL,
|
||||
onClick: async (
|
||||
item: ProjectSCIM,
|
||||
onCompleteAction: () => void,
|
||||
_onError: (error: Error) => void,
|
||||
) => {
|
||||
onCompleteAction();
|
||||
setCurrentSCIMConfig(item);
|
||||
setShowSCIMUrlId(item.id?.toString() || "");
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Reset Bearer Token",
|
||||
buttonStyleType: ButtonStyleType.OUTLINE,
|
||||
icon: IconProp.Refresh,
|
||||
onClick: async (
|
||||
item: ProjectSCIM,
|
||||
onCompleteAction: () => void,
|
||||
_onError: (error: Error) => void,
|
||||
) => {
|
||||
onCompleteAction();
|
||||
setResetSCIMId(item.id?.toString() || "");
|
||||
setShowResetModal(true);
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{showSCIMUrlId && currentSCIMConfig && (
|
||||
<ConfirmModal
|
||||
title={`SCIM Configuration URLs`}
|
||||
description={
|
||||
<div>
|
||||
<p className="text-gray-500 mb-4">
|
||||
Use these URLs to configure SCIM in your identity provider:
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="font-medium text-gray-700 mb-1">
|
||||
SCIM Base URL:
|
||||
</p>
|
||||
<code className="block p-2 bg-gray-100 rounded text-sm break-all">
|
||||
{IDENTITY_URL.toString()}/scim/v2/{showSCIMUrlId}
|
||||
</code>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Use this as the SCIM endpoint URL in your identity
|
||||
provider
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-medium text-gray-700 mb-1">
|
||||
Service Provider Config URL:
|
||||
</p>
|
||||
<code className="block p-2 bg-gray-100 rounded text-sm break-all">
|
||||
{IDENTITY_URL.toString()}/scim/v2/{showSCIMUrlId}
|
||||
/ServiceProviderConfig
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-medium text-gray-700 mb-1">
|
||||
Users Endpoint:
|
||||
</p>
|
||||
<code className="block p-2 bg-gray-100 rounded text-sm break-all">
|
||||
{IDENTITY_URL.toString()}/scim/v2/{showSCIMUrlId}/Users
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-medium text-gray-700 mb-1">
|
||||
Unique identifier field for users:
|
||||
</p>
|
||||
<code className="block p-2 bg-gray-100 rounded text-sm break-all">
|
||||
userName
|
||||
</code>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Use this field as the unique identifier for users in your
|
||||
identity provider SCIM configuration
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<p className="font-medium text-gray-700 mb-1">
|
||||
Bearer Token:
|
||||
</p>
|
||||
<div className="mb-2">
|
||||
<HiddenText
|
||||
text={currentSCIMConfig.bearerToken || ""}
|
||||
isCopyable={true}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
Use this bearer token for authentication in your identity
|
||||
provider SCIM configuration.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
submitButtonText={"Close"}
|
||||
onSubmit={() => {
|
||||
setShowSCIMUrlId("");
|
||||
setCurrentSCIMConfig(null);
|
||||
}}
|
||||
submitButtonType={ButtonStyleType.NORMAL}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Reset Bearer Token Modals */}
|
||||
{showResetModal && (
|
||||
<ConfirmModal
|
||||
title="Reset Bearer Token"
|
||||
description="Are you sure you want to reset the Bearer Token? You will need to update your identity provider with the new token."
|
||||
onSubmit={async () => {
|
||||
await resetBearerToken();
|
||||
}}
|
||||
isLoading={isResetLoading}
|
||||
onClose={() => {
|
||||
setShowResetModal(false);
|
||||
setResetSCIMId("");
|
||||
}}
|
||||
submitButtonText="Reset"
|
||||
submitButtonType={ButtonStyleType.DANGER}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showResetErrorModal && (
|
||||
<ConfirmModal
|
||||
title="Reset Error"
|
||||
description={resetError}
|
||||
onSubmit={() => {
|
||||
setShowResetErrorModal(false);
|
||||
setResetError("");
|
||||
setResetSCIMId("");
|
||||
}}
|
||||
submitButtonText="Close"
|
||||
submitButtonType={ButtonStyleType.NORMAL}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showResetSuccessModal && (
|
||||
<ConfirmModal
|
||||
title="New Bearer Token"
|
||||
description={
|
||||
<div>
|
||||
<p className="mb-3">
|
||||
Your new Bearer Token has been generated:
|
||||
</p>
|
||||
<div className="mb-2">
|
||||
<HiddenText text={newBearerToken} isCopyable={true} />
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
Please update your identity provider with this new token.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
onSubmit={() => {
|
||||
setShowResetSuccessModal(false);
|
||||
setNewBearerToken("");
|
||||
setResetSCIMId("");
|
||||
}}
|
||||
submitButtonText="Close"
|
||||
submitButtonType={ButtonStyleType.NORMAL}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default SCIMPage;
|
||||
@@ -398,6 +398,15 @@ const DashboardSideMenu: () => JSX.Element = (): ReactElement => {
|
||||
},
|
||||
icon: IconProp.Lock,
|
||||
},
|
||||
{
|
||||
link: {
|
||||
title: "SCIM",
|
||||
to: RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.SETTINGS_SCIM] as Route,
|
||||
),
|
||||
},
|
||||
icon: IconProp.Refresh,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import StatusPagesElement from "../../Components/StatusPage/StatusPagesElement";
|
||||
import PageComponentProps from "../PageComponentProps";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import CheckboxViewer from "Common/UI/Components/Checkbox/CheckboxViewer";
|
||||
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
|
||||
import SubscriberNotificationStatus from "../../Components/StatusPageSubscribers/SubscriberNotificationStatus";
|
||||
import CardModelDetail from "Common/UI/Components/ModelDetail/CardModelDetail";
|
||||
import FieldType from "Common/UI/Components/Types/FieldType";
|
||||
import Navigation from "Common/UI/Utils/Navigation";
|
||||
import StatusPageAnnouncement from "Common/Models/DatabaseModels/StatusPageAnnouncement";
|
||||
import StatusPage from "Common/Models/DatabaseModels/StatusPage";
|
||||
import React, { Fragment, FunctionComponent, ReactElement } from "react";
|
||||
import StatusPageSubscriberNotificationStatus from "Common/Types/StatusPage/StatusPageSubscriberNotificationStatus";
|
||||
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
|
||||
import React, {
|
||||
Fragment,
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useState,
|
||||
} from "react";
|
||||
import MarkdownUtil from "Common/UI/Utils/Markdown";
|
||||
import PageMap from "../../Utils/PageMap";
|
||||
import RouteMap, { RouteUtil } from "../../Utils/RouteMap";
|
||||
@@ -20,6 +27,29 @@ const AnnouncementView: FunctionComponent<
|
||||
PageComponentProps
|
||||
> = (): ReactElement => {
|
||||
const modelId: ObjectID = Navigation.getLastParamAsObjectID();
|
||||
const [refreshToggle, setRefreshToggle] = useState<boolean>(false);
|
||||
|
||||
const handleResendNotification: () => Promise<void> =
|
||||
async (): Promise<void> => {
|
||||
try {
|
||||
// Reset the notification status to Pending so the worker can pick it up again
|
||||
await ModelAPI.updateById({
|
||||
id: modelId,
|
||||
modelType: StatusPageAnnouncement,
|
||||
data: {
|
||||
subscriberNotificationStatus:
|
||||
StatusPageSubscriberNotificationStatus.Pending,
|
||||
subscriberNotificationStatusMessage:
|
||||
"Notification queued for resending",
|
||||
},
|
||||
});
|
||||
|
||||
// Trigger a refresh by toggling the refresh state
|
||||
setRefreshToggle(!refreshToggle);
|
||||
} catch {
|
||||
// Error resending notification: handle appropriately
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Page
|
||||
@@ -146,6 +176,9 @@ const AnnouncementView: FunctionComponent<
|
||||
showDetailsInNumberOfColumns: 2,
|
||||
modelType: StatusPageAnnouncement,
|
||||
id: "model-detail-status-page-announcement",
|
||||
selectMoreFields: {
|
||||
subscriberNotificationStatusMessage: true,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
field: {
|
||||
@@ -199,21 +232,18 @@ const AnnouncementView: FunctionComponent<
|
||||
},
|
||||
{
|
||||
field: {
|
||||
shouldStatusPageSubscribersBeNotified: true,
|
||||
subscriberNotificationStatus: true,
|
||||
},
|
||||
title: "Notify Status Page Subscribers",
|
||||
fieldType: FieldType.Boolean,
|
||||
title: "Subscriber Notification Status",
|
||||
fieldType: FieldType.Element,
|
||||
getElement: (item: StatusPageAnnouncement): ReactElement => {
|
||||
return (
|
||||
<CheckboxViewer
|
||||
isChecked={
|
||||
item.shouldStatusPageSubscribersBeNotified as boolean
|
||||
}
|
||||
text={
|
||||
item.shouldStatusPageSubscribersBeNotified
|
||||
? "Notify Subscribers"
|
||||
: "Do Not Notify Subscribers"
|
||||
<SubscriberNotificationStatus
|
||||
status={item.subscriberNotificationStatus}
|
||||
subscriberNotificationStatusMessage={
|
||||
item.subscriberNotificationStatusMessage
|
||||
}
|
||||
onResendNotification={handleResendNotification}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
362
Dashboard/src/Pages/StatusPages/View/SCIM.tsx
Normal file
362
Dashboard/src/Pages/StatusPages/View/SCIM.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
import PageComponentProps from "../../PageComponentProps";
|
||||
import { VoidFunction } from "Common/Types/FunctionTypes";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import Banner from "Common/UI/Components/Banner/Banner";
|
||||
import { ButtonStyleType } from "Common/UI/Components/Button/Button";
|
||||
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
|
||||
import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal";
|
||||
import ModelTable from "Common/UI/Components/ModelTable/ModelTable";
|
||||
import FieldType from "Common/UI/Components/Types/FieldType";
|
||||
import HiddenText from "Common/UI/Components/HiddenText/HiddenText";
|
||||
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
|
||||
import API from "Common/UI/Utils/API/API";
|
||||
import { IDENTITY_URL } from "Common/UI/Config";
|
||||
import Navigation from "Common/UI/Utils/Navigation";
|
||||
import StatusPageSCIM from "Common/Models/DatabaseModels/StatusPageSCIM";
|
||||
import React, {
|
||||
Fragment,
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useState,
|
||||
} from "react";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import Route from "Common/Types/API/Route";
|
||||
|
||||
const SCIMPage: FunctionComponent<PageComponentProps> = (
|
||||
_props: PageComponentProps,
|
||||
): ReactElement => {
|
||||
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
|
||||
const [showSCIMUrlId, setShowSCIMUrlId] = useState<string>("");
|
||||
const [currentSCIMConfig, setCurrentSCIMConfig] =
|
||||
useState<StatusPageSCIM | null>(null);
|
||||
const [refresher, setRefresher] = useState<boolean>(false);
|
||||
const [resetSCIMId, setResetSCIMId] = useState<string>("");
|
||||
const [showResetModal, setShowResetModal] = useState<boolean>(false);
|
||||
const [isResetLoading, setIsResetLoading] = useState<boolean>(false);
|
||||
const [resetError, setResetError] = useState<string>("");
|
||||
const [showResetErrorModal, setShowResetErrorModal] =
|
||||
useState<boolean>(false);
|
||||
const [showResetSuccessModal, setShowResetSuccessModal] =
|
||||
useState<boolean>(false);
|
||||
const [newBearerToken, setNewBearerToken] = useState<string>("");
|
||||
|
||||
const resetBearerToken: () => Promise<void> = async (): Promise<void> => {
|
||||
setIsResetLoading(true);
|
||||
try {
|
||||
const newToken: ObjectID = ObjectID.generate();
|
||||
await ModelAPI.updateById<StatusPageSCIM>({
|
||||
modelType: StatusPageSCIM,
|
||||
id: new ObjectID(resetSCIMId),
|
||||
data: {
|
||||
bearerToken: newToken.toString(),
|
||||
},
|
||||
});
|
||||
setNewBearerToken(newToken.toString());
|
||||
setShowResetModal(false);
|
||||
setShowResetSuccessModal(true);
|
||||
setRefresher(!refresher);
|
||||
} catch (err) {
|
||||
setResetError(API.getFriendlyMessage(err));
|
||||
setShowResetErrorModal(true);
|
||||
setShowResetModal(false);
|
||||
}
|
||||
setIsResetLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<>
|
||||
<Banner
|
||||
openInNewTab={true}
|
||||
title="Need help with configuring SCIM?"
|
||||
description="Learn more about SCIM (System for Cross-domain Identity Management) setup and configuration for Status Pages"
|
||||
link={Route.fromString("/docs/identity/scim")}
|
||||
hideOnMobile={true}
|
||||
/>
|
||||
|
||||
<ModelTable<StatusPageSCIM>
|
||||
key={refresher.toString()}
|
||||
modelType={StatusPageSCIM}
|
||||
userPreferencesKey={"status-page-scim-table"}
|
||||
query={{
|
||||
statusPageId: modelId,
|
||||
}}
|
||||
id="status-page-scim-table"
|
||||
name="Status Page > SCIM"
|
||||
isDeleteable={true}
|
||||
isEditable={true}
|
||||
isCreateable={true}
|
||||
cardProps={{
|
||||
title: "SCIM (System for Cross-domain Identity Management)",
|
||||
description:
|
||||
"SCIM is an open standard for automating the exchange of user identity information between identity domains, or IT systems. Use SCIM to automatically provision and deprovision users with access to your private Status Page.",
|
||||
}}
|
||||
formSteps={[
|
||||
{
|
||||
title: "Basic Info",
|
||||
id: "basic",
|
||||
},
|
||||
{
|
||||
title: "Configuration",
|
||||
id: "configuration",
|
||||
},
|
||||
]}
|
||||
onBeforeCreate={(scim: StatusPageSCIM) => {
|
||||
scim.statusPageId = modelId;
|
||||
return Promise.resolve(scim);
|
||||
}}
|
||||
noItemsMessage={"No SCIM configuration found."}
|
||||
viewPageRoute={Navigation.getCurrentRoute()}
|
||||
formFields={[
|
||||
{
|
||||
field: {
|
||||
name: true,
|
||||
},
|
||||
title: "Name",
|
||||
fieldType: FormFieldSchemaType.Text,
|
||||
required: true,
|
||||
description:
|
||||
"Friendly name to help you remember this SCIM configuration.",
|
||||
placeholder: "Okta SCIM for Status Page",
|
||||
validation: {
|
||||
minLength: 2,
|
||||
},
|
||||
stepId: "basic",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
description: true,
|
||||
},
|
||||
title: "Description",
|
||||
fieldType: FormFieldSchemaType.LongText,
|
||||
required: false,
|
||||
description: "Optional description for this SCIM configuration.",
|
||||
placeholder:
|
||||
"SCIM configuration for automatic user provisioning to the Status Page from Okta",
|
||||
stepId: "basic",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
autoProvisionUsers: true,
|
||||
},
|
||||
title: "Auto Provision Users",
|
||||
fieldType: FormFieldSchemaType.Checkbox,
|
||||
required: false,
|
||||
description:
|
||||
"Automatically create users when they are added in your identity provider.",
|
||||
stepId: "configuration",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
autoDeprovisionUsers: true,
|
||||
},
|
||||
title: "Auto Deprovision Users",
|
||||
fieldType: FormFieldSchemaType.Checkbox,
|
||||
required: false,
|
||||
description:
|
||||
"Automatically remove users when they are removed from your identity provider.",
|
||||
stepId: "configuration",
|
||||
},
|
||||
]}
|
||||
showRefreshButton={true}
|
||||
selectMoreFields={{
|
||||
bearerToken: true,
|
||||
}}
|
||||
filters={[]}
|
||||
columns={[
|
||||
{
|
||||
field: {
|
||||
name: true,
|
||||
},
|
||||
title: "Name",
|
||||
type: FieldType.Text,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
autoProvisionUsers: true,
|
||||
},
|
||||
title: "Auto Provision Users",
|
||||
type: FieldType.Boolean,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
autoDeprovisionUsers: true,
|
||||
},
|
||||
title: "Auto Deprovision Users",
|
||||
type: FieldType.Boolean,
|
||||
},
|
||||
]}
|
||||
actionButtons={[
|
||||
{
|
||||
title: "Show SCIM Endpoint URLs",
|
||||
buttonStyleType: ButtonStyleType.NORMAL,
|
||||
icon: IconProp.List,
|
||||
onClick: async (
|
||||
item: StatusPageSCIM,
|
||||
onCompleteAction: VoidFunction,
|
||||
onError: (err: Error) => void,
|
||||
) => {
|
||||
try {
|
||||
setCurrentSCIMConfig(item);
|
||||
setShowSCIMUrlId(item["_id"] as string);
|
||||
onCompleteAction();
|
||||
} catch (err) {
|
||||
onError(err as Error);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Reset Bearer Token",
|
||||
buttonStyleType: ButtonStyleType.OUTLINE,
|
||||
icon: IconProp.Refresh,
|
||||
onClick: async (
|
||||
item: StatusPageSCIM,
|
||||
onCompleteAction: VoidFunction,
|
||||
onError: (err: Error) => void,
|
||||
) => {
|
||||
try {
|
||||
setResetSCIMId(item["_id"] as string);
|
||||
setShowResetModal(true);
|
||||
onCompleteAction();
|
||||
} catch (err) {
|
||||
onError(err as Error);
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{showSCIMUrlId && currentSCIMConfig ? (
|
||||
<ConfirmModal
|
||||
title={`SCIM URLs - ${currentSCIMConfig.name}`}
|
||||
description={
|
||||
<div>
|
||||
<p>
|
||||
Configure your identity provider with these SCIM endpoint
|
||||
URLs:
|
||||
</p>
|
||||
<br />
|
||||
<div>
|
||||
<strong>SCIM Base URL:</strong>
|
||||
<br />
|
||||
<code className="block p-2 bg-gray-100 rounded text-sm break-all">
|
||||
{IDENTITY_URL.toString()}/status-page-scim/v2/
|
||||
{showSCIMUrlId}
|
||||
</code>
|
||||
</div>
|
||||
<br />
|
||||
<div>
|
||||
<p className="font-medium text-gray-700 mb-1">
|
||||
Unique identifier field for users:
|
||||
</p>
|
||||
<code className="block p-2 bg-gray-100 rounded text-sm break-all">
|
||||
userName
|
||||
</code>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Use this field as the unique identifier for users in your
|
||||
identity provider SCIM configuration
|
||||
</p>
|
||||
</div>
|
||||
<br />
|
||||
<div>
|
||||
<strong>Users Endpoint:</strong>
|
||||
<br />
|
||||
<code className="block p-2 bg-gray-100 rounded text-sm break-all">
|
||||
{IDENTITY_URL.toString()}/status-page-scim/v2/
|
||||
{showSCIMUrlId}/Users
|
||||
</code>
|
||||
</div>
|
||||
<br />
|
||||
<div>
|
||||
<strong>Bearer Token:</strong>
|
||||
<br />
|
||||
<HiddenText
|
||||
text={currentSCIMConfig.bearerToken as string}
|
||||
isCopyable={true}
|
||||
/>
|
||||
</div>
|
||||
<br />
|
||||
<p>
|
||||
<strong>Note:</strong> Make sure to use this bearer token in
|
||||
the Authorization header when making SCIM API requests.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
submitButtonText={"Close"}
|
||||
onSubmit={() => {
|
||||
setShowSCIMUrlId("");
|
||||
setCurrentSCIMConfig(null);
|
||||
}}
|
||||
submitButtonType={ButtonStyleType.NORMAL}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
{showResetModal ? (
|
||||
<ConfirmModal
|
||||
title={"Reset Bearer Token"}
|
||||
description={
|
||||
"Are you sure you want to reset the bearer token? This will invalidate the current token and you will need to update your identity provider with the new token."
|
||||
}
|
||||
submitButtonText={"Reset"}
|
||||
onSubmit={resetBearerToken}
|
||||
isLoading={isResetLoading}
|
||||
submitButtonType={ButtonStyleType.DANGER}
|
||||
onClose={() => {
|
||||
setShowResetModal(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
{showResetErrorModal ? (
|
||||
<ConfirmModal
|
||||
title={"Error"}
|
||||
description={resetError}
|
||||
submitButtonText={"Close"}
|
||||
onSubmit={() => {
|
||||
setShowResetErrorModal(false);
|
||||
}}
|
||||
submitButtonType={ButtonStyleType.NORMAL}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
{showResetSuccessModal ? (
|
||||
<ConfirmModal
|
||||
title={"Bearer Token Reset"}
|
||||
description={
|
||||
<div>
|
||||
<p>Bearer token has been reset successfully.</p>
|
||||
<br />
|
||||
<div>
|
||||
<strong>New Bearer Token:</strong>
|
||||
<br />
|
||||
<HiddenText text={newBearerToken} isCopyable={true} />
|
||||
</div>
|
||||
<br />
|
||||
<p>
|
||||
<strong>Important:</strong> Make sure to update your identity
|
||||
provider with this new bearer token.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
submitButtonText={"Close"}
|
||||
onSubmit={() => {
|
||||
setShowResetSuccessModal(false);
|
||||
setNewBearerToken("");
|
||||
}}
|
||||
submitButtonType={ButtonStyleType.NORMAL}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default SCIMPage;
|
||||
@@ -230,6 +230,17 @@ const DashboardSideMenu: FunctionComponent<ComponentProps> = (
|
||||
icon={IconProp.Lock}
|
||||
/>
|
||||
|
||||
<SideMenuItem
|
||||
link={{
|
||||
title: "SCIM",
|
||||
to: RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.STATUS_PAGE_VIEW_SCIM] as Route,
|
||||
{ modelId: props.modelId },
|
||||
),
|
||||
}}
|
||||
icon={IconProp.Refresh}
|
||||
/>
|
||||
|
||||
<SideMenuItem
|
||||
link={{
|
||||
title: "Authentication Settings",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import PageComponentProps from "../PageComponentProps";
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
import ComingSoon from "Common/UI/Components/ComingSoon/ComingSoon";
|
||||
import MicrosoftTeamsIntegration from "../../Components/MicrosoftTeams/MicrosoftTeamsIntegration";
|
||||
|
||||
const Settings: FunctionComponent<PageComponentProps> = (): ReactElement => {
|
||||
return (
|
||||
<ComingSoon
|
||||
title="Microsoft Teams Integration is coming soon, but you can still integrate Teams with Workflows!"
|
||||
description="We are working hard to bring you the Microsoft Teams integration. In the meantime, you can still integrate with Workflows to receive alerts in Microsoft Teams. Please click on Workflows in the top navigation to get started."
|
||||
<MicrosoftTeamsIntegration
|
||||
onConnected={() => {}}
|
||||
onDisconnected={() => {}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -196,6 +196,12 @@ const SettingsSSO: LazyExoticComponent<FunctionComponent<ComponentProps>> =
|
||||
lazy(() => {
|
||||
return import("../Pages/Settings/SSO");
|
||||
});
|
||||
|
||||
const SettingsSCIM: LazyExoticComponent<FunctionComponent<ComponentProps>> =
|
||||
lazy(() => {
|
||||
return import("../Pages/Settings/SCIM");
|
||||
});
|
||||
|
||||
const SettingsSmsLog: LazyExoticComponent<FunctionComponent<ComponentProps>> =
|
||||
lazy(() => {
|
||||
return import("../Pages/Settings/SmsLog");
|
||||
@@ -680,6 +686,18 @@ const SettingsRoutes: FunctionComponent<ComponentProps> = (
|
||||
}
|
||||
/>
|
||||
|
||||
<PageRoute
|
||||
path={RouteUtil.getLastPathForKey(PageMap.SETTINGS_SCIM)}
|
||||
element={
|
||||
<Suspense fallback={Loader}>
|
||||
<SettingsSCIM
|
||||
{...props}
|
||||
pageRoute={RouteMap[PageMap.SETTINGS_SCIM] as Route}
|
||||
/>
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
|
||||
<PageRoute
|
||||
path={RouteUtil.getLastPathForKey(
|
||||
PageMap.SETTINGS_INCIDENTS_SEVERITY,
|
||||
|
||||
@@ -119,6 +119,11 @@ const StatusPageViewSSO: LazyExoticComponent<
|
||||
> = lazy(() => {
|
||||
return import("../Pages/StatusPages/View/SSO");
|
||||
});
|
||||
const StatusPageViewSCIM: LazyExoticComponent<
|
||||
FunctionComponent<ComponentProps>
|
||||
> = lazy(() => {
|
||||
return import("../Pages/StatusPages/View/SCIM");
|
||||
});
|
||||
const StatusPageViewPrivateUser: LazyExoticComponent<
|
||||
FunctionComponent<ComponentProps>
|
||||
> = lazy(() => {
|
||||
@@ -360,6 +365,18 @@ const StatusPagesRoutes: FunctionComponent<ComponentProps> = (
|
||||
}
|
||||
/>
|
||||
|
||||
<PageRoute
|
||||
path={RouteUtil.getLastPathForKey(PageMap.STATUS_PAGE_VIEW_SCIM)}
|
||||
element={
|
||||
<Suspense fallback={Loader}>
|
||||
<StatusPageViewSCIM
|
||||
{...props}
|
||||
pageRoute={RouteMap[PageMap.STATUS_PAGE_VIEW_SCIM] as Route}
|
||||
/>
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
|
||||
<PageRoute
|
||||
path={RouteUtil.getLastPathForKey(
|
||||
PageMap.STATUS_PAGE_VIEW_EMAIL_SUBSCRIBERS,
|
||||
|
||||
@@ -211,6 +211,7 @@ enum PageMap {
|
||||
STATUS_PAGE_VIEW_CUSTOM_FIELDS = "STATUS_PAGE_VIEW_CUSTOM_FIELDS",
|
||||
STATUS_PAGE_VIEW_REPORTS = "STATUS_PAGE_VIEW_REPORTS",
|
||||
STATUS_PAGE_VIEW_SSO = "STATUS_PAGE_VIEW_SSO",
|
||||
STATUS_PAGE_VIEW_SCIM = "STATUS_PAGE_VIEW_SCIM",
|
||||
STATUS_PAGE_VIEW_OWNERS = "STATUS_PAGE_VIEW_OWNERS",
|
||||
STATUS_PAGE_VIEW_SETTINGS = "STATUS_PAGE_VIEW_SETTINGS",
|
||||
|
||||
@@ -338,6 +339,9 @@ enum PageMap {
|
||||
// SSO.
|
||||
SETTINGS_SSO = "SETTINGS_SSO",
|
||||
|
||||
// SCIM.
|
||||
SETTINGS_SCIM = "SETTINGS_SCIM",
|
||||
|
||||
// Domains
|
||||
|
||||
SETTINGS_DOMAINS = "SETTINGS_DOMAINS",
|
||||
|
||||
@@ -136,6 +136,7 @@ export const StatusPagesRoutePath: Dictionary<string> = {
|
||||
[PageMap.STATUS_PAGE_VIEW_EMBEDDED]: `${RouteParams.ModelID}/embedded`,
|
||||
[PageMap.STATUS_PAGE_VIEW_SUBSCRIBER_SETTINGS]: `${RouteParams.ModelID}/subscriber-settings`,
|
||||
[PageMap.STATUS_PAGE_VIEW_SSO]: `${RouteParams.ModelID}/sso`,
|
||||
[PageMap.STATUS_PAGE_VIEW_SCIM]: `${RouteParams.ModelID}/scim`,
|
||||
[PageMap.STATUS_PAGE_VIEW_CUSTOM_HTML_CSS]: `${RouteParams.ModelID}/custom-code`,
|
||||
[PageMap.STATUS_PAGE_VIEW_RESOURCES]: `${RouteParams.ModelID}/resources`,
|
||||
[PageMap.STATUS_PAGE_VIEW_ADVANCED_OPTIONS]: `${RouteParams.ModelID}/advanced-options`,
|
||||
@@ -244,6 +245,7 @@ export const SettingsRoutePath: Dictionary<string> = {
|
||||
[PageMap.SETTINGS_DOMAINS]: "domains",
|
||||
[PageMap.SETTINGS_FEATURE_FLAGS]: "feature-flags",
|
||||
[PageMap.SETTINGS_SSO]: "sso",
|
||||
[PageMap.SETTINGS_SCIM]: "scim",
|
||||
[PageMap.SETTINGS_TEAMS]: "teams",
|
||||
[PageMap.SETTINGS_USERS]: "users",
|
||||
[PageMap.SETTINGS_USER_VIEW]: `users/${RouteParams.ModelID}`,
|
||||
@@ -1096,6 +1098,12 @@ const RouteMap: Dictionary<Route> = {
|
||||
}`,
|
||||
),
|
||||
|
||||
[PageMap.STATUS_PAGE_VIEW_SCIM]: new Route(
|
||||
`/dashboard/${RouteParams.ProjectID}/status-pages/${
|
||||
StatusPagesRoutePath[PageMap.STATUS_PAGE_VIEW_SCIM]
|
||||
}`,
|
||||
),
|
||||
|
||||
[PageMap.STATUS_PAGE_VIEW_CUSTOM_HTML_CSS]: new Route(
|
||||
`/dashboard/${RouteParams.ProjectID}/status-pages/${
|
||||
StatusPagesRoutePath[PageMap.STATUS_PAGE_VIEW_CUSTOM_HTML_CSS]
|
||||
@@ -1702,6 +1710,12 @@ const RouteMap: Dictionary<Route> = {
|
||||
}`,
|
||||
),
|
||||
|
||||
[PageMap.SETTINGS_SCIM]: new Route(
|
||||
`/dashboard/${RouteParams.ProjectID}/settings/${
|
||||
SettingsRoutePath[PageMap.SETTINGS_SCIM]
|
||||
}`,
|
||||
),
|
||||
|
||||
[PageMap.SETTINGS_TEAMS]: new Route(
|
||||
`/dashboard/${RouteParams.ProjectID}/settings/${
|
||||
SettingsRoutePath[PageMap.SETTINGS_TEAMS]
|
||||
|
||||
136
Docs/Content/identity/scim.md
Normal file
136
Docs/Content/identity/scim.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# SCIM (System for Cross-domain Identity Management)
|
||||
|
||||
OneUptime supports SCIM v2.0 protocol for automated user provisioning and deprovisioning. SCIM enables identity providers (IdPs) like Azure AD, Okta, and other enterprise identity systems to automatically manage user access to OneUptime projects and status pages.
|
||||
|
||||
## Overview
|
||||
|
||||
SCIM integration provides the following benefits:
|
||||
|
||||
- **Automated User Provisioning**: Automatically create users in OneUptime when they're assigned in your IdP
|
||||
- **Automated User Deprovisioning**: Automatically remove users from OneUptime when they're unassigned in your IdP
|
||||
- **User Attribute Synchronization**: Keep user information synchronized between your IdP and OneUptime
|
||||
- **Centralized Access Management**: Manage OneUptime access from your existing identity management system
|
||||
|
||||
## SCIM for Projects
|
||||
|
||||
Project SCIM allows identity providers to manage team members within OneUptime projects.
|
||||
|
||||
### Setting Up Project SCIM
|
||||
|
||||
1. **Navigate to Project Settings**
|
||||
- Go to your OneUptime project
|
||||
- Navigate to **Project Settings** > **Team** > **SCIM**
|
||||
|
||||
2. **Configure SCIM Settings**
|
||||
- Enable **Auto Provision Users** to automatically add users when they're assigned in your IdP
|
||||
- Enable **Auto Deprovision Users** to automatically remove users when they're unassigned in your IdP
|
||||
- Select the **Default Teams** that new users should be added to
|
||||
- Copy the **SCIM Base URL** and **Bearer Token** for your IdP configuration
|
||||
|
||||
3. **Configure Your Identity Provider**
|
||||
- Use the SCIM Base URL: `https://oneuptime.com/scim/v2/{scimId}`
|
||||
- Configure bearer token authentication with the provided token
|
||||
- Map user attributes (email is required)
|
||||
|
||||
### Project SCIM Endpoints
|
||||
|
||||
- **Service Provider Config**: `GET /scim/v2/{scimId}/ServiceProviderConfig`
|
||||
- **List Users**: `GET /scim/v2/{scimId}/Users`
|
||||
- **Get User**: `GET /scim/v2/{scimId}/Users/{userId}`
|
||||
- **Create User**: `POST /scim/v2/{scimId}/Users`
|
||||
- **Update User**: `PUT /scim/v2/{scimId}/Users/{userId}`
|
||||
- **Delete User**: `DELETE /scim/v2/{scimId}/Users/{userId}`
|
||||
|
||||
### Project SCIM User Lifecycle
|
||||
|
||||
1. **User Assignment in IdP**: When a user is assigned to OneUptime in your IdP
|
||||
2. **SCIM Provisioning**: IdP calls OneUptime SCIM API to create the user
|
||||
3. **Team Membership**: User is automatically added to configured default teams
|
||||
4. **Access Granted**: User can now access the OneUptime project
|
||||
5. **User Unassignment**: When user is unassigned in IdP
|
||||
6. **SCIM Deprovisioning**: IdP calls OneUptime SCIM API to remove the user
|
||||
7. **Access Revoked**: User loses access to the project
|
||||
|
||||
## SCIM for Status Pages
|
||||
|
||||
Status Page SCIM allows identity providers to manage subscribers to private status pages.
|
||||
|
||||
### Setting Up Status Page SCIM
|
||||
|
||||
1. **Navigate to Status Page Settings**
|
||||
- Go to your OneUptime status page
|
||||
- Navigate to **Status Page Settings** > **Private Users** > **SCIM**
|
||||
|
||||
2. **Configure SCIM Settings**
|
||||
- Enable **Auto Provision Users** to automatically add subscribers when they're assigned in your IdP
|
||||
- Enable **Auto Deprovision Users** to automatically remove subscribers when they're unassigned in your IdP
|
||||
- Copy the **SCIM Base URL** and **Bearer Token** for your IdP configuration
|
||||
|
||||
3. **Configure Your Identity Provider**
|
||||
- Use the SCIM Base URL: `https://oneuptime.com/status-page-scim/v2/{scimId}`
|
||||
- Configure bearer token authentication with the provided token
|
||||
- Map user attributes (email is required)
|
||||
|
||||
### Status Page SCIM Endpoints
|
||||
|
||||
- **Service Provider Config**: `GET /status-page-scim/v2/{scimId}/ServiceProviderConfig`
|
||||
- **List Users**: `GET /status-page-scim/v2/{scimId}/Users`
|
||||
- **Get User**: `GET /status-page-scim/v2/{scimId}/Users/{userId}`
|
||||
- **Create User**: `POST /status-page-scim/v2/{scimId}/Users`
|
||||
- **Update User**: `PUT /status-page-scim/v2/{scimId}/Users/{userId}`
|
||||
- **Delete User**: `DELETE /status-page-scim/v2/{scimId}/Users/{userId}`
|
||||
|
||||
### Status Page SCIM User Lifecycle
|
||||
|
||||
1. **User Assignment in IdP**: When a user is assigned to OneUptime Status Page in your IdP
|
||||
2. **SCIM Provisioning**: IdP calls OneUptime SCIM API to create the subscriber
|
||||
3. **Access Granted**: User can now access the private status page
|
||||
4. **User Unassignment**: When user is unassigned in IdP
|
||||
5. **SCIM Deprovisioning**: IdP calls OneUptime SCIM API to remove the subscriber
|
||||
6. **Access Revoked**: User loses access to the status page
|
||||
|
||||
## Identity Provider Configuration
|
||||
|
||||
### Azure Active Directory (Azure AD)
|
||||
|
||||
1. **Add OneUptime from Azure AD Gallery**
|
||||
- In Azure AD, go to **Enterprise Applications** > **New Application**
|
||||
- Search for "OneUptime" or add a **Non-gallery application**
|
||||
|
||||
2. **Configure SCIM Settings**
|
||||
- In the OneUptime application, go to **Provisioning**
|
||||
- Set **Provisioning Mode** to **Automatic**
|
||||
- Enter the **Tenant URL** (SCIM Base URL from OneUptime)
|
||||
- Enter the **Secret Token** (Bearer Token from OneUptime)
|
||||
- Test the connection and save
|
||||
|
||||
3. **Configure Attribute Mappings**
|
||||
- Map Azure AD attributes to OneUptime SCIM attributes
|
||||
- Ensure `userPrincipalName` or `mail` is mapped to `userName`
|
||||
- Configure any additional attribute mappings as needed
|
||||
|
||||
4. **Assign Users**
|
||||
- Go to **Users and groups** and assign users to the OneUptime application
|
||||
- Users will be automatically provisioned to OneUptime
|
||||
|
||||
### Okta
|
||||
|
||||
1. **Add OneUptime Application**
|
||||
- In Okta Admin Console, go to **Applications** > **Add Application**
|
||||
- Create a **Web** application or use **SCIM 2.0 Test App (Header Auth)**
|
||||
|
||||
2. **Configure SCIM Settings**
|
||||
- In the application settings, go to **Provisioning**
|
||||
- Set **SCIM connector base URL** to the OneUptime SCIM Base URL
|
||||
- Set **Unique identifier field for users** to `userName`
|
||||
- Enter the **Bearer Token** in the authentication header
|
||||
|
||||
3. **Configure Attribute Mappings**
|
||||
- Map Okta user attributes to SCIM attributes
|
||||
- Ensure `email` is mapped to `userName`
|
||||
- Configure additional mappings as needed
|
||||
|
||||
4. **Assign Users**
|
||||
- Assign users to the OneUptime application
|
||||
- Users will be automatically provisioned to OneUptime
|
||||
|
||||
90
Docs/Content/workspace-connections/microsoft-teams.md
Normal file
90
Docs/Content/workspace-connections/microsoft-teams.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# Connecting OneUptime to Microsoft Teams
|
||||
|
||||
This guide walks you through creating a Microsoft Teams app (Azure AD app registration), granting the right permissions, and connecting it to OneUptime so your team can receive notifications in Teams.
|
||||
|
||||
## Prerequisites
|
||||
- OneUptime is running and accessible (self-hosted or cloud)
|
||||
- Admin access to your Microsoft 365 tenant
|
||||
- Permission to register apps in Azure AD (Entra ID)
|
||||
|
||||
## Step 1: Create an Azure AD app for Microsoft Teams
|
||||
1. Go to https://portal.azure.com and open "Microsoft Entra ID" (Azure Active Directory).
|
||||
2. Navigate to "App registrations" → "New registration".
|
||||
3. Name: OneUptime (or any clear name).
|
||||
4. Supported account types: leave as default (Single tenant is fine; Multi-tenant also works if needed).
|
||||
5. Redirect URI (type Web): add your OneUptime App API callback URL:
|
||||
- https://YOUR-HOST/api/microsoft-teams/auth/{projectId}/{userId}
|
||||
- You’ll copy the actual values from the connect flow; keep the same base format. If using HTTPS behind a proxy, ensure the external URL matches your Dashboard’s host.
|
||||
6. Click "Register".
|
||||
|
||||
Make note of:
|
||||
- Application (client) ID → MICROSOFT_TEAMS_APP_CLIENT_ID
|
||||
- Directory (tenant) ID (if you want to restrict to a tenant)
|
||||
|
||||
## Step 2: Create a client secret
|
||||
1. In the app blade → "Certificates & secrets" → "New client secret".
|
||||
2. Description: OneUptime
|
||||
3. Expiry: choose per your policy.
|
||||
4. Save and copy the Value. This is your MICROSOFT_TEAMS_APP_CLIENT_SECRET.
|
||||
|
||||
Store the secret securely. You cannot retrieve it later.
|
||||
|
||||
## Step 3: API permissions
|
||||
Open "API permissions" → add the following Microsoft Graph delegated permissions:
|
||||
- openid
|
||||
- profile
|
||||
- offline_access
|
||||
- User.Read
|
||||
- Team.ReadBasic.All
|
||||
- Channel.ReadBasic.All
|
||||
- ChannelMessage.Send
|
||||
|
||||
Click "Grant admin consent" for your tenant so users aren’t prompted individually.
|
||||
|
||||
## Step 4: Configure OneUptime environment
|
||||
Add these environment variables to OneUptime and restart services:
|
||||
|
||||
- MICROSOFT_TEAMS_APP_CLIENT_ID=your-client-id
|
||||
- MICROSOFT_TEAMS_APP_CLIENT_SECRET=your-client-secret
|
||||
|
||||
Docker Compose example (config.env or .env):
|
||||
|
||||
MICROSOFT_TEAMS_APP_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
MICROSOFT_TEAMS_APP_CLIENT_SECRET=YOUR_SUPER_SECRET
|
||||
|
||||
Helm values.yaml example:
|
||||
|
||||
microsoftTeamsApp:
|
||||
clientId: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
clientSecret: "YOUR_SUPER_SECRET"
|
||||
|
||||
## Step 5: Connect Microsoft Teams in OneUptime
|
||||
1. In the OneUptime Dashboard, go to Project Settings → Workspace Connections → Microsoft Teams.
|
||||
2. Click "Connect Microsoft Teams".
|
||||
3. You’ll be redirected to Microsoft’s consent screen. Accept the requested permissions.
|
||||
4. You’ll be redirected back to OneUptime; the project and user tokens will be saved.
|
||||
|
||||
## Step 6: Configure notification rules
|
||||
Once connected, set up rules to post to Teams for:
|
||||
- Incidents → Microsoft Teams tab
|
||||
- Alerts → Microsoft Teams tab
|
||||
- Monitors → Microsoft Teams tab
|
||||
- Scheduled Maintenance → Microsoft Teams tab
|
||||
- On-Call Duty → Microsoft Teams tab
|
||||
|
||||
Choose a Team and Channel (e.g., General). OneUptime will send notifications using the app’s access.
|
||||
|
||||
## Notes & limitations
|
||||
- Channel creation and member invites via Graph require additional permissions and admin consent; the initial release focuses on sending messages to existing channels.
|
||||
- Messages are posted as HTML-rendered content in Teams. Formatting closely matches Slack-style blocks where possible.
|
||||
- If you use a custom tenant, the authorize URL can target your tenant instead of "common" to limit consent to your directory.
|
||||
|
||||
## Troubleshooting
|
||||
- Error: "Microsoft Teams client id is not configured."
|
||||
- Ensure MICROSOFT_TEAMS_APP_CLIENT_ID is set on the Dashboard/UI environment and the server; rebuild/restart.
|
||||
- Consent or permission errors
|
||||
- Verify API permissions and that admin consent is granted.
|
||||
- Messages not appearing
|
||||
- Confirm the Team/Channel exist and your app has access via Graph. Check OneUptime logs for Graph API responses.
|
||||
|
||||
That’s it — your OneUptime project is now connected to Microsoft Teams.
|
||||
@@ -1,5 +1,7 @@
|
||||
# Connecting OneUptime to Slack
|
||||
|
||||
Looking for Microsoft Teams? See the companion guide in this folder: "Connecting OneUptime to Microsoft Teams".
|
||||
|
||||
### Steps to Connect OneUptime to Slack
|
||||
|
||||
1. **Create an Account on OneUptime**
|
||||
|
||||
@@ -81,6 +81,15 @@ const DocsNav: NavGroup[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Identity",
|
||||
links: [
|
||||
{
|
||||
title: "SCIM",
|
||||
url: "/docs/identity/scim",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Terraform Provider",
|
||||
links: [
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
import TelemetryIngest, {
|
||||
TelemetryRequest,
|
||||
} from "Common/Server/Middleware/TelemetryIngest";
|
||||
import OneUptimeDate from "Common/Types/Date";
|
||||
import { JSONObject } from "Common/Types/JSON";
|
||||
import ProductType from "Common/Types/MeteredPlan/ProductType";
|
||||
import LogService from "Common/Server/Services/LogService";
|
||||
import Express, {
|
||||
ExpressRequest,
|
||||
ExpressResponse,
|
||||
ExpressRouter,
|
||||
NextFunction,
|
||||
} from "Common/Server/Utils/Express";
|
||||
import logger from "Common/Server/Utils/Logger";
|
||||
import Response from "Common/Server/Utils/Response";
|
||||
import Log from "Common/Models/AnalyticsModels/Log";
|
||||
import LogSeverity from "Common/Types/Log/LogSeverity";
|
||||
import OTelIngestService from "Common/Server/Services/OpenTelemetryIngestService";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import JSONFunctions from "Common/Types/JSONFunctions";
|
||||
import FluentIngestQueueService from "../Services/Queue/FluentIngestQueueService";
|
||||
import ClusterKeyAuthorization from "Common/Server/Middleware/ClusterKeyAuthorization";
|
||||
import BadRequestException from "Common/Types/Exception/BadRequestException";
|
||||
|
||||
export class FluentRequestMiddleware {
|
||||
public static async getProductType(
|
||||
@@ -46,96 +40,108 @@ router.post(
|
||||
next: NextFunction,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
logger.debug("Fluent ProbeIngest API called");
|
||||
|
||||
const dbLogs: Array<Log> = [];
|
||||
|
||||
let logItems: Array<JSONObject | string> | JSONObject = req.body as
|
||||
| Array<JSONObject | string>
|
||||
| JSONObject;
|
||||
|
||||
let oneuptimeServiceName: string | string[] | undefined =
|
||||
req.headers["x-oneuptime-service-name"];
|
||||
|
||||
if (!oneuptimeServiceName) {
|
||||
oneuptimeServiceName = "Unknown Service";
|
||||
if (!(req as TelemetryRequest).projectId) {
|
||||
throw new BadRequestException(
|
||||
"Invalid request - projectId not found in request.",
|
||||
);
|
||||
}
|
||||
|
||||
const telemetryService: {
|
||||
serviceId: ObjectID;
|
||||
dataRententionInDays: number;
|
||||
} = await OTelIngestService.telemetryServiceFromName({
|
||||
serviceName: oneuptimeServiceName as string,
|
||||
projectId: (req as TelemetryRequest).projectId,
|
||||
req.body = req.body.toJSON ? req.body.toJSON() : req.body;
|
||||
|
||||
// Return response immediately
|
||||
Response.sendEmptySuccessResponse(req, res);
|
||||
|
||||
// Add to queue for asynchronous processing
|
||||
await FluentIngestQueueService.addFluentIngestJob(
|
||||
req as TelemetryRequest,
|
||||
);
|
||||
|
||||
return;
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Queue stats endpoint
|
||||
router.get(
|
||||
"/fluent/queue/stats",
|
||||
ClusterKeyAuthorization.isAuthorizedServiceMiddleware,
|
||||
async (
|
||||
req: ExpressRequest,
|
||||
res: ExpressResponse,
|
||||
next: NextFunction,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const stats: {
|
||||
waiting: number;
|
||||
active: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
delayed: number;
|
||||
total: number;
|
||||
} = await FluentIngestQueueService.getQueueStats();
|
||||
return Response.sendJsonObjectResponse(req, res, stats);
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Queue size endpoint
|
||||
router.get(
|
||||
"/fluent/queue/size",
|
||||
ClusterKeyAuthorization.isAuthorizedServiceMiddleware,
|
||||
async (
|
||||
req: ExpressRequest,
|
||||
res: ExpressResponse,
|
||||
next: NextFunction,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const size: number = await FluentIngestQueueService.getQueueSize();
|
||||
return Response.sendJsonObjectResponse(req, res, { size });
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Queue failed jobs endpoint
|
||||
router.get(
|
||||
"/fluent/queue/failed",
|
||||
ClusterKeyAuthorization.isAuthorizedServiceMiddleware,
|
||||
async (
|
||||
req: ExpressRequest,
|
||||
res: ExpressResponse,
|
||||
next: NextFunction,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
// Parse pagination parameters from query string
|
||||
const start: number = parseInt(req.query["start"] as string) || 0;
|
||||
const end: number = parseInt(req.query["end"] as string) || 100;
|
||||
|
||||
const failedJobs: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
data: any;
|
||||
failedReason: string;
|
||||
stackTrace?: string;
|
||||
processedOn: Date | null;
|
||||
finishedOn: Date | null;
|
||||
attemptsMade: number;
|
||||
}> = await FluentIngestQueueService.getFailedJobs({
|
||||
start,
|
||||
end,
|
||||
});
|
||||
|
||||
if (
|
||||
logItems &&
|
||||
typeof logItems === "object" &&
|
||||
(logItems as JSONObject)["json"]
|
||||
) {
|
||||
logItems = (logItems as JSONObject)["json"] as
|
||||
| Array<JSONObject | string>
|
||||
| JSONObject;
|
||||
}
|
||||
|
||||
if (!Array.isArray(logItems)) {
|
||||
logItems = [logItems];
|
||||
}
|
||||
|
||||
for (let logItem of logItems) {
|
||||
const dbLog: Log = new Log();
|
||||
|
||||
dbLog.projectId = (req as TelemetryRequest).projectId;
|
||||
dbLog.serviceId = telemetryService.serviceId;
|
||||
dbLog.severityNumber = 0;
|
||||
const currentTimeAndDate: Date = OneUptimeDate.getCurrentDate();
|
||||
dbLog.timeUnixNano = OneUptimeDate.toUnixNano(currentTimeAndDate);
|
||||
dbLog.time = currentTimeAndDate;
|
||||
|
||||
dbLog.severityText = LogSeverity.Unspecified;
|
||||
|
||||
if (typeof logItem === "string") {
|
||||
// check if its parseable to json
|
||||
try {
|
||||
logItem = JSON.parse(logItem);
|
||||
} catch {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof logItem !== "string") {
|
||||
logItem = JSON.stringify(logItem);
|
||||
}
|
||||
|
||||
dbLog.body = logItem as string;
|
||||
|
||||
dbLogs.push(dbLog);
|
||||
}
|
||||
|
||||
await LogService.createMany({
|
||||
items: dbLogs,
|
||||
props: {
|
||||
isRoot: true,
|
||||
return Response.sendJsonObjectResponse(req, res, {
|
||||
failedJobs,
|
||||
pagination: {
|
||||
start,
|
||||
end,
|
||||
count: failedJobs.length,
|
||||
},
|
||||
});
|
||||
|
||||
OTelIngestService.recordDataIngestedUsgaeBilling({
|
||||
services: {
|
||||
[oneuptimeServiceName as string]: {
|
||||
dataIngestedInGB: JSONFunctions.getSizeOfJSONinGB(req.body),
|
||||
dataRententionInDays: telemetryService.dataRententionInDays,
|
||||
serviceId: telemetryService.serviceId,
|
||||
serviceName: oneuptimeServiceName as string,
|
||||
},
|
||||
},
|
||||
projectId: (req as TelemetryRequest).projectId,
|
||||
productType: ProductType.Logs,
|
||||
}).catch((err: Error) => {
|
||||
logger.error(err);
|
||||
});
|
||||
|
||||
return Response.sendEmptySuccessResponse(req, res);
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user