diff --git a/app/views/authentication_providers/_canvas_fields.html.erb b/app/views/authentication_providers/_canvas_fields.html.erb index a148375c6dc..f42aa85ca17 100644 --- a/app/views/authentication_providers/_canvas_fields.html.erb +++ b/app/views/authentication_providers/_canvas_fields.html.erb @@ -44,4 +44,8 @@ <% end %> + <% if @domain_root_account.feature_enabled?(:password_complexity)%> + <% js_bundle :password_complexity_configuration %> +
+ <% end %> diff --git a/ui/featureBundles.ts b/ui/featureBundles.ts index e5cca5ec6c2..3bc44d186bf 100644 --- a/ui/featureBundles.ts +++ b/ui/featureBundles.ts @@ -156,6 +156,8 @@ const featureBundles: { outcome_alignments: () => import('./features/outcome_alignments/index'), outcome_management: () => import('./features/outcome_management/index'), page_views: () => import('./features/page_views/index'), + password_complexity_configuration: () => + import('./features/password_complexity_configuration/index'), past_global_alert: () => import('./features/past_global_alert/index'), past_global_announcements: () => import('./features/past_global_announcements/index'), permissions: () => import('./features/permissions/index'), diff --git a/ui/features/password_complexity_configuration/index.tsx b/ui/features/password_complexity_configuration/index.tsx new file mode 100644 index 00000000000..de369ef6989 --- /dev/null +++ b/ui/features/password_complexity_configuration/index.tsx @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * This file is part of Canvas. + * + * Canvas is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3 of the License. + * + * Canvas is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License along + * with this program. If not, see . + */ + +import ready from '@instructure/ready' +import React from 'react' +import ReactDOM from 'react-dom' +import PasswordComplexityConfiguration from './react/PasswordComplexityConfiguration' + +ready(() => { + const container = document.querySelector('#password_complexity_configuration') + + if (container) { + ReactDOM.render(, container) + } +}) diff --git a/ui/features/password_complexity_configuration/package.json b/ui/features/password_complexity_configuration/package.json new file mode 100644 index 00000000000..1f5bf39318a --- /dev/null +++ b/ui/features/password_complexity_configuration/package.json @@ -0,0 +1,6 @@ +{ + "name": "@canvas-features/password_complexity_configuration", + "private": true, + "version": "1.0.0", + "owner": "FOO" +} \ No newline at end of file diff --git a/ui/features/password_complexity_configuration/react/NumberInputControlled.tsx b/ui/features/password_complexity_configuration/react/NumberInputControlled.tsx new file mode 100644 index 00000000000..1764c8bde67 --- /dev/null +++ b/ui/features/password_complexity_configuration/react/NumberInputControlled.tsx @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * This file is part of Canvas. + * + * Canvas is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3 of the License. + * + * Canvas is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License along + * with this program. If not, see . + */ + +import React, {useState} from 'react' +import {NumberInput} from '@instructure/ui-number-input' + +interface NumberInputControlledProps { + minimum: number + maximum: number + defaultValue: number + disabled: boolean + 'data-testid': string +} + +const NumberInputControlled: React.FC = ({ + minimum, + maximum, + defaultValue, + disabled, + 'data-testid': dataTestid, +}) => { + const [number, setNumber] = useState(defaultValue) + const handleChange = (event: React.ChangeEvent, value: string) => { + setNumber(value ? Number(value) : 0) // Replace null with 0 + } + + const handleDecrement = () => { + if (Number.isNaN(number)) return + if (number === null) setBoundedNumber(minimum) + else setBoundedNumber(Math.floor(number) - 1) + } + + const handleIncrement = () => { + if (Number.isNaN(number)) return + if (number === null) setBoundedNumber(minimum + 1) + else setBoundedNumber(Math.ceil(number) + 1) + } + + const handleBlur = () => { + if (Number.isNaN(number)) return + if (number === null) return + setBoundedNumber(Math.round(number)) + } + + const setBoundedNumber = (n: number) => { + if (n < minimum) setNumber(minimum) + else if (n > maximum) setNumber(maximum) + else setNumber(n) + } + + return ( + + ) +} + +export default NumberInputControlled diff --git a/ui/features/password_complexity_configuration/react/PasswordComplexityConfiguration.tsx b/ui/features/password_complexity_configuration/react/PasswordComplexityConfiguration.tsx new file mode 100644 index 00000000000..212d14eec74 --- /dev/null +++ b/ui/features/password_complexity_configuration/react/PasswordComplexityConfiguration.tsx @@ -0,0 +1,344 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * This file is part of Canvas. + * + * Canvas is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3 of the License. + * + * Canvas is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License along + * with this program. If not, see . + */ + +import React, {useState} from 'react' +import {useScope as useI18nScope} from '@canvas/i18n' +import {Heading} from '@instructure/ui-heading' +import {Text} from '@instructure/ui-text' +import {View} from '@instructure/ui-view' +import {Button, CloseButton, IconButton} from '@instructure/ui-buttons' +import {Tray} from '@instructure/ui-tray' +import {Alert} from '@instructure/ui-alerts' +import {List} from '@instructure/ui-list' +import {Checkbox} from '@instructure/ui-checkbox' +import NumberInputControlled from './NumberInputControlled' +import {Link} from '@instructure/ui-link' +import {IconTrashLine, IconUploadSolid} from '@instructure/ui-icons' +import {Flex} from '@instructure/ui-flex' +import {Modal} from '@instructure/ui-modal' +import {FileDrop} from '@instructure/ui-file-drop' +import {Billboard} from '@instructure/ui-billboard' +import {Img} from '@instructure/ui-img' + +const I18n = useI18nScope('password_complexity_configuration') + +const PasswordComplexityConfiguration = () => { + const [showTray, setShowTray] = useState(false) + const [minimumCharacterLengthEnabled, setMinimumCharacterLengthEnabled] = useState(true) + const [requireNumbersEnabled, setRequireNumbersEnabled] = useState(true) + const [requireSymbolsEnabled, setRequireSymbolsEnabled] = useState(true) + const [customForbiddenWordsEnabled, setCustomForbiddenWordsEnabled] = useState(false) + const [customListFile, setCustomListFile] = useState(null) + const [customListUploaded, setCustomListUploaded] = useState(false) + const [fileModalOpen, setFileModalOpen] = useState(false) + const [customMaxLoginAttemptsEnabled, setCustomMaxLoginAttemptsEnabled] = useState(false) + + const handleCustomMaxLoginAttemptToggle = (event: React.ChangeEvent) => { + const checked = event.target.checked + setCustomMaxLoginAttemptsEnabled(checked) + } + + const handleFileDrop = (file: File) => { + setCustomListFile(file) + } + + const handleCancelModal = () => { + setFileModalOpen(false) + setCustomListFile(null) + } + + const handleUploadModal = () => { + setCustomListUploaded(true) + setFileModalOpen(false) + } + + const handleDeleteCustomList = () => { + setCustomListFile(null) + setCustomListUploaded(false) + } + + return ( + <> + + {I18n.t('Password Options')} + + + setShowTray(false)} + placement="end" + size="medium" + > + + + + + + {I18n.t('Password Options')} + + + + setShowTray(false)} + /> + + + + + + {I18n.t( + 'Some institutions have very strict policies regarding passwords. This feature enables customization of password requirements and options for this auth provider. Modifications to password options will customize the password configuration text as seen below.' + )} + + + {I18n.t('Current Password Configuration')} + + + {I18n.t('Your password must meet the following requirements')} + + + {I18n.t('Must be at least 8 Characters in length.')} + + {I18n.t( + 'Must not use words or sequences of characters common in passwords (ie: password, 12345, etc...)' + )} + + + + + setMinimumCharacterLengthEnabled(!minimumCharacterLengthEnabled)} + defaultChecked={true} + data-testid="minimumCharacterLengthCheckbox" + /> + + + + + + + + setRequireNumbersEnabled(!requireNumbersEnabled)} + defaultChecked={true} + data-testid="requireNumbersCheckbox" + /> + + + setRequireSymbolsEnabled(!requireSymbolsEnabled)} + defaultChecked={true} + data-testid="requireSymbolsCheckbox" + /> + + + + { + setCustomForbiddenWordsEnabled(!customForbiddenWordsEnabled) + }} + label={I18n.t('Customize forbidden words/termslist (see default list here)')} + data-testid="customForbiddenWordsCheckbox" + /> + + + + {I18n.t( + 'Upload a list of forbidden words/terms in addition to the default list. The file should be text file (.txt) with a single word or term per line.' + )} + + + {!customListUploaded && ( + + + + )} + + + {customListUploaded && ( + + {I18n.t('Current Custom List')} +
+ + + + {customListFile?.name} + + + { + handleDeleteCustomList() + }} + > + + + + +
+
+ )} + + + + + + {I18n.t( + 'This option controls the number of attempts a single user can make consecutively to login without success before their user’s login is suspended. Users can be unsuspended by institutional admins. Cannot be higher than 20 attempts.' + )} + + + + + + + + +
+ + + + + + + + + +
+
+ + + { + setFileModalOpen(false) + }} + size="medium" + label="Upload Forbidden Words/Terms List" + shouldCloseOnDocumentClick={true} + overflow="scroll" + > + + {I18n.t('Upload Forbidden Words/Terms List')} + setFileModalOpen(false)} + screenReaderLabel="Close" + /> + + + {!customListFile && ( +
+ { + const file = files[0] as File + handleFileDrop(file) + }} + renderLabel={ + } + /> + } + /> +
+ )} + {customListFile && {customListFile.name}} +
+ + + + +
+
+ + ) +} + +export default PasswordComplexityConfiguration diff --git a/ui/features/password_complexity_configuration/react/__tests__/PasswordComplexityConfiguration.test.tsx b/ui/features/password_complexity_configuration/react/__tests__/PasswordComplexityConfiguration.test.tsx new file mode 100644 index 00000000000..c55820bb80f --- /dev/null +++ b/ui/features/password_complexity_configuration/react/__tests__/PasswordComplexityConfiguration.test.tsx @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * This file is part of Canvas. + * + * Canvas is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3 of the License. + * + * Canvas is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License along + * with this program. If not, see . + */ + +import React from 'react' +import {fireEvent, render} from '@testing-library/react' +import PasswordComplexityConfiguration from '../PasswordComplexityConfiguration' + +describe('PasswordComplexityConfiguration', () => { + it('opens the Tray when "View Options" button is clicked', () => { + const {getByText} = render() + fireEvent.click(getByText('View Options')) + expect(getByText('Current Password Configuration')).toBeInTheDocument() + }) + + it('toggles all checkboxes with defaults set', async () => { + const {getByText, findByTestId} = render() + fireEvent.click(getByText('View Options')) + + let checkbox = await findByTestId('minimumCharacterLengthCheckbox') + fireEvent.click(checkbox) + expect(checkbox).not.toBeChecked() + + fireEvent.click(checkbox) + expect(checkbox).toBeChecked() + + checkbox = await findByTestId('requireNumbersCheckbox') + fireEvent.click(checkbox) + expect(checkbox).not.toBeChecked() + + fireEvent.click(checkbox) + expect(checkbox).toBeChecked() + + checkbox = await findByTestId('requireSymbolsCheckbox') + fireEvent.click(checkbox) + expect(checkbox).not.toBeChecked() + + fireEvent.click(checkbox) + expect(checkbox).toBeChecked() + + checkbox = await findByTestId('customForbiddenWordsCheckbox') + fireEvent.click(checkbox) + expect(checkbox).toBeChecked() + + fireEvent.click(checkbox) + expect(checkbox).not.toBeChecked() + }) + + it('toggle input fields when checkbox is checked', async () => { + const {getByText, findByTestId} = render() + fireEvent.click(getByText('View Options')) + + let checkbox = await findByTestId('minimumCharacterLengthCheckbox') + let input = await findByTestId('minimumCharacterLengthInput') + expect(input).toBeEnabled() + + checkbox = await findByTestId('customMaxLoginAttemptsCheckbox') + fireEvent.click(checkbox) + input = await findByTestId('customMaxLoginAttemptsInput') + expect(input).toBeEnabled() + }) + + it('opens the file upload modal when "Upload" button is clicked', async () => { + const {getByText, findByTestId} = render() + fireEvent.click(getByText('View Options')) + const checkbox = await findByTestId('customForbiddenWordsCheckbox') + fireEvent.click(checkbox) + fireEvent.click(getByText('Upload')) + expect(getByText('Upload Forbidden Words/Terms List')).toBeInTheDocument() + }) + + it('closes the Tray when "Cancel" button is clicked', async () => { + const {getByText, queryByText, findByTestId} = render() + fireEvent.click(getByText('View Options')) + const cancelButton = await findByTestId('cancelButton') + fireEvent.click(cancelButton) + expect(queryByText('Password Options Tray')).not.toBeInTheDocument() + }) +})