diff --git a/apps/webservice/package.json b/apps/webservice/package.json index f676affa3..e62d987c0 100644 --- a/apps/webservice/package.json +++ b/apps/webservice/package.json @@ -76,6 +76,7 @@ "pretty-ms": "^9.2.0", "randomcolor": "^0.6.2", "react": "19.0.0", + "react-big-calendar": "^1.18.0", "react-dom": "19.0.0", "react-grid-layout": "^1.5.1", "react-hook-form": "catalog:", @@ -104,6 +105,7 @@ "@types/node": "catalog:node22", "@types/randomcolor": "^0.5.9", "@types/react": "19.0.8", + "@types/react-big-calendar": "^1.16.1", "@types/react-dom": "19.0.3", "@types/react-grid-layout": "^1.3.5", "@types/swagger-ui-react": "^4.19.0", diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/policies/(sidebar)/deny-windows/_components/CreateDenyRule.css b/apps/webservice/src/app/[workspaceSlug]/(app)/policies/(sidebar)/deny-windows/_components/CreateDenyRule.css new file mode 100644 index 000000000..3cc697f51 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/policies/(sidebar)/deny-windows/_components/CreateDenyRule.css @@ -0,0 +1,947 @@ +@charset "UTF-8"; +.rbc-btn { + color: inherit; + font: inherit; + margin: 0; +} + +button.rbc-btn { + overflow: visible; + text-transform: none; + -webkit-appearance: button; + -moz-appearance: button; + appearance: button; + cursor: pointer; +} + +button[disabled].rbc-btn { + cursor: not-allowed; +} + +button.rbc-input::-moz-focus-inner { + border: 0; + padding: 0; +} + +.rbc-calendar { + -webkit-box-sizing: border-box; + box-sizing: border-box; + height: 100%; + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-align: stretch; + -ms-flex-align: stretch; + align-items: stretch; + + border-radius: 5px; +} + +.rbc-m-b-negative-3 { + margin-bottom: -3px; +} + +.rbc-h-full { + height: 100%; +} + +.rbc-calendar *, +.rbc-calendar *:before, +.rbc-calendar *:after { + -webkit-box-sizing: inherit; + box-sizing: inherit; +} + +.rbc-abs-full, .rbc-row-bg { + overflow: hidden; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +} + +.rbc-ellipsis, .rbc-show-more, .rbc-row-segment .rbc-event-content, .rbc-event-label { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rbc-rtl { + direction: rtl; +} + +.rbc-off-range { + color: hsl(var(--muted-foreground)); +} + +.rbc-off-range-bg { + background: hsl(var(--muted)); +} + +.rbc-header { + overflow: hidden; + -webkit-box-flex: 1; + -ms-flex: 1 0 0%; + flex: 1 0 0%; + text-overflow: ellipsis; + white-space: nowrap; + padding: 0 3px; + text-align: center; + vertical-align: middle; + font-weight: bold; + font-size: 90%; + min-height: 0; + border-bottom: 1px solid hsl(var(--border)); +} +.rbc-header + .rbc-header { + border-left: 1px solid hsl(var(--border)); +} +.rbc-rtl .rbc-header + .rbc-header { + border-left-width: 0; + border-right: 1px solid hsl(var(--border)); +} +.rbc-header > a, .rbc-header > a:active, .rbc-header > a:visited { + color: inherit; + text-decoration: none; +} + +.rbc-button-link { + color: inherit; + background: none; + margin: 0; + padding: 0; + border: none; + cursor: pointer; + -webkit-user-select: text; + -moz-user-select: text; + -ms-user-select: text; + user-select: text; +} + +.rbc-row-content { + position: relative; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + -webkit-user-select: none; + z-index: 4; +} + +.rbc-row-content-scrollable { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -ms-flex-direction: column; + flex-direction: column; + height: 100%; +} +.rbc-row-content-scrollable .rbc-row-content-scroll-container { + height: 100%; + overflow-y: scroll; + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ + /* Hide scrollbar for Chrome, Safari and Opera */ +} +.rbc-row-content-scrollable .rbc-row-content-scroll-container::-webkit-scrollbar { + display: none; +} + +.rbc-today { + background-color: hsl(var(--primary) / 0.1); +} + +.rbc-toolbar { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -ms-flex-wrap: wrap; + flex-wrap: wrap; + -webkit-box-pack: center; + -ms-flex-pack: center; + justify-content: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + margin-bottom: 10px; + font-size: 16px; +} +.rbc-toolbar .rbc-toolbar-label { + -webkit-box-flex: 1; + -ms-flex-positive: 1; + flex-grow: 1; + padding: 0 10px; + text-align: center; +} +.rbc-toolbar button { + color: hsl(var(--foreground)); + display: inline-block; + margin: 0; + text-align: center; + vertical-align: middle; + background: hsl(var(--background)); + background-image: none; + border: 1px solid hsl(var(--border)); + padding: 0.375rem 1rem; + border-radius: 4px; + line-height: normal; + white-space: nowrap; +} +.rbc-toolbar button:active, .rbc-toolbar button.rbc-active { + background-image: none; + -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + background-color: hsl(var(--accent)); + border-color: hsl(var(--border)); +} +.rbc-toolbar button:active:hover, .rbc-toolbar button:active:focus, .rbc-toolbar button.rbc-active:hover, .rbc-toolbar button.rbc-active:focus { + color: hsl(var(--foreground)); + background-color: hsl(var(--accent)); + border-color: hsl(var(--border)); +} +.rbc-toolbar button:focus { + color: hsl(var(--foreground)); + background-color: hsl(var(--accent)); + border-color: hsl(var(--border)); +} +.rbc-toolbar button:hover { + color: hsl(var(--foreground)); + cursor: pointer; + background-color: hsl(var(--accent)); + border-color: hsl(var(--border)); +} + +.rbc-btn-group { + display: inline-block; + white-space: nowrap; +} +.rbc-btn-group > button:first-child:not(:last-child) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} +.rbc-btn-group > button:last-child:not(:first-child) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} +.rbc-rtl .rbc-btn-group > button:first-child:not(:last-child) { + border-radius: 4px; + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} +.rbc-rtl .rbc-btn-group > button:last-child:not(:first-child) { + border-radius: 4px; + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} +.rbc-btn-group > button:not(:first-child):not(:last-child) { + border-radius: 0; +} +.rbc-btn-group button + button { + margin-left: -1px; +} +.rbc-rtl .rbc-btn-group button + button { + margin-left: 0; + margin-right: -1px; +} +.rbc-btn-group + .rbc-btn-group, .rbc-btn-group + button { + margin-left: 10px; +} + +@media (max-width: 767px) { + .rbc-toolbar { + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -ms-flex-direction: column; + flex-direction: column; + } +} +.rbc-event, .rbc-day-slot .rbc-background-event { + border: none; + -webkit-box-sizing: border-box; + box-sizing: border-box; + -webkit-box-shadow: none; + box-shadow: none; + margin: 0; + /* padding: 2px 5px; */ + background-color: hsl(var(--primary)); + border-radius: 5px; + color: hsl(var(--primary-foreground)); + cursor: pointer; + width: 100%; + text-align: left; +} +.rbc-slot-selecting .rbc-event, .rbc-slot-selecting .rbc-day-slot .rbc-background-event, .rbc-day-slot .rbc-slot-selecting .rbc-background-event { + cursor: inherit; + pointer-events: none; +} +.rbc-event.rbc-selected, .rbc-day-slot .rbc-selected.rbc-background-event { + background-color: hsl(var(--primary)); + opacity: 0.8; +} +.rbc-event:focus, .rbc-day-slot .rbc-background-event:focus { + outline: 5px auto hsl(var(--primary)); +} + +.rbc-event-label { + /* font-size: 80%; */ + display: none; +} + +.rbc-event-overlaps { + -webkit-box-shadow: -1px 1px 5px 0px rgba(51, 51, 51, 0.5); + box-shadow: -1px 1px 5px 0px rgba(51, 51, 51, 0.5); +} + +.rbc-event-continues-prior { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.rbc-event-continues-after { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.rbc-event-continues-earlier { + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.rbc-event-continues-later { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.rbc-row { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-orient: horizontal; + -webkit-box-direction: normal; + -ms-flex-direction: row; + flex-direction: row; +} + +.rbc-row-segment { + padding: 0 1px 1px 1px; +} +.rbc-selected-cell { + background-color: rgba(0, 0, 0, 0.1); +} + +.rbc-show-more { + background-color: hsl(var(--background) / 0.3); + z-index: 4; + font-weight: bold; + font-size: 85%; + height: auto; + line-height: normal; + color: hsl(var(--primary)); +} +.rbc-show-more:hover, .rbc-show-more:focus { + color: hsl(var(--primary)); + opacity: 0.8; +} + +.rbc-month-view { + position: relative; + border: 1px solid hsl(var(--border)); + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-flex: 1; + -ms-flex: 1 0 0px; + flex: 1 0 0; + width: 100%; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + -webkit-user-select: none; + height: 100%; +} + +.rbc-month-header { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-orient: horizontal; + -webkit-box-direction: normal; + -ms-flex-direction: row; + flex-direction: row; +} + +.rbc-month-row { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + position: relative; + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-flex: 1; + -ms-flex: 1 0 0px; + flex: 1 0 0; + -ms-flex-preferred-size: 0px; + flex-basis: 0px; + overflow: hidden; + height: 100%; +} +.rbc-month-row + .rbc-month-row { + border-top: 1px solid hsl(var(--border)); +} + +.rbc-date-cell { + -webkit-box-flex: 1; + -ms-flex: 1 1 0px; + flex: 1 1 0; + min-width: 0; + padding-right: 5px; + text-align: right; +} +.rbc-date-cell.rbc-now { + font-weight: bold; +} +.rbc-date-cell > a, .rbc-date-cell > a:active, .rbc-date-cell > a:visited { + color: inherit; + text-decoration: none; +} + +.rbc-row-bg { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-orient: horizontal; + -webkit-box-direction: normal; + -ms-flex-direction: row; + flex-direction: row; + -webkit-box-flex: 1; + -ms-flex: 1 0 0px; + flex: 1 0 0; + overflow: hidden; + right: 1px; +} + +.rbc-day-bg { + -webkit-box-flex: 1; + -ms-flex: 1 0 0%; + flex: 1 0 0%; +} +.rbc-day-bg + .rbc-day-bg { + border-left: 1px solid hsl(var(--border)); +} +.rbc-rtl .rbc-day-bg + .rbc-day-bg { + border-left-width: 0; + border-right: 1px solid hsl(var(--border)); +} + +.rbc-overlay { + position: absolute; + z-index: 5; + border: 1px solid hsl(var(--border)); + background-color: hsl(var(--background)); + -webkit-box-shadow: 0 5px 15px rgba(0, 0, 0, 0.25); + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.25); + padding: 10px; +} +.rbc-overlay > * + * { + margin-top: 1px; +} + +.rbc-overlay-header { + border-bottom: 1px solid hsl(var(--border)); + margin: -10px -10px 5px -10px; + padding: 2px 10px; +} + +.rbc-agenda-view { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-flex: 1; + -ms-flex: 1 0 0px; + flex: 1 0 0; + overflow: auto; +} +.rbc-agenda-view table.rbc-agenda-table { + width: 100%; + border: 1px solid hsl(var(--border)); + border-spacing: 0; + border-collapse: collapse; +} +.rbc-agenda-view table.rbc-agenda-table tbody > tr > td { + padding: 5px 10px; + vertical-align: top; +} +.rbc-agenda-view table.rbc-agenda-table .rbc-agenda-time-cell { + padding-left: 15px; + padding-right: 15px; + text-transform: lowercase; +} +.rbc-agenda-view table.rbc-agenda-table tbody > tr > td + td { + border-left: 1px solid hsl(var(--border)); +} +.rbc-rtl .rbc-agenda-view table.rbc-agenda-table tbody > tr > td + td { + border-left-width: 0; + border-right: 1px solid hsl(var(--border)); +} +.rbc-agenda-view table.rbc-agenda-table tbody > tr + tr { + border-top: 1px solid hsl(var(--border)); +} +.rbc-agenda-view table.rbc-agenda-table thead > tr > th { + padding: 3px 5px; + text-align: left; + border-bottom: 1px solid hsl(var(--border)); +} +.rbc-rtl .rbc-agenda-view table.rbc-agenda-table thead > tr > th { + text-align: right; +} + +.rbc-agenda-time-cell { + text-transform: lowercase; +} +.rbc-agenda-time-cell .rbc-continues-after:after { + content: " »"; +} +.rbc-agenda-time-cell .rbc-continues-prior:before { + content: "« "; +} + +.rbc-agenda-date-cell, +.rbc-agenda-time-cell { + white-space: nowrap; +} + +.rbc-agenda-event-cell { + width: 100%; +} + +.rbc-time-column { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -ms-flex-direction: column; + flex-direction: column; + min-height: 100%; +} +.rbc-time-column .rbc-timeslot-group { + -webkit-box-flex: 1; + -ms-flex: 1; + flex: 1; +} + +.rbc-timeslot-group { + border-bottom: 1px solid hsl(var(--border)); + min-height: 40px; + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -ms-flex-flow: column nowrap; + flex-flow: column nowrap; +} + +.rbc-time-gutter, +.rbc-header-gutter { + -webkit-box-flex: 0; + -ms-flex: none; + flex: none; + width: 75px; +} + +.rbc-label { + padding: 0 5px; +} + +.rbc-day-slot { + position: relative; +} +.rbc-day-slot .rbc-events-container { + bottom: 0; + left: 0; + position: absolute; + right: 0; + /* margin-right: 10px; */ + top: 0; +} +.rbc-day-slot .rbc-events-container.rbc-rtl { + left: 10px; + right: 0; +} +.rbc-day-slot .rbc-event, .rbc-day-slot .rbc-background-event { + border: 1px solid #265985; + display: -webkit-box; + display: -ms-flexbox; + display: flex; + max-height: 100%; + min-height: 20px; + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -ms-flex-flow: column wrap; + flex-flow: column wrap; + -webkit-box-align: start; + -ms-flex-align: start; + align-items: flex-start; + overflow: hidden; + position: absolute; +} +.rbc-day-slot .rbc-background-event { + opacity: 0.75; +} +.rbc-day-slot .rbc-event-label { + -webkit-box-flex: 0; + -ms-flex: none; + flex: none; + padding-right: 5px; + width: auto; +} +.rbc-day-slot .rbc-event-content { + width: 100%; + -webkit-box-flex: 1; + -ms-flex: 1 1 0px; + flex: 1 1 0; + word-wrap: break-word; + line-height: 1; + height: 100%; + min-height: 1em; +} +.rbc-day-slot .rbc-time-slot { + /* border-top: 1px solid #404040; */ +} + +.rbc-time-view-resources .rbc-time-gutter, +.rbc-time-view-resources .rbc-time-header-gutter { + position: sticky; + left: 0; + background-color: hsl(var(--background)); + border-right: 1px solid hsl(var(--border)); + z-index: 10; + /* margin-right: -1px; */ + width: 75px; +} +.rbc-time-view-resources .rbc-time-header { + overflow: hidden; +} +.rbc-time-view-resources .rbc-time-header-content { + min-width: auto; + -webkit-box-flex: 1; + -ms-flex: 1 0 0px; + flex: 1 0 0; + -ms-flex-preferred-size: 0px; + flex-basis: 0px; +} +.rbc-time-view-resources .rbc-time-header-cell-single-day { + display: none; +} +.rbc-time-view-resources .rbc-day-slot { + min-width: 75px; +} +.rbc-time-view-resources .rbc-header, +.rbc-time-view-resources .rbc-day-bg { + width: 75px; + -webkit-box-flex: 1; + -ms-flex: 1 1 0px; + flex: 1 1 0; + -ms-flex-preferred-size: 0px; + flex-basis: 0px; +} + +.rbc-time-header-content + .rbc-time-header-content { + margin-left: -1px; +} + +.rbc-time-slot { + -webkit-box-flex: 1; + -ms-flex: 1 0 0px; + flex: 1 0 0; +} +.rbc-time-slot.rbc-now { + font-weight: bold; +} + +.rbc-day-header { + text-align: center; +} + +.rbc-slot-selection { + z-index: 10; + position: absolute; + background-color: hsl(var(--primary) / 0.3); + color: white; + font-size: 75%; + width: 100%; + padding: 3px; +} + +.rbc-slot-selecting { + cursor: move; +} + +.rbc-time-view { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-flex: 1; + -ms-flex: 1; + flex: 1; + width: 100%; + border: 1px solid hsl(var(--border)); + min-height: 0; + overflow: hidden; +} +.rbc-time-view .rbc-time-gutter { + white-space: nowrap; + text-align: right; +} +.rbc-time-view .rbc-allday-cell { + -webkit-box-sizing: content-box; + box-sizing: content-box; + width: 100%; + height: 100%; + position: relative; +} +.rbc-time-view .rbc-allday-cell + .rbc-allday-cell { + border-left: 1px solid hsl(var(--border)); +} +.rbc-time-view .rbc-allday-events { + position: relative; + z-index: 4; +} +.rbc-time-view .rbc-row { + -webkit-box-sizing: border-box; + box-sizing: border-box; + min-height: 20px; +} + +.rbc-time-header { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-flex: 0; + -ms-flex: 0 0 auto; + flex: 0 0 auto; + -webkit-box-orient: horizontal; + -webkit-box-direction: normal; + -ms-flex-direction: row; + flex-direction: row; + border-bottom: 1px solid hsl(var(--border)); +} +.rbc-time-header.rbc-overflowing { + border-right: 1px solid hsl(var(--border)); +} +.rbc-rtl .rbc-time-header.rbc-overflowing { + border-right-width: 0; + border-left: 1px solid hsl(var(--border)); +} +.rbc-time-header > .rbc-row:first-child { + border-bottom: 1px solid hsl(var(--border)); +} +.rbc-time-header > .rbc-row.rbc-row-resource { + border-bottom: 1px solid hsl(var(--border)); +} + +.rbc-time-header-cell-single-day { + display: none; +} + +.rbc-time-header-content { + -webkit-box-flex: 1; + -ms-flex: 1; + flex: 1; + display: -webkit-box; + display: -ms-flexbox; + display: flex; + min-width: 0; + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -ms-flex-direction: column; + flex-direction: column; + border-left: 1px solid hsl(var(--border)); + overflow: hidden; +} +.rbc-rtl .rbc-time-header-content { + border-left-width: 0; + border-right: 1px solid hsl(var(--border)); +} +.rbc-time-header-content > .rbc-row.rbc-row-resource { + border-bottom: 1px solid hsl(var(--border)); + -ms-flex-negative: 0; + flex-shrink: 0; +} + +.rbc-time-content { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-flex: 1; + -ms-flex: 1 0 0%; + flex: 1 0 0%; + -webkit-box-align: start; + -ms-flex-align: start; + align-items: flex-start; + width: 100%; + border-top: 2px solid hsl(var(--border)); + overflow-y: auto; + position: relative; +} + +/* Custom scrollbar styles */ +.rbc-time-content::-webkit-scrollbar { + width: 8px; +} + +.rbc-time-content::-webkit-scrollbar-track { + background: hsl(var(--background)); +} + +.rbc-time-content::-webkit-scrollbar-thumb { + background: hsl(var(--border)); + border-radius: 4px; +} + +.rbc-time-content::-webkit-scrollbar-thumb:hover { + background: hsl(var(--border)); +} + +/* Firefox */ +.rbc-time-content { + scrollbar-width: thin; + scrollbar-color: hsl(var(--border)) hsl(var(--background)); +} + +.rbc-time-content > .rbc-time-gutter { + -webkit-box-flex: 0; + -ms-flex: none; + flex: none; +} +.rbc-time-content > * + * > * { + border-left: 1px solid hsl(var(--border)); +} +.rbc-rtl .rbc-time-content > * + * > * { + border-left-width: 0; + border-right: 1px solid hsl(var(--border)); +} +.rbc-time-content > .rbc-day-slot { + width: 100%; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + -webkit-user-select: none; + min-width: 0; +} + +.rbc-current-time-indicator { + position: absolute; + z-index: 3; + left: 0; + right: 0; + height: 1px; + background-color: hsl(var(--primary)); + pointer-events: none; +} + +.rbc-resource-grouping.rbc-time-header-content { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -ms-flex-direction: column; + flex-direction: column; +} +.rbc-resource-grouping .rbc-row .rbc-header { + width: 141px; +} + +/*# sourceMappingURL=react-big-calendar.css.map */ + +.rbc-addons-dnd .rbc-addons-dnd-row-body { + position: relative; +} +.rbc-addons-dnd .rbc-addons-dnd-drag-row { + position: absolute; + top: 0; + left: 0; + right: 0; +} +.rbc-addons-dnd .rbc-addons-dnd-over { + background-color: rgba(0, 0, 0, 0.3); +} +.rbc-addons-dnd .rbc-event { + transition: opacity 150ms; +} +.rbc-addons-dnd .rbc-event:hover .rbc-addons-dnd-resize-ns-icon, .rbc-addons-dnd .rbc-event:hover .rbc-addons-dnd-resize-ew-icon { + display: block; +} +.rbc-addons-dnd .rbc-addons-dnd-dragged-event { + opacity: 0; +} +.rbc-addons-dnd.rbc-addons-dnd-is-dragging .rbc-event:not(.rbc-addons-dnd-dragged-event):not(.rbc-addons-dnd-drag-preview) { + opacity: 0.5; +} +.rbc-addons-dnd .rbc-addons-dnd-resizable { + position: relative; + width: 100%; + height: 100%; +} +.rbc-addons-dnd .rbc-addons-dnd-resize-ns-anchor { + width: 100%; + text-align: center; + position: absolute; +} +.rbc-addons-dnd .rbc-addons-dnd-resize-ns-anchor:first-child { + top: 0; +} +.rbc-addons-dnd .rbc-addons-dnd-resize-ns-anchor:last-child { + bottom: 0; +} +.rbc-addons-dnd .rbc-addons-dnd-resize-ns-anchor .rbc-addons-dnd-resize-ns-icon { + display: none; + border-top: 3px double; + margin: 0 auto; + width: 10px; + cursor: ns-resize; +} +.rbc-addons-dnd .rbc-addons-dnd-resize-ew-anchor { + position: absolute; + top: 4px; + bottom: 0; +} +.rbc-addons-dnd .rbc-addons-dnd-resize-ew-anchor:first-child { + left: 0; +} +.rbc-addons-dnd .rbc-addons-dnd-resize-ew-anchor:last-child { + right: 0; +} +.rbc-addons-dnd .rbc-addons-dnd-resize-ew-anchor .rbc-addons-dnd-resize-ew-icon { + display: none; + border-left: 3px double; + margin-top: auto; + margin-bottom: auto; + height: 10px; + cursor: ew-resize; +} \ No newline at end of file diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/policies/(sidebar)/deny-windows/_components/CreateDenyRule.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/policies/(sidebar)/deny-windows/_components/CreateDenyRule.tsx new file mode 100644 index 000000000..f2472df4b --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/policies/(sidebar)/deny-windows/_components/CreateDenyRule.tsx @@ -0,0 +1,458 @@ +"use client"; + +import type * as SCHEMA from "@ctrlplane/db/schema"; +import React, { useEffect, useMemo, useState } from "react"; +import { + differenceInMilliseconds, + endOfDay, + endOfWeek, + format, + getDay, + parse, + startOfDay, + startOfWeek, +} from "date-fns"; +import { enUS } from "date-fns/locale"; +import { Calendar, dateFnsLocalizer, Views } from "react-big-calendar"; +import withDragAndDrop from "react-big-calendar/lib/addons/dragAndDrop"; + +import "./CreateDenyRule.css"; + +import { IconDeviceFloppy, IconEdit, IconX } from "@tabler/icons-react"; +import { z } from "zod"; + +import { Button } from "@ctrlplane/ui/button"; +import { TimePicker } from "@ctrlplane/ui/datetime-picker"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + useForm, +} from "@ctrlplane/ui/form"; +import { Popover, PopoverContent, PopoverTrigger } from "@ctrlplane/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@ctrlplane/ui/select"; + +import { api } from "~/trpc/react"; +import { DenyWindowProvider, useDenyWindow } from "./DenyWindowContext"; + +const locales = { "en-US": enUS }; + +const localizer = dateFnsLocalizer({ + format, + parse, + startOfWeek, + getDay, + locales, +}); + +const DnDCalendar = withDragAndDrop(Calendar); + +type Event = { + id: string; + title: string; + start: Date; + end: Date; +}; + +const EditDenyWindow: React.FC<{ + denyWindow: SCHEMA.PolicyRuleDenyWindow & { policy: SCHEMA.Policy }; + event: Event; + setEditing: () => void; +}> = ({ denyWindow, event, setEditing }) => { + const form = useForm({ + schema: z.object({ + start: z.date(), + end: z.date(), + recurrence: z.enum(["daily", "weekly", "monthly"]), + }), + defaultValues: { + start: event.start, + end: event.end, + recurrence: + Number(denyWindow.rrule.freq) === 1 + ? "monthly" + : Number(denyWindow.rrule.freq) === 2 + ? "weekly" + : "daily", + }, + }); + + const updateDenyWindow = api.policy.denyWindow.update.useMutation(); + const onSubmit = form.handleSubmit((data) => { + console.log("data", data); + // updateDenyWindow.mutate({ + // id: denyWindow.id, + // data, + // }); + // setEditing(); + }); + + return ( +
+ +
+
+ {denyWindow.policy.name === "" + ? "Deny Window" + : denyWindow.policy.name} +
+
+ + +
+
+ ( + + Start + + + + + )} + /> + ( + + End + + + + + )} + /> + ( + + Recurrence + + + + + )} + /> + + + ); +}; + +const DenyWindowInfo: React.FC<{ + denyWindow: SCHEMA.PolicyRuleDenyWindow & { policy: SCHEMA.Policy }; + setEditing: () => void; + fullStartString: string; + endString: string; +}> = ({ denyWindow, setEditing, fullStartString, endString }) => ( +
+
+
+
+ {denyWindow.policy.name === "" + ? "Deny Window" + : denyWindow.policy.name} +
+ +
+
+
+ {fullStartString} - {endString} +
+
+ {Number(denyWindow.rrule.freq) === 1 && "Monthly"} + {Number(denyWindow.rrule.freq) === 2 && "Weekly"} + {Number(denyWindow.rrule.freq) === 3 && "Daily"} +
+
+
+
+); + +const EventComponent: React.FC<{ + event: any; + denyWindow: SCHEMA.PolicyRuleDenyWindow & { policy: SCHEMA.Policy }; +}> = ({ event, denyWindow }) => { + const [editing, setEditing] = useState(false); + const { openEventId, setOpenEventId } = useDenyWindow(); + const start = format(event.start, "h:mm a"); + const end = format(event.end, "h:mm a"); + const fullStartString = format(event.start, "EEEE, MMMM d, h:mm aa"); + return ( + + +
{ + setOpenEventId(event.id); + }} + > +
+ {" "} + {start} - {end} +
+
{event.title}
+
+
+ + {!editing && ( + setEditing(true)} + fullStartString={fullStartString} + endString={end} + /> + )} + {editing && ( + setEditing(false)} + /> + )} + +
+ ); +}; + +type EventChange = { + event: object; + start: Date; + end: Date; +}; + +type EventCreate = { + start: Date; + end: Date; +}; + +export const CreateDenyRuleDialog: React.FC<{ workspaceId: string }> = ({ + workspaceId, +}) => { + return ( + + + + ); +}; + +const CreateDenyRuleDialogContent: React.FC<{ workspaceId: string }> = ({ + workspaceId, +}) => { + const { timeZone } = Intl.DateTimeFormat().resolvedOptions(); + const now = useMemo(() => new Date(), []); + const { openEventId, setOpenEventId } = useDenyWindow(); + console.log("openEventId", openEventId); + + const [currentRange, setCurrentRange] = useState<{ + start: Date; + end: Date; + }>({ start: startOfWeek(now), end: endOfWeek(now) }); + + const denyWindowsQ = api.policy.denyWindow.list.byWorkspaceId.useQuery({ + workspaceId, + start: currentRange.start, + end: currentRange.end, + timeZone, + }); + + const denyWindows = useMemo( + () => denyWindowsQ.data ?? [], + [denyWindowsQ.data], + ); + const [events, setEvents] = useState( + denyWindows.flatMap((denyWindow) => denyWindow.events), + ); + + useEffect( + () => setEvents(denyWindows.flatMap((denyWindow) => denyWindow.events)), + [denyWindows], + ); + + const resizeDenyWindow = api.policy.denyWindow.resize.useMutation(); + const dragDenyWindow = api.policy.denyWindow.drag.useMutation(); + const createDenyWindow = api.policy.denyWindow.createInCalendar.useMutation(); + + const handleEventResize = (event: EventChange) => { + const { start, end } = event; + const e = event.event as { + end: Date; + start: Date; + id: string; + }; + + const [denyWindowId] = e.id.split("|"); + const denyWindow = denyWindows.find( + (denyWindow) => denyWindow.id === denyWindowId, + ); + const ev = denyWindow?.events.find((event) => event.id === e.id); + if (denyWindow == null || ev == null) return; + + const dtstartOffset = differenceInMilliseconds(start, ev.start); + const dtendOffset = differenceInMilliseconds(end, ev.end); + + const { id } = denyWindow; + resizeDenyWindow.mutate({ windowId: id, dtstartOffset, dtendOffset }); + + setEvents((prev) => { + const newEvents = prev.filter((event) => event.id !== e.id); + return [...newEvents, { ...ev, start: start, end: end }]; + }); + }; + + const handleEventDrag = (event: EventChange) => { + const { start, end } = event; + const e = event.event as { + end: Date; + start: Date; + id: string; + }; + + const [denyWindowId] = e.id.split("|"); + const denyWindow = denyWindows.find( + (denyWindow) => denyWindow.id === denyWindowId, + ); + const ev = denyWindow?.events.find((event) => event.id === e.id); + if (denyWindow == null || ev == null) return; + + const offset = differenceInMilliseconds(start, ev.start); + const dayOfNewStart = getDay(start); + + const { id } = denyWindow; + dragDenyWindow.mutate({ windowId: id, offset, day: dayOfNewStart }); + + setEvents((prev) => { + const newEvents = prev.filter((event) => event.id !== e.id); + return [...newEvents, { ...ev, start: start, end: end }]; + }); + }; + + const handleEventCreate = (event: EventCreate) => { + console.log("creating deny window", event); + const { start, end } = event; + // // console.log("creating deny window", start, end); + // // createDenyWindow.mutate({ + // // policyId: "123", + // // start, + // end, + // timeZone, + // }); + setEvents((prev) => [...prev, { id: "new", start, end, title: "" }]); + }; + + return ( + { + if (Array.isArray(range)) { + const rangeStart = range.at(0); + const rangeEnd = range.at(-1); + + if (rangeStart && rangeEnd) + setCurrentRange({ + start: startOfDay(new Date(rangeStart)), + end: endOfDay(new Date(rangeEnd)), + }); + + return; + } + const { start, end } = range; + + setCurrentRange({ + start: new Date(start), + end: endOfDay(new Date(end)), + }); + }} + defaultView={Views.WEEK} + localizer={localizer} + events={events} + resizableAccessor={() => true} + draggableAccessor={() => true} + selectable={true} + onSelectSlot={(event) => { + if (!openEventId) { + handleEventCreate(event as EventCreate); + return; + } + setOpenEventId(null); + setEvents((prev) => prev.filter((event) => event.id !== "new")); + }} + onEventDrop={(event) => handleEventDrag(event as EventChange)} + onEventResize={(event) => handleEventResize(event as EventChange)} + style={{ height: 500 }} + step={30} + resizable={true} + components={{ + event: (props) => { + const event = props.event as Event; + const [denyWindowId] = event.id.split("|"); + const denyWindow = denyWindows.find( + (denyWindow) => denyWindow.id === denyWindowId, + ); + if (denyWindow == null) return null; + return ; + }, + }} + /> + ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/policies/(sidebar)/deny-windows/_components/DenyWindowContext.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/policies/(sidebar)/deny-windows/_components/DenyWindowContext.tsx new file mode 100644 index 000000000..a32a89853 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/policies/(sidebar)/deny-windows/_components/DenyWindowContext.tsx @@ -0,0 +1,29 @@ +import React, { createContext, useContext, useState } from "react"; + +type DenyWindowContextType = { + openEventId: string | null; + setOpenEventId: (id: string | null) => void; +}; + +const DenyWindowContext = createContext( + undefined, +); + +export const DenyWindowProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [openEventId, setOpenEventId] = useState(null); + + return ( + + {children} + + ); +}; + +export const useDenyWindow = () => { + const context = useContext(DenyWindowContext); + if (context === undefined) + throw new Error("useDenyWindow must be used within a DenyWindowProvider"); + return context; +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/policies/(sidebar)/deny-windows/page.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/policies/(sidebar)/deny-windows/page.tsx index 4c5283ee6..a49730810 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/policies/(sidebar)/deny-windows/page.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/policies/(sidebar)/deny-windows/page.tsx @@ -1,3 +1,4 @@ +import { notFound } from "next/navigation"; import { IconMenu2 } from "@tabler/icons-react"; import { @@ -20,14 +21,19 @@ import { SidebarTrigger } from "@ctrlplane/ui/sidebar"; import { Sidebars } from "~/app/[workspaceSlug]/sidebars"; import { urls } from "~/app/urls"; +import { api } from "~/trpc/server"; import { PageHeader } from "../../../_components/PageHeader"; +import { CreateDenyRuleDialog } from "./_components/CreateDenyRule"; export default async function DenyWindowsPage({ params, }: { params: Promise<{ workspaceSlug: string }>; }) { - const workspaceSlug = (await params).workspaceSlug; + const { workspaceSlug } = await params; + const workspace = await api.workspace.bySlug(workspaceSlug); + if (workspace == null) notFound(); + return (
@@ -54,7 +60,7 @@ export default async function DenyWindowsPage({
- + Available Deny Windows @@ -75,6 +81,8 @@ export default async function DenyWindowsPage({ + +
); diff --git a/packages/api/package.json b/packages/api/package.json index 70539ef19..1ba8cef40 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -29,6 +29,7 @@ "@ctrlplane/events": "workspace:*", "@ctrlplane/job-dispatch": "workspace:*", "@ctrlplane/logger": "workspace:*", + "@ctrlplane/rule-engine": "workspace:*", "@ctrlplane/secrets": "workspace:*", "@ctrlplane/validators": "workspace:*", "@octokit/auth-app": "catalog:", diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts index f82039270..a2bd2a413 100644 --- a/packages/api/src/root.ts +++ b/packages/api/src/root.ts @@ -3,7 +3,7 @@ import { deploymentRouter } from "./router/deployment"; import { environmentRouter } from "./router/environment"; import { githubRouter } from "./router/github"; import { jobRouter } from "./router/job"; -import { policyRouter } from "./router/policy"; +import { policyRouter } from "./router/policy/router"; import { resourceRouter } from "./router/resources"; import { runbookRouter } from "./router/runbook"; import { runtimeRouter } from "./router/runtime"; diff --git a/packages/api/src/router/policy/deny-window.ts b/packages/api/src/router/policy/deny-window.ts new file mode 100644 index 000000000..b072bda6e --- /dev/null +++ b/packages/api/src/router/policy/deny-window.ts @@ -0,0 +1,275 @@ +import type * as rrule from "rrule"; +import { addMilliseconds } from "date-fns"; +import { z } from "zod"; + +import { eq, takeFirst } from "@ctrlplane/db"; +import { + createPolicyRuleDenyWindow, + policy, + policyRuleDenyWindow, + updatePolicyRuleDenyWindow, +} from "@ctrlplane/db/schema"; +import { DeploymentDenyRule } from "@ctrlplane/rule-engine"; +import { Permission } from "@ctrlplane/validators/auth"; + +import { createTRPCRouter, protectedProcedure } from "../../trpc"; + +type Weekday = 0 | 1 | 2 | 3 | 4 | 5 | 6; +const weekdayMap: Record = { + 0: "SU", + 1: "MO", + 2: "TU", + 3: "WE", + 4: "TH", + 5: "FR", + 6: "SA", +}; + +export const denyWindowRouter = createTRPCRouter({ + list: createTRPCRouter({ + byWorkspaceId: protectedProcedure + .meta({ + authorizationCheck: ({ canUser, input }) => + canUser + .perform(Permission.PolicyGet) + .on({ type: "workspace", id: input.workspaceId }), + }) + .input( + z.object({ + workspaceId: z.string().uuid(), + start: z.date(), + end: z.date(), + timeZone: z.string(), + }), + ) + .query(async ({ ctx, input }) => { + const denyWindows = await ctx.db + .select() + .from(policyRuleDenyWindow) + .innerJoin(policy, eq(policyRuleDenyWindow.policyId, policy.id)) + .where(eq(policy.workspaceId, input.workspaceId)); + + return denyWindows.flatMap((dw) => { + const { policy_rule_deny_window: denyWindow, policy } = dw; + const rrule = { ...denyWindow.rrule, tzid: denyWindow.timeZone }; + const dtstart = + denyWindow.rrule.dtstart == null + ? null + : new Date(denyWindow.rrule.dtstart); + const rule = new DeploymentDenyRule({ + ...rrule, + dtend: denyWindow.dtend, + dtstart, + }); + const windows = rule.getWindowsInRange(input.start, input.end); + const events = windows.map((window, idx) => ({ + id: `${denyWindow.id}|${idx}`, + start: window.start, + end: window.end, + title: "Deny Window", + })); + return { ...denyWindow, events, policy }; + }); + }), + }), + + create: protectedProcedure + .meta({ + authorizationCheck: ({ canUser, input }) => + canUser + .perform(Permission.PolicyCreate) + .on({ type: "workspace", id: input.worspaceId }), + }) + .input( + z.object({ + workspaceId: z.string().uuid(), + data: createPolicyRuleDenyWindow.extend({ + policyId: z.string().uuid().optional(), + }), + }), + ) + .mutation(async ({ ctx, input }) => { + const { workspaceId, data } = input; + const policyId: string = + data.policyId ?? + (await ctx.db + .insert(policy) + .values({ workspaceId, name: "" }) + .returning() + .then(takeFirst) + .then((policy) => policy.id)); + + return ctx.db + .insert(policyRuleDenyWindow) + .values({ ...data, policyId }) + .returning() + .then(takeFirst); + }), + + createInCalendar: protectedProcedure + .meta({ + authorizationCheck: ({ canUser, input }) => + canUser + .perform(Permission.PolicyCreate) + .on({ type: "policy", id: input.policyId }), + }) + .input( + z.object({ + policyId: z.string().uuid(), + start: z.date(), + end: z.date(), + timeZone: z.string(), + }), + ) + .mutation(({ ctx, input }) => { + console.log(input); + + return; + }), + + resize: protectedProcedure + .meta({ + authorizationCheck: async ({ ctx, canUser, input }) => { + const denyWindow = await ctx.db + .select() + .from(policyRuleDenyWindow) + .where(eq(policyRuleDenyWindow.id, input.windowId)) + .then(takeFirst); + + return canUser + .perform(Permission.PolicyUpdate) + .on({ type: "policy", id: denyWindow.policyId }); + }, + }) + .input( + z.object({ + windowId: z.string().uuid(), + dtstartOffset: z.number(), + dtendOffset: z.number(), + }), + ) + .mutation(async ({ ctx, input }) => { + const denyWindow = await ctx.db + .select() + .from(policyRuleDenyWindow) + .where(eq(policyRuleDenyWindow.id, input.windowId)) + .then(takeFirst); + + const currStart = denyWindow.rrule.dtstart; + const currEnd = denyWindow.dtend; + + const newStart = + currStart != null + ? addMilliseconds(currStart, input.dtstartOffset) + : null; + + const newRrule = { ...denyWindow.rrule, dtstart: newStart }; + const newdtend = + currEnd != null ? addMilliseconds(currEnd, input.dtendOffset) : null; + + return ctx.db + .update(policyRuleDenyWindow) + .set({ rrule: newRrule, dtend: newdtend }) + .where(eq(policyRuleDenyWindow.id, input.windowId)) + .returning() + .then(takeFirst); + }), + + drag: protectedProcedure + .meta({ + authorizationCheck: async ({ ctx, canUser, input }) => { + const denyWindow = await ctx.db + .select() + .from(policyRuleDenyWindow) + .where(eq(policyRuleDenyWindow.id, input.windowId)) + .then(takeFirst); + + return canUser + .perform(Permission.PolicyUpdate) + .on({ type: "policy", id: denyWindow.policyId }); + }, + }) + .input( + z.object({ + windowId: z.string().uuid(), + offset: z.number(), + day: z.number().transform((val) => weekdayMap[val as Weekday]), + }), + ) + .mutation(async ({ ctx, input }) => { + const denyWindow = await ctx.db + .select() + .from(policyRuleDenyWindow) + .where(eq(policyRuleDenyWindow.id, input.windowId)) + .then(takeFirst); + + const currStart = denyWindow.rrule.dtstart; + const currEnd = denyWindow.dtend; + + const newStart = + currStart != null ? addMilliseconds(currStart, input.offset) : null; + + const newRrule = { + ...denyWindow.rrule, + dtstart: newStart, + byweekday: [input.day as rrule.ByWeekday], + }; + const newdtend = + currEnd != null ? addMilliseconds(currEnd, input.offset) : null; + + return ctx.db + .update(policyRuleDenyWindow) + .set({ rrule: newRrule, dtend: newdtend }) + .where(eq(policyRuleDenyWindow.id, input.windowId)) + .returning() + .then(takeFirst); + }), + update: protectedProcedure + .meta({ + authorizationCheck: async ({ canUser, input, ctx }) => { + const denyWindow = await ctx.db + .select() + .from(policyRuleDenyWindow) + .where(eq(policyRuleDenyWindow.id, input.id)) + .then(takeFirst); + + return canUser + .perform(Permission.PolicyUpdate) + .on({ type: "policy", id: denyWindow.policyId }); + }, + }) + .input( + z.object({ id: z.string().uuid(), data: updatePolicyRuleDenyWindow }), + ) + .mutation(({ ctx, input }) => { + return ctx.db + .update(policyRuleDenyWindow) + .set(input.data) + .where(eq(policyRuleDenyWindow.id, input.id)) + .returning() + .then(takeFirst); + }), + + delete: protectedProcedure + .meta({ + authorizationCheck: async ({ canUser, input, ctx }) => { + const denyWindow = await ctx.db + .select() + .from(policyRuleDenyWindow) + .where(eq(policyRuleDenyWindow.id, input)) + .then(takeFirst); + + return canUser + .perform(Permission.PolicyDelete) + .on({ type: "policy", id: denyWindow.policyId }); + }, + }) + .input(z.string().uuid()) + .mutation(({ ctx, input }) => + ctx.db + .delete(policyRuleDenyWindow) + .where(eq(policyRuleDenyWindow.id, input)) + .returning() + .then(takeFirst), + ), +}); diff --git a/packages/api/src/router/policy.ts b/packages/api/src/router/policy/router.ts similarity index 66% rename from packages/api/src/router/policy.ts rename to packages/api/src/router/policy/router.ts index c31b51aab..011a37d21 100644 --- a/packages/api/src/router/policy.ts +++ b/packages/api/src/router/policy/router.ts @@ -4,18 +4,16 @@ import { z } from "zod"; import { eq, takeFirst } from "@ctrlplane/db"; import { createPolicy, - createPolicyRuleDenyWindow, createPolicyTarget, policy, - policyRuleDenyWindow, policyTarget, updatePolicy, - updatePolicyRuleDenyWindow, updatePolicyTarget, } from "@ctrlplane/db/schema"; import { Permission } from "@ctrlplane/validators/auth"; -import { createTRPCRouter, protectedProcedure } from "../trpc"; +import { createTRPCRouter, protectedProcedure } from "../../trpc"; +import { denyWindowRouter } from "./deny-window"; export const policyRouter = createTRPCRouter({ list: protectedProcedure @@ -151,69 +149,5 @@ export const policyRouter = createTRPCRouter({ .then(takeFirst), ), - // Deny Window endpoints - createDenyWindow: protectedProcedure - .meta({ - authorizationCheck: ({ canUser, input }) => - canUser - .perform(Permission.PolicyCreate) - .on({ type: "policy", id: input.policyId }), - }) - .input(createPolicyRuleDenyWindow) - .mutation(({ ctx, input }) => { - return ctx.db - .insert(policyRuleDenyWindow) - .values(input) - .returning() - .then(takeFirst); - }), - - updateDenyWindow: protectedProcedure - .meta({ - authorizationCheck: async ({ canUser, input, ctx }) => { - const denyWindow = await ctx.db - .select() - .from(policyRuleDenyWindow) - .where(eq(policyRuleDenyWindow.id, input.id)) - .then(takeFirst); - - return canUser - .perform(Permission.PolicyUpdate) - .on({ type: "policy", id: denyWindow.policyId }); - }, - }) - .input( - z.object({ id: z.string().uuid(), data: updatePolicyRuleDenyWindow }), - ) - .mutation(({ ctx, input }) => { - return ctx.db - .update(policyRuleDenyWindow) - .set(input.data) - .where(eq(policyRuleDenyWindow.id, input.id)) - .returning() - .then(takeFirst); - }), - - deleteDenyWindow: protectedProcedure - .meta({ - authorizationCheck: async ({ canUser, input, ctx }) => { - const denyWindow = await ctx.db - .select() - .from(policyRuleDenyWindow) - .where(eq(policyRuleDenyWindow.id, input)) - .then(takeFirst); - - return canUser - .perform(Permission.PolicyDelete) - .on({ type: "policy", id: denyWindow.policyId }); - }, - }) - .input(z.string().uuid()) - .mutation(({ ctx, input }) => - ctx.db - .delete(policyRuleDenyWindow) - .where(eq(policyRuleDenyWindow.id, input)) - .returning() - .then(takeFirst), - ), + denyWindow: denyWindowRouter, }); diff --git a/packages/rule-engine/src/rules/deployment-deny-rule.ts b/packages/rule-engine/src/rules/deployment-deny-rule.ts index ecda16191..8d838ec01 100644 --- a/packages/rule-engine/src/rules/deployment-deny-rule.ts +++ b/packages/rule-engine/src/rules/deployment-deny-rule.ts @@ -4,8 +4,9 @@ import { differenceInMilliseconds, isSameDay, isWithinInterval, + subHours, } from "date-fns"; -import rrule from "rrule"; +import * as rrule from "rrule"; import type { RuleEngineContext, @@ -163,6 +164,87 @@ export class DeploymentDenyRule implements RuleEngineFilter { return isSameDay(occurrence, now, { in: tz(this.timezone) }); } + /** + * Returns all denied windows within a given date range + * + * @param start - Start of the date range to check (inclusive) + * @param end - End of the date range to check (inclusive) + * @returns Array of denied windows, where each window has a start and end time + */ + getWindowsInRange(start: Date, end: Date): Array<{ start: Date; end: Date }> { + // since the rrule just treats its internal timezone as UTC, we need to convert + // the requested times to the rule's timezone + const startParts = getDatePartsInTimeZone(start, this.timezone); + const endParts = getDatePartsInTimeZone(end, this.timezone); + const startDt = datetime( + startParts.year, + startParts.month, + startParts.day, + startParts.hour, + startParts.minute, + startParts.second, + ); + const endDt = datetime( + endParts.year, + endParts.month, + endParts.day, + endParts.hour, + endParts.minute, + endParts.second, + ); + + const occurrences = this.rrule.between(startDt, endDt, true); + + if (occurrences.length === 0) return []; + + // Calculate duration if dtend is specified + const durationMs = + this.dtend != null && this.dtstart != null + ? differenceInMilliseconds(this.dtend, this.dtstart) + : 0; + + // Create windows for each occurrence + return occurrences.map((occurrence) => { + const windowStart = occurrence; + const windowEnd = + this.dtend != null + ? addMilliseconds(occurrence, durationMs) + : this.castTimezone( + new Date( + occurrence.getFullYear(), + occurrence.getMonth(), + occurrence.getDate(), + 23, + 59, + 59, + ), + this.timezone, + ); + + /** + * Window start and end are in the rrule's internal pretend UTC timezone + * Since we know the rule's timezone, we first figure out the offset of this timezone + * to the actual UTC time. Then we convert the window start and end to the actual UTC time + * by subtracting the offset. We end up not needing to know the requester's timezone at all + * because timezone in parts will + */ + const formatter = new Intl.DateTimeFormat("en-US", { + timeZone: this.timezone, + timeZoneName: "longOffset", + }); + + const offsetStr = formatter.format(windowStart).split("GMT")[1]; + const offsetHours = parseInt(offsetStr?.split(":")[0] ?? "0", 10); + + const realStartUTC = subHours(windowStart, offsetHours); + const realEndUTC = subHours(windowEnd, offsetHours); + + // Create UTC dates using Date.UTC to get the correct UTC time + + return { start: realStartUTC, end: realEndUTC }; + }); + } + /** * Converts a date to the specified timezone * diff --git a/packages/ui/src/datetime-picker.tsx b/packages/ui/src/datetime-picker.tsx index 8b1bf1642..2721b4fac 100644 --- a/packages/ui/src/datetime-picker.tsx +++ b/packages/ui/src/datetime-picker.tsx @@ -376,6 +376,7 @@ interface PeriodSelectorProps { onDateChange?: (date: Date | undefined) => void; onRightFocus?: () => void; onLeftFocus?: () => void; + className?: string; } const TimePeriodSelect = React.forwardRef< @@ -383,7 +384,15 @@ const TimePeriodSelect = React.forwardRef< PeriodSelectorProps >( ( - { period, setPeriod, date, onDateChange, onLeftFocus, onRightFocus }, + { + period, + setPeriod, + date, + onDateChange, + onLeftFocus, + onRightFocus, + className, + }, ref, ) => { const handleKeyDown = (e: React.KeyboardEvent) => { @@ -413,14 +422,17 @@ const TimePeriodSelect = React.forwardRef< }; return ( -
+