From 74fd5576b5c13a8797f6318f5a37aa87a8317867 Mon Sep 17 00:00:00 2001 From: Carla Martinez Date: Wed, 29 Nov 2023 16:56:27 +0100 Subject: [PATCH] Add 'Add OTP token' option The 'Add OTP token' option creates a new OTP token associated to a specific user. The QR code has been generated using the qrcode.react[1] library. [1]- https://github.com/zpao/qrcode.react Signed-off-by: Carla Martinez --- src/components/Form/DateTimeSelector.tsx | 107 ++++ src/components/UserSettings.tsx | 20 +- src/components/layouts/HelperTextWithIcon.tsx | 51 ++ src/components/modals/AddOtpToken.tsx | 595 ++++++++++++++++++ src/components/modals/QrCodeModal.tsx | 96 +++ src/services/rpc.ts | 11 + src/utils/datatypes/globalDataTypes.ts | 12 + 7 files changed, 891 insertions(+), 1 deletion(-) create mode 100644 src/components/Form/DateTimeSelector.tsx create mode 100644 src/components/layouts/HelperTextWithIcon.tsx create mode 100644 src/components/modals/AddOtpToken.tsx create mode 100644 src/components/modals/QrCodeModal.tsx diff --git a/src/components/Form/DateTimeSelector.tsx b/src/components/Form/DateTimeSelector.tsx new file mode 100644 index 000000000..b06cdc6c6 --- /dev/null +++ b/src/components/Form/DateTimeSelector.tsx @@ -0,0 +1,107 @@ +import React from "react"; +// PatternFly +import { + DatePicker, + InputGroup, + TimePicker, + isValidDate, + yyyyMMddFormat, +} from "@patternfly/react-core"; +// Utils +import { + parseFullDateStringToUTCFormat, + toGeneralizedTime, +} from "src/utils/utils"; + +interface PropsToDateTimeSelector { + timeValue: string; // Generalized time format ('YYYYMMDDHHMMSSZ') + setTimeValue: (timeValue: string) => void; + name: string; + ariaLabel?: string; + isDisabled?: boolean; +} + +const DateTimeSelector = (props: PropsToDateTimeSelector) => { + const [valueDate, setValueDate] = React.useState(null); + + // Parse the current date into 'Date' format + React.useEffect(() => { + if (props.timeValue) { + const date = parseFullDateStringToUTCFormat(props.timeValue); + setValueDate(date); + } + }, [props.timeValue]); + + // On change date handler + const onDateChange = ( + _event: React.FormEvent, + inputDate: string, + newFromDate: Date | undefined + ) => { + if (newFromDate !== undefined) { + if ( + valueDate && + isValidDate(valueDate) && + isValidDate(newFromDate) && + inputDate === yyyyMMddFormat(newFromDate) + ) { + newFromDate.setHours(valueDate.getHours()); + newFromDate.setMinutes(valueDate.getMinutes()); + } + if ( + isValidDate(newFromDate) && + inputDate === yyyyMMddFormat(newFromDate) + ) { + setValueDate(newFromDate); + // Parse to generalized format + props.setTimeValue(toGeneralizedTime(newFromDate)); + } + } + }; + + // On change time handler + const onTimeChange = (_event, time, hour, minute) => { + let updatedFromDate = new Date(); + if (valueDate && isValidDate(valueDate)) { + updatedFromDate = valueDate; + } + updatedFromDate.setHours(hour); + updatedFromDate.setMinutes(minute); + + setValueDate(updatedFromDate); + // Parse to generalized format + props.setTimeValue(toGeneralizedTime(updatedFromDate)); + }; + + // Parse the current date into 'HH:MM' format + const hhMMFormat = (date: Date) => { + const hours = date.getHours().toString().padStart(2, "0"); + const minutes = date.getMinutes().toString().padStart(2, "0"); + + return hours + ":" + minutes; + }; + + return ( + + + + + ); +}; + +export default DateTimeSelector; diff --git a/src/components/UserSettings.tsx b/src/components/UserSettings.tsx index 47549a7fd..f0f44267f 100644 --- a/src/components/UserSettings.tsx +++ b/src/components/UserSettings.tsx @@ -51,6 +51,7 @@ import DisableEnableUsers from "./modals/DisableEnableUsers"; import DeleteUsers from "./modals/DeleteUsers"; import RebuildAutoMembership from "./modals/RebuildAutoMembership"; import UnlockUser from "./modals/UnlockUser"; +import AddOtpToken from "./modals/AddOtpToken"; export interface PropsToUserSettings { originalUser: Partial; @@ -160,6 +161,12 @@ const UserSettings = (props: PropsToUserSettings) => { return isLocked; }; + // 'Add OTP token' option + const [isAddOtpTokenModalOpen, setIsAddOtpTokenModalOpen] = useState(false); + const onCloseAddOtpTokenModal = () => { + setIsAddOtpTokenModalOpen(false); + }; + // Kebab const [isKebabOpen, setIsKebabOpen] = useState(false); @@ -189,7 +196,12 @@ const UserSettings = (props: PropsToUserSettings) => { > Unlock , - Add OTP token, + setIsAddOtpTokenModalOpen(true)} + > + Add OTP token + , setIsRebuildAutoMembershipModalOpen(true)} @@ -488,6 +500,12 @@ const UserSettings = (props: PropsToUserSettings) => { entriesToRebuild={userToRebuild} entity="users" /> + ); }; diff --git a/src/components/layouts/HelperTextWithIcon.tsx b/src/components/layouts/HelperTextWithIcon.tsx new file mode 100644 index 000000000..578035a2a --- /dev/null +++ b/src/components/layouts/HelperTextWithIcon.tsx @@ -0,0 +1,51 @@ +import React from "react"; +// PatternFly +import { HelperText, HelperTextItem } from "@patternfly/react-core"; +// Icons +import { + InfoIcon, + QuestionIcon, + ExclamationIcon, + CheckIcon, + TimesIcon, +} from "@patternfly/react-icons"; + +type IconType = "info" | "question" | "warning" | "success" | "error"; + +interface PropsToHelperTextWithIcon { + message: string | React.ReactNode; + type?: IconType; +} + +const getIcon = (type?: IconType) => { + switch (type) { + case "info": + return ; + case "question": + return ; + case "warning": + return ; + case "success": + return ; + case "error": + return ; + default: + return ; + } +}; + +const HelperTextWithIcon = (props: PropsToHelperTextWithIcon) => { + return ( + + {!props.type ? ( + {props.message} + ) : ( + + {props.message} + + )} + + ); +}; + +export default HelperTextWithIcon; diff --git a/src/components/modals/AddOtpToken.tsx b/src/components/modals/AddOtpToken.tsx new file mode 100644 index 000000000..e56074758 --- /dev/null +++ b/src/components/modals/AddOtpToken.tsx @@ -0,0 +1,595 @@ +import React from "react"; +// PatternFly +import { + Radio, + FlexItem, + Flex, + TextInput, + Select, + SelectOption, + Button, + MenuToggleElement, + MenuToggle, +} from "@patternfly/react-core"; +// Hooks +import useAlerts from "src/hooks/useAlerts"; +// Modals +import ModalWithFormLayout, { Field } from "../layouts/ModalWithFormLayout"; +import QrCodeModal from "./QrCodeModal"; +// Components +import DateTimeSelector from "../Form/DateTimeSelector"; +// Utils +import { NO_SELECTION_OPTION } from "src/utils/constUtils"; +// RTK +import { + ErrorResult, + useAddOtpTokenMutation, + useGetActiveUsersQuery, +} from "src/services/rpc"; +// Utils +import { API_VERSION_BACKUP } from "src/utils/utils"; + +interface PropsToAddOtpToken { + uid: string | undefined; + isOpen: boolean; + setIsOpen: (isOpen: boolean) => void; + onClose: () => void; +} + +const AddOtpToken = (props: PropsToAddOtpToken) => { + // Alerts to show in the UI + const alerts = useAlerts(); + + // RPC hooks + const activeUsersListQuery = useGetActiveUsersQuery(); + const [addOtpToken] = useAddOtpTokenMutation(); + + // Get active users UIDs list + const activeUsersUids: string[] = []; + const activeUsersListData = activeUsersListQuery.data; + const isActiveUsersListLoading = activeUsersListQuery.isLoading; + + React.useEffect(() => { + if (!isActiveUsersListLoading) { + if (activeUsersListData !== undefined) { + activeUsersListData.forEach((user) => { + activeUsersUids.push(user.uid[0]); + }); + } + // Add empty option at the beginning of the list + activeUsersUids.unshift(NO_SELECTION_OPTION); + setOwnersToSelect(activeUsersUids); + } + }, [isActiveUsersListLoading]); + + // Track initial values to detect changes + const initialValues = { + tokenType: "totp", + tokenAlgorithm: "sha1", + tokenDigits: "6", + uniqueId: "", + description: "", + selectedOwner: props.uid, + validityStart: "", + validityEnd: "", + model: "", + vendor: "", + serial: "", + key: "", + clockInterval: "", + }; + + const [tokenType, setTokenType] = React.useState(initialValues.tokenType); + const [tokenAlgorithm, setTokenAlgorithm] = React.useState( + initialValues.tokenAlgorithm + ); + const [tokenDigits, setTokenDigits] = React.useState( + initialValues.tokenDigits + ); + + const onTokenTypeChange = (checked: boolean, value: string) => { + if (checked) { + setTokenType(value); + } + }; + + const onTokenAlgorithmChange = (checked: boolean, value: string) => { + if (checked) { + setTokenAlgorithm(value); + } + }; + + const onTokenDigitsChange = (checked: boolean, value: string) => { + if (checked) { + setTokenDigits(value); + } + }; + + const typeComponent: JSX.Element = ( + + + onTokenTypeChange(checked, "totp")} + label="Time-based (TOTP)" + id="time-based-radio" + /> + + + onTokenTypeChange(checked, "hotp")} + label="Counter-based (HOTP)" + id="counter-based-radio" + /> + + + ); + + const algorithmComponent: JSX.Element = ( + + + + onTokenAlgorithmChange(checked, "sha1") + } + label="sha1" + id="sha1-radio" + /> + + + + onTokenAlgorithmChange(checked, "sha256") + } + label="sha256" + id="sha256-radio" + /> + + + + onTokenAlgorithmChange(checked, "sha384") + } + label="sha384" + id="sha384-radio" + /> + + + + onTokenAlgorithmChange(checked, "sha512") + } + label="sha512" + id="sha512-radio" + /> + + + ); + + const digitsComponent: JSX.Element = ( + + + onTokenDigitsChange(checked, "6")} + label="6" + id="6-radio" + /> + + + onTokenDigitsChange(checked, "8")} + label="8" + id="8-radio" + /> + + + ); + + // Text inputs + const [uniqueId, setUniqueId] = React.useState(initialValues.uniqueId); + const [description, setDescription] = React.useState( + initialValues.description + ); + const [vendor, setVendor] = React.useState(initialValues.vendor); + const [model, setModel] = React.useState(initialValues.model); + const [serial, setSerial] = React.useState(initialValues.serial); + const [key, setKey] = React.useState(initialValues.key); + const [clockInterval, setClockInterval] = React.useState( + initialValues.clockInterval + ); + + // Selector + const [isOwnerOpen, setIsOwnerOpen] = React.useState(false); + const [selectedOwner, setSelectedOwner] = React.useState( + initialValues.selectedOwner + ); + const [ownersToSelect, setOwnersToSelect] = + React.useState(activeUsersUids); + + const onOwnerSelect = ( + _event: React.MouseEvent | undefined, + selection: string | number | undefined + ) => { + let valueToUpdate = ""; + + if (selection !== NO_SELECTION_OPTION) { + valueToUpdate = selection as string; + } + + setSelectedOwner(valueToUpdate); + setIsOwnerOpen(!isOwnerOpen); + }; + + const onToggle = () => { + setIsOwnerOpen(!isOwnerOpen); + }; + + // Toggle + const toggleOwnersToSelect = (toggleRef: React.Ref) => ( + + {selectedOwner} + + ); + + const ownerComponent: JSX.Element = ( + + ); + + // Date-time selectors + // - Generalized time format ('YYYYMMDDHHMMSSZ') + const [validityStart, setValidityStart] = React.useState( + initialValues.validityStart + ); + const [validityEnd, setValidityEnd] = React.useState( + initialValues.validityEnd + ); + + // List of fields + const fields: Field[] = [ + { + id: "radio-buttons-type", + name: "type", + pfComponent: typeComponent, + }, + { + id: "unique-id", + name: "Unique ID", + pfComponent: ( + setUniqueId(value)} + /> + ), + }, + { + id: "description", + name: "Description", + pfComponent: ( + setDescription(value)} + /> + ), + }, + { + id: "owner", + name: "Owner", + pfComponent: ownerComponent, + }, + { + id: "validity-start", + name: "Validity start", + pfComponent: ( + + ), + }, + { + id: "validity-end", + name: "Validity end", + pfComponent: ( + + ), + }, + { + id: "vendor", + name: "Vendor", + pfComponent: ( + setVendor(value)} + /> + ), + }, + { + id: "model", + name: "Model", + pfComponent: ( + setModel(value)} + /> + ), + }, + { + id: "serial", + name: "Serial", + pfComponent: ( + setSerial(value)} + /> + ), + }, + { + id: "key", + name: "Key", + pfComponent: ( + setKey(value)} + /> + ), + }, + { + id: "algorithm", + name: "Algorithm", + pfComponent: algorithmComponent, + }, + { + id: "digits", + name: "Digits", + pfComponent: digitsComponent, + }, + { + id: "clock-interval", + name: "Clock interval (seconds)", + pfComponent: ( + setClockInterval(value)} + className="pf-u-mb-md" + isDisabled={tokenType === "hotp"} + /> + ), + }, + ]; + + // Return the updated values (taking as reference the initial ones) + const getModifiedValues = () => { + const modifiedValues = {}; + if (tokenType !== initialValues.tokenType) { + modifiedValues["type"] = tokenType; + } + if (description !== initialValues.description) { + modifiedValues["description"] = description; + } + if (selectedOwner) { + modifiedValues["ipatokenowner"] = selectedOwner; + } + if (validityStart !== initialValues.validityStart) { + modifiedValues["ipatokennotbefore"] = validityStart; + } + if (validityEnd !== initialValues.validityEnd) { + modifiedValues["ipatokennotafter"] = validityEnd; + } + if (vendor !== initialValues.vendor) { + modifiedValues["ipatokenvendor"] = vendor; + } + if (model !== initialValues.model) { + modifiedValues["ipatokenmodel"] = model; + } + if (serial !== initialValues.serial) { + modifiedValues["ipatokenserial"] = serial; + } + if (key !== initialValues.key) { + modifiedValues["key"] = key; + } + if (tokenAlgorithm !== initialValues.tokenAlgorithm) { + modifiedValues["ipatokenotpalgorithm"] = tokenAlgorithm; + } + if (tokenDigits !== initialValues.tokenDigits) { + modifiedValues["ipatokenotpdigits"] = tokenDigits; + } + if (clockInterval !== initialValues.clockInterval) { + modifiedValues["ipatokentotptimestep"] = clockInterval; + } + modifiedValues["version"] = API_VERSION_BACKUP; + return modifiedValues; + }; + + // QR data + const [uri, setUri] = React.useState(""); + + // QR code modal + const [isQrModalOpen, setIsQrModalOpen] = React.useState(false); + const [addAndAddAnotherChecked, setAddAndAddAnotherChecked] = + React.useState(false); + const onQrModalClose = () => { + setIsQrModalOpen(false); + }; + + // Buttons functionality + // - API call to add otp token + const onAddOtpToken = (addAndAddAnotherChecked: boolean) => { + // Get updated values as params + const params = getModifiedValues(); + // Payload + const payload = [[uniqueId], params]; + + addOtpToken(payload).then((response) => { + if ("data" in response) { + if (response.data.result) { + // Update URI + setUri(response.data.result.result.uri as string); + // Close modal + if (addAndAddAnotherChecked) { + props.onClose(); + setAddAndAddAnotherChecked(true); + } else { + props.onClose(); + } + // Show QR modal + setIsQrModalOpen(true); + // Reset fields + resetFields(); + } else if (response.data.error) { + // Set alert: error + const errorMessage = response.data.error as ErrorResult; + alerts.addAlert("add-otp-error", errorMessage.message, "danger"); + } + } + }); + }; + + // If the 'add and add another' option has been selected, + // open the modal again after showing the previous generated QR code. + React.useEffect(() => { + if (!isQrModalOpen && addAndAddAnotherChecked) { + props.setIsOpen(true); + setAddAndAddAnotherChecked(false); + } + }, [isQrModalOpen, addAndAddAnotherChecked]); + + // Reset fields + const resetFields = () => { + setTokenType(initialValues.tokenType); + setUniqueId(initialValues.uniqueId); + setDescription(initialValues.description); + setSelectedOwner(initialValues.selectedOwner); + setValidityStart(initialValues.validityStart); + setValidityEnd(initialValues.validityEnd); + setModel(initialValues.model); + setVendor(initialValues.vendor); + setSerial(initialValues.serial); + setKey(initialValues.key); + setTokenAlgorithm(initialValues.tokenAlgorithm); + setTokenDigits(initialValues.tokenDigits); + setClockInterval(initialValues.clockInterval); + }; + + // Reset fields and close modal + const onResetFieldsAndCloseModal = () => { + resetFields(); + props.onClose(); + }; + + const actions = [ + , + , + , + ]; + + // Render component + return ( + <> + + + + + ); +}; + +export default AddOtpToken; diff --git a/src/components/modals/QrCodeModal.tsx b/src/components/modals/QrCodeModal.tsx new file mode 100644 index 000000000..95dceb54e --- /dev/null +++ b/src/components/modals/QrCodeModal.tsx @@ -0,0 +1,96 @@ +import React from "react"; +// Modals +import ModalWithFormLayout from "../layouts/ModalWithFormLayout"; +// PatternFly +import { Button, Text, TextVariants } from "@patternfly/react-core"; +// Components +import HelperTextWithIcon from "../layouts/HelperTextWithIcon"; +// qrcode.react +import { QRCodeCanvas } from "qrcode.react"; + +interface PropsToQrCodeModal { + isOpen: boolean; + onClose: () => void; + QrUri: string; +} + +/* + * qrcode.react library documentation: + * https://github.com/zpao/qrcode.react + */ + +const QrCodeModal = (props: PropsToQrCodeModal) => { + // Banner messages + const messageQrConfiguration = + "Configure your token by scanning the QR code below. Click on the QR code if you see this on the device you want to configure."; + + const messageQrViaFreeOtp = ( + + You can use{" "} + + FreeOTP + {" "} + as a software OTP token application. + + ); + + // Generate QR code + const qrCode = ( + <> + + + + + ); + + // List of fields + const fields = [ + { + id: "banner-info-1", + pfComponent: ( + + ), + }, + { + id: "banner-info-2", + pfComponent: ( + + ), + }, + { + id: "qr-code", + pfComponent: ( +
{qrCode}
+ ), + }, + ]; + + // Actions + const actions = [ + , + ]; + + // Render component + return ( + + ); +}; + +export default QrCodeModal; diff --git a/src/services/rpc.ts b/src/services/rpc.ts index 549cced7b..67c27bf2a 100644 --- a/src/services/rpc.ts +++ b/src/services/rpc.ts @@ -714,6 +714,16 @@ export const api = createApi({ }); }, }), + addOtpToken: build.mutation({ + query: (payload) => { + const params = [payload[0], payload[1]]; + + return getCommand({ + method: "otptoken_add", + params: params, + }); + }, + }), }), }); @@ -782,4 +792,5 @@ export const { useEnableUserMutation, useDisableUserMutation, useUnlockUserMutation, + useAddOtpTokenMutation, } = api; diff --git a/src/utils/datatypes/globalDataTypes.ts b/src/utils/datatypes/globalDataTypes.ts index 79c090d5c..c680023fe 100644 --- a/src/utils/datatypes/globalDataTypes.ts +++ b/src/utils/datatypes/globalDataTypes.ts @@ -312,3 +312,15 @@ export interface fqdnType { dn: string; fqdn: string[]; } +export interface OTPToken { + ipatokenotpalgorithm: string; + ipatokenuniqueid: string; + ipatokenotpkey: string; + ipatokenowner: string; + ipatokentotptimestep: string; + ipatokentotpclockoffset: string; + ipatokenotpdigits: string; + uri: string; + type: string; + dn: string; +}