Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions web/messages/en/modal.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,13 @@
"modal_add_api_token_copy_warning": "Please copy and save the API token below now. You won't be able to see it again.",
"modal_rename_api_title": "Rename API token",
"modal_add_user_title": "Add new user",
"modal_add_new_device_title": "Add new device",
"modal_add_new_device_subtitle": "Device activation settings",
"modal_add_new_device_subtitle_description": "Choose how to deliver the activation token and configure IP assignment for the user's device.",
"modal_add_new_device_choice_email_title": "Send token by email",
"modal_add_new_device_choice_email_content": "The device activation token will be sent to the user's email. You can use the one specified in the user's profile or enter an email manually.",
"modal_add_new_device_choice_manual_title": "Deliver token by yourself",
"modal_add_new_device_choice_manual_content": "At the next step, you will receive an activation token and a URL, which you can share with the user in any way that is convenient for you.",
"modal_add_vector_destination_title": "Add Vector destination",
"modal_add_logstash_destination_title": "Add Logstash destination",
"modal_logstash_destination_title": "Logstash",
Expand Down Expand Up @@ -123,6 +130,7 @@
"modal_ce_webhook_events_title": "Trigger events",
"modal_ce_webhook_events_text": "",
"modal_assign_users_groups_title": "Assign groups to selected users",
"modal_add_new_device_delivery_title": "Share activation credentials",
"modal_assign_user_ip_title": "{firstName} {lastName} IP settings",
"modal_assign_user_ip_title_fallback": "IP settings",
"modal_assign_user_ip_assignment_mode_title": "Single IP Assignment",
Expand Down
1 change: 1 addition & 0 deletions web/messages/en/users.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"users_row_menu_edit": "Edit details",
"users_row_menu_change_password": "Change password",
"users_row_menu_edit_groups": "Edit groups",
"user_row_menu_add_new_device": "Add new device",
"users_row_menu_initiate_self_enrollment": "Initiate self-enrollment",
"users_row_menu_ip_settings": "User devices IP settings",
"modal_edit_user_groups_title": "Edit user groups"
Expand Down
2 changes: 2 additions & 0 deletions web/src/pages/UsersOverviewPage/UsersOverviewPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { AddAuthKeyModal } from '../../shared/components/modals/AddAuthKeyModal/
import { ChangePasswordModal } from '../../shared/components/modals/ChangePasswordModal/ChangePasswordModal';
import { TableSkeleton } from '../../shared/components/skeleton/TableSkeleton/TableSkeleton';
import { TablePageLayout } from '../../shared/layout/TablePageLayout/TablePageLayout';
import { AddNewDeviceModal } from './modals/AddNewDeviceModal/AddNewDeviceModal';
import { AddUserModal } from './modals/AddUserModal/AddUserModal';
import { AssignUserIPModal } from './modals/AssignUserIPModal/AssignUserIPModal';
import { AssignUsersToGroupsModal } from './modals/AssignUsersToGroupsModal/AssignUsersToGroupsModal';
Expand All @@ -23,6 +24,7 @@ export const UsersOverviewPage = () => {
</TablePageLayout>
</Suspense>
</Page>
<AddNewDeviceModal />
<AddUserModal />
<EditUserModal />
<EnrollmentTokenModal />
Expand Down
13 changes: 13 additions & 0 deletions web/src/pages/UsersOverviewPage/UsersTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,19 @@ export const UsersTable = () => {
],
});
}
if (rowData.enrolled) {
menuItems.splice(1, 0, {
items: [
{
text: m.user_row_menu_add_new_device(),
icon: IconKind.AddDevice,
onClick: () => {
openModal(ModalName.AddNewDevice, rowData);
},
},
],
});
}

return (
<TableCell>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import './style.scss';
import { useStore } from '@tanstack/react-form';
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 type { StartEnrollmentResponse, User } from '../../../../shared/api/types';
import { Controls } from '../../../../shared/components/Controls/Controls';
import { AppText } from '../../../../shared/defguard-ui/components/AppText/AppText';
import { Button } from '../../../../shared/defguard-ui/components/Button/Button';
import { Fold } from '../../../../shared/defguard-ui/components/Fold/Fold';
import { Modal } from '../../../../shared/defguard-ui/components/Modal/Modal';
import { SectionSelect } from '../../../../shared/defguard-ui/components/SectionSelect/SectionSelect';
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 { useApp } from '../../../../shared/hooks/useApp';
import { DeliverTokenStep } from './steps/DeliverTokenStep/DeliverTokenStep';

const modalName = ModalName.AddNewDevice;

type DeliveryMethod = 'email' | 'manual';

export const AddNewDeviceModal = () => {
const [isOpen, setOpen] = useState(false);
const [user, setUser] = useState<User | null>(null);
const [enrollmentData, setEnrollmentData] = useState<StartEnrollmentResponse | null>(
null,
);

const handleAfterClose = () => {
setUser(null);
setEnrollmentData(null);
};

useEffect(() => {
const openSub = subscribeOpenModal(modalName, (data) => {
setUser(data);
setOpen(true);
});
const closeSub = subscribeCloseModal(modalName, () => setOpen(false));
return () => {
openSub.unsubscribe();
closeSub.unsubscribe();
};
}, []);

return (
<Modal
id="add-new-device-modal"
title={m.modal_add_new_device_title()}
isOpen={isOpen}
onClose={() => closeModal(modalName)}
afterClose={handleAfterClose}
>
{isPresent(user) && !isPresent(enrollmentData) && (
<EnrollmentChoice user={user} onEnrollmentReady={setEnrollmentData} />
)}
{isPresent(enrollmentData) && <DeliverTokenStep enrollmentData={enrollmentData} />}
</Modal>
);
};

const EnrollmentChoice = ({
user,
onEnrollmentReady,
}: {
user: User;
onEnrollmentReady: (data: StartEnrollmentResponse) => void;
}) => {
const smtpEnabled = useApp((s) => s.appInfo.smtp_enabled);
const [selected, setSelected] = useState<DeliveryMethod>(() =>
smtpEnabled ? 'email' : 'manual',
);

const { mutateAsync: startClientActivation } = useMutation({
mutationFn: api.user.startClientActivation,
onError: (error) => {
Snackbar.error(m.failed_to_start_enrollment());
console.error(error);
},
});

const formSchema = useMemo(
() =>
z
.object({
email: z.string(),
})
.superRefine((values, ctx) => {
if (selected === 'email') {
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,
});
}
}
}),
[selected],
);

const form = useAppForm({
defaultValues: {
email: user.email ?? '',
},
validationLogic: formChangeLogic,
validators: {
onSubmit: formSchema,
onChange: formSchema,
},
onSubmit: async ({ value }) => {
if (selected === 'manual') {
const { data } = await startClientActivation({
username: user.username,
send_enrollment_notification: false,
});
onEnrollmentReady(data);
} else {
await startClientActivation({
username: user.username,
send_enrollment_notification: true,
email: value.email,
});
Snackbar.success(m.sucessfull_enrollment_email());
closeModal(modalName);
}
},
});
const isSubmitting = useStore(form.store, (s) => s.isSubmitting);

return (
<>
<div className="enrollment-info">
<AppText font={TextStyle.TBodySm500}>{m.modal_add_new_device_subtitle()}</AppText>
<SizedBox height={ThemeSpacing.Xs} />
<AppText font={TextStyle.TBodySm400} color={ThemeVariable.FgMuted}>
{m.modal_add_new_device_subtitle_description()}
</AppText>
</div>
<SizedBox height={ThemeSpacing.Xl2} />
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
>
<form.AppForm>
<SectionSelect
image="token-email"
radio
selected={selected === 'email'}
disabled={!smtpEnabled}
badgeProps={
!smtpEnabled
? { variant: 'critical', text: m.state_not_configured() }
: undefined
}
title={m.modal_add_new_device_choice_email_title()}
content={m.modal_add_new_device_choice_email_content()}
data-testid="add-new-device-email"
onClick={() => {
if (smtpEnabled) setSelected('email');
}}
>
<Fold open={selected === 'email'}>
<SizedBox height={ThemeSpacing.Lg} />
<form.AppField name="email">
{(field) => <field.FormInput label={m.form_label_email()} required />}
</form.AppField>
</Fold>
</SectionSelect>
<SizedBox height={ThemeSpacing.Md} />
<SectionSelect
image="token-chat"
radio
selected={selected === 'manual'}
title={m.modal_add_new_device_choice_manual_title()}
content={m.modal_add_new_device_choice_manual_content()}
data-testid="add-new-device-manually"
onClick={() => setSelected('manual')}
/>
<SizedBox height={ThemeSpacing.Xl2} />
<Controls>
<div className="right">
<Button
type="button"
variant="secondary"
text={m.controls_cancel()}
onClick={() => closeModal(modalName)}
/>
<Button
type="submit"
text={m.controls_submit()}
variant="primary"
loading={isSubmitting}
/>
</div>
</Controls>
</form.AppForm>
</form>
</>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { m } from '../../../../../../paraglide/messages';
import type { StartEnrollmentResponse } from '../../../../../../shared/api/types';
import { Controls } from '../../../../../../shared/components/Controls/Controls';
import { Button } from '../../../../../../shared/defguard-ui/components/Button/Button';
import { CopyField } from '../../../../../../shared/defguard-ui/components/CopyField/CopyField';
import { SizedBox } from '../../../../../../shared/defguard-ui/components/SizedBox/SizedBox';
import { ThemeSpacing } from '../../../../../../shared/defguard-ui/types';
import { closeModal } from '../../../../../../shared/hooks/modalControls/modalsSubjects';
import { ModalName } from '../../../../../../shared/hooks/modalControls/modalTypes';
import './style.scss';

type Props = {
enrollmentData: StartEnrollmentResponse;
};

export const DeliverTokenStep = ({ enrollmentData }: Props) => {
return (
<div id="add-new-device-delivery-step">
<div className="share-credentials">
<p className="section-title">{m.modal_add_new_device_delivery_title()}</p>
<SizedBox height={ThemeSpacing.Lg} />
<CopyField
label={m.modal_add_user_enrollment_form_label_instance_url()}
copyTooltip={m.controls_copy_clipboard()}
text={enrollmentData.enrollment_url}
/>
<SizedBox height={ThemeSpacing.Lg} />
<CopyField
label={m.modal_add_user_enrollment_form_label_token()}
copyTooltip={m.controls_copy_clipboard()}
text={enrollmentData.enrollment_token}
/>
</div>

<Controls>
<div className="right">
<Button
text={m.controls_close()}
onClick={() => closeModal(ModalName.AddNewDevice)}
variant="primary"
/>
</div>
</Controls>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#add-new-device-delivery-step {
.section-title {
font: var(--t-body-sm-500);
}

.share-credentials {
padding-top: var(--spacing-lg) 0;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#add-user-modal {
form {
& > p {
font: var(--t-body-sm-500);
}
}
}
5 changes: 5 additions & 0 deletions web/src/shared/hooks/modalControls/modalTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export const ModalName = {
EditLogStreaming: 'editLogStreaming',
DeleteLogStreaming: 'deleteLogStreaming',
SelfEnrollmentToken: 'selfEnrollmentToken',
AddNewDevice: 'addNewDevice',
AssignUserIP: 'assignUserIP',
} as const;

Expand Down Expand Up @@ -182,6 +183,10 @@ const modalOpenArgsSchema = z.discriminatedUnion('name', [
name: z.literal(ModalName.LicenseExpired),
data: z.custom<OpenLicenseExpiredModal>(),
}),
z.object({
name: z.literal(ModalName.AddNewDevice),
data: z.custom<User>(),
}),
z.object({
name: z.literal(ModalName.AssignUserIP),
data: z.custom<OpenAssignUserIPModal>(),
Expand Down
4 changes: 4 additions & 0 deletions web/src/shared/hooks/modalControls/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ export interface OpenEnrollmentTokenModal {
enrollmentResponse: StartEnrollmentResponse;
}

export interface OpenAddNewDeviceModal {
user: User;
}

export interface OpenCEWebhookModal {
webhook?: Webhook;
}
Expand Down
Loading