feat: Add DashboardDomain model and associated API services

- Introduced DashboardDomain model with comprehensive fields for managing custom domains for dashboards.
- Implemented DashboardDomainAPI for handling CNAME verification and SSL provisioning.
- Created DashboardDomainService to manage domain-related operations, including SSL ordering and CNAME validation.
- Added master password handling in DashboardAPI for enhanced security.
- Defined constants for master password messages and cookie management.
This commit is contained in:
Nawaz Dhandala
2026-03-26 11:32:06 +00:00
parent 46c150f6df
commit a62ba231be
19 changed files with 2813 additions and 0 deletions

View File

@@ -29,6 +29,8 @@ import ShortLinkAPI from "Common/Server/API/ShortLinkAPI";
import StatusPageAPI from "Common/Server/API/StatusPageAPI";
import WorkspaceNotificationRuleAPI from "Common/Server/API/WorkspaceNotificationRuleAPI";
import WorkspaceNotificationSummaryAPI from "Common/Server/API/WorkspaceNotificationSummaryAPI";
import DashboardAPI from "Common/Server/API/DashboardAPI";
import DashboardDomainAPI from "Common/Server/API/DashboardDomainAPI";
import StatusPageDomainAPI from "Common/Server/API/StatusPageDomainAPI";
import StatusPageSubscriberAPI from "Common/Server/API/StatusPageSubscriberAPI";
import UserCallAPI from "Common/Server/API/UserCallAPI";
@@ -2073,6 +2075,16 @@ const BaseAPIFeatureSet: FeatureSet = {
new StatusPageDomainAPI().getRouter(),
);
app.use(
`/${APP_NAME.toLocaleLowerCase()}`,
new DashboardAPI().getRouter(),
);
app.use(
`/${APP_NAME.toLocaleLowerCase()}`,
new DashboardDomainAPI().getRouter(),
);
app.use(
`/${APP_NAME.toLocaleLowerCase()}`,
new ProjectSsoAPI().getRouter(),

View File

@@ -0,0 +1,160 @@
import PageComponentProps from "../../PageComponentProps";
import ObjectID from "Common/Types/ObjectID";
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
import CardModelDetail from "Common/UI/Components/ModelDetail/CardModelDetail";
import FieldType from "Common/UI/Components/Types/FieldType";
import Navigation from "Common/UI/Utils/Navigation";
import Dashboard from "Common/Models/DatabaseModels/Dashboard";
import React, { Fragment, FunctionComponent, ReactElement } from "react";
const DashboardAuthenticationSettings: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
return (
<Fragment>
<CardModelDetail<Dashboard>
name="Dashboard > Authentication Settings"
cardProps={{
title: "Authentication Settings",
description: "Authentication settings for this dashboard.",
}}
editButtonText="Edit Settings"
isEditable={true}
formFields={[
{
field: {
isPublicDashboard: true,
},
title: "Is Visible to Public",
fieldType: FormFieldSchemaType.Toggle,
required: false,
placeholder: "Is this dashboard visible to public",
},
]}
modelDetailProps={{
showDetailsInNumberOfColumns: 1,
modelType: Dashboard,
id: "model-detail-dashboard",
fields: [
{
field: {
isPublicDashboard: true,
},
fieldType: FieldType.Boolean,
title: "Is Visible to Public",
},
],
modelId: modelId,
}}
/>
<CardModelDetail<Dashboard>
name="Dashboard > Master Password"
cardProps={{
title: "Master Password",
description:
"Rotate the password required to unlock a private dashboard. This value is stored as a secure hash and cannot be retrieved.",
}}
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 dashboard.",
},
{
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: Dashboard,
id: "model-detail-dashboard-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<Dashboard>
name="Dashboard > IP Whitelist"
cardProps={{
title: "IP Whitelist",
description:
"IP Whitelist for this dashboard. If the dashboard is public then only IP addresses in this whitelist will be able to access the dashboard. If the dashboard is not public then only users who have access from the IP addresses in this whitelist will be able to access the dashboard.",
}}
editButtonText="Edit IP Whitelist"
isEditable={true}
formFields={[
{
field: {
ipWhitelist: true,
},
title: "IP Whitelist",
fieldType: FormFieldSchemaType.LongText,
required: false,
placeholder:
"Please enter the IP addresses or CIDR ranges to whitelist. One per line. This can be IPv4 or IPv6 addresses.",
},
]}
modelDetailProps={{
showDetailsInNumberOfColumns: 1,
modelType: Dashboard,
id: "model-detail-dashboard-ip-whitelist",
fields: [
{
field: {
ipWhitelist: true,
},
fieldType: FieldType.LongText,
title: "IP Whitelist",
placeholder:
"No IP addresses or CIDR ranges whitelisted. This will allow all IP addresses to access the dashboard.",
},
],
modelId: modelId,
}}
/>
</Fragment>
);
};
export default DashboardAuthenticationSettings;

View File

@@ -0,0 +1,489 @@
import PageComponentProps from "../../PageComponentProps";
import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse";
import HTTPResponse from "Common/Types/API/HTTPResponse";
import URL from "Common/Types/API/URL";
import BadDataException from "Common/Types/Exception/BadDataException";
import { ErrorFunction, VoidFunction } from "Common/Types/FunctionTypes";
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 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 { APP_API_URL, StatusPageCNameRecord } from "Common/UI/Config";
import API from "Common/UI/Utils/API/API";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import Navigation from "Common/UI/Utils/Navigation";
import Domain from "Common/Models/DatabaseModels/Domain";
import DashboardDomain from "Common/Models/DatabaseModels/DashboardDomain";
import React, {
Fragment,
FunctionComponent,
ReactElement,
useState,
} from "react";
import OneUptimeDate from "Common/Types/Date";
import FormValues from "Common/UI/Components/Forms/Types/FormValues";
import ProjectUtil from "Common/UI/Utils/Project";
const DashboardCustomDomains: FunctionComponent<PageComponentProps> = (
props: PageComponentProps,
): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
const [refreshToggle, setRefreshToggle] = useState<string>(
OneUptimeDate.getCurrentDate().toString(),
);
const [showCnameModal, setShowCnameModal] = useState<boolean>(false);
const [selectedDashboardDomain, setSelectedDashboardDomain] =
useState<DashboardDomain | null>(null);
const [verifyCnameLoading, setVerifyCnameLoading] =
useState<boolean>(false);
const [orderSslLoading, setOrderSslLoading] = useState<boolean>(false);
const [error, setError] = useState<string>("");
const [showOrderSSLModal, setShowOrderSSLModal] =
useState<boolean>(false);
return (
<Fragment>
<>
<ModelTable<DashboardDomain>
modelType={DashboardDomain}
query={{
projectId: ProjectUtil.getCurrentProjectId()!,
dashboardId: modelId,
}}
name="Dashboard > Domains"
userPreferencesKey="dashboard-domains-table"
id="dashboard-domains-table"
isDeleteable={true}
isCreateable={true}
isEditable={true}
cardProps={{
title: "Custom Domains",
description: `Important: Please add ${StatusPageCNameRecord} as your CNAME for these domains for this to work.`,
}}
refreshToggle={refreshToggle}
onBeforeCreate={(
item: DashboardDomain,
): Promise<DashboardDomain> => {
if (!props.currentProject || !props.currentProject._id) {
throw new BadDataException("Project ID cannot be null");
}
item.dashboardId = modelId;
item.projectId = new ObjectID(props.currentProject._id);
return Promise.resolve(item);
}}
actionButtons={[
{
title: "Add CNAME",
buttonStyleType: ButtonStyleType.SUCCESS_OUTLINE,
icon: IconProp.Check,
isVisible: (item: DashboardDomain): boolean => {
if (item["isCnameVerified"]) {
return false;
}
return true;
},
onClick: async (
item: DashboardDomain,
onCompleteAction: VoidFunction,
onError: ErrorFunction,
) => {
try {
setShowCnameModal(true);
setSelectedDashboardDomain(item);
onCompleteAction();
} catch (err) {
onCompleteAction();
onError(err as Error);
}
},
},
{
title: "Order Free SSL",
buttonStyleType: ButtonStyleType.SUCCESS_OUTLINE,
icon: IconProp.Check,
isVisible: (item: DashboardDomain): boolean => {
if (
!item.isCustomCertificate &&
item["isCnameVerified"] &&
!item.isSslOrdered
) {
return true;
}
return false;
},
onClick: async (
item: DashboardDomain,
onCompleteAction: VoidFunction,
onError: ErrorFunction,
) => {
try {
setShowOrderSSLModal(true);
setSelectedDashboardDomain(item);
onCompleteAction();
} catch (err) {
onCompleteAction();
setSelectedDashboardDomain(null);
onError(err as Error);
}
},
},
]}
noItemsMessage={"No custom domains found."}
viewPageRoute={Navigation.getCurrentRoute()}
selectMoreFields={{
isSslOrdered: true,
isSslProvisioned: true,
isCnameVerified: true,
isCustomCertificate: true,
}}
formSteps={[
{
title: "Basic",
id: "basic",
},
{
title: "More",
id: "more",
},
]}
formFields={[
{
field: {
subdomain: true,
},
title: "Subdomain",
fieldType: FormFieldSchemaType.Text,
required: false,
placeholder: "dashboard (leave blank for root)",
description:
"Enter the subdomain label only (for example, dashboard). Leave blank or enter @ to use the root/apex domain.",
stepId: "basic",
disableSpellCheck: true,
},
{
field: {
domain: true,
},
title: "Domain",
description:
"Please select a verified domain from this list. If you do not see any domains in this list, please head over to More -> Project Settings -> Custom Domains to add one.",
fieldType: FormFieldSchemaType.Dropdown,
dropdownModal: {
type: Domain,
labelField: "domain",
valueField: "_id",
},
required: true,
placeholder: "Select domain",
stepId: "basic",
},
{
field: {
isCustomCertificate: true,
},
title: "Upload Custom Certificate",
fieldType: FormFieldSchemaType.Toggle,
required: false,
defaultValue: false,
stepId: "more",
description:
"If you have a custom certificate, you can upload it here. If you do not have a certificate, we will order a free SSL certificate for you.",
},
{
field: {
customCertificate: true,
},
title: "Certificate",
fieldType: FormFieldSchemaType.LongText,
required: false,
stepId: "more",
placeholder:
"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----",
disableSpellCheck: true,
showIf: (item: FormValues<DashboardDomain>): boolean => {
return Boolean(item.isCustomCertificate);
},
},
{
field: {
customCertificateKey: true,
},
title: "Certificate Private Key",
fieldType: FormFieldSchemaType.LongText,
required: false,
placeholder:
"-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----",
stepId: "more",
disableSpellCheck: true,
showIf: (item: FormValues<DashboardDomain>): boolean => {
return Boolean(item.isCustomCertificate);
},
},
]}
showRefreshButton={true}
filters={[
{
field: {
fullDomain: true,
},
title: "Domain",
type: FieldType.Text,
},
{
field: {},
title: "CNAME Valid",
type: FieldType.Boolean,
},
{
field: {},
title: "SSL Provisioned",
type: FieldType.Boolean,
},
]}
columns={[
{
field: {
fullDomain: true,
},
title: "Domain",
type: FieldType.Text,
},
{
field: {
isCnameVerified: true,
},
title: "Status",
type: FieldType.Element,
getElement: (item: DashboardDomain): ReactElement => {
if (!item.isCnameVerified) {
return (
<span>
<span className="font-semibold">
Action Required:
</span>{" "}
Please add your CNAME record.
</span>
);
}
if (item.isCustomCertificate) {
return (
<span>
No action is required. Please allow 30 minutes for
the certificate to be provisioned.
</span>
);
}
if (!item.isSslOrdered) {
return (
<span>
<span className="font-semibold">
Action Required:
</span>{" "}
Please order SSL certificate.
</span>
);
}
if (!item.isSslProvisioned) {
return (
<span>
No action is required. This SSL certificate will be
provisioned in 1 hour. If this does not happen.
Please contact support.
</span>
);
}
return (
<span>
Certificate Provisioned. We will automatically renew
this certificate. No action required.{" "}
</span>
);
},
},
]}
/>
{selectedDashboardDomain?.fullDomain && showCnameModal && (
<ConfirmModal
title={`Add CNAME`}
description={
StatusPageCNameRecord ? (
<div>
<span>
Please add CNAME record to your domain. Details of
the CNAME records are:
</span>
<br />
<br />
<span>
<b>Record Type: </b> CNAME
</span>
<br />
<span>
<b>Name: </b>
{selectedDashboardDomain?.fullDomain}
</span>
<br />
<span>
<b>Content: </b>
{StatusPageCNameRecord}
</span>
<br />
<br />
<span>
Once you have done this, it should take 24 hours to
automatically verify.
</span>
</div>
) : (
<div>
<span>
Custom Domains not enabled for this OneUptime
installation. Please contact your server admin to
enable this feature. To enable this feature, if you
are using Docker compose, the
<b>STATUS_PAGE_CNAME_RECORD</b> environment variable
must be set when starting the OneUptime cluster. If
you are using Helm and Kubernetes then set
statusPage.cnameRecord in the values.yaml file.
</span>
</div>
)
}
submitButtonText={"Verify CNAME"}
onClose={() => {
setShowCnameModal(false);
setError("");
return setSelectedDashboardDomain(null);
}}
isLoading={verifyCnameLoading}
error={error}
onSubmit={async () => {
try {
setVerifyCnameLoading(true);
setError("");
const response:
| HTTPResponse<JSONObject>
| HTTPErrorResponse =
await API.get<JSONObject>({
url: URL.fromString(
APP_API_URL.toString(),
).addRoute(
`/${
new DashboardDomain().crudApiPath
}/verify-cname/${selectedDashboardDomain?.id?.toString()}`,
),
data: {},
headers: ModelAPI.getCommonHeaders(),
});
if (response.isFailure()) {
throw response;
}
setShowCnameModal(false);
setRefreshToggle(
OneUptimeDate.getCurrentDate().toString(),
);
setSelectedDashboardDomain(null);
} catch (err) {
setError(API.getFriendlyMessage(err));
}
setVerifyCnameLoading(false);
}}
/>
)}
{showOrderSSLModal && selectedDashboardDomain && (
<ConfirmModal
title={`Order Free SSL Certificate for this Dashboard`}
description={
StatusPageCNameRecord ? (
<div>
Please click on the button below to order SSL for this
domain. We will use LetsEncrypt to order a certificate.
This process is secure and completely free. The
certificate takes 3 hours to provision after its been
ordered.
</div>
) : (
<div>
<span>
Custom Domains not enabled for this OneUptime
installation. Please contact your server admin to
enable this feature.
</span>
</div>
)
}
submitButtonText={"Order Free SSL"}
onClose={() => {
setShowOrderSSLModal(false);
setError("");
return setSelectedDashboardDomain(null);
}}
isLoading={orderSslLoading}
error={error}
onSubmit={async () => {
try {
setOrderSslLoading(true);
setError("");
const response:
| HTTPResponse<JSONObject>
| HTTPErrorResponse =
await API.get<JSONObject>({
url: URL.fromString(
APP_API_URL.toString(),
).addRoute(
`/${
new DashboardDomain().crudApiPath
}/order-ssl/${selectedDashboardDomain?.id?.toString()}`,
),
data: {},
headers: ModelAPI.getCommonHeaders(),
});
if (response.isFailure()) {
throw response;
}
setShowOrderSSLModal(false);
setRefreshToggle(
OneUptimeDate.getCurrentDate().toString(),
);
setSelectedDashboardDomain(null);
} catch (err) {
setError(API.getFriendlyMessage(err));
}
setOrderSslLoading(false);
}}
/>
)}
</>
</Fragment>
);
};
export default DashboardCustomDomains;

View File

@@ -41,7 +41,33 @@ const DashboardSideMenu: FunctionComponent<ComponentProps> = (
/>
</SideMenuSection>
<SideMenuSection title="Custom Domains">
<SideMenuItem
link={{
title: "Custom Domains",
to: RouteUtil.populateRouteParams(
RouteMap[PageMap.DASHBOARD_VIEW_CUSTOM_DOMAINS] as Route,
{ modelId: props.modelId },
),
}}
icon={IconProp.Globe}
/>
</SideMenuSection>
<SideMenuSection title="Advanced">
<SideMenuItem
link={{
title: "Authentication",
to: RouteUtil.populateRouteParams(
RouteMap[
PageMap.DASHBOARD_VIEW_AUTHENTICATION_SETTINGS
] as Route,
{ modelId: props.modelId },
),
}}
icon={IconProp.Lock}
/>
<SideMenuItem
link={{
title: "Settings",

View File

@@ -16,6 +16,10 @@ import DashboardViewDelete from "../Pages/Dashboards/View/Delete";
import DashboardViewSettings from "../Pages/Dashboards/View/Settings";
import DashboardViewAuthenticationSettings from "../Pages/Dashboards/View/AuthenticationSettings";
import DashboardViewCustomDomains from "../Pages/Dashboards/View/CustomDomains";
const DashboardsRoutes: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
@@ -74,6 +78,36 @@ const DashboardsRoutes: FunctionComponent<ComponentProps> = (
/>
}
/>
<PageRoute
path={RouteUtil.getLastPathForKey(
PageMap.DASHBOARD_VIEW_AUTHENTICATION_SETTINGS,
)}
element={
<DashboardViewAuthenticationSettings
{...props}
pageRoute={
RouteMap[
PageMap.DASHBOARD_VIEW_AUTHENTICATION_SETTINGS
] as Route
}
/>
}
/>
<PageRoute
path={RouteUtil.getLastPathForKey(
PageMap.DASHBOARD_VIEW_CUSTOM_DOMAINS,
)}
element={
<DashboardViewCustomDomains
{...props}
pageRoute={
RouteMap[PageMap.DASHBOARD_VIEW_CUSTOM_DOMAINS] as Route
}
/>
}
/>
</PageRoute>
</Routes>
);

View File

@@ -268,6 +268,8 @@ enum PageMap {
DASHBOARD_VIEW_OVERVIEW = "DASHBOARD_VIEW_OVERVIEW",
DASHBOARD_VIEW_DELETE = "DASHBOARD_VIEW_DELETE",
DASHBOARD_VIEW_SETTINGS = "DASHBOARD_VIEW_SETTINGS",
DASHBOARD_VIEW_AUTHENTICATION_SETTINGS = "DASHBOARD_VIEW_AUTHENTICATION_SETTINGS",
DASHBOARD_VIEW_CUSTOM_DOMAINS = "DASHBOARD_VIEW_CUSTOM_DOMAINS",
STATUS_PAGES_ROOT = "STATUS_PAGES_ROOT",
STATUS_PAGES = "STATUS_PAGES",

View File

@@ -148,6 +148,8 @@ export const DashboardsRoutePath: Dictionary<string> = {
[PageMap.DASHBOARD_VIEW_OVERVIEW]: `${RouteParams.ModelID}/overview`,
[PageMap.DASHBOARD_VIEW_DELETE]: `${RouteParams.ModelID}/delete`,
[PageMap.DASHBOARD_VIEW_SETTINGS]: `${RouteParams.ModelID}/settings`,
[PageMap.DASHBOARD_VIEW_AUTHENTICATION_SETTINGS]: `${RouteParams.ModelID}/authentication-settings`,
[PageMap.DASHBOARD_VIEW_CUSTOM_DOMAINS]: `${RouteParams.ModelID}/custom-domains`,
};
export const StatusPagesRoutePath: Dictionary<string> = {
@@ -1754,6 +1756,18 @@ const RouteMap: Dictionary<Route> = {
}`,
),
[PageMap.DASHBOARD_VIEW_AUTHENTICATION_SETTINGS]: new Route(
`/dashboard/${RouteParams.ProjectID}/dashboards/${
DashboardsRoutePath[PageMap.DASHBOARD_VIEW_AUTHENTICATION_SETTINGS]
}`,
),
[PageMap.DASHBOARD_VIEW_CUSTOM_DOMAINS]: new Route(
`/dashboard/${RouteParams.ProjectID}/dashboards/${
DashboardsRoutePath[PageMap.DASHBOARD_VIEW_CUSTOM_DOMAINS]
}`,
),
// Status Pages
[PageMap.STATUS_PAGES_ROOT]: new Route(

View File

@@ -1,7 +1,9 @@
import Project from "./Project";
import User from "./User";
import Route from "../../Types/API/Route";
import { PlanType } from "../../Types/Billing/SubscriptionPlan";
import ColumnAccessControl from "../../Types/Database/AccessControl/ColumnAccessControl";
import ColumnBillingAccessControl from "../../Types/Database/AccessControl/ColumnBillingAccessControl";
import TableAccessControl from "../../Types/Database/AccessControl/TableAccessControl";
import ColumnLength from "../../Types/Database/ColumnLength";
import ColumnType from "../../Types/Database/ColumnType";
@@ -15,6 +17,7 @@ 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 HashedString from "../../Types/HashedString";
import ObjectID from "../../Types/ObjectID";
import Permission from "../../Types/Permission";
import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel";
@@ -448,4 +451,147 @@ export default class Dashboard extends BaseModel {
type: ColumnType.JSON,
})
public dashboardViewConfig?: DashboardViewConfig = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.CreateDashboard,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadDashboard,
Permission.ReadAllProjectResources,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.EditDashboard,
],
})
@TableColumn({
isDefaultValueColumn: true,
type: TableColumnType.Boolean,
title: "Public Dashboard",
description: "Is this dashboard public?",
defaultValue: false,
})
@Column({
type: ColumnType.Boolean,
default: false,
})
@ColumnBillingAccessControl({
read: PlanType.Free,
update: PlanType.Growth,
create: PlanType.Free,
})
public isPublicDashboard?: boolean = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.CreateDashboard,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadDashboard,
Permission.ReadAllProjectResources,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.EditDashboard,
],
})
@TableColumn({
isDefaultValueColumn: true,
type: TableColumnType.Boolean,
title: "Enable Master Password",
description:
"Require visitors to enter a master password before viewing a private dashboard.",
defaultValue: false,
})
@Column({
type: ColumnType.Boolean,
default: false,
})
public enableMasterPassword?: boolean = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.CreateDashboard,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadDashboard,
Permission.ReadAllProjectResources,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.EditDashboard,
],
})
@TableColumn({
title: "Master Password",
description:
"Password required to unlock a private dashboard. 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,
Permission.ProjectAdmin,
Permission.CreateDashboard,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadDashboard,
Permission.ReadAllProjectResources,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.EditDashboard,
],
})
@TableColumn({
isDefaultValueColumn: false,
required: false,
type: TableColumnType.VeryLongText,
title: "IP Whitelist",
description:
"IP Whitelist for this Dashboard. One IP per line. Only used if the dashboard is private.",
})
@Column({
type: ColumnType.VeryLongText,
nullable: true,
})
@ColumnBillingAccessControl({
read: PlanType.Free,
update: PlanType.Scale,
create: PlanType.Free,
})
public ipWhitelist?: string = undefined;
}

View File

@@ -0,0 +1,659 @@
import Dashboard from "./Dashboard";
import Domain from "./Domain";
import Project from "./Project";
import User from "./User";
import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel";
import Route from "../../Types/API/Route";
import ColumnAccessControl from "../../Types/Database/AccessControl/ColumnAccessControl";
import TableAccessControl from "../../Types/Database/AccessControl/TableAccessControl";
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 EnableWorkflow from "../../Types/Database/EnableWorkflow";
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()
@CanAccessIfCanReadOn("dashboard")
@TenantColumn("projectId")
@TableAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateDashboardDomain,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadDashboardDomain,
Permission.ReadAllProjectResources,
],
delete: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.DeleteDashboardDomain,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditDashboardDomain,
],
})
@EnableWorkflow({
create: true,
delete: true,
update: true,
read: true,
})
@CrudApiEndpoint(new Route("/dashboard-domain"))
@TableMetadata({
tableName: "DashboardDomain",
singularName: "Dashboard Domain",
pluralName: "Dashboard Domains",
icon: IconProp.Globe,
tableDescription: "Manage custom domains for your dashboard",
})
@Entity({
name: "DashboardDomain",
})
export default class DashboardDomain extends BaseModel {
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateDashboardDomain,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadDashboardDomain,
Permission.ReadAllProjectResources,
],
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.ProjectMember,
Permission.CreateDashboardDomain,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadDashboardDomain,
Permission.ReadAllProjectResources,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: true,
canReadOnRelationQuery: true,
title: "Project ID",
description: "ID of your OneUptime Project in which this object belongs",
example: "5f8b9c0d-e1a2-4b3c-8d5e-6f7a8b9c0d1e",
})
@Column({
type: ColumnType.ObjectID,
nullable: false,
transformer: ObjectID.getDatabaseTransformer(),
})
public projectId?: ObjectID = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateDashboardDomain,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadDashboardDomain,
Permission.ReadAllProjectResources,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "domainId",
type: TableColumnType.Entity,
modelType: Domain,
})
@ManyToOne(
() => {
return Domain;
},
{
eager: false,
nullable: true,
onDelete: "CASCADE",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "domainId" })
public domain?: Domain = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateDashboardDomain,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadDashboardDomain,
Permission.ReadAllProjectResources,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: true,
example: "d4e5f6a7-b8c9-0123-def4-567890abcdef",
})
@Column({
type: ColumnType.ObjectID,
nullable: false,
transformer: ObjectID.getDatabaseTransformer(),
})
public domainId?: ObjectID = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateDashboardDomain,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadDashboardDomain,
Permission.ReadAllProjectResources,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "dashboardId",
type: TableColumnType.Entity,
modelType: Dashboard,
title: "Dashboard",
description:
"Relation to Dashboard Resource in which this object belongs",
})
@ManyToOne(
() => {
return Dashboard;
},
{
eager: false,
nullable: true,
onDelete: "CASCADE",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "dashboardId" })
public dashboard?: Dashboard = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateDashboardDomain,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadDashboardDomain,
Permission.ReadAllProjectResources,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: true,
title: "Dashboard ID",
description: "ID of your Dashboard resource where this object belongs",
example: "b2c3d4e5-f6a7-8901-bcde-f1234567890a",
})
@Column({
type: ColumnType.ObjectID,
nullable: false,
transformer: ObjectID.getDatabaseTransformer(),
})
public dashboardId?: ObjectID = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateDashboardDomain,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadDashboardDomain,
Permission.ReadAllProjectResources,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditDashboardDomain,
],
})
@TableColumn({
required: false,
type: TableColumnType.ShortText,
title: "Subdomain",
description:
"Subdomain label for your dashboard such as 'dashboard'. Leave blank or enter @ to use the root domain.",
example: "dashboard",
})
@Column({
nullable: false,
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
})
public subdomain?: string = undefined;
@UniqueColumnBy("projectId")
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadDashboardDomain,
Permission.ReadAllProjectResources,
],
update: [],
})
@TableColumn({
required: false,
computed: true,
type: TableColumnType.ShortText,
title: "Full Domain",
description:
"Full domain of your dashboard (like dashboard.acmeinc.com). This is autogenerated and is derived from subdomain and domain.",
example: "dashboard.acmeinc.com",
})
@Column({
nullable: false,
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
})
public fullDomain?: string = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateDashboardDomain,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadDashboardDomain,
Permission.ReadAllProjectResources,
],
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.ProjectMember,
Permission.CreateDashboardDomain,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadDashboardDomain,
Permission.ReadAllProjectResources,
],
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)",
example: "c3d4e5f6-a7b8-9012-cdef-234567890abc",
})
@Column({
type: ColumnType.ObjectID,
nullable: true,
transformer: ObjectID.getDatabaseTransformer(),
})
public createdByUserId?: ObjectID = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadDashboardDomain,
Permission.ReadAllProjectResources,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "deletedByUserId",
type: TableColumnType.Entity,
title: "Deleted by User",
modelType: 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: [],
update: [],
})
@TableColumn({
required: false,
computed: true,
type: TableColumnType.ShortText,
title: "CNAME Verification Token",
description: "CNAME Verification Token",
})
@Column({
nullable: false,
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
})
public cnameVerificationToken?: string = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadDashboardDomain,
Permission.ReadAllProjectResources,
],
update: [],
})
@TableColumn({
isDefaultValueColumn: true,
required: true,
type: TableColumnType.Boolean,
title: "CNAME Verified",
description: "Is CNAME Verified?",
defaultValue: false,
})
@Column({
type: ColumnType.Boolean,
nullable: false,
unique: false,
default: false,
})
public isCnameVerified?: boolean = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateDashboardDomain,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadDashboardDomain,
Permission.ReadAllProjectResources,
],
update: [],
})
@TableColumn({
isDefaultValueColumn: true,
required: true,
computed: true,
type: TableColumnType.Boolean,
title: "SSL Ordered",
description: "Is SSL ordered?",
defaultValue: false,
})
@Column({
type: ColumnType.Boolean,
nullable: false,
unique: false,
default: false,
})
public isSslOrdered?: boolean = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateDashboardDomain,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadDashboardDomain,
Permission.ReadAllProjectResources,
],
update: [],
})
@TableColumn({
isDefaultValueColumn: true,
required: true,
computed: true,
type: TableColumnType.Boolean,
title: "SSL Provisioned",
description: "Is SSL provisioned?",
defaultValue: false,
})
@Column({
type: ColumnType.Boolean,
nullable: false,
unique: false,
default: false,
})
public isSslProvisioned?: boolean = undefined;
@ColumnAccessControl({
create: [],
read: [],
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;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateDashboardDomain,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadDashboardDomain,
Permission.ReadAllProjectResources,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditDashboardDomain,
],
})
@TableColumn({ type: TableColumnType.VeryLongText })
@Column({
type: ColumnType.VeryLongText,
nullable: true,
unique: false,
})
public customCertificate?: string = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateDashboardDomain,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadDashboardDomain,
Permission.ReadAllProjectResources,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditDashboardDomain,
],
})
@TableColumn({ type: TableColumnType.VeryLongText })
@Column({
type: ColumnType.VeryLongText,
nullable: true,
unique: false,
})
public customCertificateKey?: string = undefined;
// If this is true, then the certificate is custom and not managed by OneUptime (LetsEncrypt)
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateDashboardDomain,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadDashboardDomain,
Permission.ReadAllProjectResources,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditDashboardDomain,
],
})
@TableColumn({ type: TableColumnType.Boolean })
@Column({
type: ColumnType.Boolean,
nullable: false,
unique: false,
default: false,
})
public isCustomCertificate?: boolean = undefined;
}

View File

@@ -224,6 +224,7 @@ import IncidentSla from "./IncidentSla";
import TableView from "./TableView";
import Dashboard from "./Dashboard";
import DashboardDomain from "./DashboardDomain";
import MonitorTest from "./MonitorTest";
import ScheduledMaintenanceFeed from "./ScheduledMaintenanceFeed";
@@ -486,6 +487,7 @@ const AllModelTypes: Array<{
// Dashboards
Dashboard,
DashboardDomain,
MonitorTest,

View File

@@ -0,0 +1,111 @@
import UserMiddleware from "../Middleware/UserAuthorization";
import DashboardService, {
Service as DashboardServiceType,
} from "../Services/DashboardService";
import CookieUtil from "../Utils/Cookie";
import {
ExpressRequest,
ExpressResponse,
NextFunction,
} from "../Utils/Express";
import Response from "../Utils/Response";
import BaseAPI from "./BaseAPI";
import BadDataException from "../../Types/Exception/BadDataException";
import NotFoundException from "../../Types/Exception/NotFoundException";
import HashedString from "../../Types/HashedString";
import ObjectID from "../../Types/ObjectID";
import Dashboard from "../../Models/DatabaseModels/Dashboard";
import { EncryptionSecret } from "../EnvironmentConfig";
import { DASHBOARD_MASTER_PASSWORD_INVALID_MESSAGE } from "../../Types/Dashboard/MasterPassword";
export default class DashboardAPI extends BaseAPI<
Dashboard,
DashboardServiceType
> {
public constructor() {
super(Dashboard, DashboardService);
this.router.post(
`${new this.entityType()
.getCrudApiPath()
?.toString()}/master-password/:dashboardId`,
UserMiddleware.getUserMiddleware,
async (
req: ExpressRequest,
res: ExpressResponse,
next: NextFunction,
) => {
try {
if (!req.params["dashboardId"]) {
throw new BadDataException("Dashboard ID not found");
}
const dashboardId: ObjectID = new ObjectID(
req.params["dashboardId"] as string,
);
const password: string | undefined =
req.body && (req.body["password"] as string);
if (!password) {
throw new BadDataException("Master password is required.");
}
const dashboard: Dashboard | null =
await DashboardService.findOneById({
id: dashboardId,
select: {
_id: true,
projectId: true,
enableMasterPassword: true,
masterPassword: true,
isPublicDashboard: true,
},
props: {
isRoot: true,
},
});
if (!dashboard) {
throw new NotFoundException("Dashboard not found");
}
if (dashboard.isPublicDashboard) {
throw new BadDataException(
"This dashboard is already visible to everyone.",
);
}
if (
!dashboard.enableMasterPassword ||
!dashboard.masterPassword
) {
throw new BadDataException(
"Master password has not been configured for this dashboard.",
);
}
const hashedInput: string = await HashedString.hashValue(
password,
EncryptionSecret,
);
if (hashedInput !== dashboard.masterPassword.toString()) {
throw new BadDataException(
DASHBOARD_MASTER_PASSWORD_INVALID_MESSAGE,
);
}
CookieUtil.setDashboardMasterPasswordCookie({
expressResponse: res,
dashboardId,
});
return Response.sendEmptySuccessResponse(req, res);
} catch (err) {
next(err);
}
},
);
}
}

View File

@@ -0,0 +1,244 @@
import { StatusPageCNameRecord } from "../EnvironmentConfig";
import UserMiddleware from "../Middleware/UserAuthorization";
import DashboardDomainService, {
Service as DashboardDomainServiceType,
} from "../Services/DashboardDomainService";
import {
ExpressRequest,
ExpressResponse,
NextFunction,
} from "../Utils/Express";
import logger from "../Utils/Logger";
import Response from "../Utils/Response";
import BaseAPI from "./BaseAPI";
import CommonAPI from "./CommonAPI";
import DatabaseCommonInteractionProps from "../../Types/BaseDatabase/DatabaseCommonInteractionProps";
import BadDataException from "../../Types/Exception/BadDataException";
import ObjectID from "../../Types/ObjectID";
import PositiveNumber from "../../Types/PositiveNumber";
import DashboardDomain from "../../Models/DatabaseModels/DashboardDomain";
export default class DashboardDomainAPI extends BaseAPI<
DashboardDomain,
DashboardDomainServiceType
> {
public constructor() {
super(DashboardDomain, DashboardDomainService);
// CNAME verification api
this.router.get(
`${new this.entityType().getCrudApiPath()?.toString()}/verify-cname/:id`,
UserMiddleware.getUserMiddleware,
async (
req: ExpressRequest,
res: ExpressResponse,
next: NextFunction,
) => {
try {
if (!StatusPageCNameRecord) {
return Response.sendErrorResponse(
req,
res,
new BadDataException(
`Custom Domains not enabled for this
OneUptime installation. Please contact
your server admin to enable this
feature.`,
),
);
}
const databaseProps: DatabaseCommonInteractionProps =
await CommonAPI.getDatabaseCommonInteractionProps(req);
const id: ObjectID = new ObjectID(req.params["id"] as string);
const domainCount: PositiveNumber =
await DashboardDomainService.countBy({
query: {
_id: id.toString(),
},
props: databaseProps,
});
if (domainCount.toNumber() === 0) {
return Response.sendErrorResponse(
req,
res,
new BadDataException(
"The domain does not exist or user does not have access to it.",
),
);
}
const domain: DashboardDomain | null =
await DashboardDomainService.findOneBy({
query: {
_id: id.toString(),
},
select: {
_id: true,
fullDomain: true,
},
props: {
isRoot: true,
},
});
if (!domain) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid token."),
);
}
if (!domain.fullDomain) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid domain."),
);
}
const isValid: boolean =
await DashboardDomainService.isCnameValid(domain.fullDomain!);
if (!isValid) {
return Response.sendErrorResponse(
req,
res,
new BadDataException(
"CNAME is not verified. Please make sure you have the correct record and please verify CNAME again. If you are sure that the record is correct, please wait for some time for the DNS to propagate.",
),
);
}
return Response.sendEmptySuccessResponse(req, res);
} catch (e) {
next(e);
}
},
);
// Provision SSL API
this.router.get(
`${new this.entityType().getCrudApiPath()?.toString()}/order-ssl/:id`,
UserMiddleware.getUserMiddleware,
async (
req: ExpressRequest,
res: ExpressResponse,
next: NextFunction,
) => {
try {
if (!StatusPageCNameRecord) {
return Response.sendErrorResponse(
req,
res,
new BadDataException(
`Custom Domains not enabled for this
OneUptime installation. Please contact
your server admin to enable this
feature.`,
),
);
}
const databaseProps: DatabaseCommonInteractionProps =
await CommonAPI.getDatabaseCommonInteractionProps(req);
const id: ObjectID = new ObjectID(req.params["id"] as string);
const domainCount: PositiveNumber =
await DashboardDomainService.countBy({
query: {
_id: id.toString(),
},
props: databaseProps,
});
if (domainCount.toNumber() === 0) {
return Response.sendErrorResponse(
req,
res,
new BadDataException(
"The domain does not exist or user does not have access to it.",
),
);
}
const domain: DashboardDomain | null =
await DashboardDomainService.findOneBy({
query: {
_id: id.toString(),
},
select: {
_id: true,
fullDomain: true,
cnameVerificationToken: true,
isCnameVerified: true,
isSslProvisioned: true,
},
props: {
isRoot: true,
},
});
if (!domain) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid token."),
);
}
if (!domain.cnameVerificationToken) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid token."),
);
}
if (!domain.isCnameVerified) {
return Response.sendErrorResponse(
req,
res,
new BadDataException(
"CNAME is not verified. Please verify CNAME first before you provision SSL.",
),
);
}
if (domain.isSslProvisioned) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("SSL is already provisioned."),
);
}
if (!domain.fullDomain) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid domain."),
);
}
logger.debug("Ordering SSL");
await DashboardDomainService.orderCert(domain);
logger.debug(
"SSL Provisioned for domain - " + domain.fullDomain,
);
return Response.sendEmptySuccessResponse(req, res);
} catch (e) {
next(e);
}
},
);
}
}

View File

@@ -0,0 +1,655 @@
import CreateBy from "../Types/Database/CreateBy";
import DeleteBy from "../Types/Database/DeleteBy";
import { OnCreate, OnDelete } from "../Types/Database/Hooks";
import GreenlockUtil from "../Utils/Greenlock/Greenlock";
import logger from "../Utils/Logger";
import DatabaseService from "./DatabaseService";
import DomainService from "./DomainService";
import HTTPErrorResponse from "../../Types/API/HTTPErrorResponse";
import HTTPResponse from "../../Types/API/HTTPResponse";
import URL from "../../Types/API/URL";
import LIMIT_MAX from "../../Types/Database/LimitMax";
import BadDataException from "../../Types/Exception/BadDataException";
import { JSONObject } from "../../Types/JSON";
import ObjectID from "../../Types/ObjectID";
import API from "../../Utils/API";
import AcmeCertificate from "../../Models/DatabaseModels/AcmeCertificate";
import DomainModel from "../../Models/DatabaseModels/Domain";
import DashboardDomain from "../../Models/DatabaseModels/DashboardDomain";
import AcmeCertificateService from "./AcmeCertificateService";
import Telemetry, { Span } from "../Utils/Telemetry";
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
import { StatusPageCNameRecord } from "../EnvironmentConfig";
import Domain from "../Types/Domain";
export class Service extends DatabaseService<DashboardDomain> {
public constructor() {
super(DashboardDomain);
}
@CaptureSpan()
protected override async onBeforeCreate(
createBy: CreateBy<DashboardDomain>,
): Promise<OnCreate<DashboardDomain>> {
const domain: DomainModel | null = await DomainService.findOneBy({
query: {
_id:
createBy.data.domainId?.toString() ||
createBy.data.domain?._id ||
"",
},
select: { domain: true, isVerified: true },
props: {
isRoot: true,
},
});
if (!domain?.isVerified) {
throw new BadDataException(
"This domain is not verified. Please verify it by going to Settings > Domains",
);
}
let normalizedSubdomain: string =
createBy.data.subdomain?.trim().toLowerCase() || "";
if (normalizedSubdomain === "@") {
normalizedSubdomain = "";
}
createBy.data.subdomain = normalizedSubdomain;
if (domain) {
const baseDomain: string =
domain.domain?.toString().toLowerCase().trim() || "";
if (!baseDomain) {
throw new BadDataException("Please select a valid domain.");
}
createBy.data.fullDomain = normalizedSubdomain
? `${normalizedSubdomain}.${baseDomain}`
: baseDomain;
}
createBy.data.cnameVerificationToken = ObjectID.generate().toString();
if (createBy.data.isCustomCertificate) {
if (
!createBy.data.customCertificate ||
!createBy.data.customCertificateKey
) {
throw new BadDataException(
"Custom certificate or private key is missing",
);
}
}
return { createBy, carryForward: null };
}
@CaptureSpan()
protected override async onBeforeDelete(
deleteBy: DeleteBy<DashboardDomain>,
): Promise<OnDelete<DashboardDomain>> {
const domains: Array<DashboardDomain> = await this.findBy({
query: {
...deleteBy.query,
},
skip: 0,
limit: LIMIT_MAX,
select: { fullDomain: true },
props: {
isRoot: true,
},
});
return { deleteBy, carryForward: domains };
}
@CaptureSpan()
protected override async onDeleteSuccess(
onDelete: OnDelete<DashboardDomain>,
_itemIdsBeforeDelete: ObjectID[],
): Promise<OnDelete<DashboardDomain>> {
for (const domain of onDelete.carryForward) {
await this.removeDomainFromGreenlock(domain.fullDomain as string);
}
return onDelete;
}
@CaptureSpan()
public async removeDomainFromGreenlock(domain: string): Promise<void> {
await GreenlockUtil.removeDomain(domain);
}
@CaptureSpan()
public async orderCert(dashboardDomain: DashboardDomain): Promise<void> {
return Telemetry.startActiveSpan<Promise<void>>({
name: "DashboardDomainService.orderCert",
options: {
attributes: {
fullDomain: dashboardDomain.fullDomain,
_id: dashboardDomain.id?.toString(),
},
},
fn: async (span: Span): Promise<void> => {
try {
if (!dashboardDomain.fullDomain) {
const fetchedDashboardDomain: DashboardDomain | null =
await this.findOneBy({
query: {
_id: dashboardDomain.id!.toString(),
},
select: {
_id: true,
fullDomain: true,
},
props: {
isRoot: true,
},
});
if (!fetchedDashboardDomain) {
throw new BadDataException("DomainModel not found");
}
dashboardDomain = fetchedDashboardDomain;
}
if (!dashboardDomain.fullDomain) {
throw new BadDataException(
"Unable to order certificate because domain is null",
);
}
logger.debug(
"Ordering SSL for domain: " + dashboardDomain.fullDomain,
);
await GreenlockUtil.orderCert({
domain: dashboardDomain.fullDomain as string,
validateCname: async (fullDomain: string) => {
return await this.isCnameValid(fullDomain);
},
});
logger.debug(
"SSL ordered for domain: " + dashboardDomain.fullDomain,
);
await this.updateOneById({
id: dashboardDomain.id!,
data: {
isSslOrdered: true,
},
props: {
isRoot: true,
},
});
Telemetry.endSpan(span);
} catch (err) {
Telemetry.recordExceptionMarkSpanAsErrorAndEndSpan({
span,
exception: err,
});
throw err;
}
},
});
}
@CaptureSpan()
public async updateSslProvisioningStatusForAllDomains(): Promise<void> {
const domains: Array<DashboardDomain> = await this.findBy({
query: {
isSslOrdered: true,
isCustomCertificate: false,
},
select: {
_id: true,
},
limit: LIMIT_MAX,
skip: 0,
props: {
isRoot: true,
},
});
for (const domain of domains) {
await this.updateSslProvisioningStatus(domain);
}
}
private async isSSLProvisioned(
fulldomain: string,
token: string,
): Promise<boolean> {
try {
const result: HTTPErrorResponse | HTTPResponse<JSONObject> =
await API.get({
url: URL.fromString(
"https://" +
fulldomain +
"/dashboard-api/cname-verification/" +
token,
),
});
if (result.isFailure()) {
return false;
}
return true;
} catch (err) {
logger.error(err);
return false;
}
}
@CaptureSpan()
public async updateCnameStatusForDashboardDomain(data: {
domain: string;
cnameStatus: boolean;
}): Promise<void> {
if (!data.cnameStatus) {
await this.updateOneBy({
query: {
fullDomain: data.domain,
},
data: {
isCnameVerified: false,
isSslOrdered: false,
isSslProvisioned: false,
},
props: {
isRoot: true,
},
});
} else {
await this.updateOneBy({
query: {
fullDomain: data.domain,
},
data: {
isCnameVerified: true,
},
props: {
isRoot: true,
},
});
}
}
@CaptureSpan()
public async isCnameValid(fullDomain: string): Promise<boolean> {
try {
logger.debug("Checking for CNAME " + fullDomain);
const dashboardDomain: DashboardDomain | null = await this.findOneBy({
query: {
fullDomain: fullDomain,
},
select: {
_id: true,
cnameVerificationToken: true,
},
props: {
isRoot: true,
},
});
if (!dashboardDomain) {
return false;
}
const token: string = dashboardDomain.cnameVerificationToken!;
logger.debug(
"Checking for CNAME " + fullDomain + " with token " + token,
);
try {
const result: HTTPErrorResponse | HTTPResponse<JSONObject> =
await API.get({
url: URL.fromString(
"http://" +
fullDomain +
"/dashboard-api/cname-verification/" +
token,
),
});
logger.debug("CNAME verification result");
logger.debug(result);
if (result.isSuccess()) {
await this.updateCnameStatusForDashboardDomain({
domain: fullDomain,
cnameStatus: true,
});
return true;
}
} catch (err) {
logger.debug("Failed checking for CNAME " + fullDomain);
logger.debug(err);
}
try {
const resultHttps: HTTPErrorResponse | HTTPResponse<JSONObject> =
await API.get({
url: URL.fromString(
"https://" +
fullDomain +
"/dashboard-api/cname-verification/" +
token,
),
});
logger.debug("CNAME verification result for https");
logger.debug(resultHttps);
if (resultHttps.isSuccess()) {
await this.updateCnameStatusForDashboardDomain({
domain: fullDomain,
cnameStatus: true,
});
return true;
}
} catch (err) {
logger.debug("Failed checking for CNAME " + fullDomain);
logger.debug(err);
}
try {
if (StatusPageCNameRecord) {
const cnameRecords: Array<string> = await Domain.getCnameRecords({
domain: fullDomain,
});
let cnameRecord: string | undefined = undefined;
if (cnameRecords.length > 0) {
cnameRecord = cnameRecords[0];
}
if (!cnameRecord) {
logger.debug(
`No CNAME record found for ${fullDomain}. Expected record: ${StatusPageCNameRecord}`,
);
await this.updateCnameStatusForDashboardDomain({
domain: fullDomain,
cnameStatus: false,
});
return false;
}
if (
cnameRecord &&
cnameRecord.trim().toLocaleLowerCase() ===
StatusPageCNameRecord.trim().toLocaleLowerCase()
) {
logger.debug(
`CNAME record for ${fullDomain} matches the expected record: ${StatusPageCNameRecord}`,
);
await this.updateCnameStatusForDashboardDomain({
domain: fullDomain,
cnameStatus: true,
});
return true;
}
logger.debug(
`CNAME record for ${fullDomain} is ${cnameRecord} and it does not match the expected record: ${StatusPageCNameRecord}`,
);
}
} catch (err) {
logger.debug("Failed checking for CNAME " + fullDomain);
logger.debug(err);
}
await this.updateCnameStatusForDashboardDomain({
domain: fullDomain,
cnameStatus: false,
});
return false;
} catch (err) {
logger.debug("Failed checking for CNAME " + fullDomain);
logger.debug(err);
await this.updateCnameStatusForDashboardDomain({
domain: fullDomain,
cnameStatus: false,
});
return false;
}
}
@CaptureSpan()
public async updateSslProvisioningStatus(
domain: DashboardDomain,
): Promise<void> {
if (!domain.id) {
throw new BadDataException("DomainModel ID is required");
}
const dashboardDomain: DashboardDomain | null = await this.findOneBy({
query: {
_id: domain.id?.toString(),
},
select: {
_id: true,
fullDomain: true,
cnameVerificationToken: true,
},
props: {
isRoot: true,
},
});
if (!dashboardDomain) {
throw new BadDataException("DomainModel not found");
}
logger.debug(
`DashboardCerts:RemoveCerts - Checking CNAME ${dashboardDomain.fullDomain}`,
);
const isValid: boolean = await this.isSSLProvisioned(
dashboardDomain.fullDomain!,
dashboardDomain.cnameVerificationToken!,
);
if (!isValid) {
const isCnameValid: boolean = await this.isCnameValid(
dashboardDomain.fullDomain!,
);
await this.updateOneById({
id: dashboardDomain.id!,
data: {
isSslProvisioned: false,
},
props: {
isRoot: true,
},
});
if (isCnameValid) {
try {
await this.orderCert(dashboardDomain);
} catch (err) {
logger.error(
"Cannot order cert for domain: " + dashboardDomain.fullDomain,
);
logger.error(err);
}
}
} else {
await this.updateOneById({
id: dashboardDomain.id!,
data: {
isSslProvisioned: true,
},
props: {
isRoot: true,
},
});
}
}
@CaptureSpan()
public async orderSSLForDomainsWhichAreNotOrderedYet(): Promise<void> {
return Telemetry.startActiveSpan<Promise<void>>({
name: "DashboardDomainService.orderSSLForDomainsWhichAreNotOrderedYet",
options: { attributes: {} },
fn: async (span: Span): Promise<void> => {
try {
const domains: Array<DashboardDomain> = await this.findBy({
query: {
isSslOrdered: false,
isCustomCertificate: false,
},
select: {
_id: true,
fullDomain: true,
},
limit: LIMIT_MAX,
skip: 0,
props: {
isRoot: true,
},
});
for (const domain of domains) {
try {
logger.debug("Ordering SSL for domain: " + domain.fullDomain);
await this.orderCert(domain);
} catch (e) {
logger.error(e);
}
}
Telemetry.endSpan(span);
} catch (err) {
Telemetry.recordExceptionMarkSpanAsErrorAndEndSpan({
span,
exception: err,
});
throw err;
}
},
});
}
@CaptureSpan()
public async verifyCnameWhoseCnameisNotVerified(): Promise<void> {
const domains: Array<DashboardDomain> = await this.findBy({
query: {
isCnameVerified: false,
},
select: {
_id: true,
fullDomain: true,
},
limit: LIMIT_MAX,
skip: 0,
props: {
isRoot: true,
},
});
for (const domain of domains) {
try {
await this.isCnameValid(domain.fullDomain as string);
} catch (e) {
logger.error(e);
}
}
}
@CaptureSpan()
public async renewCertsWhichAreExpiringSoon(): Promise<void> {
await GreenlockUtil.renewAllCertsWhichAreExpiringSoon({
validateCname: async (fullDomain: string) => {
return await this.isCnameValid(fullDomain);
},
notifyDomainRemoved: async (domain: string) => {
await this.updateOneBy({
query: {
fullDomain: domain,
},
data: {
isSslOrdered: false,
isSslProvisioned: false,
},
props: {
isRoot: true,
},
});
logger.debug(`DomainModel removed from greenlock: ${domain}`);
},
});
}
@CaptureSpan()
public async checkOrderStatus(): Promise<void> {
const domains: Array<DashboardDomain> = await this.findBy({
query: {
isSslOrdered: true,
isCustomCertificate: false,
},
select: {
_id: true,
fullDomain: true,
cnameVerificationToken: true,
},
limit: LIMIT_MAX,
skip: 0,
props: {
isRoot: true,
},
});
for (const domain of domains) {
if (!domain.fullDomain) {
continue;
}
const acmeCert: AcmeCertificate | null =
await AcmeCertificateService.findOneBy({
query: {
domain: domain.fullDomain,
},
select: {
_id: true,
},
props: {
isRoot: true,
},
});
if (!acmeCert) {
try {
await this.orderCert(domain);
} catch (err) {
logger.error(
"Cannot order cert for domain: " + domain.fullDomain,
);
logger.error(err);
}
}
}
}
}
export default new Service();

View File

@@ -1,12 +1,27 @@
import CreateBy from "../Types/Database/CreateBy";
import { OnCreate } from "../Types/Database/Hooks";
import CookieUtil from "../Utils/Cookie";
import { ExpressRequest } from "../Utils/Express";
import JSONWebToken from "../Utils/JsonWebToken";
import logger from "../Utils/Logger";
import DatabaseService from "./DatabaseService";
import BadDataException from "../../Types/Exception/BadDataException";
import NotAuthenticatedException from "../../Types/Exception/NotAuthenticatedException";
import ForbiddenException from "../../Types/Exception/ForbiddenException";
import MasterPasswordRequiredException from "../../Types/Exception/MasterPasswordRequiredException";
import Model from "../../Models/DatabaseModels/Dashboard";
import { IsBillingEnabled } from "../EnvironmentConfig";
import { PlanType } from "../../Types/Billing/SubscriptionPlan";
import DashboardViewConfigUtil from "../../Utils/Dashboard/DashboardViewConfig";
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
import ObjectID from "../../Types/ObjectID";
import { JSONObject } from "../../Types/JSON";
import IP from "../../Types/IP/IP";
import {
DASHBOARD_MASTER_PASSWORD_COOKIE_IDENTIFIER,
DASHBOARD_MASTER_PASSWORD_REQUIRED_MESSAGE,
} from "../../Types/Dashboard/MasterPassword";
export class Service extends DatabaseService<Model> {
public constructor() {
super(Model);
@@ -46,6 +61,145 @@ export class Service extends DatabaseService<Model> {
return Promise.resolve({ createBy, carryForward: null });
}
public async hasReadAccess(data: {
dashboardId: ObjectID;
req: ExpressRequest;
}): Promise<{
hasReadAccess: boolean;
error?: NotAuthenticatedException | ForbiddenException;
}> {
const dashboardId: ObjectID = data.dashboardId;
const req: ExpressRequest = data.req;
try {
const dashboard: Model | null = await this.findOneById({
id: dashboardId,
props: {
isRoot: true,
},
select: {
_id: true,
isPublicDashboard: true,
ipWhitelist: true,
enableMasterPassword: true,
masterPassword: true,
},
});
if (dashboard?.ipWhitelist && dashboard.ipWhitelist.length > 0) {
const ipWhitelist: Array<string> = dashboard.ipWhitelist?.split("\n");
const ipAccessedFrom: string | undefined =
req.headers["x-forwarded-for"]?.toString() ||
req.headers["x-real-ip"]?.toString() ||
req.socket.remoteAddress ||
req.ip ||
req.ips[0];
if (!ipAccessedFrom) {
logger.error("IP address not found in request.");
return {
hasReadAccess: false,
error: new ForbiddenException(
"Unable to verify IP address for dashboard access.",
),
};
}
const isIPWhitelisted: boolean = IP.isInWhitelist({
ips:
ipAccessedFrom?.split(",").map((i: string) => {
return i.trim();
}) || [],
whitelist: ipWhitelist,
});
if (!isIPWhitelisted) {
logger.error(
`IP address ${ipAccessedFrom} is not whitelisted for dashboard ${dashboardId.toString()}.`,
);
return {
hasReadAccess: false,
error: new ForbiddenException(
`Your IP address ${ipAccessedFrom} is blocked from accessing this dashboard.`,
),
};
}
}
if (dashboard && dashboard.isPublicDashboard) {
return {
hasReadAccess: true,
};
}
const shouldEnforceMasterPassword: boolean = Boolean(
dashboard &&
dashboard.enableMasterPassword &&
dashboard.masterPassword &&
!dashboard.isPublicDashboard,
);
if (shouldEnforceMasterPassword) {
const hasValidMasterPassword: boolean =
this.hasValidMasterPasswordCookie({
req,
dashboardId,
});
if (hasValidMasterPassword) {
return {
hasReadAccess: true,
};
}
return {
hasReadAccess: false,
error: new MasterPasswordRequiredException(
DASHBOARD_MASTER_PASSWORD_REQUIRED_MESSAGE,
),
};
}
} catch (err) {
logger.error(err);
}
return {
hasReadAccess: false,
error: new NotAuthenticatedException(
"You do not have access to this dashboard. Please login to view the dashboard.",
),
};
}
private hasValidMasterPasswordCookie(data: {
req: ExpressRequest;
dashboardId: ObjectID;
}): boolean {
const token: string | undefined = CookieUtil.getCookieFromExpressRequest(
data.req,
CookieUtil.getDashboardMasterPasswordKey(data.dashboardId),
);
if (!token) {
return false;
}
try {
const payload: JSONObject = JSONWebToken.decodeJsonPayload(token);
return (
payload["dashboardId"] === data.dashboardId.toString() &&
payload["type"] === DASHBOARD_MASTER_PASSWORD_COOKIE_IDENTIFIER
);
} catch (err) {
logger.error(err);
}
return false;
}
}
export default new Service();

View File

@@ -105,6 +105,7 @@ import SpanService from "./SpanService";
import StatusPageAnnouncementService from "./StatusPageAnnouncementService";
import StatusPageAnnouncementTemplateService from "./StatusPageAnnouncementTemplateService";
import StatusPageCustomFieldService from "./StatusPageCustomFieldService";
import DashboardDomainService from "./DashboardDomainService";
import StatusPageDomainService from "./StatusPageDomainService";
import StatusPageFooterLinkService from "./StatusPageFooterLinkService";
import StatusPageGroupService from "./StatusPageGroupService";
@@ -305,6 +306,7 @@ const services: Array<BaseService> = [
StatusPageAnnouncementService,
StatusPageAnnouncementTemplateService,
StatusPageCustomFieldService,
DashboardDomainService,
StatusPageDomainService,
StatusPageFooterLinkService,
StatusPageGroupService,

View File

@@ -12,6 +12,10 @@ import {
MASTER_PASSWORD_COOKIE_IDENTIFIER,
MASTER_PASSWORD_COOKIE_MAX_AGE_IN_DAYS,
} from "../../Types/StatusPage/MasterPassword";
import {
DASHBOARD_MASTER_PASSWORD_COOKIE_IDENTIFIER,
DASHBOARD_MASTER_PASSWORD_COOKIE_MAX_AGE_IN_DAYS,
} from "../../Types/Dashboard/MasterPassword";
import CaptureSpan from "./Telemetry/CaptureSpan";
export default class CookieUtil {
@@ -323,6 +327,50 @@ export default class CookieUtil {
);
}
@CaptureSpan()
public static setDashboardMasterPasswordCookie(data: {
expressResponse: ExpressResponse;
dashboardId: ObjectID;
}): void {
const expiresInDays: PositiveNumber = new PositiveNumber(
DASHBOARD_MASTER_PASSWORD_COOKIE_MAX_AGE_IN_DAYS,
);
const token: string = JSONWebToken.signJsonPayload(
{
dashboardId: data.dashboardId.toString(),
type: DASHBOARD_MASTER_PASSWORD_COOKIE_IDENTIFIER,
},
OneUptimeDate.getSecondsInDays(expiresInDays),
);
CookieUtil.setCookie(
data.expressResponse,
CookieUtil.getDashboardMasterPasswordKey(data.dashboardId),
token,
{
maxAge: OneUptimeDate.getMillisecondsInDays(expiresInDays),
httpOnly: true,
},
);
}
@CaptureSpan()
public static removeDashboardMasterPasswordCookie(
res: ExpressResponse,
dashboardId: ObjectID,
): void {
CookieUtil.removeCookie(
res,
CookieUtil.getDashboardMasterPasswordKey(dashboardId),
);
}
@CaptureSpan()
public static getDashboardMasterPasswordKey(id: ObjectID): string {
return `${CookieName.DashboardMasterPassword}-${id.toString()}`;
}
// get all cookies with express request
@CaptureSpan()
public static getAllCookies(req: ExpressRequest): Dictionary<string> {

View File

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

View File

@@ -0,0 +1,10 @@
export const DASHBOARD_MASTER_PASSWORD_REQUIRED_MESSAGE: string =
"Master password required";
export const DASHBOARD_MASTER_PASSWORD_INVALID_MESSAGE: string =
"Invalid master password. Please try again.";
export const DASHBOARD_MASTER_PASSWORD_COOKIE_IDENTIFIER: string =
"dashboard-master-password";
export const DASHBOARD_MASTER_PASSWORD_COOKIE_MAX_AGE_IN_DAYS: number = 7;

View File

@@ -76,6 +76,12 @@ enum Permission {
ReadDashboard = "ReadDashboard",
EditDashboard = "EditDashboard",
// Dashboard Domains
CreateDashboardDomain = "CreateDashboardDomain",
DeleteDashboardDomain = "DeleteDashboardDomain",
EditDashboardDomain = "EditDashboardDomain",
ReadDashboardDomain = "ReadDashboardDomain",
// Logs
CreateTelemetryServiceLog = "CreateTelemetryServiceLog",
DeleteTelemetryServiceLog = "DeleteTelemetryServiceLog",
@@ -1215,6 +1221,44 @@ export class PermissionHelper {
group: PermissionGroup.Settings,
},
// Dashboard Domain permissions.
{
permission: Permission.CreateDashboardDomain,
title: "Create Dashboard Domain",
description:
"This permission can create Dashboard Domains of this project.",
isAssignableToTenant: true,
isAccessControlPermission: false,
group: PermissionGroup.Settings,
},
{
permission: Permission.DeleteDashboardDomain,
title: "Delete Dashboard Domain",
description:
"This permission can delete Dashboard Domains of this project.",
isAssignableToTenant: true,
isAccessControlPermission: false,
group: PermissionGroup.Settings,
},
{
permission: Permission.EditDashboardDomain,
title: "Edit Dashboard Domain",
description:
"This permission can edit Dashboard Domains of this project.",
isAssignableToTenant: true,
isAccessControlPermission: false,
group: PermissionGroup.Settings,
},
{
permission: Permission.ReadDashboardDomain,
title: "Read Dashboard Domain",
description:
"This permission can read Dashboard Domains of this project.",
isAssignableToTenant: true,
isAccessControlPermission: false,
group: PermissionGroup.Settings,
},
// Table view permissions
{