diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 421fcc9a864d5..5271c3f64f89a 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3659,6 +3659,7 @@ const getNotificationTemplatesByKind = `-- name: GetNotificationTemplatesByKind SELECT id, name, title_template, body_template, actions, "group", method, kind FROM notification_templates WHERE kind = $1::notification_template_kind +ORDER BY name ASC ` func (q *sqlQuerier) GetNotificationTemplatesByKind(ctx context.Context, kind NotificationTemplateKind) ([]NotificationTemplate, error) { diff --git a/coderd/database/queries/notifications.sql b/coderd/database/queries/notifications.sql index f5b8601871ccc..3500a9c413068 100644 --- a/coderd/database/queries/notifications.sql +++ b/coderd/database/queries/notifications.sql @@ -170,4 +170,5 @@ WHERE id = @id::uuid; -- name: GetNotificationTemplatesByKind :many SELECT * FROM notification_templates -WHERE kind = @kind::notification_template_kind; +WHERE kind = @kind::notification_template_kind +ORDER BY name ASC; diff --git a/site/src/@types/storybook.d.ts b/site/src/@types/storybook.d.ts index 778bf53d0a0b1..31ab2f32fed6b 100644 --- a/site/src/@types/storybook.d.ts +++ b/site/src/@types/storybook.d.ts @@ -1,6 +1,13 @@ import * as _storybook_types from "@storybook/react"; import type { QueryKey } from "react-query"; -import type { Experiments, FeatureName } from "api/typesGenerated"; +import type { + Experiments, + FeatureName, + SerpentOption, + User, + DeploymentValues, +} from "api/typesGenerated"; +import type { Permissions } from "contexts/auth/permissions"; declare module "@storybook/react" { type WebSocketEvent = @@ -11,5 +18,9 @@ declare module "@storybook/react" { experiments?: Experiments; queries?: { key: QueryKey; data: unknown }[]; webSocket?: WebSocketEvent[]; + user?: User; + permissions?: Partial; + deploymentValues?: DeploymentValues; + deploymentOptions?: SerpentOption[]; } } diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 1512d1f9e245a..d2e32def327b0 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2036,6 +2036,49 @@ class ApiMethods { return response.data; }; + + getUserNotificationPreferences = async (userId: string) => { + const res = await this.axios.get( + `/api/v2/users/${userId}/notifications/preferences`, + ); + return res.data ?? []; + }; + + putUserNotificationPreferences = async ( + userId: string, + req: TypesGen.UpdateUserNotificationPreferences, + ) => { + const res = await this.axios.put( + `/api/v2/users/${userId}/notifications/preferences`, + req, + ); + return res.data; + }; + + getSystemNotificationTemplates = async () => { + const res = await this.axios.get( + `/api/v2/notifications/templates/system`, + ); + return res.data; + }; + + getNotificationDispatchMethods = async () => { + const res = await this.axios.get( + `/api/v2/notifications/dispatch-methods`, + ); + return res.data; + }; + + updateNotificationTemplateMethod = async ( + templateId: string, + req: TypesGen.UpdateNotificationTemplateMethod, + ) => { + const res = await this.axios.put( + `/api/v2/notifications/templates/${templateId}/method`, + req, + ); + return res.data; + }; } // This is a hard coded CSRF token/cookie pair for local development. In prod, diff --git a/site/src/api/queries/notifications.ts b/site/src/api/queries/notifications.ts new file mode 100644 index 0000000000000..7c6f9c4f6e804 --- /dev/null +++ b/site/src/api/queries/notifications.ts @@ -0,0 +1,138 @@ +import type { QueryClient, UseMutationOptions } from "react-query"; +import { API } from "api/api"; +import type { + NotificationPreference, + NotificationTemplate, + UpdateNotificationTemplateMethod, + UpdateUserNotificationPreferences, +} from "api/typesGenerated"; + +export const userNotificationPreferencesKey = (userId: string) => [ + "users", + userId, + "notifications", + "preferences", +]; + +export const userNotificationPreferences = (userId: string) => { + return { + queryKey: userNotificationPreferencesKey(userId), + queryFn: () => API.getUserNotificationPreferences(userId), + }; +}; + +export const updateUserNotificationPreferences = ( + userId: string, + queryClient: QueryClient, +) => { + return { + mutationFn: (req) => { + return API.putUserNotificationPreferences(userId, req); + }, + onMutate: (data) => { + queryClient.setQueryData( + userNotificationPreferencesKey(userId), + Object.entries(data.template_disabled_map).map( + ([id, disabled]) => + ({ + id, + disabled, + updated_at: new Date().toISOString(), + }) satisfies NotificationPreference, + ), + ); + }, + } satisfies UseMutationOptions< + NotificationPreference[], + unknown, + UpdateUserNotificationPreferences + >; +}; + +export const systemNotificationTemplatesKey = [ + "notifications", + "templates", + "system", +]; + +export const systemNotificationTemplates = () => { + return { + queryKey: systemNotificationTemplatesKey, + queryFn: () => API.getSystemNotificationTemplates(), + }; +}; + +export function selectTemplatesByGroup( + data: NotificationTemplate[], +): Record { + const grouped = data.reduce( + (acc, tpl) => { + if (!acc[tpl.group]) { + acc[tpl.group] = []; + } + acc[tpl.group].push(tpl); + return acc; + }, + {} as Record, + ); + + // Sort templates within each group + for (const group in grouped) { + grouped[group].sort((a, b) => a.name.localeCompare(b.name)); + } + + // Sort groups by name + const sortedGroups = Object.keys(grouped).sort((a, b) => a.localeCompare(b)); + const sortedGrouped: Record = {}; + for (const group of sortedGroups) { + sortedGrouped[group] = grouped[group]; + } + + return sortedGrouped; +} + +export const notificationDispatchMethodsKey = [ + "notifications", + "dispatchMethods", +]; + +export const notificationDispatchMethods = () => { + return { + staleTime: Infinity, + queryKey: notificationDispatchMethodsKey, + queryFn: () => API.getNotificationDispatchMethods(), + }; +}; + +export const updateNotificationTemplateMethod = ( + templateId: string, + queryClient: QueryClient, +) => { + return { + mutationFn: (req: UpdateNotificationTemplateMethod) => + API.updateNotificationTemplateMethod(templateId, req), + onMutate: (data) => { + const prevData = queryClient.getQueryData( + systemNotificationTemplatesKey, + ); + if (!prevData) { + return; + } + queryClient.setQueryData( + systemNotificationTemplatesKey, + prevData.map((tpl) => + tpl.id === templateId + ? { + ...tpl, + method: data.method, + } + : tpl, + ), + ); + }, + } satisfies UseMutationOptions< + void, + unknown, + UpdateNotificationTemplateMethod + >; +}; diff --git a/site/src/api/queries/users.ts b/site/src/api/queries/users.ts index 8417dade576c8..700449b41ff7c 100644 --- a/site/src/api/queries/users.ts +++ b/site/src/api/queries/users.ts @@ -141,10 +141,12 @@ export function apiKey(): UseQueryOptions { }; } +export const hasFirstUserKey = ["hasFirstUser"]; + export const hasFirstUser = (userMetadata: MetadataState) => { return cachedQuery({ metadata: userMetadata, - queryKey: ["hasFirstUser"], + queryKey: hasFirstUserKey, queryFn: API.hasFirstUser, }); }; diff --git a/site/src/modules/notifications/utils.tsx b/site/src/modules/notifications/utils.tsx new file mode 100644 index 0000000000000..1511d40aa8400 --- /dev/null +++ b/site/src/modules/notifications/utils.tsx @@ -0,0 +1,29 @@ +import EmailIcon from "@mui/icons-material/EmailOutlined"; +import WebhookIcon from "@mui/icons-material/WebhookOutlined"; + +// TODO: This should be provided by the auto generated types from codersdk +const notificationMethods = ["smtp", "webhook"] as const; + +export type NotificationMethod = (typeof notificationMethods)[number]; + +export const methodIcons: Record = { + smtp: EmailIcon, + webhook: WebhookIcon, +}; + +export const methodLabels: Record = { + smtp: "SMTP", + webhook: "Webhook", +}; + +export const castNotificationMethod = (value: string) => { + if (notificationMethods.includes(value as NotificationMethod)) { + return value as NotificationMethod; + } + + throw new Error( + `Invalid notification method: ${value}. Accepted values: ${notificationMethods.join( + ", ", + )}`, + ); +}; diff --git a/site/src/pages/DeploySettingsPage/DeploySettingsLayout.tsx b/site/src/pages/DeploySettingsPage/DeploySettingsLayout.tsx index 287114fdcc989..4a9cf7fbba74e 100644 --- a/site/src/pages/DeploySettingsPage/DeploySettingsLayout.tsx +++ b/site/src/pages/DeploySettingsPage/DeploySettingsLayout.tsx @@ -24,7 +24,7 @@ export const useDeploySettings = (): DeploySettingsContextValue => { const context = useContext(DeploySettingsContext); if (!context) { throw new Error( - "useDeploySettings should be used inside of DeploySettingsLayout", + "useDeploySettings should be used inside of DeploySettingsContext or DeploySettingsLayout", ); } return context; diff --git a/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationsPage.stories.tsx b/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationsPage.stories.tsx new file mode 100644 index 0000000000000..e902d3d5cd940 --- /dev/null +++ b/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationsPage.stories.tsx @@ -0,0 +1,279 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { spyOn, userEvent, within } from "@storybook/test"; +import { API } from "api/api"; +import { + notificationDispatchMethodsKey, + systemNotificationTemplatesKey, +} from "api/queries/notifications"; +import type { DeploymentValues, SerpentOption } from "api/typesGenerated"; +import { + MockNotificationMethodsResponse, + MockNotificationTemplates, + MockUser, +} from "testHelpers/entities"; +import { + withAuthProvider, + withDashboardProvider, + withDeploySettings, + withGlobalSnackbar, +} from "testHelpers/storybook"; +import { NotificationsPage } from "./NotificationsPage"; + +const meta: Meta = { + title: "pages/DeploymentSettings/NotificationsPage", + component: NotificationsPage, + parameters: { + experiments: ["notifications"], + queries: [ + { key: systemNotificationTemplatesKey, data: MockNotificationTemplates }, + { + key: notificationDispatchMethodsKey, + data: MockNotificationMethodsResponse, + }, + ], + user: MockUser, + permissions: { viewDeploymentValues: true }, + deploymentOptions: mockNotificationOptions(), + deploymentValues: { + notifications: { + webhook: { + endpoint: "https://example.com", + }, + email: { + smarthost: "smtp.example.com", + }, + }, + } as DeploymentValues, + }, + decorators: [ + withGlobalSnackbar, + withAuthProvider, + withDashboardProvider, + withDeploySettings, + ], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const NoEmailSmarthost: Story = { + parameters: { + deploymentValues: { + notifications: { + webhook: { + endpoint: "https://example.com", + }, + email: { + smarthost: "", + }, + }, + } as DeploymentValues, + }, +}; + +export const NoWebhookEndpoint: Story = { + parameters: { + deploymentValues: { + notifications: { + webhook: { + endpoint: "", + }, + email: { + smarthost: "smtp.example.com", + }, + }, + } as DeploymentValues, + }, +}; + +export const Toggle: Story = { + play: async ({ canvasElement }) => { + spyOn(API, "updateNotificationTemplateMethod").mockResolvedValue(); + const user = userEvent.setup(); + const canvas = within(canvasElement); + const option = await canvas.findByText("Workspace Marked as Dormant"); + const toggleButton = within(option.closest("li")!).getByRole("button", { + name: "Webhook", + }); + await user.click(toggleButton); + }, +}; + +export const Settings: Story = { + play: async ({ canvasElement }) => { + const user = userEvent.setup(); + const canvas = within(canvasElement); + const settingsTab = await canvas.findByText("Settings"); + await user.click(settingsTab); + }, +}; + +function mockNotificationOptions(): SerpentOption[] { + return [ + { + name: "Notifications: Dispatch Timeout", + description: + "How long to wait while a notification is being sent before giving up.", + flag: "notifications-dispatch-timeout", + env: "CODER_NOTIFICATIONS_DISPATCH_TIMEOUT", + yaml: "dispatchTimeout", + default: "1m0s", + value: 60000000000, + annotations: { + format_duration: "true", + }, + group: { + name: "Notifications", + yaml: "notifications", + description: "Configure how notifications are processed and delivered.", + }, + value_source: "default", + }, + { + name: "Notifications: Fetch Interval", + description: "How often to query the database for queued notifications.", + flag: "notifications-fetch-interval", + env: "CODER_NOTIFICATIONS_FETCH_INTERVAL", + yaml: "fetchInterval", + default: "15s", + value: 15000000000, + annotations: { + format_duration: "true", + }, + group: { + name: "Notifications", + yaml: "notifications", + description: "Configure how notifications are processed and delivered.", + }, + hidden: true, + value_source: "default", + }, + { + name: "Notifications: Lease Count", + description: + "How many notifications a notifier should lease per fetch interval.", + flag: "notifications-lease-count", + env: "CODER_NOTIFICATIONS_LEASE_COUNT", + yaml: "leaseCount", + default: "20", + value: 20, + group: { + name: "Notifications", + yaml: "notifications", + description: "Configure how notifications are processed and delivered.", + }, + hidden: true, + value_source: "default", + }, + { + name: "Notifications: Lease Period", + description: + "How long a notifier should lease a message. This is effectively how long a notification is 'owned' by a notifier, and once this period expires it will be available for lease by another notifier. Leasing is important in order for multiple running notifiers to not pick the same messages to deliver concurrently. This lease period will only expire if a notifier shuts down ungracefully; a dispatch of the notification releases the lease.", + flag: "notifications-lease-period", + env: "CODER_NOTIFICATIONS_LEASE_PERIOD", + yaml: "leasePeriod", + default: "2m0s", + value: 120000000000, + annotations: { + format_duration: "true", + }, + group: { + name: "Notifications", + yaml: "notifications", + description: "Configure how notifications are processed and delivered.", + }, + hidden: true, + value_source: "default", + }, + { + name: "Notifications: Max Send Attempts", + description: "The upper limit of attempts to send a notification.", + flag: "notifications-max-send-attempts", + env: "CODER_NOTIFICATIONS_MAX_SEND_ATTEMPTS", + yaml: "maxSendAttempts", + default: "5", + value: 5, + group: { + name: "Notifications", + yaml: "notifications", + description: "Configure how notifications are processed and delivered.", + }, + value_source: "default", + }, + { + name: "Notifications: Method", + description: + "Which delivery method to use (available options: 'smtp', 'webhook').", + flag: "notifications-method", + env: "CODER_NOTIFICATIONS_METHOD", + yaml: "method", + default: "smtp", + value: "smtp", + group: { + name: "Notifications", + yaml: "notifications", + description: "Configure how notifications are processed and delivered.", + }, + value_source: "env", + }, + { + name: "Notifications: Retry Interval", + description: "The minimum time between retries.", + flag: "notifications-retry-interval", + env: "CODER_NOTIFICATIONS_RETRY_INTERVAL", + yaml: "retryInterval", + default: "5m0s", + value: 300000000000, + annotations: { + format_duration: "true", + }, + group: { + name: "Notifications", + yaml: "notifications", + description: "Configure how notifications are processed and delivered.", + }, + hidden: true, + value_source: "default", + }, + { + name: "Notifications: Store Sync Buffer Size", + description: + "The notifications system buffers message updates in memory to ease pressure on the database. This option controls how many updates are kept in memory. The lower this value the lower the change of state inconsistency in a non-graceful shutdown - but it also increases load on the database. It is recommended to keep this option at its default value.", + flag: "notifications-store-sync-buffer-size", + env: "CODER_NOTIFICATIONS_STORE_SYNC_BUFFER_SIZE", + yaml: "storeSyncBufferSize", + default: "50", + value: 50, + group: { + name: "Notifications", + yaml: "notifications", + description: "Configure how notifications are processed and delivered.", + }, + hidden: true, + value_source: "default", + }, + { + name: "Notifications: Store Sync Interval", + description: + "The notifications system buffers message updates in memory to ease pressure on the database. This option controls how often it synchronizes its state with the database. The shorter this value the lower the change of state inconsistency in a non-graceful shutdown - but it also increases load on the database. It is recommended to keep this option at its default value.", + flag: "notifications-store-sync-interval", + env: "CODER_NOTIFICATIONS_STORE_SYNC_INTERVAL", + yaml: "storeSyncInterval", + default: "2s", + value: 2000000000, + annotations: { + format_duration: "true", + }, + group: { + name: "Notifications", + yaml: "notifications", + description: "Configure how notifications are processed and delivered.", + }, + hidden: true, + value_source: "default", + }, + ]; +} diff --git a/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationsPage.tsx b/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationsPage.tsx new file mode 100644 index 0000000000000..a76b9e08d9274 --- /dev/null +++ b/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationsPage.tsx @@ -0,0 +1,297 @@ +import type { Interpolation, Theme } from "@emotion/react"; +import Button from "@mui/material/Button"; +import Card from "@mui/material/Card"; +import Divider from "@mui/material/Divider"; +import List from "@mui/material/List"; +import ListItem from "@mui/material/ListItem"; +import ListItemText, { listItemTextClasses } from "@mui/material/ListItemText"; +import ToggleButton from "@mui/material/ToggleButton"; +import ToggleButtonGroup from "@mui/material/ToggleButtonGroup"; +import Tooltip from "@mui/material/Tooltip"; +import { Fragment, type FC } from "react"; +import { Helmet } from "react-helmet-async"; +import { useMutation, useQueries, useQueryClient } from "react-query"; +import { useSearchParams } from "react-router-dom"; +import { + notificationDispatchMethods, + selectTemplatesByGroup, + systemNotificationTemplates, + updateNotificationTemplateMethod, +} from "api/queries/notifications"; +import type { DeploymentValues } from "api/typesGenerated"; +import { Alert } from "components/Alert/Alert"; +import { displaySuccess } from "components/GlobalSnackbar/utils"; +import { Loader } from "components/Loader/Loader"; +import { Stack } from "components/Stack/Stack"; +import { TabLink, Tabs, TabsList } from "components/Tabs/Tabs"; +import { + castNotificationMethod, + methodIcons, + methodLabels, + type NotificationMethod, +} from "modules/notifications/utils"; +import { Section } from "pages/UserSettingsPage/Section"; +import { deploymentGroupHasParent } from "utils/deployOptions"; +import { docs } from "utils/docs"; +import { pageTitle } from "utils/page"; +import { useDeploySettings } from "../DeploySettingsLayout"; +import OptionsTable from "../OptionsTable"; + +type MethodToggleGroupProps = { + templateId: string; + options: NotificationMethod[]; + value: NotificationMethod; +}; + +const MethodToggleGroup: FC = ({ + value, + options, + templateId, +}) => { + const queryClient = useQueryClient(); + const updateMethodMutation = useMutation( + updateNotificationTemplateMethod(templateId, queryClient), + ); + + return ( + { + await updateMethodMutation.mutateAsync({ + method, + }); + displaySuccess("Notification method updated"); + }} + > + {options.map((method) => { + const Icon = methodIcons[method]; + const label = methodLabels[method]; + return ( + + { + // Retain the value if the user clicks the same button, ensuring + // at least one value remains selected. + if (method === value) { + e.preventDefault(); + e.stopPropagation(); + return; + } + }} + > + + + + ); + })} + + ); +}; + +export const NotificationsPage: FC = () => { + const [searchParams] = useSearchParams(); + const { deploymentValues } = useDeploySettings(); + const [templatesByGroup, dispatchMethods] = useQueries({ + queries: [ + { + ...systemNotificationTemplates(), + select: selectTemplatesByGroup, + }, + notificationDispatchMethods(), + ], + }); + const ready = + templatesByGroup.data && dispatchMethods.data && deploymentValues; + const tab = searchParams.get("tab") || "events"; + + return ( + <> + + {pageTitle("Notifications Settings")} + +
+ + + + Events + + + Settings + + + + +
+ {ready ? ( + tab === "events" ? ( + + ) : ( + + deploymentGroupHasParent(o.group, "Notifications"), + )} + /> + ) + ) : ( + + )} +
+
+ + ); +}; + +type EventsViewProps = { + defaultMethod: NotificationMethod; + availableMethods: NotificationMethod[]; + templatesByGroup: ReturnType; + deploymentValues: DeploymentValues; +}; + +const EventsView: FC = ({ + defaultMethod, + availableMethods, + templatesByGroup, + deploymentValues, +}) => { + return ( + + {availableMethods.includes("smtp") && + deploymentValues.notifications?.webhook.endpoint === "" && ( + + Read the docs + + } + > + Webhook notifications are enabled, but no endpoint has been + configured. + + )} + + {availableMethods.includes("smtp") && + deploymentValues.notifications?.email.smarthost === "" && ( + + Read the docs + + } + > + SMTP notifications are enabled, but no smarthost has been + configured. + + )} + + {Object.entries(templatesByGroup).map(([group, templates]) => ( + + + + + + + {templates.map((tpl, i) => { + const value = castNotificationMethod(tpl.method || defaultMethod); + const isLastItem = i === templates.length - 1; + + return ( + + + + + + {!isLastItem && } + + ); + })} + + + ))} + + ); +}; + +export default NotificationsPage; + +const styles = { + content: { paddingTop: 24 }, + listHeader: (theme) => ({ + background: theme.palette.background.paper, + borderBottom: `1px solid ${theme.palette.divider}`, + }), + listItemText: { + [`& .${listItemTextClasses.primary}`]: { + fontSize: 14, + fontWeight: 500, + }, + [`& .${listItemTextClasses.secondary}`]: { + fontSize: 14, + }, + }, + toggleGroup: (theme) => ({ + border: `1px solid ${theme.palette.divider}`, + borderRadius: 4, + }), + toggleButton: (theme) => ({ + border: 0, + borderRadius: 4, + fontSize: 16, + padding: "4px 8px", + color: theme.palette.text.disabled, + + "&:hover": { + color: theme.palette.text.primary, + }, + + "& svg": { + fontSize: "inherit", + }, + }), +} as Record>; diff --git a/site/src/pages/DeploySettingsPage/Sidebar.tsx b/site/src/pages/DeploySettingsPage/Sidebar.tsx index e473ab94ca510..c12149b298cd7 100644 --- a/site/src/pages/DeploySettingsPage/Sidebar.tsx +++ b/site/src/pages/DeploySettingsPage/Sidebar.tsx @@ -3,6 +3,7 @@ import HubOutlinedIcon from "@mui/icons-material/HubOutlined"; import InsertChartIcon from "@mui/icons-material/InsertChart"; import LaunchOutlined from "@mui/icons-material/LaunchOutlined"; import LockRounded from "@mui/icons-material/LockOutlined"; +import NotificationsIcon from "@mui/icons-material/NotificationsNoneOutlined"; import Globe from "@mui/icons-material/PublicOutlined"; import ApprovalIcon from "@mui/icons-material/VerifiedUserOutlined"; import VpnKeyOutlined from "@mui/icons-material/VpnKeyOutlined"; @@ -12,8 +13,11 @@ import { Sidebar as BaseSidebar, SidebarNavItem, } from "components/Sidebar/Sidebar"; +import { useDashboard } from "modules/dashboard/useDashboard"; export const Sidebar: FC = () => { + const { experiments } = useDashboard(); + return ( @@ -47,6 +51,11 @@ export const Sidebar: FC = () => { Observability + {experiments.includes("notifications") && ( + + Notifications + + )} ); }; diff --git a/site/src/pages/ManagementSettingsPage/Sidebar.tsx b/site/src/pages/ManagementSettingsPage/Sidebar.tsx index 6ac55c59b999f..44ee6021c8d6f 100644 --- a/site/src/pages/ManagementSettingsPage/Sidebar.tsx +++ b/site/src/pages/ManagementSettingsPage/Sidebar.tsx @@ -3,6 +3,7 @@ import { useQuery } from "react-query"; import { useLocation, useParams } from "react-router-dom"; import { organizationsPermissions } from "api/queries/organizations"; import { useAuthenticated } from "contexts/auth/RequireAuth"; +import { useDashboard } from "modules/dashboard/useDashboard"; import { canEditOrganization, useOrganizationSettings, @@ -19,6 +20,7 @@ import { type OrganizationWithPermissions, SidebarView } from "./SidebarView"; export const Sidebar: FC = () => { const location = useLocation(); const { permissions } = useAuthenticated(); + const { experiments } = useDashboard(); const { organizations } = useOrganizationSettings(); const { organization: organizationName } = useParams() as { organization?: string; @@ -54,6 +56,7 @@ export const Sidebar: FC = () => { activeOrganizationName={organizationName} organizations={editableOrgs} permissions={permissions} + experiments={experiments} /> ); }; diff --git a/site/src/pages/ManagementSettingsPage/SidebarView.stories.tsx b/site/src/pages/ManagementSettingsPage/SidebarView.stories.tsx index 44cb0de7fc97a..adb0688844c85 100644 --- a/site/src/pages/ManagementSettingsPage/SidebarView.stories.tsx +++ b/site/src/pages/ManagementSettingsPage/SidebarView.stories.tsx @@ -35,6 +35,7 @@ const meta: Meta = { }, ], permissions: MockPermissions, + experiments: ["notifications"], }, }; diff --git a/site/src/pages/ManagementSettingsPage/SidebarView.tsx b/site/src/pages/ManagementSettingsPage/SidebarView.tsx index 57accc6fa1a5a..0571f17c8eaf3 100644 --- a/site/src/pages/ManagementSettingsPage/SidebarView.tsx +++ b/site/src/pages/ManagementSettingsPage/SidebarView.tsx @@ -4,7 +4,11 @@ import AddIcon from "@mui/icons-material/Add"; import SettingsIcon from "@mui/icons-material/Settings"; import type { FC, ReactNode } from "react"; import { Link, NavLink } from "react-router-dom"; -import type { AuthorizationResponse, Organization } from "api/typesGenerated"; +import type { + AuthorizationResponse, + Experiments, + Organization, +} from "api/typesGenerated"; import { Loader } from "components/Loader/Loader"; import { Sidebar as BaseSidebar } from "components/Sidebar/Sidebar"; import { Stack } from "components/Stack/Stack"; @@ -26,6 +30,8 @@ interface SidebarProps { organizations: OrganizationWithPermissions[] | undefined; /** Site-wide permissions. */ permissions: AuthorizationResponse; + /** Active experiments */ + experiments: Experiments; } /** @@ -36,6 +42,7 @@ export const SidebarView: FC = ({ activeOrganizationName, organizations, permissions, + experiments, }) => { // TODO: Do something nice to scroll to the active org. return ( @@ -43,6 +50,7 @@ export const SidebarView: FC = ({
Deployment
= ({ active, permissions, + experiments, }) => { return (
@@ -133,6 +144,11 @@ const DeploymentSettingsNavigation: FC = ({ Users )} + {experiments.includes("notifications") && ( + + Notifications + + )} )}
diff --git a/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.stories.tsx b/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.stories.tsx new file mode 100644 index 0000000000000..3d7db1c428b06 --- /dev/null +++ b/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.stories.tsx @@ -0,0 +1,78 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { spyOn, userEvent, within } from "@storybook/test"; +import { API } from "api/api"; +import { + notificationDispatchMethodsKey, + systemNotificationTemplatesKey, + userNotificationPreferencesKey, +} from "api/queries/notifications"; +import { + MockNotificationMethodsResponse, + MockNotificationPreferences, + MockNotificationTemplates, + MockUser, +} from "testHelpers/entities"; +import { + withAuthProvider, + withDashboardProvider, + withGlobalSnackbar, +} from "testHelpers/storybook"; +import { NotificationsPage } from "./NotificationsPage"; + +const meta: Meta = { + title: "pages/UserSettingsPage/NotificationsPage", + component: NotificationsPage, + parameters: { + experiments: ["notifications"], + queries: [ + { + key: userNotificationPreferencesKey(MockUser.id), + data: MockNotificationPreferences, + }, + { + key: systemNotificationTemplatesKey, + data: MockNotificationTemplates, + }, + { + key: notificationDispatchMethodsKey, + data: MockNotificationMethodsResponse, + }, + ], + user: MockUser, + permissions: { viewDeploymentValues: true }, + }, + decorators: [withGlobalSnackbar, withAuthProvider, withDashboardProvider], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const ToggleGroup: Story = { + play: async ({ canvasElement }) => { + spyOn(API, "putUserNotificationPreferences").mockResolvedValue([]); + const user = userEvent.setup(); + const canvas = within(canvasElement); + const groupLabel = await canvas.findByLabelText("Workspace Events"); + await user.click(groupLabel); + }, +}; + +export const ToggleNotification: Story = { + play: async ({ canvasElement }) => { + spyOn(API, "putUserNotificationPreferences").mockResolvedValue([]); + const user = userEvent.setup(); + const canvas = within(canvasElement); + const notificationLabel = await canvas.findByLabelText( + "Workspace Marked as Dormant", + ); + await user.click(notificationLabel); + }, +}; + +export const NonAdmin: Story = { + parameters: { + permissions: { viewDeploymentValues: false }, + }, +}; diff --git a/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx b/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx new file mode 100644 index 0000000000000..b7c399ca35acd --- /dev/null +++ b/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx @@ -0,0 +1,214 @@ +import type { Interpolation, Theme } from "@emotion/react"; +import Card from "@mui/material/Card"; +import Divider from "@mui/material/Divider"; +import List from "@mui/material/List"; +import ListItem from "@mui/material/ListItem"; +import ListItemIcon from "@mui/material/ListItemIcon"; +import ListItemText, { listItemTextClasses } from "@mui/material/ListItemText"; +import Switch from "@mui/material/Switch"; +import Tooltip from "@mui/material/Tooltip"; +import { Fragment, type FC } from "react"; +import { Helmet } from "react-helmet-async"; +import { useMutation, useQueries, useQueryClient } from "react-query"; +import { + notificationDispatchMethods, + selectTemplatesByGroup, + systemNotificationTemplates, + updateUserNotificationPreferences, + userNotificationPreferences, +} from "api/queries/notifications"; +import type { + NotificationPreference, + NotificationTemplate, +} from "api/typesGenerated"; +import { displaySuccess } from "components/GlobalSnackbar/utils"; +import { Loader } from "components/Loader/Loader"; +import { Stack } from "components/Stack/Stack"; +import { useAuthenticated } from "contexts/auth/RequireAuth"; +import { + castNotificationMethod, + methodIcons, + methodLabels, +} from "modules/notifications/utils"; +import { pageTitle } from "utils/page"; +import { Section } from "../Section"; + +export const NotificationsPage: FC = () => { + const { user, permissions } = useAuthenticated(); + const [disabledPreferences, templatesByGroup, dispatchMethods] = useQueries({ + queries: [ + { + ...userNotificationPreferences(user.id), + select: selectDisabledPreferences, + }, + { + ...systemNotificationTemplates(), + select: (data: NotificationTemplate[]) => { + const groups = selectTemplatesByGroup(data); + return permissions.viewDeploymentValues + ? groups + : { + // Members only have access to the "Workspace Notifications" group + ["Workspace Events"]: groups["Workspace Events"], + }; + }, + }, + notificationDispatchMethods(), + ], + }); + const queryClient = useQueryClient(); + const updatePreferences = useMutation( + updateUserNotificationPreferences(user.id, queryClient), + ); + const ready = + disabledPreferences.data && templatesByGroup.data && dispatchMethods.data; + + return ( + <> + + {pageTitle("Notifications Settings")} + +
+ {ready ? ( + + {Object.entries(templatesByGroup.data).map(([group, templates]) => { + const allDisabled = templates.some((tpl) => { + return disabledPreferences.data[tpl.id] === true; + }); + + return ( + + + + + { + const updated = { ...disabledPreferences.data }; + for (const tpl of templates) { + updated[tpl.id] = !checked; + } + await updatePreferences.mutateAsync({ + template_disabled_map: updated, + }); + displaySuccess("Notification preferences updated"); + }} + /> + + + + {templates.map((tmpl, i) => { + const method = castNotificationMethod( + tmpl.method || dispatchMethods.data.default, + ); + const Icon = methodIcons[method]; + const label = methodLabels[method]; + const isLastItem = i === templates.length - 1; + + return ( + + + + { + await updatePreferences.mutateAsync({ + template_disabled_map: { + ...disabledPreferences.data, + [tmpl.id]: !checked, + }, + }); + displaySuccess( + "Notification preferences updated", + ); + }} + /> + + + + + + + + + {!isLastItem && } + + ); + })} + + + ); + })} + + ) : ( + + )} +
+ + ); +}; + +export default NotificationsPage; + +function selectDisabledPreferences(data: NotificationPreference[]) { + return data.reduce( + (acc, pref) => { + acc[pref.id] = pref.disabled; + return acc; + }, + {} as Record, + ); +} + +const styles = { + listHeader: (theme) => ({ + background: theme.palette.background.paper, + borderBottom: `1px solid ${theme.palette.divider}`, + }), + listItemText: { + [`& .${listItemTextClasses.primary}`]: { + fontSize: 14, + fontWeight: 500, + textTransform: "capitalize", + }, + [`& .${listItemTextClasses.secondary}`]: { + fontSize: 14, + }, + }, + listItemEndIcon: (theme) => ({ + minWidth: 0, + fontSize: 20, + color: theme.palette.text.secondary, + + "& svg": { + fontSize: "inherit", + }, + }), +} as Record>; diff --git a/site/src/pages/UserSettingsPage/Section.tsx b/site/src/pages/UserSettingsPage/Section.tsx index 64fa369ef68ce..0ce892540b5c5 100644 --- a/site/src/pages/UserSettingsPage/Section.tsx +++ b/site/src/pages/UserSettingsPage/Section.tsx @@ -70,6 +70,7 @@ const styles = { description: (theme) => ({ color: theme.palette.text.secondary, fontSize: 16, + margin: 0, marginTop: 4, lineHeight: "140%", }), diff --git a/site/src/pages/UserSettingsPage/Sidebar.tsx b/site/src/pages/UserSettingsPage/Sidebar.tsx index 01b2ba8be88b6..e05ca300381fd 100644 --- a/site/src/pages/UserSettingsPage/Sidebar.tsx +++ b/site/src/pages/UserSettingsPage/Sidebar.tsx @@ -2,6 +2,7 @@ import AppearanceIcon from "@mui/icons-material/Brush"; import ScheduleIcon from "@mui/icons-material/EditCalendarOutlined"; import FingerprintOutlinedIcon from "@mui/icons-material/FingerprintOutlined"; import SecurityIcon from "@mui/icons-material/LockOutlined"; +import NotificationsIcon from "@mui/icons-material/NotificationsNoneOutlined"; import AccountIcon from "@mui/icons-material/Person"; import VpnKeyOutlined from "@mui/icons-material/VpnKeyOutlined"; import type { FC } from "react"; @@ -20,7 +21,7 @@ interface SidebarProps { } export const Sidebar: FC = ({ user }) => { - const { entitlements } = useDashboard(); + const { entitlements, experiments } = useDashboard(); const showSchedulePage = entitlements.features.advanced_template_scheduling.enabled; @@ -56,6 +57,11 @@ export const Sidebar: FC = ({ user }) => { Tokens + {experiments.includes("notifications") && ( + + Notifications + + )} ); }; diff --git a/site/src/router.tsx b/site/src/router.tsx index 7f68576ed1911..152c458b83fb4 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -270,6 +270,13 @@ const WorkspaceProxyHealthPage = lazy( const ProvisionerDaemonsHealthPage = lazy( () => import("./pages/HealthPage/ProvisionerDaemonsPage"), ); +const UserNotificationsPage = lazy( + () => import("./pages/UserSettingsPage/NotificationsPage/NotificationsPage"), +); +const DeploymentNotificationsPage = lazy( + () => + import("./pages/DeploySettingsPage/NotificationsPage/NotificationsPage"), +); const RoutesWithSuspense = () => { return ( @@ -422,6 +429,10 @@ export const router = createBrowserRouter( } /> } /> {groupsRouter()} + } + /> }> @@ -442,6 +453,7 @@ export const router = createBrowserRouter( } /> } /> + } /> {/* In order for the 404 page to work properly the routes that start with diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index f2ad7362111b8..288bd3d708fb7 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -3720,3 +3720,131 @@ export const MockOAuth2ProviderAppSecrets: TypesGen.OAuth2ProviderAppSecret[] = client_secret_truncated: "foo", }, ]; + +export const MockNotificationPreferences: TypesGen.NotificationPreference[] = [ + { + id: "f44d9314-ad03-4bc8-95d0-5cad491da6b6", + disabled: false, + updated_at: "2024-08-06T11:58:37.755053Z", + }, + { + id: "381df2a9-c0c0-4749-420f-80a9280c66f9", + disabled: true, + updated_at: "2024-08-06T11:58:37.755053Z", + }, + { + id: "f517da0b-cdc9-410f-ab89-a86107c420ed", + disabled: false, + updated_at: "2024-08-06T11:58:37.755053Z", + }, + { + id: "c34a0c09-0704-4cac-bd1c-0c0146811c2b", + disabled: false, + updated_at: "2024-08-06T11:58:37.755053Z", + }, + { + id: "0ea69165-ec14-4314-91f1-69566ac3c5a0", + disabled: false, + updated_at: "2024-08-06T11:58:37.755053Z", + }, + { + id: "51ce2fdf-c9ca-4be1-8d70-628674f9bc42", + disabled: false, + updated_at: "2024-08-06T11:58:37.755053Z", + }, + { + id: "4e19c0ac-94e1-4532-9515-d1801aa283b2", + disabled: true, + updated_at: "2024-08-06T11:58:37.755053Z", + }, +]; + +export const MockNotificationTemplates: TypesGen.NotificationTemplate[] = [ + { + id: "381df2a9-c0c0-4749-420f-80a9280c66f9", + name: "Workspace Autobuild Failed", + title_template: 'Workspace "{{.Labels.name}}" autobuild failed', + body_template: + 'Hi {{.UserName}}\nAutomatic build of your workspace **{{.Labels.name}}** failed.\nThe specified reason was "**{{.Labels.reason}}**".', + actions: + '[{"url": "{{ base_url }}/@{{.UserUsername}}/{{.Labels.name}}", "label": "View workspace"}]', + group: "Workspace Events", + method: "webhook", + kind: "system", + }, + { + id: "f517da0b-cdc9-410f-ab89-a86107c420ed", + name: "Workspace Deleted", + title_template: 'Workspace "{{.Labels.name}}" deleted', + body_template: + 'Hi {{.UserName}}\n\nYour workspace **{{.Labels.name}}** was deleted.\nThe specified reason was "**{{.Labels.reason}}{{ if .Labels.initiator }} ({{ .Labels.initiator }}){{end}}**".', + actions: + '[{"url": "{{ base_url }}/workspaces", "label": "View workspaces"}, {"url": "{{ base_url }}/templates", "label": "View templates"}]', + group: "Workspace Events", + method: "smtp", + kind: "system", + }, + { + id: "f44d9314-ad03-4bc8-95d0-5cad491da6b6", + name: "User account deleted", + title_template: 'User account "{{.Labels.deleted_account_name}}" deleted', + body_template: + "Hi {{.UserName}},\n\nUser account **{{.Labels.deleted_account_name}}** has been deleted.", + actions: + '[{"url": "{{ base_url }}/deployment/users?filter=status%3Aactive", "label": "View accounts"}]', + group: "User Events", + method: "", + kind: "system", + }, + { + id: "4e19c0ac-94e1-4532-9515-d1801aa283b2", + name: "User account created", + title_template: 'User account "{{.Labels.created_account_name}}" created', + body_template: + "Hi {{.UserName}},\n\nNew user account **{{.Labels.created_account_name}}** has been created.", + actions: + '[{"url": "{{ base_url }}/deployment/users?filter=status%3Aactive", "label": "View accounts"}]', + group: "User Events", + method: "", + kind: "system", + }, + { + id: "0ea69165-ec14-4314-91f1-69566ac3c5a0", + name: "Workspace Marked as Dormant", + title_template: 'Workspace "{{.Labels.name}}" marked as dormant', + body_template: + "Hi {{.UserName}}\n\nYour workspace **{{.Labels.name}}** has been marked as [**dormant**](https://coder.com/docs/templates/schedule#dormancy-threshold-enterprise) because of {{.Labels.reason}}.\nDormant workspaces are [automatically deleted](https://coder.com/docs/templates/schedule#dormancy-auto-deletion-enterprise) after {{.Labels.timeTilDormant}} of inactivity.\nTo prevent deletion, use your workspace with the link below.", + actions: + '[{"url": "{{ base_url }}/@{{.UserUsername}}/{{.Labels.name}}", "label": "View workspace"}]', + group: "Workspace Events", + method: "smtp", + kind: "system", + }, + { + id: "c34a0c09-0704-4cac-bd1c-0c0146811c2b", + name: "Workspace updated automatically", + title_template: 'Workspace "{{.Labels.name}}" updated automatically', + body_template: + "Hi {{.UserName}}\nYour workspace **{{.Labels.name}}** has been updated automatically to the latest template version ({{.Labels.template_version_name}}).", + actions: + '[{"url": "{{ base_url }}/@{{.UserUsername}}/{{.Labels.name}}", "label": "View workspace"}]', + group: "Workspace Events", + method: "smtp", + kind: "system", + }, + { + id: "51ce2fdf-c9ca-4be1-8d70-628674f9bc42", + name: "Workspace Marked for Deletion", + title_template: 'Workspace "{{.Labels.name}}" marked for deletion', + body_template: + "Hi {{.UserName}}\n\nYour workspace **{{.Labels.name}}** has been marked for **deletion** after {{.Labels.timeTilDormant}} of [dormancy](https://coder.com/docs/templates/schedule#dormancy-auto-deletion-enterprise) because of {{.Labels.reason}}.\nTo prevent deletion, use your workspace with the link below.", + actions: + '[{"url": "{{ base_url }}/@{{.UserUsername}}/{{.Labels.name}}", "label": "View workspace"}]', + group: "Workspace Events", + method: "webhook", + kind: "system", + }, +]; + +export const MockNotificationMethodsResponse: TypesGen.NotificationMethodsResponse = + { available: ["smtp", "webhook"], default: "smtp" }; diff --git a/site/src/testHelpers/storybook.tsx b/site/src/testHelpers/storybook.tsx index 30fbc28d0fccc..a84765f581d0b 100644 --- a/site/src/testHelpers/storybook.tsx +++ b/site/src/testHelpers/storybook.tsx @@ -1,8 +1,15 @@ import type { StoryContext } from "@storybook/react"; import type { FC } from "react"; +import { useQueryClient } from "react-query"; import { withDefaultFeatures } from "api/api"; +import { getAuthorizationKey } from "api/queries/authCheck"; +import { hasFirstUserKey, meKey } from "api/queries/users"; import type { Entitlements } from "api/typesGenerated"; +import { GlobalSnackbar } from "components/GlobalSnackbar/GlobalSnackbar"; +import { AuthProvider } from "contexts/auth/AuthProvider"; +import { permissionsToCheck } from "contexts/auth/permissions"; import { DashboardContext } from "modules/dashboard/DashboardProvider"; +import { DeploySettingsContext } from "pages/DeploySettingsPage/DeploySettingsLayout"; import { MockAppearanceConfig, MockDefaultOrganization, @@ -87,3 +94,45 @@ export const withDesktopViewport = (Story: FC) => ( ); + +export const withAuthProvider = (Story: FC, { parameters }: StoryContext) => { + if (!parameters.user) { + throw new Error("You forgot to add `parameters.user` to your story"); + } + // eslint-disable-next-line react-hooks/rules-of-hooks -- decorators are components + const queryClient = useQueryClient(); + queryClient.setQueryData(meKey, parameters.user); + queryClient.setQueryData(hasFirstUserKey, true); + queryClient.setQueryData( + getAuthorizationKey({ checks: permissionsToCheck }), + parameters.permissions ?? {}, + ); + + return ( + + + + ); +}; + +export const withGlobalSnackbar = (Story: FC) => ( + <> + + + +); + +export const withDeploySettings = (Story: FC, { parameters }: StoryContext) => { + return ( + + + + ); +};