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 (
+
+
+ );
+};
+
+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 (
-
+