Compare commits

...

34 Commits

Author SHA1 Message Date
Nawaz Dhandala
3f99b9680f feat(Migration): add UserSession and StatusPagePrivateUserSession migrations with constraints and indexes 2025-11-11 19:51:54 +00:00
Nawaz Dhandala
b08c39037d feat(Index): add StatusPagePrivateUserSessionService and UserSessionService to services 2025-11-11 19:49:32 +00:00
Nawaz Dhandala
f7cc3c00da feat(Migration): add migration for StatusPagePrivateUserSession and UserSession tables 2025-11-11 19:48:42 +00:00
Nawaz Dhandala
ac4286935a refactor(StatusPagePrivateUserSession): remove unnecessary blank line for cleaner code
refactor(UserSession): format description for additional info column for improved readability
2025-11-11 19:47:08 +00:00
Nawaz Dhandala
90a0b2e4a8 refactor(StatusPagePrivateUserSession): simplify access control by removing specific permissions 2025-11-11 19:46:19 +00:00
Nawaz Dhandala
9b22c48d27 feat(UserSession): add UserSession model for managing active user sessions and security tokens 2025-11-11 19:41:41 +00:00
Nawaz Dhandala
9c9dad5da0 feat(UserSettings): add user settings page and integrate into side menu 2025-11-11 19:18:20 +00:00
Nawaz Dhandala
e986f74025 fix(TeamMemberService): skip one-member guard when SCIM manages project membership 2025-11-11 19:07:11 +00:00
Nawaz Dhandala
bb85c9f8c8 refactor(BaseModelTable): enhance filter function for dropdown labels to improve type safety 2025-11-11 17:23:14 +00:00
Nawaz Dhandala
25ab1cdbf9 refactor(BaseModelTable): improve code formatting and simplify access control value handling 2025-11-11 17:21:51 +00:00
Nawaz Dhandala
44b8a9ddc9 feat(BaseModelTable): integrate access control column handling and update dropdown labels with color support 2025-11-11 17:16:52 +00:00
Nawaz Dhandala
c388ff9550 fix(IncidentCreate): remove unnecessary whitespace in color mapping function 2025-11-11 15:59:04 +00:00
Nawaz Dhandala
321d1680e6 feat(IncidentCreate): enhance incident state options with color attribute 2025-11-11 15:58:13 +00:00
Nawaz Dhandala
6c0e9f0fed refactor: remove unnecessary whitespace in model classes and improve code formatting 2025-11-11 15:49:20 +00:00
Nawaz Dhandala
99349ecb30 fix: restore @ColorField decorator in multiple model classes for color handling 2025-11-11 15:47:57 +00:00
Nawaz Dhandala
258bbbd9cf feat(BaseModelTable): enhance dropdown options with color handling based on the first color column 2025-11-11 15:37:56 +00:00
Nawaz Dhandala
1094a07fc6 fix(DropdownUtil): ensure color variable is explicitly typed as Color | null
refactor(ColorField): add return type annotation for ColorField function
2025-11-11 14:30:33 +00:00
Nawaz Dhandala
14a5671645 feat(Color, Dropdown, ModelForm): enhance color handling in Color class and Dropdown options 2025-11-11 14:05:30 +00:00
Nawaz Dhandala
5a41c66953 refactor(DatabaseBaseModel, ColorField): improve formatting and readability of type definitions and method implementations 2025-11-11 13:26:02 +00:00
Nawaz Dhandala
af605fce4c feat(DatabaseBaseModel): add getFirstColorColumn method to retrieve the first color field column 2025-11-11 13:23:55 +00:00
Nawaz Dhandala
f8ef6c69fe feat(ColorField): add ColorField decorator and related utility functions for color field management in database models 2025-11-11 13:20:42 +00:00
Simon Larsen
e1848f44f7 Merge pull request #2100 from OneUptime/dropdown-lbl
Dropdown lbl
2025-11-11 12:49:54 +00:00
Nawaz Dhandala
825bd39dda feat(Dropdown): enhance type definitions for styling functions and improve label rendering 2025-11-11 12:49:25 +00:00
Nawaz Dhandala
b99905dfe8 fix(Telemetry): add npm install step for Common directory in workflow 2025-11-11 12:45:06 +00:00
Nawaz Dhandala
a4bf40a2c1 fix(Label): correct indentation for icon property in LabelElement 2025-11-11 12:44:05 +00:00
Nawaz Dhandala
711998b048 feat(Icon): add strokeWidth to EmptyCircle icon rendering 2025-11-11 12:42:36 +00:00
Nawaz Dhandala
132e044c07 feat(Icon): add EmptyCircle icon and update Label to use it 2025-11-11 12:16:01 +00:00
Nawaz Dhandala
8ecc307451 feat(Dropdown): implement label rendering and color resolution for selected labels 2025-11-11 12:02:49 +00:00
Nawaz Dhandala
c85c29989f feat(Dropdown): update styling for focused and selected states, enhance multi-value appearance 2025-11-10 23:29:16 +00:00
Nawaz Dhandala
95726e0f21 feat(Dropdown): enhance dropdown options with label support and improve styling 2025-11-10 23:13:10 +00:00
Nawaz Dhandala
adc15992e9 refactor(Label): simplify import statements for Pill component 2025-11-10 22:49:36 +00:00
Nawaz Dhandala
58d83a2a80 feat(Pill): add thickness prop to Icon component in Pill 2025-11-10 22:48:57 +00:00
Nawaz Dhandala
5461cd4502 feat(Pill): add icon support to Pill component and update tests 2025-11-10 22:44:07 +00:00
Nawaz Dhandala
478465a65b refactor: update label imports and restructure label components for consistency 2025-11-10 22:34:33 +00:00
77 changed files with 1930 additions and 150 deletions

View File

@@ -17,5 +17,6 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: latest
- run: cd Common && npm install
- run: cd Telemetry && npm install && npm run test

View File

@@ -26,6 +26,7 @@ import {
} from "react-router-dom";
import UserView from "./Pages/Users/View/Index";
import UserDelete from "./Pages/Users/View/Delete";
import UserSettings from "./Pages/Users/View/Settings";
import ProjectView from "./Pages/Projects/View/Index";
import ProjectDelete from "./Pages/Projects/View/Delete";
@@ -71,6 +72,11 @@ const App: () => JSX.Element = () => {
element={<UserView />}
/>
<PageRoute
path={RouteMap[PageMap.USER_SETTINGS]?.toString() || ""}
element={<UserSettings />}
/>
<PageRoute
path={RouteMap[PageMap.USER_DELETE]?.toString() || ""}
element={<UserDelete />}

View File

@@ -0,0 +1,94 @@
import PageMap from "../../../Utils/PageMap";
import RouteMap, { RouteUtil } from "../../../Utils/RouteMap";
import Route from "Common/Types/API/Route";
import ObjectID from "Common/Types/ObjectID";
import Navigation from "Common/UI/Utils/Navigation";
import React, { FunctionComponent, ReactElement } from "react";
import SideMenuComponent from "./SideMenu";
import User from "Common/Models/DatabaseModels/User";
import ModelPage from "Common/UI/Components/Page/ModelPage";
import CardModelDetail from "Common/UI/Components/ModelDetail/CardModelDetail";
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
import FieldType from "Common/UI/Components/Types/FieldType";
const UserSettings: FunctionComponent = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
return (
<ModelPage<User>
modelId={modelId}
modelNameField="email"
modelType={User}
title={"User"}
breadcrumbLinks={[
{
title: "Admin Dashboard",
to: RouteUtil.populateRouteParams(RouteMap[PageMap.HOME] as Route),
},
{
title: "Users",
to: RouteUtil.populateRouteParams(RouteMap[PageMap.USERS] as Route),
},
{
title: "User",
to: RouteUtil.populateRouteParams(
RouteMap[PageMap.USER_VIEW] as Route,
{
modelId: modelId,
},
),
},
{
title: "Settings",
to: RouteUtil.populateRouteParams(
RouteMap[PageMap.USER_SETTINGS] as Route,
{
modelId: modelId,
},
),
},
]}
sideMenu={<SideMenuComponent modelId={modelId} />}
>
<CardModelDetail<User>
name="user-master-admin-settings"
cardProps={{
title: "Master Admin Access",
description:
"Grant or revoke master admin access for this user. Master admins can manage every project and workspace.",
}}
isEditable={true}
editButtonText="Update Access"
formFields={[
{
field: {
isMasterAdmin: true,
},
title: "Master Admin",
description:
"Enable to give this user full access to the entire platform.",
fieldType: FormFieldSchemaType.Toggle,
required: true,
},
]}
modelDetailProps={{
modelType: User,
id: "user-master-admin-settings-detail",
fields: [
{
field: {
isMasterAdmin: true,
},
title: "Master Admin",
fieldType: FieldType.Boolean,
placeholder: "No",
},
],
modelId: modelId,
}}
/>
</ModelPage>
);
};
export default UserSettings;

View File

@@ -30,6 +30,18 @@ const SideMenuComponent: FunctionComponent<SideMenuProps> = (
}}
icon={IconProp.Info}
/>
<SideMenuItem
link={{
title: "Settings",
to: RouteUtil.populateRouteParams(
RouteMap[PageMap.USER_SETTINGS] as Route,
{
modelId: props.modelId,
},
),
}}
icon={IconProp.Settings}
/>
</SideMenuSection>
<SideMenuSection title="Advanced">

View File

@@ -6,6 +6,7 @@ enum PageMap {
USERS = "USERS",
USER_VIEW = "USER_VIEW",
USER_SETTINGS = "USER_SETTINGS",
USER_DELETE = "USER_DELETE",
PROJECTS = "PROJECTS",

View File

@@ -18,6 +18,9 @@ const RouteMap: Dictionary<Route> = {
[PageMap.USERS]: new Route(`/admin/users`),
[PageMap.USER_VIEW]: new Route(`/admin/users/${RouteParams.ModelID}`),
[PageMap.USER_SETTINGS]: new Route(
`/admin/users/${RouteParams.ModelID}/settings`,
),
[PageMap.USER_DELETE]: new Route(
`/admin/users/${RouteParams.ModelID}/delete`,
),

View File

@@ -10,6 +10,7 @@ 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 ColorField from "../../Types/Database/ColorField";
import TableColumn from "../../Types/Database/TableColumn";
import TableColumnType from "../../Types/Database/TableColumnType";
import TableMetadata from "../../Types/Database/TableMetadata";
@@ -418,6 +419,7 @@ export default class AlertFeed extends BaseModel {
],
update: [],
})
@ColorField()
@TableColumn({
type: TableColumnType.Color,
required: true,

View File

@@ -12,6 +12,7 @@ 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 ColorField from "../../Types/Database/ColorField";
import SlugifyColumn from "../../Types/Database/SlugifyColumn";
import TableColumn from "../../Types/Database/TableColumn";
import TableColumnType from "../../Types/Database/TableColumnType";
@@ -378,6 +379,7 @@ export default class AlertSeverity extends BaseModel {
Permission.EditAlertSeverity,
],
})
@ColorField()
@TableColumn({
title: "Color",
required: true,

View File

@@ -12,6 +12,7 @@ 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 ColorField from "../../Types/Database/ColorField";
import SlugifyColumn from "../../Types/Database/SlugifyColumn";
import TableColumn from "../../Types/Database/TableColumn";
import TableColumnType from "../../Types/Database/TableColumnType";
@@ -356,6 +357,7 @@ export default class AlertState extends BaseModel {
Permission.EditAlertState,
],
})
@ColorField()
@TableColumn({
title: "Color",
required: true,

View File

@@ -15,6 +15,7 @@ import TableColumn, {
getTableColumns,
} from "../../../Types/Database/TableColumn";
import TableColumnType from "../../../Types/Database/TableColumnType";
import { getFirstColorFieldColumn } from "../../../Types/Database/ColorField";
import Dictionary from "../../../Types/Dictionary";
import Email from "../../../Types/Email";
import BadDataException from "../../../Types/Exception/BadDataException";
@@ -203,6 +204,10 @@ export default class DatabaseBaseModel extends BaseEntity {
return new Columns(Object.keys(getTableColumns(this)));
}
public getFirstColorColumn(): string | null {
return getFirstColorFieldColumn(this);
}
public canQueryMultiTenant(): boolean {
return Boolean(this.isMultiTenantRequestAllowed);
}

View File

@@ -12,6 +12,7 @@ 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 ColorField from "../../Types/Database/ColorField";
import SlugifyColumn from "../../Types/Database/SlugifyColumn";
import TableColumn from "../../Types/Database/TableColumn";
import TableColumnType from "../../Types/Database/TableColumnType";
@@ -378,6 +379,7 @@ export default class IncidentSeverity extends BaseModel {
Permission.EditIncidentSeverity,
],
})
@ColorField()
@TableColumn({
title: "Color",
required: true,

View File

@@ -12,6 +12,7 @@ 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 ColorField from "../../Types/Database/ColorField";
import SlugifyColumn from "../../Types/Database/SlugifyColumn";
import TableColumn from "../../Types/Database/TableColumn";
import TableColumnType from "../../Types/Database/TableColumnType";
@@ -380,6 +381,7 @@ export default class IncidentState extends BaseModel {
Permission.EditIncidentState,
],
})
@ColorField()
@TableColumn({
title: "Color",
required: true,

View File

@@ -118,6 +118,7 @@ import StatusPageHistoryChartBarColorRule from "./StatusPageHistoryChartBarColor
import StatusPageOwnerTeam from "./StatusPageOwnerTeam";
import StatusPageOwnerUser from "./StatusPageOwnerUser";
import StatusPagePrivateUser from "./StatusPagePrivateUser";
import StatusPagePrivateUserSession from "./StatusPagePrivateUserSession";
import StatusPageResource from "./StatusPageResource";
import StatusPageSCIM from "./StatusPageSCIM";
import StatusPageSSO from "./StatusPageSso";
@@ -130,6 +131,7 @@ import TeamComplianceSetting from "./TeamComplianceSetting";
import TelemetryService from "./TelemetryService";
import UsageBilling from "./TelemetryUsageBilling";
import User from "./User";
import UserSession from "./UserSession";
import UserCall from "./UserCall";
// Notification Methods
import UserEmail from "./UserEmail";
@@ -266,6 +268,7 @@ const AllModelTypes: Array<{
StatusPageFooterLink,
StatusPageHeaderLink,
StatusPagePrivateUser,
StatusPagePrivateUserSession,
StatusPageHistoryChartBarColorRule,
ScheduledMaintenanceState,
@@ -375,6 +378,7 @@ const AllModelTypes: Array<{
ProbeOwnerTeam,
ProbeOwnerUser,
UserSession,
UserTotpAuth,
UserWebAuthn,

View File

@@ -12,6 +12,7 @@ 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 ColorField from "../../Types/Database/ColorField";
import SlugifyColumn from "../../Types/Database/SlugifyColumn";
import TableColumn from "../../Types/Database/TableColumn";
import TableColumnType from "../../Types/Database/TableColumnType";
@@ -365,6 +366,7 @@ export default class Label extends AccessControlModel {
Permission.EditProjectLabel,
],
})
@ColorField()
@TableColumn({
title: "Color",
required: true,

View File

@@ -12,6 +12,7 @@ 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 ColorField from "../../Types/Database/ColorField";
import SlugifyColumn from "../../Types/Database/SlugifyColumn";
import TableColumn from "../../Types/Database/TableColumn";
import TableColumnType from "../../Types/Database/TableColumnType";
@@ -379,6 +380,7 @@ export default class MonitorStatus extends BaseModel {
Permission.EditProjectMonitorStatus,
],
})
@ColorField()
@TableColumn({
title: "Color",
required: true,

View File

@@ -10,6 +10,7 @@ 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 ColorField from "../../Types/Database/ColorField";
import TableColumn from "../../Types/Database/TableColumn";
import TableColumnType from "../../Types/Database/TableColumnType";
import TableMetadata from "../../Types/Database/TableMetadata";
@@ -422,6 +423,7 @@ export default class ScheduledMaintenanceFeed extends BaseModel {
],
update: [],
})
@ColorField()
@TableColumn({
type: TableColumnType.Color,
required: true,

View File

@@ -12,6 +12,7 @@ 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 ColorField from "../../Types/Database/ColorField";
import SlugifyColumn from "../../Types/Database/SlugifyColumn";
import TableColumn from "../../Types/Database/TableColumn";
import TableColumnType from "../../Types/Database/TableColumnType";
@@ -380,6 +381,7 @@ export default class ScheduledMaintenanceState extends BaseModel {
Permission.EditScheduledMaintenanceState,
],
})
@ColorField()
@TableColumn({
title: "Color",
required: true,

View File

@@ -14,6 +14,7 @@ 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 ColorField from "../../Types/Database/ColorField";
import SlugifyColumn from "../../Types/Database/SlugifyColumn";
import TableColumn from "../../Types/Database/TableColumn";
import TableColumnType from "../../Types/Database/TableColumnType";
@@ -448,6 +449,7 @@ export default class ServiceCatalog extends BaseModel {
Permission.EditServiceCatalog,
],
})
@ColorField()
@TableColumn({
type: TableColumnType.Color,
title: "Service Color",

View File

@@ -0,0 +1,413 @@
import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel";
import Project from "./Project";
import StatusPage from "./StatusPage";
import StatusPagePrivateUser from "./StatusPagePrivateUser";
import Route from "../../Types/API/Route";
import { PlanType } from "../../Types/Billing/SubscriptionPlan";
import AllowAccessIfSubscriptionIsUnpaid from "../../Types/Database/AccessControl/AllowAccessIfSubscriptionIsUnpaid";
import ColumnAccessControl from "../../Types/Database/AccessControl/ColumnAccessControl";
import TableAccessControl from "../../Types/Database/AccessControl/TableAccessControl";
import TableBillingAccessControl from "../../Types/Database/AccessControl/TableBillingAccessControl";
import CanAccessIfCanReadOn from "../../Types/Database/CanAccessIfCanReadOn";
import ColumnLength from "../../Types/Database/ColumnLength";
import ColumnType from "../../Types/Database/ColumnType";
import CrudApiEndpoint from "../../Types/Database/CrudApiEndpoint";
import TableColumn from "../../Types/Database/TableColumn";
import TableColumnType from "../../Types/Database/TableColumnType";
import TableMetadata from "../../Types/Database/TableMetadata";
import TenantColumn from "../../Types/Database/TenantColumn";
import HashedString from "../../Types/HashedString";
import IconProp from "../../Types/Icon/IconProp";
import { JSONObject } from "../../Types/JSON";
import ObjectID from "../../Types/ObjectID";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
@AllowAccessIfSubscriptionIsUnpaid()
@TableBillingAccessControl({
create: PlanType.Growth,
read: PlanType.Growth,
update: PlanType.Growth,
delete: PlanType.Growth,
})
@CanAccessIfCanReadOn("statusPage")
@TenantColumn("projectId")
@TableAccessControl({
create: [],
read: [],
delete: [],
update: [],
})
@CrudApiEndpoint(new Route("/status-page-private-user-session"))
@Entity({
name: "StatusPagePrivateUserSession",
})
@TableMetadata({
tableName: "StatusPagePrivateUserSession",
singularName: "Status Page Private User Session",
pluralName: "Status Page Private User Sessions",
icon: IconProp.Lock,
tableDescription:
"Stores status page private user sessions, refresh tokens, and device metadata for secure access control.",
})
export default class StatusPagePrivateUserSession extends BaseModel {
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "projectId",
type: TableColumnType.Entity,
modelType: Project,
title: "Project",
description: "Project that owns this private status page session.",
})
@ManyToOne(
() => {
return Project;
},
{
eager: false,
nullable: true,
onDelete: "CASCADE",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "projectId" })
public project?: Project = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
title: "Project ID",
description: "Project identifier for this session.",
required: true,
canReadOnRelationQuery: true,
})
@Column({
type: ColumnType.ObjectID,
nullable: false,
transformer: ObjectID.getDatabaseTransformer(),
})
public projectId?: ObjectID = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "statusPageId",
type: TableColumnType.Entity,
modelType: StatusPage,
title: "Status Page",
description: "Status page associated with this session.",
})
@ManyToOne(
() => {
return StatusPage;
},
{
eager: false,
nullable: true,
onDelete: "CASCADE",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "statusPageId" })
public statusPage?: StatusPage = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
title: "Status Page ID",
description: "Identifier for the status page.",
required: true,
})
@Column({
type: ColumnType.ObjectID,
nullable: false,
transformer: ObjectID.getDatabaseTransformer(),
})
public statusPageId?: ObjectID = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "statusPagePrivateUserId",
type: TableColumnType.Entity,
modelType: StatusPagePrivateUser,
title: "Status Page Private User",
description: "Private user record associated with this session.",
})
@ManyToOne(
() => {
return StatusPagePrivateUser;
},
{
eager: false,
nullable: false,
onDelete: "CASCADE",
orphanedRowAction: "delete",
},
)
@JoinColumn({ name: "statusPagePrivateUserId" })
public statusPagePrivateUser?: StatusPagePrivateUser = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
title: "Status Page Private User ID",
description: "Identifier for the status page private user.",
required: true,
canReadOnRelationQuery: true,
})
@Column({
type: ColumnType.ObjectID,
nullable: false,
transformer: ObjectID.getDatabaseTransformer(),
})
public statusPagePrivateUserId?: ObjectID = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@Index({ unique: true })
@TableColumn({
type: TableColumnType.HashedString,
title: "Refresh Token",
description: "Hashed refresh token for the private user session.",
required: true,
hideColumnInDocumentation: true,
})
@Column({
type: ColumnType.HashedString,
length: ColumnLength.HashedString,
nullable: false,
unique: true,
transformer: HashedString.getDatabaseTransformer(),
})
public refreshToken?: HashedString = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
type: TableColumnType.Date,
title: "Refresh Token Expires At",
description: "Expiration timestamp for the refresh token.",
required: true,
})
@Column({
type: ColumnType.Date,
nullable: false,
})
public refreshTokenExpiresAt?: Date = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
type: TableColumnType.Date,
title: "Last Active At",
description: "Last time this session was active.",
})
@Column({
type: ColumnType.Date,
nullable: true,
})
public lastActiveAt?: Date = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
type: TableColumnType.ShortText,
title: "Device Name",
description: "Friendly name for the device used to access the status page.",
})
@Column({
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
nullable: true,
})
public deviceName?: string = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
type: TableColumnType.ShortText,
title: "Device Type",
description: "Type of device (desktop, mobile, etc).",
})
@Column({
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
nullable: true,
})
public deviceType?: string = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
type: TableColumnType.ShortText,
title: "Device OS",
description: "Operating system reported for this session.",
})
@Column({
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
nullable: true,
})
public deviceOS?: string = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
type: TableColumnType.ShortText,
title: "Browser",
description: "Browser or client application used for the session.",
})
@Column({
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
nullable: true,
})
public deviceBrowser?: string = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
type: TableColumnType.ShortText,
title: "IP Address",
description: "IP address recorded for this session.",
})
@Column({
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
nullable: true,
})
public ipAddress?: string = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
type: TableColumnType.VeryLongText,
title: "User Agent",
description: "User agent string supplied by the client.",
})
@Column({
type: ColumnType.VeryLongText,
nullable: true,
})
public userAgent?: string = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
type: TableColumnType.Boolean,
title: "Is Revoked",
description: "Indicates if the session has been revoked.",
isDefaultValueColumn: true,
defaultValue: false,
})
@Column({
type: ColumnType.Boolean,
nullable: false,
default: false,
})
public isRevoked?: boolean = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
type: TableColumnType.Date,
title: "Revoked At",
description: "Timestamp when the session was revoked, if applicable.",
})
@Column({
type: ColumnType.Date,
nullable: true,
})
public revokedAt?: Date = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
type: TableColumnType.ShortText,
title: "Revoked Reason",
description: "Reason provided for revoking this session.",
})
@Column({
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
nullable: true,
})
public revokedReason?: string = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
type: TableColumnType.JSON,
title: "Additional Info",
description: "Flexible JSON payload for storing structured metadata.",
})
@Column({
type: ColumnType.JSON,
nullable: true,
})
public additionalInfo?: JSONObject = undefined;
}

View File

@@ -12,6 +12,7 @@ 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 ColorField from "../../Types/Database/ColorField";
import SlugifyColumn from "../../Types/Database/SlugifyColumn";
import TableColumn from "../../Types/Database/TableColumn";
import TableColumnType from "../../Types/Database/TableColumnType";
@@ -505,6 +506,7 @@ export default class TelemetryService extends BaseModel {
Permission.EditTelemetryService,
],
})
@ColorField()
@TableColumn({
type: TableColumnType.Color,
title: "Service Color",

View File

@@ -0,0 +1,318 @@
import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel";
import User from "./User";
import Route from "../../Types/API/Route";
import AllowAccessIfSubscriptionIsUnpaid from "../../Types/Database/AccessControl/AllowAccessIfSubscriptionIsUnpaid";
import ColumnAccessControl from "../../Types/Database/AccessControl/ColumnAccessControl";
import TableAccessControl from "../../Types/Database/AccessControl/TableAccessControl";
import ColumnLength from "../../Types/Database/ColumnLength";
import ColumnType from "../../Types/Database/ColumnType";
import CrudApiEndpoint from "../../Types/Database/CrudApiEndpoint";
import CurrentUserCanAccessRecordBy from "../../Types/Database/CurrentUserCanAccessRecordBy";
import EnableDocumentation from "../../Types/Database/EnableDocumentation";
import TableColumn from "../../Types/Database/TableColumn";
import TableColumnType from "../../Types/Database/TableColumnType";
import TableMetadata from "../../Types/Database/TableMetadata";
import HashedString from "../../Types/HashedString";
import IconProp from "../../Types/Icon/IconProp";
import { JSONObject } from "../../Types/JSON";
import ObjectID from "../../Types/ObjectID";
import Permission from "../../Types/Permission";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
@EnableDocumentation({
isMasterAdminApiDocs: true,
})
@AllowAccessIfSubscriptionIsUnpaid()
@TableAccessControl({
create: [Permission.CurrentUser],
read: [Permission.CurrentUser],
delete: [Permission.CurrentUser],
update: [Permission.CurrentUser],
})
@CrudApiEndpoint(new Route("/user-session"))
@Entity({
name: "UserSession",
})
@TableMetadata({
tableName: "UserSession",
singularName: "User Session",
pluralName: "User Sessions",
icon: IconProp.Lock,
tableDescription:
"Active user sessions with refresh tokens and device metadata for enhanced authentication security.",
})
@CurrentUserCanAccessRecordBy("userId")
class UserSession extends BaseModel {
@ColumnAccessControl({
create: [Permission.CurrentUser],
read: [Permission.CurrentUser],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "userId",
type: TableColumnType.Entity,
modelType: User,
title: "User",
description: "User account this session belongs to.",
})
@ManyToOne(
() => {
return User;
},
{
eager: false,
nullable: false,
onDelete: "CASCADE",
orphanedRowAction: "delete",
},
)
@JoinColumn({ name: "userId" })
public user?: User = undefined;
@ColumnAccessControl({
create: [Permission.CurrentUser],
read: [Permission.CurrentUser],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: true,
title: "User ID",
description: "Identifier for the user that owns this session.",
canReadOnRelationQuery: true,
})
@Column({
type: ColumnType.ObjectID,
nullable: false,
transformer: ObjectID.getDatabaseTransformer(),
})
public userId?: ObjectID = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@Index({ unique: true })
@TableColumn({
type: TableColumnType.HashedString,
title: "Refresh Token",
description: "Hashed refresh token for this session.",
required: true,
hideColumnInDocumentation: true,
})
@Column({
type: ColumnType.HashedString,
length: ColumnLength.HashedString,
nullable: false,
unique: true,
transformer: HashedString.getDatabaseTransformer(),
})
public refreshToken?: HashedString = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
type: TableColumnType.Date,
title: "Refresh Token Expires At",
description: "Expiration timestamp for the refresh token.",
required: true,
})
@Column({
type: ColumnType.Date,
nullable: false,
})
public refreshTokenExpiresAt?: Date = undefined;
@ColumnAccessControl({
create: [],
read: [Permission.CurrentUser],
update: [],
})
@TableColumn({
type: TableColumnType.Date,
title: "Last Active At",
description: "Last time this session was used.",
})
@Column({
type: ColumnType.Date,
nullable: true,
})
public lastActiveAt?: Date = undefined;
@ColumnAccessControl({
create: [],
read: [Permission.CurrentUser],
update: [],
})
@TableColumn({
type: TableColumnType.ShortText,
title: "Device Name",
description: "Friendly name for the device used to sign in.",
})
@Column({
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
nullable: true,
})
public deviceName?: string = undefined;
@ColumnAccessControl({
create: [],
read: [Permission.CurrentUser],
update: [],
})
@TableColumn({
type: TableColumnType.ShortText,
title: "Device Type",
description: "Type of device (e.g., desktop, mobile).",
})
@Column({
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
nullable: true,
})
public deviceType?: string = undefined;
@ColumnAccessControl({
create: [],
read: [Permission.CurrentUser],
update: [],
})
@TableColumn({
type: TableColumnType.ShortText,
title: "Device OS",
description: "Operating system reported for this session.",
})
@Column({
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
nullable: true,
})
public deviceOS?: string = undefined;
@ColumnAccessControl({
create: [],
read: [Permission.CurrentUser],
update: [],
})
@TableColumn({
type: TableColumnType.ShortText,
title: "Browser",
description: "Browser or client application used for this session.",
})
@Column({
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
nullable: true,
})
public deviceBrowser?: string = undefined;
@ColumnAccessControl({
create: [],
read: [Permission.CurrentUser],
update: [],
})
@TableColumn({
type: TableColumnType.ShortText,
title: "IP Address",
description: "IP address observed for this session.",
})
@Column({
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
nullable: true,
})
public ipAddress?: string = undefined;
@ColumnAccessControl({
create: [],
read: [Permission.CurrentUser],
update: [],
})
@TableColumn({
type: TableColumnType.VeryLongText,
title: "User Agent",
description: "Complete user agent string supplied by the client.",
})
@Column({
type: ColumnType.VeryLongText,
nullable: true,
})
public userAgent?: string = undefined;
@ColumnAccessControl({
create: [],
read: [Permission.CurrentUser],
update: [],
})
@TableColumn({
type: TableColumnType.Boolean,
title: "Is Revoked",
description: "Marks whether the session has been explicitly revoked.",
isDefaultValueColumn: true,
defaultValue: false,
})
@Column({
type: ColumnType.Boolean,
nullable: false,
default: false,
})
public isRevoked?: boolean = undefined;
@ColumnAccessControl({
create: [],
read: [Permission.CurrentUser],
update: [],
})
@TableColumn({
type: TableColumnType.Date,
title: "Revoked At",
description: "Timestamp when the session was revoked, if applicable.",
})
@Column({
type: ColumnType.Date,
nullable: true,
})
public revokedAt?: Date = undefined;
@ColumnAccessControl({
create: [],
read: [Permission.CurrentUser],
update: [],
})
@TableColumn({
type: TableColumnType.ShortText,
title: "Revoked Reason",
description: "Optional reason describing why the session was revoked.",
})
@Column({
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
nullable: true,
})
public revokedReason?: string = undefined;
@ColumnAccessControl({
create: [],
read: [Permission.CurrentUser],
update: [],
})
@TableColumn({
type: TableColumnType.JSON,
title: "Additional Info",
description:
"Flexible JSON payload for storing structured session metadata.",
})
@Column({
type: ColumnType.JSON,
nullable: true,
})
public additionalInfo?: JSONObject = undefined;
}
export default UserSession;

View File

@@ -0,0 +1,91 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class MigrationName1762890441920 implements MigrationInterface {
public name = "MigrationName1762890441920";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "StatusPagePrivateUserSession" ("_id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP WITH TIME ZONE, "version" integer NOT NULL, "projectId" uuid NOT NULL, "statusPageId" uuid NOT NULL, "statusPagePrivateUserId" uuid NOT NULL, "refreshToken" character varying(64) NOT NULL, "refreshTokenExpiresAt" TIMESTAMP WITH TIME ZONE NOT NULL, "lastActiveAt" TIMESTAMP WITH TIME ZONE, "deviceName" character varying(100), "deviceType" character varying(100), "deviceOS" character varying(100), "deviceBrowser" character varying(100), "ipAddress" character varying(100), "userAgent" text, "isRevoked" boolean NOT NULL DEFAULT false, "revokedAt" TIMESTAMP WITH TIME ZONE, "revokedReason" character varying(100), "additionalInfo" jsonb, CONSTRAINT "UQ_12ce827a16d121bf6719260b8a9" UNIQUE ("refreshToken"), CONSTRAINT "PK_cbace84fe4c9712b94e571dc133" PRIMARY KEY ("_id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_ac5f4c13d6bc9696cbfb8e5a79" ON "StatusPagePrivateUserSession" ("projectId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_7b8d9b6e068c045d56b47a484b" ON "StatusPagePrivateUserSession" ("statusPageId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_365d602943505272f8f651ff4e" ON "StatusPagePrivateUserSession" ("statusPagePrivateUserId") `,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_12ce827a16d121bf6719260b8a" ON "StatusPagePrivateUserSession" ("refreshToken") `,
);
await queryRunner.query(
`CREATE TABLE "UserSession" ("_id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP WITH TIME ZONE, "version" integer NOT NULL, "userId" uuid NOT NULL, "refreshToken" character varying(64) NOT NULL, "refreshTokenExpiresAt" TIMESTAMP WITH TIME ZONE NOT NULL, "lastActiveAt" TIMESTAMP WITH TIME ZONE, "deviceName" character varying(100), "deviceType" character varying(100), "deviceOS" character varying(100), "deviceBrowser" character varying(100), "ipAddress" character varying(100), "userAgent" text, "isRevoked" boolean NOT NULL DEFAULT false, "revokedAt" TIMESTAMP WITH TIME ZONE, "revokedReason" character varying(100), "additionalInfo" jsonb, CONSTRAINT "UQ_d66bd8342b0005c7192bdb17efc" UNIQUE ("refreshToken"), CONSTRAINT "PK_9dcd180f25755bab5fcebcbeb14" PRIMARY KEY ("_id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_7353eaf92987aeaf38c2590e94" ON "UserSession" ("userId") `,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_d66bd8342b0005c7192bdb17ef" ON "UserSession" ("refreshToken") `,
);
await queryRunner.query(
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type":"Recurring","value":{"intervalType":"Day","intervalCount":{"_type":"PositiveNumber","value":1}}}'`,
);
await queryRunner.query(
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type":"RestrictionTimes","value":{"restictionType":"None","dayRestrictionTimes":null,"weeklyRestrictionTimes":[]}}'`,
);
await queryRunner.query(
`ALTER TABLE "StatusPagePrivateUserSession" ADD CONSTRAINT "FK_ac5f4c13d6bc9696cbfb8e5a794" FOREIGN KEY ("projectId") REFERENCES "Project"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "StatusPagePrivateUserSession" ADD CONSTRAINT "FK_7b8d9b6e068c045d56b47a484be" FOREIGN KEY ("statusPageId") REFERENCES "StatusPage"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "StatusPagePrivateUserSession" ADD CONSTRAINT "FK_365d602943505272f8f651ff4e8" FOREIGN KEY ("statusPagePrivateUserId") REFERENCES "StatusPagePrivateUser"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "UserSession" ADD CONSTRAINT "FK_7353eaf92987aeaf38c2590e943" FOREIGN KEY ("userId") REFERENCES "User"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "UserSession" DROP CONSTRAINT "FK_7353eaf92987aeaf38c2590e943"`,
);
await queryRunner.query(
`ALTER TABLE "StatusPagePrivateUserSession" DROP CONSTRAINT "FK_365d602943505272f8f651ff4e8"`,
);
await queryRunner.query(
`ALTER TABLE "StatusPagePrivateUserSession" DROP CONSTRAINT "FK_7b8d9b6e068c045d56b47a484be"`,
);
await queryRunner.query(
`ALTER TABLE "StatusPagePrivateUserSession" DROP CONSTRAINT "FK_ac5f4c13d6bc9696cbfb8e5a794"`,
);
await queryRunner.query(
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type": "RestrictionTimes", "value": {"restictionType": "None", "dayRestrictionTimes": null, "weeklyRestrictionTimes": []}}'`,
);
await queryRunner.query(
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type": "Recurring", "value": {"intervalType": "Day", "intervalCount": {"_type": "PositiveNumber", "value": 1}}}'`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_d66bd8342b0005c7192bdb17ef"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_7353eaf92987aeaf38c2590e94"`,
);
await queryRunner.query(`DROP TABLE "UserSession"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_12ce827a16d121bf6719260b8a"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_365d602943505272f8f651ff4e"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_7b8d9b6e068c045d56b47a484b"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_ac5f4c13d6bc9696cbfb8e5a79"`,
);
await queryRunner.query(`DROP TABLE "StatusPagePrivateUserSession"`);
}
}

View File

@@ -181,6 +181,7 @@ import { MigrationName1761232578396 } from "./1761232578396-MigrationName";
import { MigrationName1761834523183 } from "./1761834523183-MigrationName";
import { MigrationName1762181014879 } from "./1762181014879-MigrationName";
import { MigrationName1762554602716 } from "./1762554602716-MigrationName";
import { MigrationName1762890441920 } from "./1762890441920-MigrationName";
export default [
InitialMigration,
@@ -366,4 +367,5 @@ export default [
MigrationName1761834523183,
MigrationName1762181014879,
MigrationName1762554602716,
MigrationName1762890441920,
];

View File

@@ -106,6 +106,7 @@ import StatusPageHistoryChartBarColorRuleService from "./StatusPageHistoryChartB
import StatusPageOwnerTeamService from "./StatusPageOwnerTeamService";
import StatusPageOwnerUserService from "./StatusPageOwnerUserService";
import StatusPagePrivateUserService from "./StatusPagePrivateUserService";
import StatusPagePrivateUserSessionService from "./StatusPagePrivateUserSessionService";
import StatusPageResourceService from "./StatusPageResourceService";
// Status Page
import StatusPageService from "./StatusPageService";
@@ -125,6 +126,7 @@ import UserNotificationSettingService from "./UserNotificationSettingService";
import UserOnCallLogService from "./UserOnCallLogService";
import UserOnCallLogTimelineService from "./UserOnCallLogTimelineService";
import UserService from "./UserService";
import UserSessionService from "./UserSessionService";
import UserTotpAuthService from "./UserTotpAuthService";
import UserWebAuthnService from "./UserWebAuthnService";
import UserSmsService from "./UserSmsService";
@@ -266,6 +268,7 @@ const services: Array<BaseService> = [
StatusPageOwnerTeamService,
StatusPageOwnerUserService,
StatusPagePrivateUserService,
StatusPagePrivateUserSessionService,
StatusPageResourceService,
StatusPageService,
StatusPageSsoService,
@@ -278,6 +281,7 @@ const services: Array<BaseService> = [
TeamService,
UserService,
UserSessionService,
UserCallService,
UserEmailService,
UserNotificationRuleService,

View File

@@ -0,0 +1,10 @@
import DatabaseService from "./DatabaseService";
import Model from "../../Models/DatabaseModels/StatusPagePrivateUserSession";
export class Service extends DatabaseService<Model> {
public constructor() {
super(Model);
}
}
export default new Service();

View File

@@ -345,7 +345,12 @@ export class TeamMemberService extends DatabaseService<TeamMember> {
},
});
if (membersInTeam.toNumber() <= 1) {
// Skip the one-member guard when SCIM manages membership for the project.
const isSCIMEnabled: boolean = await this.isSCIMEnabled(
member.projectId!,
);
if (!isSCIMEnabled && membersInTeam.toNumber() <= 1) {
throw new BadDataException(
Errors.TeamMemberService.ONE_MEMBER_REQUIRED,
);

View File

@@ -0,0 +1,10 @@
import DatabaseService from "./DatabaseService";
import Model from "../../Models/DatabaseModels/UserSession";
export class Service extends DatabaseService<Model> {
public constructor() {
super(Model);
}
}
export default new Service();

View File

@@ -2,6 +2,7 @@ import Pill, { PillSize } from "../../../UI/Components/Pill/Pill";
import "@testing-library/jest-dom/extend-expect";
import { render, screen } from "@testing-library/react";
import Color from "../../../Types/Color";
import IconProp from "../../../Types/Icon/IconProp";
import * as React from "react";
import { describe, expect, test } from "@jest/globals";
@@ -46,4 +47,11 @@ describe("<Pill />", () => {
render(<Pill text="Love" color={color} size={PillSize.ExtraLarge} />);
expect(screen.getByTestId("pill")).toHaveStyle("backgroundColor: #786598");
});
test("renders icon when provided", () => {
const color: Color = new Color("#807149");
const { container } = render(
<Pill text="Love" color={color} icon={IconProp.Label} />,
);
expect(container.querySelector('[role="icon"]')).not.toBeNull();
});
});

View File

@@ -19,9 +19,9 @@ export default class Color extends DatabaseProperty {
this._color = v;
}
public constructor(color: string) {
public constructor(color: string | Color) {
super();
this.color = color;
this.color = color.toString();
}
public override toString(): string {

View File

@@ -0,0 +1,64 @@
import type BaseModel from "../../Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
import { ReflectionMetadataType } from "../Reflection";
import "reflect-metadata";
const colorFieldSymbol: symbol = Symbol("ColorField");
type ColorFieldColumnsFunction = <T extends BaseModel>(
target: T,
) => Array<string>;
type FirstColorFieldColumnFunction = <T extends BaseModel>(
target: T,
) => string | null;
type IsColorFieldColumnFunction = <T extends BaseModel>(
target: T,
propertyKey: string,
) => boolean;
const ColorField: () => ReflectionMetadataType = () => {
return Reflect.metadata(colorFieldSymbol, true);
};
export const isColorFieldColumn: IsColorFieldColumnFunction = <
T extends BaseModel,
>(
target: T,
propertyKey: string,
): boolean => {
return Boolean(Reflect.getMetadata(colorFieldSymbol, target, propertyKey));
};
export const getColorFieldColumns: ColorFieldColumnsFunction = <
T extends BaseModel,
>(
target: T,
): Array<string> => {
const columns: Array<string> = [];
const keys: Array<string> = Object.keys(target);
for (const key of keys) {
if (isColorFieldColumn(target, key)) {
columns.push(key);
}
}
return columns;
};
export const getFirstColorFieldColumn: FirstColorFieldColumnFunction = <
T extends BaseModel,
>(
target: T,
): string | null => {
const columns: Array<string> = getColorFieldColumns(target);
if (columns.length === 0) {
return null;
}
return columns[0] as string;
};
export default ColorField;

View File

@@ -98,6 +98,7 @@ enum IconProp {
False = "False",
Text = "Text",
Circle = "Circle",
EmptyCircle = "EmptyCircle",
Webhook = "Webhook",
SendMessage = "SendMessage",
ExternalLink = "ExternalLink",

View File

@@ -6,13 +6,32 @@ import React, {
useRef,
useState,
} from "react";
import Select, { ControlProps, GroupBase, OptionProps } from "react-select";
import Color from "../../../Types/Color";
import Label from "../../../Models/DatabaseModels/Label";
import Select, {
ControlProps,
CSSObjectWithLabel,
FormatOptionLabelMeta,
GroupBase,
OptionProps,
} from "react-select";
export type DropdownValue = string | number | boolean;
export type DropdownOptionLabel =
| Label
| {
id?: string;
name: string;
color?: Color;
};
export interface DropdownOption {
value: DropdownValue;
label: string;
description?: string;
labels?: Array<DropdownOptionLabel>;
color?: Color;
}
export interface ComponentProps {
@@ -111,6 +130,333 @@ const Dropdown: FunctionComponent<ComponentProps> = (
const firstUpdate: React.MutableRefObject<boolean> = useRef(true);
interface NormalizedDropdownLabel {
id?: string;
name: string;
color?: string;
}
const normalizeLabelColor: (
color?: Color | string | null,
) => string | undefined = (
color?: Color | string | null,
): string | undefined => {
if (!color) {
return undefined;
}
if (color instanceof Color) {
return color.toString();
}
if (typeof color === "string" && color.trim().length > 0) {
return color;
}
return undefined;
};
const normalizeDropdownLabel: (
label: DropdownOptionLabel,
) => NormalizedDropdownLabel | null = (
label: DropdownOptionLabel,
): NormalizedDropdownLabel | null => {
if (!label) {
return null;
}
const getValueFromModel: (
columnName: string,
) => string | Color | null | undefined = (
columnName: string,
): string | Color | null | undefined => {
if (
typeof (label as Label).getColumnValue === "function" &&
typeof (label as Label).getTableColumnMetadata === "function"
) {
return (label as Label).getColumnValue(columnName) as
| string
| Color
| null
| undefined;
}
return (label as any)?.[columnName] as string | Color | null | undefined;
};
const labelName: string | undefined = (() => {
const valueFromGetter: string | null | undefined = getValueFromModel(
"name",
) as string | undefined | null;
if (valueFromGetter && valueFromGetter.trim().length > 0) {
return valueFromGetter;
}
const fallbackName: string | undefined = (label as any)?.name;
if (fallbackName && fallbackName.trim().length > 0) {
return fallbackName;
}
return undefined;
})();
if (!labelName) {
return null;
}
const rawColor: Color | string | null | undefined = getValueFromModel(
"color",
) as Color | string | null | undefined;
const color: string | undefined =
normalizeLabelColor(rawColor) ||
normalizeLabelColor((label as any)?.color);
const idValue: string | undefined = (() => {
if (typeof (label as Label).id !== "undefined") {
const idFromGetter: ObjectID | null | undefined = (label as Label).id;
if (idFromGetter) {
return idFromGetter.toString();
}
}
const fallbackId: string | undefined =
(label as any)?._id || (label as any)?.id;
if (fallbackId) {
return fallbackId;
}
return undefined;
})();
const normalized: NormalizedDropdownLabel = {
name: labelName,
};
if (idValue) {
normalized.id = idValue;
}
if (color) {
normalized.color = color;
}
return normalized;
};
const normalizeLabelCollection: (
labels?: Array<DropdownOptionLabel>,
) => Array<NormalizedDropdownLabel> = (
labels?: Array<DropdownOptionLabel>,
): Array<NormalizedDropdownLabel> => {
if (!labels || labels.length === 0) {
return [];
}
return labels
.map((label: DropdownOptionLabel) => {
return normalizeDropdownLabel(label);
})
.filter(
(
label: NormalizedDropdownLabel | null,
): label is NormalizedDropdownLabel => {
return label !== null;
},
);
};
const renderOptionColorIndicator: (
color?: Color | string,
) => ReactElement | null = (color?: Color | string): ReactElement | null => {
const normalizedColor: string | undefined = color
? new Color(color).toString()
: undefined;
if (!normalizedColor) {
return null;
}
return (
<span
aria-hidden="true"
className="h-2.5 w-2.5 flex-none rounded-full border border-gray-200"
style={{
backgroundColor: normalizedColor,
}}
title={normalizedColor}
></span>
);
};
const getLabelStyle: (color?: string) => {
backgroundColor: string;
color: string;
} = (color?: string): { backgroundColor: string; color: string } => {
if (!color) {
return {
backgroundColor: "#e5e7eb", // gray-200
color: "#374151", // gray-700
};
}
try {
const parsedColor: Color = Color.fromString(color);
return {
backgroundColor: parsedColor.toString(),
color: Color.shouldUseDarkText(parsedColor)
? "#111827" // gray-900
: "#f9fafb", // gray-50
};
} catch {
return {
backgroundColor: color,
color: "#111827",
};
}
};
const defaultSelectedLabelAccentColor: string = "#6366f1"; // indigo-500
const resolveSelectedLabelColor: (color?: string) => string = (
color?: string,
): string => {
if (!color) {
return defaultSelectedLabelAccentColor;
}
try {
return Color.fromString(color).toString();
} catch {
return defaultSelectedLabelAccentColor;
}
};
const renderAssociatedLabels: (
labels: Array<NormalizedDropdownLabel>,
context: FormatOptionLabelMeta<DropdownOption>["context"],
hiddenLabelCount: number,
) => ReactElement | null = (
labels: Array<NormalizedDropdownLabel>,
context: FormatOptionLabelMeta<DropdownOption>["context"],
hiddenLabelCount: number,
): ReactElement | null => {
if (labels.length === 0 && hiddenLabelCount === 0) {
return null;
}
if (context === "value") {
return (
<div className="flex flex-wrap items-center gap-1">
{labels.map((label: NormalizedDropdownLabel, index: number) => {
const accentColor: string = resolveSelectedLabelColor(label.color);
return (
<span
key={`${label.id || label.name}-selected-${index}`}
className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-white px-2 py-0.5 text-xs font-medium text-gray-600"
style={{
borderColor: accentColor,
}}
title={label.name}
>
<span
aria-hidden="true"
className="h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: accentColor,
}}
></span>
{label.name}
</span>
);
})}
{hiddenLabelCount > 0 ? (
<span className="inline-flex items-center rounded-full border border-gray-200 bg-white px-2 py-0.5 text-xs font-medium text-gray-500">
+{hiddenLabelCount}
</span>
) : null}
</div>
);
}
return (
<div className="flex flex-wrap gap-1">
{labels.map((label: NormalizedDropdownLabel, index: number) => {
const { backgroundColor, color } = getLabelStyle(label.color);
return (
<span
key={`${label.id || label.name}-menu-${index}`}
className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium shadow-sm"
style={{ backgroundColor, color }}
>
{label.name}
</span>
);
})}
{hiddenLabelCount > 0 ? (
<span className="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600">
+{hiddenLabelCount}
</span>
) : null}
</div>
);
};
const formatDropdownOptionLabel: (
option: DropdownOption,
meta: FormatOptionLabelMeta<DropdownOption>,
) => ReactElement = (
option: DropdownOption,
meta: FormatOptionLabelMeta<DropdownOption>,
): ReactElement => {
const normalizedLabels: Array<NormalizedDropdownLabel> =
normalizeLabelCollection(option.labels);
const maxVisibleLabels: number = meta.context === "menu" ? 4 : 2;
const visibleLabels: Array<NormalizedDropdownLabel> =
normalizedLabels.slice(0, maxVisibleLabels);
const hiddenLabelCount: number = Math.max(
normalizedLabels.length - visibleLabels.length,
0,
);
if (meta.context === "value") {
return (
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-2">
{renderOptionColorIndicator(option.color)}
<span className="text-sm font-medium text-gray-900">
{option.label}
</span>
</div>
{renderAssociatedLabels(
visibleLabels,
meta.context,
hiddenLabelCount,
)}
</div>
);
}
return (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
{renderOptionColorIndicator(option.color)}
<span className="text-sm font-medium text-gray-900">
{option.label}
</span>
</div>
{option.description ? (
<span className="text-xs text-gray-500">{option.description}</span>
) : null}
{renderAssociatedLabels(visibleLabels, meta.context, hiddenLabelCount)}
</div>
);
};
useLayoutEffect(() => {
if (firstUpdate.current && props.initialValue) {
firstUpdate.current = false;
@@ -138,6 +484,9 @@ const Dropdown: FunctionComponent<ComponentProps> = (
}}
>
<Select
classNamePrefix="ou-select"
unstyled={false}
formatOptionLabel={formatDropdownOptionLabel}
onBlur={() => {
props.onBlur?.();
}}
@@ -150,25 +499,169 @@ const Dropdown: FunctionComponent<ComponentProps> = (
}}
classNames={{
control: (
state: ControlProps<any, boolean, GroupBase<any>>,
state: ControlProps<DropdownOption, boolean, GroupBase<any>>,
): string => {
return state.isFocused
? "!border-indigo-500"
: "border-Gray500-300";
const classes: Array<string> = [
"!min-h-[40px] !rounded-lg !border !bg-white !shadow-sm !transition-all !duration-150",
state.isFocused
? "!border-indigo-400 !ring-2 !ring-indigo-100"
: "!border-gray-300 hover:!border-indigo-300",
state.isDisabled
? "!bg-gray-100 !text-gray-400"
: "!cursor-pointer",
];
if (props.error) {
classes.push("!border-red-400 !ring-2 !ring-red-100");
}
return classes.join(" ");
},
valueContainer: () => {
return "!gap-2 !px-2";
},
placeholder: () => {
return "text-sm text-gray-400";
},
input: () => {
return "text-sm text-gray-900";
},
singleValue: () => {
return "text-sm text-gray-900 font-medium";
},
indicatorsContainer: () => {
return "!gap-1 !px-1";
},
dropdownIndicator: () => {
return "text-gray-500 transition-colors duration-150 hover:text-indigo-400";
},
clearIndicator: () => {
return "text-gray-400 transition-colors duration-150 hover:text-red-500";
},
menu: () => {
return "!mt-2 !rounded-xl !border !border-gray-100 !bg-white !shadow-xl";
},
menuList: () => {
return "!py-2";
},
option: (
state: OptionProps<any, boolean, GroupBase<any>>,
state: OptionProps<DropdownOption, boolean, GroupBase<any>>,
): string => {
if (state.isDisabled) {
return "bg-gray-100";
return "px-3 py-2 text-sm text-gray-300 cursor-not-allowed";
}
if (state.isSelected) {
return "!bg-indigo-500";
return "px-3 py-2 text-sm bg-indigo-200 text-indigo-900";
}
if (state.isFocused) {
return "!bg-indigo-100";
return "px-3 py-2 text-sm bg-indigo-100 text-indigo-700";
}
return "";
return "px-3 py-2 text-sm text-gray-700";
},
noOptionsMessage: () => {
return "px-3 py-2 text-sm text-gray-500";
},
multiValue: () => {
return "flex items-center gap-2 rounded-lg border border-indigo-100 bg-indigo-50 px-2 py-1";
},
multiValueLabel: () => {
return "flex flex-wrap items-center gap-2 text-sm font-medium text-indigo-900";
},
multiValueRemove: () => {
return "text-indigo-400 hover:text-indigo-600 transition-colors duration-150";
},
}}
styles={{
dropdownIndicator: (
provided: CSSObjectWithLabel,
): CSSObjectWithLabel => {
return {
...provided,
padding: 8,
};
},
clearIndicator: (
provided: CSSObjectWithLabel,
): CSSObjectWithLabel => {
return {
...provided,
padding: 8,
};
},
indicatorSeparator: (): CSSObjectWithLabel => {
return {
display: "none",
} as CSSObjectWithLabel;
},
option: (
provided: CSSObjectWithLabel,
state: OptionProps<
DropdownOption,
boolean,
GroupBase<DropdownOption>
>,
): CSSObjectWithLabel => {
if (state.isSelected) {
return {
...provided,
backgroundColor: "#c7d2fe", // indigo-200
color: "#1e1b4b", // indigo-900
};
}
if (state.isFocused) {
return {
...provided,
backgroundColor: "#e0e7ff", // indigo-100
color: "#312e81", // indigo-800
};
}
return {
...provided,
color: "#374151", // gray-700
};
},
multiValue: (provided: CSSObjectWithLabel): CSSObjectWithLabel => {
return {
...provided,
backgroundColor: "#eef2ff", // indigo-50
borderRadius: 8,
border: "1px solid #c7d2fe", // indigo-200
paddingLeft: 4,
paddingRight: 4,
};
},
multiValueLabel: (
provided: CSSObjectWithLabel,
): CSSObjectWithLabel => {
return {
...provided,
color: "#312e81", // indigo-800
fontSize: "0.875rem",
fontWeight: 500,
};
},
multiValueRemove: (
provided: CSSObjectWithLabel,
): CSSObjectWithLabel => {
return {
...provided,
color: "#6366f1", // indigo-500
":hover": {
color: "#4f46e5", // indigo-600
backgroundColor: "transparent",
},
};
},
menuPortal: (base: CSSObjectWithLabel): CSSObjectWithLabel => {
return {
...base,
zIndex: 50,
};
},
}}
isClearable={true}

View File

@@ -12,6 +12,7 @@ import {
CategoryCheckboxOption,
CheckboxCategory,
} from "../CategoryCheckbox/CategoryCheckboxTypes";
import type { DropdownOption } from "../Dropdown/Dropdown";
import Loader, { LoaderType } from "../Loader/Loader";
import Pill, { PillSize } from "../Pill/Pill";
import { FormErrors, FormProps, FormSummaryConfig } from "./BasicForm";
@@ -24,6 +25,7 @@ import AnalyticsBaseModel from "../../../Models/AnalyticsModels/AnalyticsBaseMod
import AccessControlModel from "../../../Models/DatabaseModels/DatabaseBaseModel/AccessControlModel";
import BaseModel from "../../../Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
import FileModel from "../../../Models/DatabaseModels/DatabaseBaseModel/FileModel";
import Label from "../../../Models/DatabaseModels/Label";
import URL from "../../../Types/API/URL";
import { ColumnAccessControl } from "../../../Types/BaseDatabase/AccessControl";
import { Black, VeryLightGray } from "../../../Types/BrandColors";
@@ -412,17 +414,26 @@ const ModelForm: <TBaseModel extends BaseModel>(
[field.dropdownModal.valueField]: true,
} as any;
let hasAccessControlColumn: boolean = false;
let colorColumnName: string | null = null;
let shouldSelectColorColumn: boolean = false;
colorColumnName = tempModel.getFirstColorColumn();
if (colorColumnName) {
select[colorColumnName] = true;
shouldSelectColorColumn = true;
}
const accessControlColumnName: string | null =
tempModel.getAccessControlColumn();
// also select labels, so they can select resources by labels. This is useful for resources like monitors, etc.
if (tempModel.getAccessControlColumn()) {
select[tempModel.getAccessControlColumn()!] = {
if (accessControlColumnName) {
select[accessControlColumnName] = {
_id: true,
name: true,
color: true,
} as any;
hasAccessControlColumn = true;
}
const listResult: ListResult<BaseModel> =
@@ -436,22 +447,51 @@ const ModelForm: <TBaseModel extends BaseModel>(
});
if (listResult.data && listResult.data.length > 0) {
field.dropdownOptions = listResult.data.map((item: BaseModel) => {
if (!field.dropdownModal) {
throw new BadDataException("Dropdown Modal value mot found");
}
const dropdownOptions: Array<DropdownOption> = listResult.data.map(
(item: BaseModel) => {
if (!field.dropdownModal) {
throw new BadDataException("Dropdown Modal value mot found");
}
return {
label: (item as any)[
field.dropdownModal?.labelField
].toString(),
value: (item as any)[
field.dropdownModal?.valueField
].toString(),
};
});
const option: DropdownOption = {
label: (item as any)[
field.dropdownModal?.labelField
].toString(),
value: (item as any)[
field.dropdownModal?.valueField
].toString(),
};
if (hasAccessControlColumn) {
if (colorColumnName && shouldSelectColorColumn) {
const color: Color = item.getColumnValue(
colorColumnName,
) as Color;
if (color) {
option.color = color;
}
}
if (accessControlColumnName) {
const labelsForItem: Array<AccessControlModel> = (
((item as any)[
accessControlColumnName
] as Array<AccessControlModel>) || []
).filter((label: AccessControlModel | null) => {
return Boolean(label);
}) as Array<AccessControlModel>;
if (labelsForItem.length > 0) {
option.labels = labelsForItem as Array<Label>;
}
}
return option;
},
);
field.dropdownOptions = dropdownOptions;
if (accessControlColumnName) {
const categories: Array<CheckboxCategory> = [];
// populate categories.
@@ -459,8 +499,7 @@ const ModelForm: <TBaseModel extends BaseModel>(
let localLabels: Array<AccessControlModel> = [];
for (const item of listResult.data) {
const accessControlColumn: string | null =
tempModel.getAccessControlColumn()!;
const accessControlColumn: string = accessControlColumnName;
const labels: Array<AccessControlModel> =
((item as any)[
accessControlColumn
@@ -516,8 +555,7 @@ const ModelForm: <TBaseModel extends BaseModel>(
const options: Array<CategoryCheckboxOption> = [];
for (const item of listResult.data) {
const accessControlColumn: string =
tempModel.getAccessControlColumn()!;
const accessControlColumn: string = accessControlColumnName;
const labels: Array<AccessControlModel> =
((item as any)[
accessControlColumn
@@ -554,9 +592,8 @@ const ModelForm: <TBaseModel extends BaseModel>(
options: options,
},
accessControlColumnTitle:
tempModel.getTableColumnMetadata(
tempModel.getAccessControlColumn()!,
).title || "",
tempModel.getTableColumnMetadata(accessControlColumnName)
.title || "",
};
}
} else {

View File

@@ -1102,6 +1102,10 @@ const Icon: FunctionComponent<ComponentProps> = ({
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>,
);
} else if (icon === IconProp.EmptyCircle) {
return getSvgWrapper(<circle cx="12" cy="12" r="7.5" />, {
strokeWidth: "2.25",
});
} else if (icon === IconProp.Circle) {
return getSvgWrapper(
<path

View File

@@ -0,0 +1,50 @@
import React, { CSSProperties, FunctionComponent, ReactElement } from "react";
import LabelModel from "../../../Models/DatabaseModels/Label";
import Pill, { ComponentProps as PillProps, PillSize } from "../Pill/Pill";
import { Black } from "../../../Types/BrandColors";
import Color from "../../../Types/Color";
import IconProp from "../../../Types/Icon/IconProp";
export interface ComponentProps {
label: LabelModel;
size?: PillSize | undefined;
style?: CSSProperties;
isMinimal?: boolean | undefined;
}
const LabelElement: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const { label } = props;
const resolveColor: Color = (() => {
if (!label.color) {
return Black;
}
if (typeof label.color === "string") {
return Color.fromString(label.color);
}
return label.color;
})();
const text: string = label.name || label.slug || "";
const pillProps: PillProps = {
color: resolveColor,
text,
size: props.size,
isMinimal: props.isMinimal,
tooltip: label.description || undefined,
icon: IconProp.EmptyCircle,
};
if (props.style) {
pillProps.style = props.style;
}
return <Pill {...pillProps} />;
};
export default LabelElement;

View File

@@ -1,25 +1,29 @@
import LabelElement from "./Label";
import TableColumnListComponent from "Common/UI/Components/TableColumnList/TableColumnListComponent";
import Label from "Common/Models/DatabaseModels/Label";
import TableColumnListComponent from "../TableColumnList/TableColumnListComponent";
import LabelModel from "../../../Models/DatabaseModels/Label";
import React, { FunctionComponent, ReactElement } from "react";
export interface ComponentProps {
labels: Array<Label>;
labels: Array<LabelModel>;
}
const LabelsElement: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
return (
// {/** >4 because 3 labels are shown by default and then the more text is shown */}
<TableColumnListComponent
items={props.labels}
moreText={props.labels.length > 4 ? "more labels" : "more label"}
className={props.labels.length > 0 ? "-mb-1 -mt-1" : ""}
getEachElement={(label: Label) => {
getEachElement={(label: LabelModel) => {
return (
<div className={props.labels.length > 0 ? "my-2" : ""}>
<LabelElement label={label} />
<LabelElement
label={label}
style={{
marginRight: "5px",
}}
/>
</div>
);
}}

View File

@@ -38,6 +38,7 @@ import { ListDetailProps } from "../List/ListRow";
import ConfirmModal from "../Modal/ConfirmModal";
import { ModalWidth } from "../Modal/Modal";
import Filter from "../ModelFilter/Filter";
import { DropdownOption, DropdownOptionLabel } from "../Dropdown/Dropdown";
import OrderedStatesList from "../OrderedStatesList/OrderedStatesList";
import Pill from "../Pill/Pill";
import Table from "../Table/Table";
@@ -51,6 +52,7 @@ import AnalyticsBaseModel, {
import BaseModel, {
DatabaseBaseModelType,
} from "../../../Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
import AccessControlModel from "../../../Models/DatabaseModels/DatabaseBaseModel/AccessControlModel";
import Route from "../../../Types/API/Route";
import URL from "../../../Types/API/URL";
import { ColumnAccessControl } from "../../../Types/BaseDatabase/AccessControl";
@@ -64,6 +66,7 @@ import { Yellow } from "../../../Types/BrandColors";
import { LIMIT_PER_PROJECT } from "../../../Types/Database/LimitMax";
import Dictionary from "../../../Types/Dictionary";
import BadDataException from "../../../Types/Exception/BadDataException";
import Color from "../../../Types/Color";
import {
ErrorFunction,
PromiseVoidFunction,
@@ -641,30 +644,155 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
const query: Query<TBaseModel> = filter.filterQuery || {};
let colorColumnName: string | null = null;
let accessControlColumnName: string | null = null;
if (
filter.filterEntityType &&
filter.filterEntityType.prototype instanceof BaseModel
) {
const filterModel: BaseModel =
new (filter.filterEntityType as DatabaseBaseModelType)();
colorColumnName = filterModel.getFirstColorColumn();
accessControlColumnName = filterModel.getAccessControlColumn();
}
const select: Select<TBaseModel> = {
[filter.filterDropdownField.label]: true,
[filter.filterDropdownField.value]: true,
} as Select<TBaseModel>;
if (colorColumnName) {
(select as Dictionary<boolean>)[colorColumnName] = true;
}
if (accessControlColumnName) {
(select as Dictionary<JSONObject>)[accessControlColumnName] = {
_id: true,
name: true,
color: true,
} as JSONObject;
}
const listResult: ListResult<TBaseModel> =
await props.callbacks.getList({
modelType: filter.filterEntityType,
query: query,
limit: LIMIT_PER_PROJECT,
skip: 0,
select: {
[filter.filterDropdownField.label]: true,
[filter.filterDropdownField.value]: true,
} as any,
select: select,
sort: {},
});
filter.filterDropdownOptions = [];
for (const item of listResult.data) {
filter.filterDropdownOptions.push({
const option: DropdownOption = {
value: item.getColumnValue(
filter.filterDropdownField.value,
) as string,
label: item.getColumnValue(
filter.filterDropdownField.label,
) as string,
});
};
if (colorColumnName) {
const colorValue: Color | string | null = item.getColumnValue(
colorColumnName,
) as Color | string | null;
if (colorValue instanceof Color) {
option.color = colorValue;
} else if (
typeof colorValue === "string" &&
colorValue.trim().length > 0
) {
try {
option.color = new Color(colorValue);
} catch {
// ignore invalid colors
}
}
}
if (accessControlColumnName) {
const accessControlValue:
| AccessControlModel
| Array<AccessControlModel>
| null =
(item.getColumnValue(accessControlColumnName) as
| AccessControlModel
| Array<AccessControlModel>
| null) || null;
const accessControlItems: Array<AccessControlModel> =
Array.isArray(accessControlValue)
? accessControlValue
: accessControlValue
? [accessControlValue]
: [];
type SimplifiedDropdownLabel = {
id?: string;
name: string;
color?: Color;
};
const dropdownLabels: Array<SimplifiedDropdownLabel> =
accessControlItems
.map((label: AccessControlModel | null) => {
if (!label) {
return null;
}
const labelNameRaw: string | null = label.getColumnValue(
"name",
) as string | null;
if (!labelNameRaw) {
return null;
}
const labelName: string = labelNameRaw.toString().trim();
if (!labelName) {
return null;
}
const labelColorValue: Color | null = label.getColumnValue(
"color",
) as Color | null;
const normalizedLabel: SimplifiedDropdownLabel = {
name: labelName,
};
const labelId: ObjectID | null = label.id;
if (labelId) {
normalizedLabel.id = labelId.toString();
}
if (labelColorValue) {
normalizedLabel.color = labelColorValue;
}
return normalizedLabel;
})
.filter(
(
label: SimplifiedDropdownLabel | null,
): label is SimplifiedDropdownLabel => {
return label !== null;
},
);
if (dropdownLabels.length > 0) {
option.labels = dropdownLabels as Array<DropdownOptionLabel>;
}
}
filter.filterDropdownOptions.push(option);
}
}

View File

@@ -2,11 +2,9 @@ import Analytics from "../../Utils/Analytics";
import Breadcrumbs from "../Breadcrumbs/Breadcrumbs";
import ErrorMessage from "../ErrorMessage/ErrorMessage";
import PageLoader from "../Loader/PageLoader";
import Pill from "../Pill/Pill";
import LabelElement from "../Label/Label";
import Link from "../../../Types/Link";
import Label from "../../../Models/DatabaseModels/Label";
import Color from "../../../Types/Color";
import { Black } from "../../../Types/BrandColors";
import LabelModel from "../../../Models/DatabaseModels/Label";
import React, { FunctionComponent, ReactElement, useEffect } from "react";
export interface ComponentProps {
@@ -17,7 +15,7 @@ export interface ComponentProps {
className?: string | undefined;
isLoading?: boolean | undefined;
error?: string | undefined;
labels?: Array<Label> | undefined;
labels?: Array<LabelModel> | undefined;
}
const Page: FunctionComponent<ComponentProps> = (
@@ -71,33 +69,19 @@ const Page: FunctionComponent<ComponentProps> = (
</span>
<div className="flex flex-wrap items-center gap-2 justify-end">
{props.labels
.filter((label: Label | null) => {
.filter((label: LabelModel | null) => {
return Boolean(label && (label.name || label.slug));
})
.map((label: Label, index: number) => {
const resolveColor: Color = (() => {
if (!label.color) {
return Black;
}
if (typeof label.color === "string") {
return Color.fromString(label.color);
}
return label.color;
})();
.map((label: LabelModel, index: number) => {
return (
<Pill
<LabelElement
key={
label.id?.toString() ||
label._id ||
label.slug ||
`${label.name || "label"}-${index}`
}
color={resolveColor}
text={label.name || label.slug || "Label"}
isMinimal={false}
label={label}
/>
);
})}

View File

@@ -1,5 +1,7 @@
import { Black } from "../../../Types/BrandColors";
import Color from "../../../Types/Color";
import Icon, { SizeProp, ThickProp } from "../Icon/Icon";
import IconProp from "../../../Types/Icon/IconProp";
import React, { CSSProperties, FunctionComponent, ReactElement } from "react";
import Tooltip from "../Tooltip/Tooltip";
import { GetReactElementFunction } from "../../Types/FunctionTypes";
@@ -18,6 +20,7 @@ export interface ComponentProps {
style?: CSSProperties;
isMinimal?: boolean | undefined;
tooltip?: string | undefined;
icon?: IconProp | undefined;
}
const Pill: FunctionComponent<ComponentProps> = (
@@ -47,7 +50,7 @@ const Pill: FunctionComponent<ComponentProps> = (
return (
<span
data-testid="pill"
className="rounded-full p-1 pl-3 pr-3"
className="inline-flex items-center rounded-full p-1 pl-3 pr-3"
style={{
// https://stackoverflow.com/questions/3942878/how-to-decide-font-color-in-white-or-black-depending-on-background-color
@@ -63,8 +66,15 @@ const Pill: FunctionComponent<ComponentProps> = (
...props.style,
}}
>
{" "}
{props.text}{" "}
{props.icon ? (
<Icon
icon={props.icon}
size={SizeProp.Small}
thick={ThickProp.Thick}
className="mr-2"
/>
) : null}
{props.text}
</span>
);
};

View File

@@ -1,6 +1,7 @@
import NumberUtil from "../../Utils/Number";
import { DropdownOption } from "../Components/Dropdown/Dropdown";
import BaseModel from "../../Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
import Color from "../../Types/Color";
type Enum<E> = Record<keyof E, number | string> & { [k: number]: string };
@@ -52,10 +53,27 @@ export default class DropdownUtil {
valueField: string;
}): Array<DropdownOption> {
return data.array.map((item: TBaseModel) => {
return {
const option: DropdownOption = {
label: item.getColumnValue(data.labelField) as string,
value: item.getColumnValue(data.valueField) as string,
};
const colorColumnName: string | null =
typeof item.getFirstColorColumn === "function"
? item.getFirstColorColumn()
: null;
if (colorColumnName) {
const color: Color | null = item.getColumnValue(
colorColumnName,
) as Color | null;
if (color) {
option.color = color;
}
}
return option;
});
}

View File

@@ -1,4 +1,4 @@
import LabelsElement from "../Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import ProjectUtil from "Common/UI/Utils/Project";
import AlertElement from "./Alert";
import { Black } from "Common/Types/BrandColors";

View File

@@ -1,4 +1,4 @@
import LabelsElement from "../../Components/Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import MonitorsElement from "../../Components/Monitor/Monitors";
import ProjectUtil from "Common/UI/Utils/Project";
import IncidentElement from "./Incident";

View File

@@ -9,7 +9,7 @@ import SortOrder from "Common/Types/BaseDatabase/SortOrder";
import ListResult from "Common/Types/BaseDatabase/ListResult";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import ComponentLoader from "Common/UI/Components/ComponentLoader/ComponentLoader";
import LabelsElement from "./Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import Exception from "Common/Types/Exception/Exception";

View File

@@ -1,24 +0,0 @@
import { Black } from "Common/Types/BrandColors";
import Pill from "Common/UI/Components/Pill/Pill";
import Label from "Common/Models/DatabaseModels/Label";
import React, { FunctionComponent, ReactElement } from "react";
export interface ComponentProps {
label: Label;
}
const LabelElement: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
return (
<Pill
color={props.label.color || Black}
text={props.label.name || ""}
style={{
marginRight: "5px",
}}
/>
);
};
export default LabelElement;

View File

@@ -1,4 +1,4 @@
import LabelsElement from "../../Components/Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import MonitorTypeUtil from "../../Utils/MonitorType";
import ProjectUtil from "Common/UI/Utils/Project";

View File

@@ -1,5 +1,5 @@
import ProjectUtil from "Common/UI/Utils/Project";
import LabelsElement from "../Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import MonitorsElement from "../Monitor/Monitors";
import StatusPagesElement from "../StatusPage/StatusPagesElement";
import Route from "Common/Types/API/Route";

View File

@@ -1,4 +1,4 @@
import LabelsElement from "../../Components/Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import ProjectUtil from "Common/UI/Utils/Project";
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
import ModelTable from "Common/UI/Components/ModelTable/ModelTable";

View File

@@ -16,7 +16,7 @@ import IncidentSeverityElement from "../../IncidentSeverity/IncidentSeverityElem
import IncidentStateElement from "../../IncidentState/IncidentStateElement";
import ScheduledMaintenanceStateElement from "../../ScheduledMaintenanceState/ScheduledMaintenanceStateElement";
import MonitorStatusElement from "../../MonitorStatus/MonitorStatusElement";
import LabelElement from "../../Label/Label";
import LabelElement from "Common/UI/Components/Label/Label";
import MonitorElement from "../../Monitor/Monitor";
import { GetReactElementFunction } from "Common/UI/Types/FunctionTypes";

View File

@@ -1,4 +1,4 @@
import LabelsElement from "../../../../Components/Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import PageComponentProps from "../../../PageComponentProps";
import CodeRepositoryType from "Common/Types/CodeRepository/CodeRepositoryType";
import ObjectID from "Common/Types/ObjectID";

View File

@@ -1,5 +1,5 @@
import Banner from "Common/UI/Components/Banner/Banner";
import LabelsElement from "../../Components/Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import ProjectUtil from "Common/UI/Utils/Project";
import PageMap from "../../Utils/PageMap";
import RouteMap, { RouteUtil } from "../../Utils/RouteMap";

View File

@@ -1,5 +1,5 @@
import ChangeAlertState from "../../../Components/Alert/ChangeState";
import LabelsElement from "../../../Components/Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import OnCallDutyPoliciesView from "../../../Components/OnCallPolicy/OnCallPolicies";
import PageComponentProps from "../../PageComponentProps";
import SortOrder from "Common/Types/BaseDatabase/SortOrder";

View File

@@ -1,4 +1,4 @@
import LabelsElement from "../../Components/Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import ProjectUtil from "Common/UI/Utils/Project";
import PageMap from "../../Utils/PageMap";
import RouteMap, { RouteUtil } from "../../Utils/RouteMap";

View File

@@ -1,4 +1,4 @@
import LabelsElement from "../../../Components/Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import PageComponentProps from "../../PageComponentProps";
import ObjectID from "Common/Types/ObjectID";
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";

View File

@@ -45,6 +45,8 @@ import FetchMonitors from "../../Components/Monitor/FetchMonitors";
import FetchIncidentSeverities from "../../Components/IncidentSeverity/FetchIncidentSeverity";
import IncidentState from "Common/Models/DatabaseModels/IncidentState";
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
import Color from "Common/Types/Color";
import { DropdownOption } from "Common/UI/Components/Dropdown/Dropdown";
const IncidentCreate: FunctionComponent<
PageComponentProps
@@ -313,18 +315,24 @@ const IncidentCreate: FunctionComponent<
select: {
_id: true,
name: true,
color: true,
},
sort: {
order: SortOrder.Ascending,
},
});
return incidentStates.data.map((state: IncidentState) => {
return {
label: state.name || "",
value: state._id?.toString() || "",
};
});
return incidentStates.data.map(
(state: IncidentState): DropdownOption => {
const option: DropdownOption = {
label: state.name || "",
value: state._id?.toString() || "",
color: state.color as Color,
};
return option;
},
);
} catch {
// Silently fail and return empty array
return [];

View File

@@ -1,5 +1,5 @@
import ChangeIncidentState from "../../../Components/Incident/ChangeState";
import LabelsElement from "../../../Components/Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import MonitorsElement from "../../../Components/Monitor/Monitors";
import OnCallDutyPoliciesView from "../../../Components/OnCallPolicy/OnCallPolicies";
import SubscriberNotificationStatus from "../../../Components/StatusPageSubscribers/SubscriberNotificationStatus";

View File

@@ -1,4 +1,4 @@
import LabelsElement from "../../../Components/Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import DisabledWarning from "../../../Components/Monitor/DisabledWarning";
import IncomingMonitorLink from "../../../Components/Monitor/IncomingRequestMonitor/IncomingMonitorLink";
import ServerMonitorDocumentation from "../../../Components/Monitor/ServerMonitor/Documentation";

View File

@@ -1,4 +1,4 @@
import LabelsElement from "../../Components/Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import CurrentStatusElement from "../../Components/MonitorGroup/CurrentStatus";
import ProjectUtil from "Common/UI/Utils/Project";
import PageMap from "../../Utils/PageMap";

View File

@@ -1,4 +1,4 @@
import LabelsElement from "../../../Components/Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import PageComponentProps from "../../PageComponentProps";
import URL from "Common/Types/API/URL";
import SortOrder from "Common/Types/BaseDatabase/SortOrder";

View File

@@ -1,4 +1,4 @@
import LabelsElement from "../../Components/Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import ProjectUtil from "Common/UI/Utils/Project";
import PageComponentProps from "../PageComponentProps";
import URL from "Common/Types/API/URL";

View File

@@ -1,4 +1,4 @@
import LabelsElement from "../../../Components/Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import PageComponentProps from "../../PageComponentProps";
import ObjectID from "Common/Types/ObjectID";
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";

View File

@@ -1,4 +1,4 @@
import LabelsElement from "../../../Components/Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import PageComponentProps from "../../PageComponentProps";
import ObjectID from "Common/Types/ObjectID";
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";

View File

@@ -1,4 +1,4 @@
import LabelsElement from "../../Components/Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import ProjectUtil from "Common/UI/Utils/Project";
import PageComponentProps from "../PageComponentProps";
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";

View File

@@ -1,4 +1,4 @@
import LabelsElement from "../../../Components/Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import MonitorsElement from "../../../Components/Monitor/Monitors";
import ChangeScheduledMaintenanceState from "../../../Components/ScheduledMaintenance/ChangeState";
import StatusPagesElement from "../../../Components/StatusPage/StatusPagesElement";

View File

@@ -1,4 +1,4 @@
import LabelsElement from "../../Components/Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import ServiceCatalogElement from "../../Components/ServiceCatalog/ServiceElement";
import ProjectUtil from "Common/UI/Utils/Project";
import PageComponentProps from "../PageComponentProps";

View File

@@ -1,4 +1,4 @@
import LabelsElement from "../../../Components/Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import PageComponentProps from "../../PageComponentProps";
import ObjectID from "Common/Types/ObjectID";
import TechStack from "Common/Types/ServiceCatalog/TechStack";

View File

@@ -1,4 +1,4 @@
import LabelsElement from "../../Components/Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import ProjectUtil from "Common/UI/Utils/Project";
import PageMap from "../../Utils/PageMap";
import RouteMap, { RouteUtil } from "../../Utils/RouteMap";

View File

@@ -1,4 +1,4 @@
import LabelsElement from "../../Components/Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import MonitorsElement from "../../Components/Monitor/Monitors";
import OnCallDutyPoliciesView from "../../Components/OnCallPolicy/OnCallPolicies";
import TeamElement from "../../Components/Team/Team";

View File

@@ -1,9 +1,8 @@
import ProjectUtil from "Common/UI/Utils/Project";
import PageComponentProps from "../PageComponentProps";
import Color from "Common/Types/Color";
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
import ModelTable from "Common/UI/Components/ModelTable/ModelTable";
import Pill from "Common/UI/Components/Pill/Pill";
import LabelElement from "Common/UI/Components/Label/Label";
import FieldType from "Common/UI/Components/Types/FieldType";
import Navigation from "Common/UI/Utils/Navigation";
import Label from "Common/Models/DatabaseModels/Label";
@@ -92,12 +91,7 @@ const Labels: FunctionComponent<PageComponentProps> = (): ReactElement => {
type: FieldType.Text,
getElement: (item: Label): ReactElement => {
return (
<Pill
color={item["color"] as Color}
text={item["name"] as string}
/>
);
return <LabelElement label={item} />;
},
},
{

View File

@@ -1,4 +1,4 @@
import LabelsElement from "../../Components/Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import UserElement from "../../Components/User/User";
import ProjectUtil from "Common/UI/Utils/Project";
import PageMap from "../../Utils/PageMap";

View File

@@ -21,7 +21,7 @@ import React, {
ReactElement,
useState,
} from "react";
import LabelsElement from "../../Components/Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import CardModelDetail from "Common/UI/Components/ModelDetail/CardModelDetail";
import Project from "Common/Models/DatabaseModels/Project";

View File

@@ -1,4 +1,4 @@
import LabelsElement from "../../Components/Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import MonitorsElement from "../../Components/Monitor/Monitors";
import TeamElement from "../../Components/Team/Team";
import UserElement from "../../Components/User/User";

View File

@@ -1,4 +1,4 @@
import LabelsElement from "../../Components/Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import UserElement from "../../Components/User/User";
import ProjectUtil from "Common/UI/Utils/Project";
import PageMap from "../../Utils/PageMap";

View File

@@ -1,4 +1,4 @@
import LabelsElement from "../../Components/Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import ProjectUtil from "Common/UI/Utils/Project";
import PageComponentProps from "../PageComponentProps";
import URL from "Common/Types/API/URL";

View File

@@ -1,4 +1,4 @@
import LabelsElement from "../../../Components/Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import PageComponentProps from "../../PageComponentProps";
import StatusPagePreviewLink from "./StatusPagePreviewLink";
import ObjectID from "Common/Types/ObjectID";

View File

@@ -1,4 +1,4 @@
import LabelsElement from "../../../../Components/Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import PageComponentProps from "../../../PageComponentProps";
import ObjectID from "Common/Types/ObjectID";
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";

View File

@@ -1,4 +1,4 @@
import LabelsElement from "../../../Components/Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import PageComponentProps from "../../PageComponentProps";
import ObjectID from "Common/Types/ObjectID";
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";

View File

@@ -1,4 +1,4 @@
import LabelsElement from "../../Components/Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import ProjectUtil from "Common/UI/Utils/Project";
import PageComponentProps from "../PageComponentProps";
import URL from "Common/Types/API/URL";