Merge branch 'master' of github.com:OneUptime/oneuptime

This commit is contained in:
Simon Larsen
2025-11-21 19:15:53 +00:00
23 changed files with 963 additions and 34 deletions

View File

@@ -19,6 +19,7 @@ import StatusPagePrivateUserSessionService, {
SessionMetadata as StatusPageSessionMetadata,
} from "Common/Server/Services/StatusPagePrivateUserSessionService";
import CookieUtil from "Common/Server/Utils/Cookie";
import JSONWebToken from "Common/Server/Utils/JsonWebToken";
import Express, {
ExpressRequest,
ExpressResponse,
@@ -33,11 +34,57 @@ import Response from "Common/Server/Utils/Response";
import StatusPage from "Common/Models/DatabaseModels/StatusPage";
import StatusPagePrivateUser from "Common/Models/DatabaseModels/StatusPagePrivateUser";
import StatusPagePrivateUserSession from "Common/Models/DatabaseModels/StatusPagePrivateUserSession";
import { MASTER_PASSWORD_COOKIE_IDENTIFIER } from "Common/Types/StatusPage/MasterPassword";
const router: ExpressRouter = Express.getRouter();
const ACCESS_TOKEN_EXPIRY_SECONDS: number = 15 * 60;
type MasterPasswordAuthInput = {
req: ExpressRequest;
res?: ExpressResponse;
statusPageId: ObjectID;
};
const hasValidMasterPasswordSession: (
data: MasterPasswordAuthInput,
) => boolean = (data: MasterPasswordAuthInput): boolean => {
const token: string | undefined = CookieUtil.getCookieFromExpressRequest(
data.req,
CookieUtil.getStatusPageMasterPasswordKey(data.statusPageId),
);
if (!token) {
return false;
}
try {
const payload: JSONObject = JSONWebToken.decodeJsonPayload(token);
return (
payload["statusPageId"] === data.statusPageId.toString() &&
payload["type"] === MASTER_PASSWORD_COOKIE_IDENTIFIER
);
} catch (err) {
logger.error(err);
}
return false;
};
const respondWithMasterPasswordAccess: (
data: MasterPasswordAuthInput & { res: ExpressResponse },
) => boolean = (
data: MasterPasswordAuthInput & { res: ExpressResponse },
): boolean => {
if (!hasValidMasterPasswordSession(data)) {
return false;
}
Response.sendEmptySuccessResponse(data.req, data.res);
return true;
};
type FinalizeStatusPageLoginInput = {
req: ExpressRequest;
res: ExpressResponse;
@@ -123,6 +170,7 @@ router.post(
CookieUtil.removeCookie(res, CookieUtil.getUserTokenKey(statusPageId));
CookieUtil.removeCookie(res, CookieUtil.getRefreshTokenKey(statusPageId));
CookieUtil.removeStatusPageMasterPasswordCookie(res, statusPageId);
return Response.sendEmptySuccessResponse(req, res);
} catch (err) {
@@ -157,6 +205,16 @@ router.post(
CookieUtil.getRefreshTokenKey(statusPageId),
);
if (
respondWithMasterPasswordAccess({
req,
res,
statusPageId,
})
) {
return;
}
return Response.sendErrorResponse(
req,
res,
@@ -178,6 +236,16 @@ router.post(
CookieUtil.getRefreshTokenKey(statusPageId),
);
if (
respondWithMasterPasswordAccess({
req,
res,
statusPageId,
})
) {
return;
}
return Response.sendErrorResponse(
req,
res,
@@ -199,6 +267,16 @@ router.post(
CookieUtil.getRefreshTokenKey(statusPageId),
);
if (
respondWithMasterPasswordAccess({
req,
res,
statusPageId,
})
) {
return;
}
return Response.sendErrorResponse(
req,
res,
@@ -223,6 +301,16 @@ router.post(
CookieUtil.getRefreshTokenKey(statusPageId),
);
if (
respondWithMasterPasswordAccess({
req,
res,
statusPageId,
})
) {
return;
}
return Response.sendErrorResponse(
req,
res,
@@ -244,6 +332,16 @@ router.post(
CookieUtil.getRefreshTokenKey(statusPageId),
);
if (
respondWithMasterPasswordAccess({
req,
res,
statusPageId,
})
) {
return;
}
return Response.sendErrorResponse(
req,
res,
@@ -279,6 +377,16 @@ router.post(
CookieUtil.getRefreshTokenKey(statusPageId),
);
if (
respondWithMasterPasswordAccess({
req,
res,
statusPageId,
})
) {
return;
}
return Response.sendErrorResponse(
req,
res,

View File

@@ -30,6 +30,7 @@ import { JSONObject } from "../../Types/JSON";
import ObjectID from "../../Types/ObjectID";
import Permission from "../../Types/Permission";
import Timezone from "../../Types/Timezone";
import HashedString from "../../Types/HashedString";
import {
Column,
Entity,
@@ -883,6 +884,78 @@ export default class StatusPage extends BaseModel {
})
public isPublicStatusPage?: boolean = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateProjectStatusPage,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadProjectStatusPage,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditProjectStatusPage,
],
})
@TableColumn({
isDefaultValueColumn: true,
type: TableColumnType.Boolean,
title: "Enable Master Password",
description:
"Require visitors to enter a master password before viewing a private status page.",
defaultValue: false,
})
@Column({
type: ColumnType.Boolean,
default: false,
})
public enableMasterPassword?: boolean = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateProjectStatusPage,
],
// This is a hashed column. So, reading the value is does not affect anything.
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadProjectStatusPage,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditProjectStatusPage,
],
})
@TableColumn({
title: "Master Password",
description:
"Password required to unlock a private status page. This value is stored as a secure hash.",
hashed: true,
type: TableColumnType.HashedString,
placeholder: "Enter a new master password",
})
@Column({
type: ColumnType.HashedString,
length: ColumnLength.HashedString,
nullable: true,
transformer: HashedString.getDatabaseTransformer(),
})
public masterPassword?: HashedString = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,

View File

@@ -49,6 +49,7 @@ import JSONFunctions from "../../Types/JSONFunctions";
import ObjectID from "../../Types/ObjectID";
import Phone from "../../Types/Phone";
import PositiveNumber from "../../Types/PositiveNumber";
import HashedString from "../../Types/HashedString";
import AcmeChallenge from "../../Models/DatabaseModels/AcmeChallenge";
import Incident from "../../Models/DatabaseModels/Incident";
import IncidentPublicNote from "../../Models/DatabaseModels/IncidentPublicNote";
@@ -87,10 +88,13 @@ import EmailTemplateType from "../../Types/Email/EmailTemplateType";
import Hostname from "../../Types/API/Hostname";
import Protocol from "../../Types/API/Protocol";
import DatabaseConfig from "../DatabaseConfig";
import CookieUtil from "../Utils/Cookie";
import { EncryptionSecret } from "../EnvironmentConfig";
import { StatusPageApiRoute } from "../../ServiceRoute";
import ProjectSmtpConfigService from "../Services/ProjectSmtpConfigService";
import ForbiddenException from "../../Types/Exception/ForbiddenException";
import SlackUtil from "../Utils/Workspace/Slack/Slack";
import { MASTER_PASSWORD_INVALID_MESSAGE } from "../../Types/StatusPage/MasterPassword";
type ResolveStatusPageIdOrThrowFunction = (
statusPageIdOrDomain: string,
@@ -798,6 +802,7 @@ export default class StatusPageAPI extends BaseAPI<
enableMicrosoftTeamsSubscribers: true,
enableSmsSubscribers: true,
isPublicStatusPage: true,
enableMasterPassword: true,
allowSubscribersToChooseResources: true,
allowSubscribersToChooseEventTypes: true,
requireSsoForLogin: true,
@@ -910,6 +915,80 @@ export default class StatusPageAPI extends BaseAPI<
},
);
this.router.post(
`${new this.entityType()
.getCrudApiPath()
?.toString()}/master-password/:statusPageId`,
UserMiddleware.getUserMiddleware,
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
if (!req.params["statusPageId"]) {
throw new BadDataException("Status Page ID not found");
}
const statusPageId: ObjectID = new ObjectID(
req.params["statusPageId"] as string,
);
const password: string | undefined =
req.body && (req.body["password"] as string);
if (!password) {
throw new BadDataException("Master password is required.");
}
const statusPage: StatusPage | null =
await StatusPageService.findOneById({
id: statusPageId,
select: {
_id: true,
projectId: true,
enableMasterPassword: true,
masterPassword: true,
isPublicStatusPage: true,
},
props: {
isRoot: true,
},
});
if (!statusPage) {
throw new NotFoundException("Status Page not found");
}
if (statusPage.isPublicStatusPage) {
throw new BadDataException(
"This status page is already visible to everyone.",
);
}
if (!statusPage.enableMasterPassword || !statusPage.masterPassword) {
throw new BadDataException(
"Master password has not been configured for this status page.",
);
}
const hashedInput: string = await HashedString.hashValue(
password,
EncryptionSecret,
);
if (hashedInput !== statusPage.masterPassword.toString()) {
throw new BadDataException(MASTER_PASSWORD_INVALID_MESSAGE);
}
CookieUtil.setStatusPageMasterPasswordCookie({
expressResponse: res,
statusPageId,
});
return Response.sendEmptySuccessResponse(req, res);
} catch (err) {
next(err);
}
},
);
this.router.post(
`${new this.entityType().getCrudApiPath()?.toString()}/sso/:statusPageId`,
UserMiddleware.getUserMiddleware,

View File

@@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class MigrationName1763643080445 implements MigrationInterface {
public name = "MigrationName1763643080445";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "StatusPage" ADD "enableMasterPassword" boolean NOT NULL DEFAULT false`,
);
await queryRunner.query(
`ALTER TABLE "StatusPage" ADD "masterPassword" character varying(64)`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "StatusPage" DROP COLUMN "masterPassword"`,
);
await queryRunner.query(
`ALTER TABLE "StatusPage" DROP COLUMN "enableMasterPassword"`,
);
}
}

View File

@@ -185,6 +185,7 @@ import { MigrationName1762890441920 } from "./1762890441920-MigrationName";
import { MigrationName1763471659817 } from "./1763471659817-MigrationName";
import { MigrationName1763477560906 } from "./1763477560906-MigrationName";
import { MigrationName1763480947474 } from "./1763480947474-MigrationName";
import { MigrationName1763643080445 } from "./1763643080445-MigrationName";
export default [
InitialMigration,
@@ -374,4 +375,5 @@ export default [
MigrationName1763471659817,
MigrationName1763477560906,
MigrationName1763480947474,
MigrationName1763643080445,
];

View File

@@ -47,6 +47,7 @@ import ProjectSMTPConfigService from "./ProjectSmtpConfigService";
import StatusPageResource from "../../Models/DatabaseModels/StatusPageResource";
import StatusPageResourceService from "./StatusPageResourceService";
import Dictionary from "../../Types/Dictionary";
import { JSONObject } from "../../Types/JSON";
import MonitorGroupResource from "../../Models/DatabaseModels/MonitorGroupResource";
import MonitorGroupResourceService from "./MonitorGroupResourceService";
import QueryHelper from "../Types/Database/QueryHelper";
@@ -61,6 +62,11 @@ import IP from "../../Types/IP/IP";
import NotAuthenticatedException from "../../Types/Exception/NotAuthenticatedException";
import ForbiddenException from "../../Types/Exception/ForbiddenException";
import CommonAPI from "../API/CommonAPI";
import MasterPasswordRequiredException from "../../Types/Exception/MasterPasswordRequiredException";
import {
MASTER_PASSWORD_COOKIE_IDENTIFIER,
MASTER_PASSWORD_REQUIRED_MESSAGE,
} from "../../Types/StatusPage/MasterPassword";
export interface StatusPageReportItem {
resourceName: string;
@@ -389,6 +395,8 @@ export class Service extends DatabaseService<StatusPage> {
_id: true,
isPublicStatusPage: true,
ipWhitelist: true,
enableMasterPassword: true,
masterPassword: true,
},
});
@@ -462,6 +470,34 @@ export class Service extends DatabaseService<StatusPage> {
}
}
const shouldEnforceMasterPassword: boolean = Boolean(
statusPage &&
statusPage.enableMasterPassword &&
statusPage.masterPassword &&
!statusPage.isPublicStatusPage,
);
if (shouldEnforceMasterPassword) {
const hasValidMasterPassword: boolean =
this.hasValidMasterPasswordCookie({
req,
statusPageId,
});
if (hasValidMasterPassword) {
return {
hasReadAccess: true,
};
}
return {
hasReadAccess: false,
error: new MasterPasswordRequiredException(
MASTER_PASSWORD_REQUIRED_MESSAGE,
),
};
}
// if it does not have public access, check if this user has access.
const items: Array<StatusPage> = await this.findBy({
@@ -493,6 +529,33 @@ export class Service extends DatabaseService<StatusPage> {
};
}
private hasValidMasterPasswordCookie(data: {
req: ExpressRequest;
statusPageId: ObjectID;
}): boolean {
const token: string | undefined = CookieUtil.getCookieFromExpressRequest(
data.req,
CookieUtil.getStatusPageMasterPasswordKey(data.statusPageId),
);
if (!token) {
return false;
}
try {
const payload: JSONObject = JSONWebToken.decodeJsonPayload(token);
return (
payload["statusPageId"] === data.statusPageId.toString() &&
payload["type"] === MASTER_PASSWORD_COOKIE_IDENTIFIER
);
} catch (err) {
logger.error(err);
}
return false;
}
@CaptureSpan()
public async getMonitorStatusTimelineForStatusPage(data: {
monitorIds: Array<ObjectID>;

View File

@@ -8,6 +8,10 @@ import StatusPagePrivateUser from "../../Models/DatabaseModels/StatusPagePrivate
import OneUptimeDate from "../../Types/Date";
import PositiveNumber from "../../Types/PositiveNumber";
import CookieName from "../../Types/CookieName";
import {
MASTER_PASSWORD_COOKIE_IDENTIFIER,
MASTER_PASSWORD_COOKIE_MAX_AGE_IN_DAYS,
} from "../../Types/StatusPage/MasterPassword";
import CaptureSpan from "./Telemetry/CaptureSpan";
export default class CookieUtil {
@@ -233,6 +237,34 @@ export default class CookieUtil {
return token;
}
@CaptureSpan()
public static setStatusPageMasterPasswordCookie(data: {
expressResponse: ExpressResponse;
statusPageId: ObjectID;
}): void {
const expiresInDays: PositiveNumber = new PositiveNumber(
MASTER_PASSWORD_COOKIE_MAX_AGE_IN_DAYS,
);
const token: string = JSONWebToken.signJsonPayload(
{
statusPageId: data.statusPageId.toString(),
type: MASTER_PASSWORD_COOKIE_IDENTIFIER,
},
OneUptimeDate.getSecondsInDays(expiresInDays),
);
CookieUtil.setCookie(
data.expressResponse,
CookieUtil.getStatusPageMasterPasswordKey(data.statusPageId),
token,
{
maxAge: OneUptimeDate.getMillisecondsInDays(expiresInDays),
httpOnly: true,
},
);
}
@CaptureSpan()
public static setCookie(
res: ExpressResponse,
@@ -280,6 +312,17 @@ export default class CookieUtil {
});
}
@CaptureSpan()
public static removeStatusPageMasterPasswordCookie(
res: ExpressResponse,
statusPageId: ObjectID,
): void {
CookieUtil.removeCookie(
res,
CookieUtil.getStatusPageMasterPasswordKey(statusPageId),
);
}
// get all cookies with express request
@CaptureSpan()
public static getAllCookies(req: ExpressRequest): Dictionary<string> {
@@ -304,6 +347,11 @@ export default class CookieUtil {
return `${CookieName.RefreshToken}-${id.toString()}`;
}
@CaptureSpan()
public static getStatusPageMasterPasswordKey(id: ObjectID): string {
return `${CookieName.StatusPageMasterPassword}-${id.toString()}`;
}
@CaptureSpan()
public static getUserSSOKey(id: ObjectID): string {
return `${this.getSSOKey()}${id.toString()}`;

View File

@@ -7,6 +7,7 @@ enum CookieName {
Timezone = "user-timezone",
IsMasterAdmin = "user-is-master-admin",
ProfilePicID = "user-profile-pic-id",
StatusPageMasterPassword = "status-page-master-password",
}
export default CookieName;

View File

@@ -0,0 +1,7 @@
import NotAuthenticatedException from "./NotAuthenticatedException";
export default class MasterPasswordRequiredException extends NotAuthenticatedException {
public constructor(message: string) {
super(message);
}
}

View File

@@ -0,0 +1,10 @@
export const MASTER_PASSWORD_REQUIRED_MESSAGE: string =
"Master password required";
export const MASTER_PASSWORD_INVALID_MESSAGE: string =
"Invalid master password. Please try again.";
export const MASTER_PASSWORD_COOKIE_IDENTIFIER: string =
"status-page-master-password";
export const MASTER_PASSWORD_COOKIE_MAX_AGE_IN_DAYS: number = 7;

View File

@@ -50,6 +50,70 @@ const StatusPageDelete: FunctionComponent<
}}
/>
<CardModelDetail<StatusPage>
name="Status Page > Master Password"
cardProps={{
title: "Master Password",
description:
"Rotate the password required to unlock a private status page. This value is stored as a secure hash and cannot be retrieved. When master password is enabled, SSO/SCIM and Email + Password authentication are disabled.",
}}
editButtonText="Update Master Password"
isEditable={true}
formFields={[
{
field: {
enableMasterPassword: true,
},
title: "Require Master Password",
fieldType: FormFieldSchemaType.Toggle,
required: false,
description:
"When enabled, visitors must enter the master password before viewing a private status page.",
},
{
field: {
masterPassword: true,
},
title: "Master Password",
fieldType: FormFieldSchemaType.Password,
required: false,
placeholder: "Enter a new master password",
description:
"Updating this value immediately replaces the existing master password.",
},
]}
modelDetailProps={{
showDetailsInNumberOfColumns: 1,
modelType: StatusPage,
id: "model-detail-status-page-master-password",
fields: [
{
field: {
enableMasterPassword: true,
},
fieldType: FieldType.Boolean,
title: "Require Master Password",
placeholder: "No",
},
{
title: "Master Password",
fieldType: FieldType.Element,
placeholder: "Hidden",
getElement: (): ReactElement => {
return (
<p className="text-sm text-gray-500">
For security reasons, the current master password is never
displayed. Use the update button to set a new password at
any time.
</p>
);
},
},
],
modelId: modelId,
}}
/>
<CardModelDetail<StatusPage>
name="Status Page > IP Whitelist"
cardProps={{

View File

@@ -6,17 +6,64 @@ import ModelTable from "Common/UI/Components/ModelTable/ModelTable";
import Pill from "Common/UI/Components/Pill/Pill";
import FieldType from "Common/UI/Components/Types/FieldType";
import Navigation from "Common/UI/Utils/Navigation";
import StatusPage from "Common/Models/DatabaseModels/StatusPage";
import StatusPagePrivateUser from "Common/Models/DatabaseModels/StatusPagePrivateUser";
import React, { Fragment, FunctionComponent, ReactElement } from "react";
import Alert, { AlertType } from "Common/UI/Components/Alerts/Alert";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import React, {
Fragment,
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import ProjectUtil from "Common/UI/Utils/Project";
const StatusPageDelete: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
const [isMasterPasswordEnabled, setIsMasterPasswordEnabled] =
useState<boolean>(false);
const [fetchError, setFetchError] = useState<string | null>(null);
useEffect(() => {
const fetchStatusPage: () => Promise<void> = async (): Promise<void> => {
try {
const statusPage: StatusPage | null = await ModelAPI.getItem({
modelType: StatusPage,
id: modelId,
select: {
enableMasterPassword: true,
},
});
setIsMasterPasswordEnabled(Boolean(statusPage?.enableMasterPassword));
setFetchError(null);
} catch (error) {
const newErrorMessage: string =
error instanceof Error && error.message
? error.message
: "Failed to fetch status page details.";
setFetchError(newErrorMessage);
}
};
void fetchStatusPage();
}, [modelId]);
return (
<Fragment>
{fetchError && (
<Alert className="mb-5" type={AlertType.DANGER} title={fetchError} />
)}
{isMasterPasswordEnabled && (
<Alert
className="mb-5"
type={AlertType.INFO}
title="Master password is enabled for this status page. Private users authentication is disabled while the master password is active."
/>
)}
<ModelTable<StatusPagePrivateUser>
modelType={StatusPagePrivateUser}
id="status-page-group"

View File

@@ -21,6 +21,7 @@ import { SubscribePageProps } from "./Pages/Subscribe/SubscribePageUtils";
import { ComponentProps as ForgotPasswordComponentProps } from "./Pages/Accounts/ForgotPassword";
import { ComponentProps as LoginComponentProps } from "./Pages/Accounts/Login";
import { ComponentProps as ResetPasswordComponentProps } from "./Pages/Accounts/ResetPassword";
import { ComponentProps as MasterPasswordComponentProps } from "./Pages/Accounts/MasterPassword";
import { ComponentProps as SsoComponentProps } from "./Pages/Accounts/SSO";
import PageComponentProps from "./Pages/PageComponentProps";
@@ -43,6 +44,11 @@ const ResetPassword: React.LazyExoticComponent<
> = lazy(() => {
return import("./Pages/Accounts/ResetPassword");
});
const MasterPassword: React.LazyExoticComponent<
React.FunctionComponent<MasterPasswordComponentProps>
> = lazy(() => {
return import("./Pages/Accounts/MasterPassword");
});
const Sso: React.LazyExoticComponent<
React.FunctionComponent<SsoComponentProps>
> = lazy(() => {
@@ -214,6 +220,13 @@ const App: () => JSX.Element = () => {
"statusPage.isPublicStatusPage",
) as boolean;
const enableMasterPassword: boolean = Boolean(
JSONFunctions.getJSONValueInPath(
masterpage || {},
"statusPage.enableMasterPassword",
) as boolean,
);
const enableEmailSubscribers: boolean =
JSONFunctions.getJSONValueInPath(
masterpage || {},
@@ -261,6 +274,7 @@ const App: () => JSX.Element = () => {
StatusPageUtil.setIsPrivateStatusPage(isPrivateStatusPage);
setIsPrivateStatusPage(isPrivateStatusPage);
StatusPageUtil.setRequiresMasterPassword(enableMasterPassword);
const statusPageId: string | null = JSONFunctions.getJSONValueInPath(
masterpage || {},
@@ -334,6 +348,16 @@ const App: () => JSX.Element = () => {
}
/>
<PageRoute
path={RouteMap[PageMap.MASTER_PASSWORD]?.toString() || ""}
element={
<MasterPassword
statusPageName={statusPageName}
logoFileId={new ObjectID(statusPageLogoFileId)}
/>
}
/>
<PageRoute
path={RouteMap[PageMap.SSO]?.toString() || ""}
element={
@@ -831,6 +855,16 @@ const App: () => JSX.Element = () => {
}
/>
<PageRoute
path={RouteMap[PageMap.PREVIEW_MASTER_PASSWORD]?.toString() || ""}
element={
<MasterPassword
statusPageName={statusPageName}
logoFileId={new ObjectID(statusPageLogoFileId)}
/>
}
/>
<PageRoute
path={RouteMap[PageMap.PREVIEW_RESET_PASSWORD]?.toString() || ""}
element={

View File

@@ -244,7 +244,8 @@ const DashboardMasterPage: FunctionComponent<ComponentProps> = (
Navigation.getCurrentRoute().toString().includes("forgot-password") ||
Navigation.getCurrentRoute().toString().includes("reset-password") ||
Navigation.getCurrentRoute().toString().includes("sso") ||
Navigation.getCurrentRoute().toString().includes("forbidden")
Navigation.getCurrentRoute().toString().includes("forbidden") ||
Navigation.getCurrentRoute().toString().includes("master-password")
) {
return <>{props.children}</>;
}

View File

@@ -27,8 +27,20 @@ export interface ComponentProps {
const LoginPage: FunctionComponent<ComponentProps> = (
props: ComponentProps,
) => {
const statusPageId: ObjectID | null = StatusPageUtil.getStatusPageId();
const statusPageIdString: string | undefined = statusPageId?.toString();
const requiresMasterPasswordLock: boolean =
StatusPageUtil.isPrivateStatusPage() &&
StatusPageUtil.requiresMasterPassword() &&
!StatusPageUtil.isMasterPasswordValidated();
useEffect(() => {
if (props.forceSSO && StatusPageUtil.getStatusPageId()) {
if (requiresMasterPasswordLock || !statusPageId) {
StatusPageUtil.navigateToMasterPasswordPage();
return;
}
if (props.forceSSO) {
if (Navigation.getQueryStringByName("redirectUrl")) {
// forward redirect url to sso page
@@ -36,11 +48,11 @@ const LoginPage: FunctionComponent<ComponentProps> = (
(!StatusPageUtil.isPreviewPage()
? RouteUtil.populateRouteParams(
RouteMap[PageMap.SSO]!,
StatusPageUtil.getStatusPageId()!,
statusPageId,
)
: RouteUtil.populateRouteParams(
RouteMap[PageMap.PREVIEW_SSO]!,
StatusPageUtil.getStatusPageId()!,
statusPageId,
)
).toString() +
`?redirectUrl=${Navigation.getQueryStringByName("redirectUrl")}`,
@@ -49,31 +61,33 @@ const LoginPage: FunctionComponent<ComponentProps> = (
Navigation.navigate(navRoute);
} else {
const navRoute: Route = !StatusPageUtil.isPreviewPage()
? RouteUtil.populateRouteParams(
RouteMap[PageMap.SSO]!,
StatusPageUtil.getStatusPageId()!,
)
? RouteUtil.populateRouteParams(RouteMap[PageMap.SSO]!, statusPageId)
: RouteUtil.populateRouteParams(
RouteMap[PageMap.PREVIEW_SSO]!,
StatusPageUtil.getStatusPageId()!,
statusPageId,
);
Navigation.navigate(navRoute);
}
}
}, [props.forceSSO, StatusPageUtil.getStatusPageId()]);
}, [props.forceSSO, statusPageIdString, requiresMasterPasswordLock]);
const apiUrl: URL = LOGIN_API_URL;
const statusPageId: string | undefined =
const statusPageIdForLogo: string | undefined =
StatusPageUtil.getStatusPageId()?.toString();
const logoUrl: string | null =
props.logoFileId && props.logoFileId.toString() && statusPageId
props.logoFileId && props.logoFileId.toString() && statusPageIdForLogo
? URL.fromString(STATUS_PAGE_API_URL.toString())
.addRoute(`/logo/${statusPageId}`)
.addRoute(`/logo/${statusPageIdForLogo}`)
.toString()
: null;
if (!StatusPageUtil.getStatusPageId()) {
if (!statusPageId) {
return <></>;
}
if (requiresMasterPasswordLock) {
StatusPageUtil.navigateToMasterPasswordPage();
return <></>;
}
@@ -87,10 +101,7 @@ const LoginPage: FunctionComponent<ComponentProps> = (
Navigation.navigate(navRoute);
}
if (
StatusPageUtil.getStatusPageId() &&
UserUtil.isLoggedIn(StatusPageUtil.getStatusPageId()!)
) {
if (statusPageId && UserUtil.isLoggedIn(statusPageId)) {
if (Navigation.getQueryStringByName("redirectUrl")) {
Navigation.navigate(
new Route(Navigation.getQueryStringByName("redirectUrl")!),

View File

@@ -0,0 +1,186 @@
import { STATUS_PAGE_API_URL } from "../../Utils/Config";
import StatusPageUtil from "../../Utils/StatusPage";
import UserUtil from "../../Utils/User";
import Route from "Common/Types/API/Route";
import URL from "Common/Types/API/URL";
import BadDataException from "Common/Types/Exception/BadDataException";
import ObjectID from "Common/Types/ObjectID";
import BasicForm from "Common/UI/Components/Forms/BasicForm";
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
import { JSONObject } from "Common/Types/JSON";
import API from "../../Utils/API";
import Navigation from "Common/UI/Utils/Navigation";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
import React, { FunctionComponent, useEffect, useState } from "react";
import HTTPResponse from "Common/Types/API/HTTPResponse";
import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse";
export interface ComponentProps {
statusPageName: string;
logoFileId: ObjectID;
}
const MasterPasswordPage: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): JSX.Element => {
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [formError, setFormError] = useState<string | null>(null);
const statusPageId: ObjectID | null = StatusPageUtil.getStatusPageId();
const redirectToOverview: () => void = (): void => {
const path: string = StatusPageUtil.isPreviewPage()
? `/status-page/${StatusPageUtil.getStatusPageId()?.toString()}`
: "/";
Navigation.navigate(new Route(path), { forceNavigate: true });
};
useEffect(() => {
if (!StatusPageUtil.isPrivateStatusPage()) {
Navigation.navigate(new Route("/"), { forceNavigate: true });
return;
}
if (!StatusPageUtil.requiresMasterPassword()) {
redirectToOverview();
return;
}
if (statusPageId && UserUtil.isLoggedIn(statusPageId)) {
redirectToOverview();
return;
}
if (StatusPageUtil.isMasterPasswordValidated()) {
redirectToOverview();
return;
}
}, [statusPageId]);
if (!statusPageId || !StatusPageUtil.requiresMasterPassword()) {
return <PageLoader isVisible={true} />;
}
const logoUrl: string | null =
props.logoFileId && props.logoFileId.toString()
? URL.fromString(STATUS_PAGE_API_URL.toString())
.addRoute(`/logo/${statusPageId.toString()}`)
.toString()
: null;
const privatePageCopy: string = "Please enter the password to continue.";
const handleFormSubmit: (
values: JSONObject,
onSubmitSuccessful?: () => void,
) => Promise<void> = async (
values: JSONObject,
onSubmitSuccessful?: () => void,
): Promise<void> => {
const submittedPassword: string =
(values["password"] as { toString: () => string } | undefined)
?.toString()
.trim() || "";
if (!submittedPassword) {
setFormError("password is required.");
return;
}
if (!statusPageId) {
throw new BadDataException("Status Page ID not found");
}
const url: URL = URL.fromString(STATUS_PAGE_API_URL.toString()).addRoute(
`/master-password/${statusPageId.toString()}`,
);
setIsSubmitting(true);
setFormError(null);
try {
const response: HTTPResponse<JSONObject> | HTTPErrorResponse =
await API.post<JSONObject>({
url,
data: {
password: submittedPassword,
},
});
if (response.isFailure()) {
throw response;
}
StatusPageUtil.setMasterPasswordValidated(true);
const redirectUrl: string | null =
Navigation.getQueryStringByName("redirectUrl");
if (redirectUrl) {
Navigation.navigate(new Route(redirectUrl), { forceNavigate: true });
} else {
redirectToOverview();
}
onSubmitSuccessful?.();
} catch (err) {
setFormError(API.getFriendlyMessage(err));
} finally {
setIsSubmitting(false);
}
};
return (
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
{logoUrl ? (
<img style={{ height: "70px", margin: "auto" }} src={logoUrl} />
) : null}
<h2 className="mt-6 text-center text-2xl tracking-tight text-gray-900">
{props.statusPageName
? `Enter ${props.statusPageName} Password`
: "Enter Password"}
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
{privatePageCopy}
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
<BasicForm
id="master-password-form"
name="Password Unlock"
initialValues={{
password: "",
}}
fields={[
{
field: {
password: true,
},
title: "Password",
description: "Enter the password to unlock this page.",
required: true,
placeholder: "Enter password",
fieldType: FormFieldSchemaType.Password,
disableSpellCheck: true,
},
]}
submitButtonText="Unlock Status Page"
maxPrimaryButtonWidth={true}
isLoading={isSubmitting}
error={formError || undefined}
onSubmit={(values: JSONObject, onSubmitSuccessful?: () => void) => {
void handleFormSubmit(values, onSubmitSuccessful);
}}
footer={<></>}
/>
</div>
</div>
</div>
);
};
export default MasterPasswordPage;

View File

@@ -23,8 +23,12 @@ const LoginPage: FunctionComponent<ComponentProps> = (
props: ComponentProps,
) => {
const [isLoading, setIsLoading] = useState<boolean>(false);
const statusPageId: string | undefined =
StatusPageUtil.getStatusPageId()?.toString();
const statusPageObjectId: ObjectID | null = StatusPageUtil.getStatusPageId();
const statusPageId: string | undefined = statusPageObjectId?.toString();
const requiresMasterPasswordLock: boolean =
StatusPageUtil.isPrivateStatusPage() &&
StatusPageUtil.requiresMasterPassword() &&
!StatusPageUtil.isMasterPasswordValidated();
const logoUrl: string | null =
props.logoFileId && props.logoFileId.toString() && statusPageId
? URL.fromString(STATUS_PAGE_API_URL.toString())
@@ -32,7 +36,12 @@ const LoginPage: FunctionComponent<ComponentProps> = (
.toString()
: null;
if (!StatusPageUtil.getStatusPageId()) {
if (!statusPageObjectId) {
return <></>;
}
if (requiresMasterPasswordLock) {
StatusPageUtil.navigateToMasterPasswordPage();
return <></>;
}
@@ -46,10 +55,7 @@ const LoginPage: FunctionComponent<ComponentProps> = (
);
}
if (
StatusPageUtil.getStatusPageId() &&
UserUtil.isLoggedIn(StatusPageUtil.getStatusPageId()!)
) {
if (statusPageObjectId && UserUtil.isLoggedIn(statusPageObjectId)) {
if (Navigation.getQueryStringByName("redirectUrl")) {
Navigation.navigate(
new Route(Navigation.getQueryStringByName("redirectUrl")!),

View File

@@ -28,11 +28,19 @@ export default class API extends BaseAPI {
}
public static override getLoginRoute(): Route {
return new Route(
StatusPageUtil.isPreviewPage()
? `/status-page/${StatusPageUtil.getStatusPageId()?.toString()}/login`
: "/login",
);
const basePath: string = StatusPageUtil.isPreviewPage()
? `/status-page/${StatusPageUtil.getStatusPageId()?.toString()}`
: "";
if (
StatusPageUtil.isPrivateStatusPage() &&
StatusPageUtil.requiresMasterPassword() &&
!StatusPageUtil.isMasterPasswordValidated()
) {
return new Route(`${basePath}/master-password`);
}
return new Route(`${basePath}/login`);
}
public static override logoutUser(): void {

View File

@@ -51,6 +51,9 @@ enum PageMap {
PREVIEW_LOGOUT = "PREVIEW_LOGOUT",
PREVIEW_FORBIDDEN = "PREVIEW_FORBIDDEN",
MASTER_PASSWORD = "MASTER_PASSWORD",
PREVIEW_MASTER_PASSWORD = "PREVIEW_MASTER_PASSWORD",
}
export default PageMap;

View File

@@ -28,6 +28,7 @@ const RouteMap: Dictionary<Route> = {
[PageMap.LOGOUT]: new Route(`/logout`),
[PageMap.FORGOT_PASSWORD]: new Route(`/forgot-password`),
[PageMap.RESET_PASSWORD]: new Route(`/reset-password/:token`),
[PageMap.MASTER_PASSWORD]: new Route(`/master-password`),
// forbidden page
[PageMap.FORBIDDEN]: new Route(`/forbidden`),
@@ -91,6 +92,9 @@ const RouteMap: Dictionary<Route> = {
[PageMap.PREVIEW_RESET_PASSWORD]: new Route(
`/status-page/${RouteParams.StatusPageId}/reset-password/:token`,
),
[PageMap.PREVIEW_MASTER_PASSWORD]: new Route(
`/status-page/${RouteParams.StatusPageId}/master-password`,
),
[PageMap.PREVIEW_SSO]: new Route(
`/status-page/${RouteParams.StatusPageId}/sso`,

View File

@@ -24,11 +24,104 @@ export default class StatusPageUtil {
}
public static setIsPrivateStatusPage(isPrivate: boolean): void {
LocalStorage.setItem("isPrivateStatusPage", isPrivate);
const storageKey: string =
StatusPageUtil.getIsPrivateStatusPageStorageKey();
LocalStorage.setItem(storageKey, isPrivate);
}
public static isPrivateStatusPage(): boolean {
return Boolean(LocalStorage.getItem("isPrivateStatusPage"));
const storageKey: string =
StatusPageUtil.getIsPrivateStatusPageStorageKey();
return Boolean(LocalStorage.getItem(storageKey));
}
public static setRequiresMasterPassword(value: boolean): void {
const storageKey: string =
StatusPageUtil.getRequiresMasterPasswordStorageKey();
LocalStorage.setItem(storageKey, value);
if (!value) {
StatusPageUtil.setMasterPasswordValidated(false);
}
}
public static requiresMasterPassword(): boolean {
const storageKey: string =
StatusPageUtil.getRequiresMasterPasswordStorageKey();
return Boolean(LocalStorage.getItem(storageKey));
}
private static getStatusPageScopedStorageKey(baseKey: string): string {
const statusPageId: ObjectID | null = StatusPageUtil.getStatusPageId();
if (!statusPageId) {
return baseKey;
}
return `${baseKey}-${statusPageId.toString()}`;
}
private static getIsPrivateStatusPageStorageKey(): string {
return StatusPageUtil.getStatusPageScopedStorageKey("isPrivateStatusPage");
}
private static getRequiresMasterPasswordStorageKey(): string {
return StatusPageUtil.getStatusPageScopedStorageKey(
"requiresMasterPassword",
);
}
private static getMasterPasswordValidationStorageKey(): string {
const statusPageId: ObjectID | null = StatusPageUtil.getStatusPageId();
if (!statusPageId) {
return "masterPasswordValidated";
}
return `masterPasswordValidated-${statusPageId.toString()}`;
}
public static setMasterPasswordValidated(value: boolean): void {
const storageKey: string =
StatusPageUtil.getMasterPasswordValidationStorageKey();
LocalStorage.setItem(storageKey, value);
if (storageKey !== "masterPasswordValidated") {
LocalStorage.removeItem("masterPasswordValidated");
}
}
public static isMasterPasswordValidated(): boolean {
const storageKey: string =
StatusPageUtil.getMasterPasswordValidationStorageKey();
const currentValue: boolean = Boolean(LocalStorage.getItem(storageKey));
if (currentValue) {
return true;
}
if (storageKey === "masterPasswordValidated") {
return false;
}
const legacyValue: boolean = Boolean(
LocalStorage.getItem("masterPasswordValidated"),
);
LocalStorage.removeItem("masterPasswordValidated");
if (legacyValue) {
LocalStorage.setItem(storageKey, legacyValue);
return true;
}
return false;
}
public static isPreviewPage(): boolean {
@@ -36,6 +129,15 @@ export default class StatusPageUtil {
}
public static navigateToLoginPage(): void {
if (
StatusPageUtil.isPrivateStatusPage() &&
StatusPageUtil.requiresMasterPassword() &&
!StatusPageUtil.isMasterPasswordValidated()
) {
StatusPageUtil.navigateToMasterPasswordPage();
return;
}
const route: Route = new Route(
StatusPageUtil.isPreviewPage()
? `/status-page/${StatusPageUtil.getStatusPageId()?.toString()}/login?redirectUrl=${Navigation.getCurrentPath()}`
@@ -45,9 +147,37 @@ export default class StatusPageUtil {
Navigation.navigate(route, { forceNavigate: true });
}
public static navigateToMasterPasswordPage(): void {
if (Navigation.getCurrentRoute().toString().includes("master-password")) {
return;
}
const basePath: string = StatusPageUtil.isPreviewPage()
? `/status-page/${StatusPageUtil.getStatusPageId()?.toString()}`
: "";
const route: Route = new Route(
`${basePath}/master-password?redirectUrl=${Navigation.getCurrentPath()}`,
);
Navigation.navigate(route, { forceNavigate: true });
}
public static checkIfUserHasLoggedIn(): void {
const statusPageId: ObjectID | null = StatusPageUtil.getStatusPageId();
if (
statusPageId &&
StatusPageUtil.isPrivateStatusPage() &&
StatusPageUtil.requiresMasterPassword() &&
!UserUtil.isLoggedIn(statusPageId)
) {
if (!StatusPageUtil.isMasterPasswordValidated()) {
StatusPageUtil.navigateToMasterPasswordPage();
}
return;
}
if (
statusPageId &&
StatusPageUtil.isPrivateStatusPage() &&
@@ -64,6 +194,15 @@ export default class StatusPageUtil {
errorResponse instanceof HTTPErrorResponse &&
errorResponse.statusCode === 401
) {
if (
StatusPageUtil.isPrivateStatusPage() &&
StatusPageUtil.requiresMasterPassword() &&
!StatusPageUtil.isMasterPasswordValidated()
) {
StatusPageUtil.navigateToMasterPasswordPage();
return;
}
await UserUtil.logout(StatusPageUtil.getStatusPageId()!);
StatusPageUtil.navigateToLoginPage();
}

View File

@@ -7,6 +7,15 @@ import { IDENTITY_URL } from "Common/UI/Config";
import LocalStorage from "Common/UI/Utils/LocalStorage";
export default class User {
private static getMasterPasswordStorageKeys(
statusPageId: ObjectID,
): Array<string> {
return [
`masterPasswordValidated-${statusPageId.toString()}`,
"masterPasswordValidated",
];
}
public static setUserId(statusPageId: ObjectID, userId: ObjectID): void {
LocalStorage.setItem(
statusPageId.toString() + "user_id",
@@ -82,5 +91,8 @@ export default class User {
.addRoute("/" + statusPageId.toString()),
});
this.removeUser(statusPageId);
for (const storageKey of this.getMasterPasswordStorageKeys(statusPageId)) {
LocalStorage.removeItem(storageKey);
}
}
}

View File

@@ -1,5 +1,5 @@
{
"name": "@oneuptime/probe-ingest",
"name": "@oneuptime/telemetry",
"version": "1.0.0",
"description": "",
"main": "index.js",