diff --git a/coderd/schedule/cron/cron.go b/coderd/schedule/cron/cron.go index 1399215843999..35102d66af34a 100644 --- a/coderd/schedule/cron/cron.go +++ b/coderd/schedule/cron/cron.go @@ -173,6 +173,19 @@ func (s Schedule) Min() time.Duration { return durMin } +// TimeParsed returns the parsed time.Time of the minute and hour fields. If the +// time cannot be represented in a valid time.Time, a zero time is returned. +func (s Schedule) TimeParsed() time.Time { + minute := strings.Fields(s.cronStr)[0] + hour := strings.Fields(s.cronStr)[1] + maybeTime := fmt.Sprintf("%s:%s", hour, minute) + t, err := time.ParseInLocation("15:4", maybeTime, s.sched.Location) + if err != nil { + return time.Time{} + } + return t +} + // Time returns a humanized form of the minute and hour fields. func (s Schedule) Time() string { minute := strings.Fields(s.cronStr)[0] diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 214053b203203..833af156ec0ce 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -64,6 +64,7 @@ var FeatureNames = []FeatureName{ FeatureExternalProvisionerDaemons, FeatureAppearance, FeatureAdvancedTemplateScheduling, + FeatureTemplateAutostopRequirement, FeatureWorkspaceProxy, FeatureUserRoleManagement, FeatureExternalTokenEncryption, diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index badfdb3d6f0da..0bd10c654fa83 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -472,7 +472,7 @@ func (api *API) updateEntitlements(ctx context.Context) error { codersdk.FeatureAdvancedTemplateScheduling: true, // FeatureTemplateAutostopRequirement depends on // FeatureAdvancedTemplateScheduling. - codersdk.FeatureTemplateAutostopRequirement: api.DefaultQuietHoursSchedule != "", + codersdk.FeatureTemplateAutostopRequirement: api.AGPL.Experiments.Enabled(codersdk.ExperimentTemplateAutostopRequirement) && api.DefaultQuietHoursSchedule != "", codersdk.FeatureWorkspaceProxy: true, codersdk.FeatureUserRoleManagement: true, }) diff --git a/enterprise/coderd/users.go b/enterprise/coderd/users.go index 2af162a68a363..6398a93c95e85 100644 --- a/enterprise/coderd/users.go +++ b/enterprise/coderd/users.go @@ -68,7 +68,7 @@ func (api *API) userQuietHoursSchedule(rw http.ResponseWriter, r *http.Request) httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserQuietHoursScheduleResponse{ RawSchedule: opts.Schedule.String(), UserSet: opts.UserSet, - Time: opts.Schedule.Time(), + Time: opts.Schedule.TimeParsed().Format("15:40"), Timezone: opts.Schedule.Location().String(), Next: opts.Schedule.Next(time.Now().In(opts.Schedule.Location())), }) @@ -114,7 +114,7 @@ func (api *API) putUserQuietHoursSchedule(rw http.ResponseWriter, r *http.Reques httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserQuietHoursScheduleResponse{ RawSchedule: opts.Schedule.String(), UserSet: opts.UserSet, - Time: opts.Schedule.Time(), + Time: opts.Schedule.TimeParsed().Format("15:40"), Timezone: opts.Schedule.Location().String(), Next: opts.Schedule.Next(time.Now().In(opts.Schedule.Location())), }) diff --git a/enterprise/coderd/users_test.go b/enterprise/coderd/users_test.go index f91b3b348ca08..e88a3e4df55f3 100644 --- a/enterprise/coderd/users_test.go +++ b/enterprise/coderd/users_test.go @@ -21,14 +21,14 @@ func TestUserQuietHours(t *testing.T) { t.Run("OK", func(t *testing.T) { t.Parallel() - defaultQuietHoursSchedule := "CRON_TZ=America/Chicago 0 0 * * *" + defaultQuietHoursSchedule := "CRON_TZ=America/Chicago 0 1 * * *" defaultScheduleParsed, err := cron.Daily(defaultQuietHoursSchedule) require.NoError(t, err) nextTime := defaultScheduleParsed.Next(time.Now().In(defaultScheduleParsed.Location())) if time.Until(nextTime) < time.Hour { // Use a different default schedule instead, because we want to avoid // the schedule "ticking over" during this test run. - defaultQuietHoursSchedule = "CRON_TZ=America/Chicago 0 12 * * *" + defaultQuietHoursSchedule = "CRON_TZ=America/Chicago 0 13 * * *" defaultScheduleParsed, err = cron.Daily(defaultQuietHoursSchedule) require.NoError(t, err) } @@ -55,7 +55,7 @@ func TestUserQuietHours(t *testing.T) { require.NoError(t, err) require.Equal(t, defaultScheduleParsed.String(), sched1.RawSchedule) require.False(t, sched1.UserSet) - require.Equal(t, defaultScheduleParsed.Time(), sched1.Time) + require.Equal(t, defaultScheduleParsed.TimeParsed().Format("15:40"), sched1.Time) require.Equal(t, defaultScheduleParsed.Location().String(), sched1.Timezone) require.WithinDuration(t, defaultScheduleParsed.Next(time.Now()), sched1.Next, 15*time.Second) @@ -78,7 +78,7 @@ func TestUserQuietHours(t *testing.T) { require.NoError(t, err) require.Equal(t, customScheduleParsed.String(), sched2.RawSchedule) require.True(t, sched2.UserSet) - require.Equal(t, customScheduleParsed.Time(), sched2.Time) + require.Equal(t, customScheduleParsed.TimeParsed().Format("15:40"), sched2.Time) require.Equal(t, customScheduleParsed.Location().String(), sched2.Timezone) require.WithinDuration(t, customScheduleParsed.Next(time.Now()), sched2.Next, 15*time.Second) @@ -87,7 +87,7 @@ func TestUserQuietHours(t *testing.T) { require.NoError(t, err) require.Equal(t, customScheduleParsed.String(), sched3.RawSchedule) require.True(t, sched3.UserSet) - require.Equal(t, customScheduleParsed.Time(), sched3.Time) + require.Equal(t, customScheduleParsed.TimeParsed().Format("15:40"), sched3.Time) require.Equal(t, customScheduleParsed.Location().String(), sched3.Timezone) require.WithinDuration(t, customScheduleParsed.Next(time.Now()), sched3.Next, 15*time.Second) diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index 67af1b48d86dc..9ee5b93ba1086 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -33,6 +33,9 @@ const CliAuthenticationPage = lazy( const AccountPage = lazy( () => import("./pages/UserSettingsPage/AccountPage/AccountPage"), ); +const SchedulePage = lazy( + () => import("./pages/UserSettingsPage/SchedulePage/SchedulePage"), +); const SecurityPage = lazy( () => import("./pages/UserSettingsPage/SecurityPage/SecurityPage"), ); @@ -292,6 +295,7 @@ export const AppRouter: FC = () => { }> } /> + } /> } /> } /> diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 4c29b92017e00..1f2bc6e815057 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -665,6 +665,21 @@ export const updateProfile = async ( return response.data; }; +export const getUserQuietHoursSchedule = async ( + userId: TypesGen.User["id"], +): Promise => { + const response = await axios.get(`/api/v2/users/${userId}/quiet-hours`); + return response.data; +}; + +export const updateUserQuietHoursSchedule = async ( + userId: TypesGen.User["id"], + data: TypesGen.UpdateUserQuietHoursScheduleRequest, +): Promise => { + const response = await axios.put(`/api/v2/users/${userId}/quiet-hours`, data); + return response.data; +}; + export const activateUser = async ( userId: TypesGen.User["id"], ): Promise => { diff --git a/site/src/api/queries/settings.ts b/site/src/api/queries/settings.ts new file mode 100644 index 0000000000000..8fe57ee9b675d --- /dev/null +++ b/site/src/api/queries/settings.ts @@ -0,0 +1,34 @@ +import * as API from "api/api"; +import { + type UserQuietHoursScheduleResponse, + type UpdateUserQuietHoursScheduleRequest, +} from "api/typesGenerated"; +import { type QueryClient, type QueryOptions } from "@tanstack/react-query"; + +export const userQuietHoursScheduleKey = (userId: string) => [ + "settings", + userId, + "quietHours", +]; + +export const userQuietHoursSchedule = ( + userId: string, +): QueryOptions => { + return { + queryKey: userQuietHoursScheduleKey(userId), + queryFn: () => API.getUserQuietHoursSchedule(userId), + }; +}; + +export const updateUserQuietHoursSchedule = ( + userId: string, + queryClient: QueryClient, +) => { + return { + mutationFn: (request: UpdateUserQuietHoursScheduleRequest) => + API.updateUserQuietHoursSchedule(userId, request), + onSuccess: async () => { + await queryClient.invalidateQueries(userQuietHoursScheduleKey(userId)); + }, + }; +}; diff --git a/site/src/components/SettingsLayout/Sidebar.tsx b/site/src/components/SettingsLayout/Sidebar.tsx index 61bf644233898..54a9c2a66680f 100644 --- a/site/src/components/SettingsLayout/Sidebar.tsx +++ b/site/src/components/SettingsLayout/Sidebar.tsx @@ -8,7 +8,9 @@ import { FC, ElementType, PropsWithChildren, ReactNode } from "react"; import { NavLink } from "react-router-dom"; import { combineClasses } from "utils/combineClasses"; import AccountIcon from "@mui/icons-material/Person"; +import ScheduleIcon from "@mui/icons-material/EditCalendarOutlined"; import SecurityIcon from "@mui/icons-material/LockOutlined"; +import { useDashboard } from "components/Dashboard/DashboardProvider"; const SidebarNavItem: FC< PropsWithChildren<{ href: string; icon: ReactNode }> @@ -41,6 +43,9 @@ const SidebarNavItemIcon: React.FC<{ icon: ElementType }> = ({ export const Sidebar: React.FC<{ user: User }> = ({ user }) => { const styles = useStyles(); + const { entitlements } = useDashboard(); + const allowAutostopRequirement = + entitlements.features.template_autostop_requirement.enabled; return (