diff --git a/web/messages/en/common.json b/web/messages/en/common.json index 95fc8c9d37..6c50778f35 100644 --- a/web/messages/en/common.json +++ b/web/messages/en/common.json @@ -50,5 +50,7 @@ "search_empty_common_title": "Nothing is found.", "search_empty_common_subtitle": "Try adjusting your filters or search terms.", "search_users": "Find users", - "select_users_all": "All users" + "select_users_all": "All users", + "sucessfull_enrollment_email": "Enrollment email sent successfully", + "failed_to_start_enrollment": "Failed to start enrollment" } diff --git a/web/messages/en/modal.json b/web/messages/en/modal.json index 58f0504d99..69bc3657b6 100644 --- a/web/messages/en/modal.json +++ b/web/messages/en/modal.json @@ -62,7 +62,7 @@ "modal_add_user_device_manual_download_actions_download_all": "All locations", "modal_add_user_enrollment_details": "Enrollment details", "modal_add_user_enrollment_explain": "Provide the user with their token and URL in a convenient manner so they can complete the enrollment process independently.", - "modal_add_user_enrollment_form_label_url": "URL", + "modal_add_user_enrollment_form_label_instance_url": "Defguard instance URL", "modal_add_user_enrollment_form_label_token": "Activation token", "modal_add_user_enrollment_form_label_send": "Send enrollment details to user by email", "modal_edit_user_device_title": "Edit device", @@ -85,6 +85,7 @@ "modal_select_log_streaming_destination_title": "Select destination", "modal_add_log_streaming_destination_title": "Add destination", "modal_add_user_groups_title": "Assign user to group(s)", + "modal_initiate_self_enrollment_title": "Initiate self-enrollment for user", "modal_add_user_enroll_title": "Start enrollment for user", "modal_add_user_choice_enroll_title": "Add user with self-enrollment option", "modal_add_user_choice_enroll_content": "User will be able to configure their account during the setup of their desktop client (e.g., set their own password, etc.).", diff --git a/web/messages/en/users.json b/web/messages/en/users.json index 6844063276..75741282ff 100644 --- a/web/messages/en/users.json +++ b/web/messages/en/users.json @@ -24,5 +24,6 @@ "users_row_menu_edit": "Edit details", "users_row_menu_change_password": "Change password", "users_row_menu_edit_groups": "Edit groups", + "users_row_menu_initiate_self_enrollment": "Initiate self-enrollment", "modal_edit_user_groups_title": "Edit user groups" } diff --git a/web/src/pages/UsersOverviewPage/UsersOverviewPage.tsx b/web/src/pages/UsersOverviewPage/UsersOverviewPage.tsx index f7f3fed6d9..6bfa2bd815 100644 --- a/web/src/pages/UsersOverviewPage/UsersOverviewPage.tsx +++ b/web/src/pages/UsersOverviewPage/UsersOverviewPage.tsx @@ -10,6 +10,7 @@ import { getUsersQueryOptions } from '../../shared/query'; import { AddUserModal } from './modals/AddUserModal/AddUserModal'; import { AssignUsersToGroupsModal } from './modals/AssignUsersToGroupsModal/AssignUsersToGroupsModal'; import { EditUserModal } from './modals/EditUserModal/EditUserModal'; +import { EnrollmentTokenModal } from './modals/EnrollmentTokenModal/EnrollmentTokenModal'; import { UsersTable } from './UsersTable'; export const UsersOverviewPage = () => { @@ -23,6 +24,7 @@ export const UsersOverviewPage = () => { + diff --git a/web/src/pages/UsersOverviewPage/UsersTable.tsx b/web/src/pages/UsersOverviewPage/UsersTable.tsx index ea9b3f5099..3521b40c29 100644 --- a/web/src/pages/UsersOverviewPage/UsersTable.tsx +++ b/web/src/pages/UsersOverviewPage/UsersTable.tsx @@ -26,8 +26,9 @@ import { Button } from '../../shared/defguard-ui/components/Button/Button'; import type { ButtonProps } from '../../shared/defguard-ui/components/Button/types'; import { EmptyState } from '../../shared/defguard-ui/components/EmptyState/EmptyState'; import { EmptyStateFlexible } from '../../shared/defguard-ui/components/EmptyStateFlexible/EmptyStateFlexible'; -import { Icon } from '../../shared/defguard-ui/components/Icon'; +import { Icon, IconKind } from '../../shared/defguard-ui/components/Icon'; import { IconButtonMenu } from '../../shared/defguard-ui/components/IconButtonMenu/IconButtonMenu'; +import type { MenuItemsGroup } from '../../shared/defguard-ui/components/Menu/types'; import { Search } from '../../shared/defguard-ui/components/Search/Search'; import { tableEditColumnSize } from '../../shared/defguard-ui/components/table/consts'; import { TableBody } from '../../shared/defguard-ui/components/table/TableBody/TableBody'; @@ -35,9 +36,11 @@ import { TableCell } from '../../shared/defguard-ui/components/table/TableCell/T import { TableFlexCell } from '../../shared/defguard-ui/components/table/TableFlexCell/TableFlexCell'; import { TableRowContainer } from '../../shared/defguard-ui/components/table/TableRowContainer/TableRowContainer'; import { TableTop } from '../../shared/defguard-ui/components/table/TableTop/TableTop'; +import { Snackbar } from '../../shared/defguard-ui/providers/snackbar/snackbar'; import { isPresent } from '../../shared/defguard-ui/utils/isPresent'; import { openModal } from '../../shared/hooks/modalControls/modalsSubjects'; import { ModalName } from '../../shared/hooks/modalControls/modalTypes'; +import { useApp } from '../../shared/hooks/useApp'; import { getGroupsInfoQueryOptions } from '../../shared/query'; import { displayDate } from '../../shared/utils/displayDate'; import { useAddUserModal } from './modals/AddUserModal/useAddUserModal'; @@ -51,6 +54,7 @@ type RowData = UsersListItem; const columnHelper = createColumnHelper(); export const UsersTable = ({ users }: Props) => { + const appInfo = useApp((s) => s.appInfo); const reservedEmails = useMemo(() => users.map((u) => u.email.toLowerCase()), [users]); const reservedUsernames = useMemo(() => users.map((u) => u.username), [users]); @@ -235,109 +239,138 @@ export const UsersTable = ({ users }: Props) => { enableResizing: false, cell: (info) => { const rowData = info.row.original; - return ( - - { - openModal(ModalName.EditUserModal, { - user: rowData, - reservedEmails, - reservedUsernames, - }); - }, - }, - { - text: m.users_row_menu_change_password(), - icon: 'lock-open', - testId: 'change-password', - onClick: () => { - openModal(ModalName.ChangePassword, { - adminForm: true, - user: rowData, - }); - }, - }, - { - text: m.users_row_menu_go_profile(), - icon: 'profile', - onClick: () => { - navigate({ - to: '/user/$username', - params: { - username: rowData.username, - }, - }); - }, - }, - { - text: m.users_row_menu_edit_groups(), - icon: 'add-group', - testId: 'edit-groups', - onClick: () => { - useSelectionModal.setState({ - isOpen: true, - options: groupsOptions, - title: m.modal_edit_user_groups_title(), - selected: new Set(rowData.groups), - onSubmit: (selected) => { - handleEditGroups(rowData, selected as string[]); - }, - }); - }, - }, - ], + + const menuItems: MenuItemsGroup[] = [ + { + items: [ + { + text: m.users_row_menu_edit(), + icon: 'edit', + onClick: () => { + openModal(ModalName.EditUserModal, { + user: rowData, + reservedEmails, + reservedUsernames, + }); }, - { - items: [ - { - text: m.users_row_menu_add_auth(), - icon: 'key', - onClick: () => { - openModal(ModalName.AddAuthKey, { - username: rowData.username, - }); - }, - }, - ], + }, + { + text: m.users_row_menu_change_password(), + icon: 'lock-open', + testId: 'change-password', + onClick: () => { + openModal(ModalName.ChangePassword, { + adminForm: true, + user: rowData, + }); }, - { - items: [ - { - text: rowData.is_active - ? m.users_row_menu_disable() - : m.users_row_menu_enable(), - icon: rowData.is_active ? 'disabled' : 'check-circle', - testId: 'change-account-status', - onClick: () => { - changeAccountActiveState({ - active: !rowData.is_active, - username: rowData.username, - }); - }, + }, + { + text: m.users_row_menu_go_profile(), + icon: 'profile', + onClick: () => { + navigate({ + to: '/user/$username', + params: { + username: rowData.username, }, - ], + }); }, - { - items: [ - { - text: m.users_row_menu_delete(), - icon: 'delete', - variant: 'danger', - onClick: () => { - deleteUser(rowData.username); - }, + }, + { + text: m.users_row_menu_edit_groups(), + icon: 'add-group', + testId: 'edit-groups', + onClick: () => { + useSelectionModal.setState({ + isOpen: true, + options: groupsOptions, + title: m.modal_edit_user_groups_title(), + selected: new Set(rowData.groups), + onSubmit: (selected) => { + handleEditGroups(rowData, selected as string[]); }, - ], + }); }, - ]} - /> + }, + ], + }, + { + items: [ + { + text: m.users_row_menu_add_auth(), + icon: 'key', + onClick: () => { + openModal(ModalName.AddAuthKey, { + username: rowData.username, + }); + }, + }, + ], + }, + { + items: [ + { + text: rowData.is_active + ? m.users_row_menu_disable() + : m.users_row_menu_enable(), + icon: rowData.is_active ? 'disabled' : 'check-circle', + testId: 'change-account-status', + onClick: () => { + changeAccountActiveState({ + active: !rowData.is_active, + username: rowData.username, + }); + }, + }, + ], + }, + { + items: [ + { + text: m.users_row_menu_delete(), + icon: 'delete', + variant: 'danger', + onClick: () => { + deleteUser(rowData.username); + }, + }, + ], + }, + ]; + + if (!rowData.enrolled) { + menuItems.splice(1, 0, { + items: [ + { + text: m.users_row_menu_initiate_self_enrollment(), + icon: IconKind.AddUser, + onClick: () => { + api.user + .startEnrollment({ + send_enrollment_notification: false, + username: rowData.username, + }) + .then((response) => { + openModal(ModalName.SelfEnrollmentToken, { + user: rowData, + appInfo, + enrollmentResponse: response.data, + }); + }) + .catch((error) => { + Snackbar.error('Failed to initiate enrollment'); + console.error(error); + }); + }, + }, + ], + }); + } + + return ( + + ); }, @@ -352,6 +385,7 @@ export const UsersTable = ({ users }: Props) => { groupsOptions, handleEditGroups, groups, + appInfo, ], ); diff --git a/web/src/pages/UsersOverviewPage/modals/AddUserModal/AddUserModal.tsx b/web/src/pages/UsersOverviewPage/modals/AddUserModal/AddUserModal.tsx index 9f5dae9633..95b28196dc 100644 --- a/web/src/pages/UsersOverviewPage/modals/AddUserModal/AddUserModal.tsx +++ b/web/src/pages/UsersOverviewPage/modals/AddUserModal/AddUserModal.tsx @@ -149,7 +149,7 @@ const EnrollmentStep = () => { @@ -358,13 +358,19 @@ const AddUserModalForm = () => { data: { groups }, } = await api.group.getGroups(); if (enrollmentEnabled) { - const enrollmentResponse = ( - await api.user.startEnrollment({ - send_enrollment_notification: false, - username: created.username, - }) - ).data; - useAddUserModal.setState({ enrollResponse: enrollmentResponse }); + try { + const enrollmentResponse = ( + await api.user.startEnrollment({ + send_enrollment_notification: false, + username: created.username, + }) + ).data; + useAddUserModal.setState({ enrollResponse: enrollmentResponse, user: created }); + } catch (error) { + console.error(m.failed_to_start_enrollment(), error); + useAddUserModal.setState({ isOpen: false }); + return; + } } if (assignToGroups) { useAddUserModal.setState({ diff --git a/web/src/pages/UsersOverviewPage/modals/EnrollmentTokenModal/EnrollmentTokenModal.tsx b/web/src/pages/UsersOverviewPage/modals/EnrollmentTokenModal/EnrollmentTokenModal.tsx new file mode 100644 index 0000000000..0373d11340 --- /dev/null +++ b/web/src/pages/UsersOverviewPage/modals/EnrollmentTokenModal/EnrollmentTokenModal.tsx @@ -0,0 +1,184 @@ +import { useMutation } from '@tanstack/react-query'; +import { useEffect, useMemo, useState } from 'react'; +import z from 'zod'; +import { m } from '../../../../paraglide/messages'; +import api from '../../../../shared/api/api'; +import { AppText } from '../../../../shared/defguard-ui/components/AppText/AppText'; +import { Checkbox } from '../../../../shared/defguard-ui/components/Checkbox/Checkbox'; +import { CopyField } from '../../../../shared/defguard-ui/components/CopyField/CopyField'; +import { Modal } from '../../../../shared/defguard-ui/components/Modal/Modal'; +import { ModalControls } from '../../../../shared/defguard-ui/components/ModalControls/ModalControls'; +import { SizedBox } from '../../../../shared/defguard-ui/components/SizedBox/SizedBox'; +import { Snackbar } from '../../../../shared/defguard-ui/providers/snackbar/snackbar'; +import { + TextStyle, + ThemeSpacing, + ThemeVariable, +} from '../../../../shared/defguard-ui/types'; +import { isPresent } from '../../../../shared/defguard-ui/utils/isPresent'; +import { useAppForm } from '../../../../shared/form'; +import { formChangeLogic } from '../../../../shared/formLogic'; +import { + closeModal, + subscribeCloseModal, + subscribeOpenModal, +} from '../../../../shared/hooks/modalControls/modalsSubjects'; +import { ModalName } from '../../../../shared/hooks/modalControls/modalTypes'; +import type { OpenEnrollmentTokenModal } from '../../../../shared/hooks/modalControls/types'; + +const modalName = ModalName.SelfEnrollmentToken; + +type ModalData = OpenEnrollmentTokenModal; + +export const EnrollmentTokenModal = () => { + const [isOpen, setOpen] = useState(false); + const [modalData, setModalData] = useState(null); + + useEffect(() => { + const openSub = subscribeOpenModal(modalName, (data) => { + setModalData(data); + setOpen(true); + }); + const closeSub = subscribeCloseModal(modalName, () => setOpen(false)); + return () => { + openSub.unsubscribe(); + closeSub.unsubscribe(); + }; + }, []); + + return ( + setOpen(false)} + afterClose={() => { + setModalData(null); + }} + > + {isPresent(modalData) && } + + ); +}; + +const ModalContent = ({ user, appInfo, enrollmentResponse }: ModalData) => { + const [sendEmail, setSendEmail] = useState(false); + + const { mutateAsync: sendEnrollmentEmail } = useMutation({ + mutationFn: api.user.startEnrollment, + onSuccess: () => { + Snackbar.success(m.sucessfull_enrollment_email()); + closeModal(modalName); + }, + onError: (error) => { + Snackbar.error(m.failed_to_start_enrollment()); + console.error(error); + }, + }); + + const formSchema = useMemo( + () => + z + .object({ + email: z.string(), + }) + .superRefine((values, ctx) => { + if (sendEmail) { + const result = z + .email(m.form_error_email()) + .min(1, m.form_error_required()) + .safeParse(values.email); + if (!result.success) { + ctx.addIssue({ + code: 'custom', + path: ['email'], + message: result.error.issues[0].message, + }); + } + } + }), + [sendEmail], + ); + + const form = useAppForm({ + defaultValues: { + email: user.email ?? '', + }, + validationLogic: formChangeLogic, + validators: { + onSubmit: formSchema, + onChange: formSchema, + }, + onSubmit: async ({ value }) => { + await sendEnrollmentEmail({ + username: user.username, + send_enrollment_notification: true, + email: value.email, + }); + }, + }); + + useEffect(() => { + if (!form.state.isPristine) { + form.validateAllFields('change'); + } + }, [form.state.isPristine, form.validateAllFields]); + + return ( + <> +
+ + {m.modal_add_user_enrollment_details()} + + + + {m.modal_add_user_enrollment_explain()} + +
+ + + + + {appInfo.smtp_enabled && ( + <> + + + setSendEmail((s) => !s)} + /> + {sendEmail && ( + <> + + + {(field) => } + + + )} + + + )} + + {() => ( + closeModal(modalName), + }} + /> + )} + + + ); +}; diff --git a/web/src/shared/hooks/modalControls/modalTypes.ts b/web/src/shared/hooks/modalControls/modalTypes.ts index 7e5e0ddbe5..8c1e6f155d 100644 --- a/web/src/shared/hooks/modalControls/modalTypes.ts +++ b/web/src/shared/hooks/modalControls/modalTypes.ts @@ -12,6 +12,7 @@ import type { OpenEditDeviceModal, OpenEditNetworkDeviceModal, OpenEditUserModal, + OpenEnrollmentTokenModal, OpenLicenseModal, OpenNetworkDeviceConfigModal, OpenNetworkDeviceTokenModal, @@ -49,6 +50,7 @@ export const ModalName = { AddLogStreaming: 'addLogStreaming', EditLogStreaming: 'editLogStreaming', DeleteLogStreaming: 'deleteLogStreaming', + SelfEnrollmentToken: 'selfEnrollmentToken', } as const; export type ModalNameValue = (typeof ModalName)[keyof typeof ModalName]; @@ -99,6 +101,10 @@ const modalOpenArgsSchema = z.discriminatedUnion('name', [ name: z.literal(ModalName.EditUserModal), data: z.custom(), }), + z.object({ + name: z.literal(ModalName.SelfEnrollmentToken), + data: z.custom(), + }), z.object({ name: z.literal(ModalName.EditUserModal), data: z.custom(), diff --git a/web/src/shared/hooks/modalControls/types.ts b/web/src/shared/hooks/modalControls/types.ts index e01e845d85..bbd1f4f346 100644 --- a/web/src/shared/hooks/modalControls/types.ts +++ b/web/src/shared/hooks/modalControls/types.ts @@ -54,6 +54,14 @@ export interface OpenCEOpenIdClientModal { reservedNames: string[]; } +export interface OpenEnrollmentTokenModal { + user: User; + appInfo: { + smtp_enabled: boolean; + }; + enrollmentResponse: StartEnrollmentResponse; +} + export interface OpenCEWebhookModal { webhook?: Webhook; }