create UI for password configuration

initial creation of UI for password complexity settings

closes FOO-4437
flag=password_complexity

test plan:
- enable password complexity feature
- go to authentication page of admin
- click view options under canvas authentication provider
- check that UI matches figma attached to ticket

Change-Id: Icc333ba43862364595f863cfb39d993d82a6365f
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/347851
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Reviewed-by: August Thornton <august@instructure.com>
QA-Review: August Thornton <august@instructure.com>
Product-Review: AJ Esa <ajmal.esa@instructure.com>
This commit is contained in:
AJ Esa 2024-05-20 15:50:36 -06:00
parent ae4fce34e3
commit b1bd74fca3
7 changed files with 561 additions and 0 deletions

View File

@ -44,4 +44,8 @@
</div> </div>
<% end %> <% end %>
</div> </div>
<% if @domain_root_account.feature_enabled?(:password_complexity)%>
<% js_bundle :password_complexity_configuration %>
<div id="password_complexity_configuration"></div>
<% end %>
</div> </div>

View File

@ -156,6 +156,8 @@ const featureBundles: {
outcome_alignments: () => import('./features/outcome_alignments/index'), outcome_alignments: () => import('./features/outcome_alignments/index'),
outcome_management: () => import('./features/outcome_management/index'), outcome_management: () => import('./features/outcome_management/index'),
page_views: () => import('./features/page_views/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_alert: () => import('./features/past_global_alert/index'),
past_global_announcements: () => import('./features/past_global_announcements/index'), past_global_announcements: () => import('./features/past_global_announcements/index'),
permissions: () => import('./features/permissions/index'), permissions: () => import('./features/permissions/index'),

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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(<PasswordComplexityConfiguration />, container)
}
})

View File

@ -0,0 +1,6 @@
{
"name": "@canvas-features/password_complexity_configuration",
"private": true,
"version": "1.0.0",
"owner": "FOO"
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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<NumberInputControlledProps> = ({
minimum,
maximum,
defaultValue,
disabled,
'data-testid': dataTestid,
}) => {
const [number, setNumber] = useState(defaultValue)
const handleChange = (event: React.ChangeEvent<HTMLInputElement>, 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 (
<NumberInput
min={minimum}
max={maximum}
renderLabel=""
onIncrement={handleIncrement}
onDecrement={handleDecrement}
onChange={handleChange}
onBlur={handleBlur}
value={number}
disabled={disabled}
data-testid={dataTestid}
/>
)
}
export default NumberInputControlled

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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<File | null>(null)
const [customListUploaded, setCustomListUploaded] = useState(false)
const [fileModalOpen, setFileModalOpen] = useState(false)
const [customMaxLoginAttemptsEnabled, setCustomMaxLoginAttemptsEnabled] = useState(false)
const handleCustomMaxLoginAttemptToggle = (event: React.ChangeEvent<HTMLInputElement>) => {
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 (
<>
<Heading margin="small auto xx-small auto" level="h4">
{I18n.t('Password Options')}
</Heading>
<Button
onClick={() => {
setShowTray(true)
}}
>
{I18n.t('View Options')}
</Button>
<Tray
label="Password Options Tray"
open={showTray}
onDismiss={() => setShowTray(false)}
placement="end"
size="medium"
>
<Flex as="div" direction="column" height="100vh">
<Flex.Item shouldGrow={true} shouldShrink={true} padding="small" as="main">
<Flex as="div" direction="row" justifyItems="space-between">
<Flex.Item>
<View as="div" margin="small 0 small medium">
<Heading level="h3">{I18n.t('Password Options')}</Heading>
</View>
</Flex.Item>
<Flex.Item>
<CloseButton
margin="xxx-small 0 0 0"
offset="small"
screenReaderLabel="Close"
onClick={() => setShowTray(false)}
/>
</Flex.Item>
</Flex>
<View as="div" margin="0 0 0 medium">
<View as="div" margin="xxx-small auto small auto">
<Text size="small" lineHeight="fit">
{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.'
)}
</Text>
</View>
<Heading level="h4">{I18n.t('Current Password Configuration')}</Heading>
</View>
<Alert variant="info" margin="small medium medium medium">
{I18n.t('Your password must meet the following requirements')}
<List margin="xxx-small">
<List.Item>{I18n.t('Must be at least 8 Characters in length.')}</List.Item>
<List.Item>
{I18n.t(
'Must not use words or sequences of characters common in passwords (ie: password, 12345, etc...)'
)}
</List.Item>
</List>
</Alert>
<View as="div" margin="medium medium small medium">
<Checkbox
label={I18n.t('Minimum character length (minimum: 8 | maximum: 120)')}
checked={minimumCharacterLengthEnabled}
onChange={() => setMinimumCharacterLengthEnabled(!minimumCharacterLengthEnabled)}
defaultChecked={true}
data-testid="minimumCharacterLengthCheckbox"
/>
</View>
<View as="div" maxWidth="9rem" margin="0 medium medium medium">
<View as="div" margin="0 medium medium medium">
<NumberInputControlled
minimum={8}
maximum={120}
defaultValue={8}
disabled={!minimumCharacterLengthEnabled}
data-testid="minimumCharacterLengthInput"
/>
</View>
</View>
<View as="div" margin="medium">
<Checkbox
label={I18n.t('Require number characters (0...9)')}
checked={requireNumbersEnabled}
onChange={() => setRequireNumbersEnabled(!requireNumbersEnabled)}
defaultChecked={true}
data-testid="requireNumbersCheckbox"
/>
</View>
<View as="div" margin="medium">
<Checkbox
label={I18n.t('Require symbol characters (ie: ! @ # $ %)')}
checked={requireSymbolsEnabled}
onChange={() => setRequireSymbolsEnabled(!requireSymbolsEnabled)}
defaultChecked={true}
data-testid="requireSymbolsCheckbox"
/>
</View>
<View as="div" margin="medium">
<Checkbox
checked={customForbiddenWordsEnabled}
onChange={() => {
setCustomForbiddenWordsEnabled(!customForbiddenWordsEnabled)
}}
label={I18n.t('Customize forbidden words/termslist (see default list here)')}
data-testid="customForbiddenWordsCheckbox"
/>
<View
as="div"
insetInlineStart="1.75em"
position="relative"
margin="xx-small small small 0"
>
<Text size="small">
{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.'
)}
</Text>
{!customListUploaded && (
<View as="div" margin="small 0">
<Button
disabled={!customForbiddenWordsEnabled}
renderIcon={IconUploadSolid}
onClick={() => setFileModalOpen(true)}
data-testid="uploadButton"
>
{I18n.t('Upload')}
</Button>
</View>
)}
</View>
</View>
{customListUploaded && (
<View as="div" margin="0 medium medium medium">
<Heading level="h4">{I18n.t('Current Custom List')}</Heading>
<hr />
<Flex justifyItems="space-between">
<Flex.Item>
<Link href="#">{customListFile?.name}</Link>
</Flex.Item>
<Flex.Item>
<IconButton
withBackground={false}
withBorder={false}
screenReaderLabel="Delete tag"
onClick={() => {
handleDeleteCustomList()
}}
>
<IconTrashLine color="warning" />
</IconButton>
</Flex.Item>
</Flex>
<hr />
</View>
)}
<View as="div" margin="medium medium small medium">
<Checkbox
onChange={handleCustomMaxLoginAttemptToggle}
checked={customMaxLoginAttemptsEnabled}
label={I18n.t('Customize maximum login attempts (default 10 attempts)')}
data-testid="customMaxLoginAttemptsCheckbox"
/>
<View
as="div"
insetInlineStart="1.75em"
position="relative"
margin="xx-small small xxx-small 0"
>
<Text size="small">
{I18n.t(
'This option controls the number of attempts a single user can make consecutively to login without success before their users login is suspended. Users can be unsuspended by institutional admins. Cannot be higher than 20 attempts.'
)}
</Text>
</View>
</View>
<View as="div" maxWidth="9rem" margin="0 medium medium medium">
<View as="div" margin="0 medium medium medium">
<NumberInputControlled
minimum={3}
maximum={20}
defaultValue={10}
disabled={!customMaxLoginAttemptsEnabled}
data-testid="customMaxLoginAttemptsInput"
/>
</View>
</View>
</Flex.Item>
<Flex.Item as="footer">
<View as="div" background="secondary" width="100%" textAlign="end">
<View as="div" display="inline-block">
<Button
margin="small 0"
color="secondary"
onClick={() => setShowTray(false)}
data-testid="cancelButton"
>
{I18n.t('Cancel')}
</Button>
<Button margin="small" color="primary">
{I18n.t('Apply')}
</Button>
</View>
</View>
</Flex.Item>
</Flex>
</Tray>
<View as="div">
<Modal
open={fileModalOpen}
onDismiss={() => {
setFileModalOpen(false)
}}
size="medium"
label="Upload Forbidden Words/Terms List"
shouldCloseOnDocumentClick={true}
overflow="scroll"
>
<Modal.Header>
<Heading>{I18n.t('Upload Forbidden Words/Terms List')}</Heading>
<CloseButton
margin="small 0 0 0"
placement="end"
offset="small"
onClick={() => setFileModalOpen(false)}
screenReaderLabel="Close"
/>
</Modal.Header>
<Modal.Body>
{!customListFile && (
<div style={{overflowY: 'clip'}}>
<FileDrop
accept=".txt"
onDrop={files => {
const file = files[0] as File
handleFileDrop(file)
}}
renderLabel={
<Billboard
heading={I18n.t('Upload File')}
headingLevel="h2"
message={I18n.t('Drag and drop, or click to browse your local filesystem')}
hero={<Img src="/images/upload_rocket.svg" height="10rem" />}
/>
}
/>
</div>
)}
{customListFile && <Text>{customListFile.name}</Text>}
</Modal.Body>
<Modal.Footer>
<Button onClick={() => handleCancelModal()} margin="0 x-small 0 0">
{I18n.t('Close')}
</Button>
<Button color="primary" onClick={() => handleUploadModal()}>
{I18n.t('Upload')}
</Button>
</Modal.Footer>
</Modal>
</View>
</>
)
}
export default PasswordComplexityConfiguration

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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(<PasswordComplexityConfiguration />)
fireEvent.click(getByText('View Options'))
expect(getByText('Current Password Configuration')).toBeInTheDocument()
})
it('toggles all checkboxes with defaults set', async () => {
const {getByText, findByTestId} = render(<PasswordComplexityConfiguration />)
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(<PasswordComplexityConfiguration />)
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(<PasswordComplexityConfiguration />)
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(<PasswordComplexityConfiguration />)
fireEvent.click(getByText('View Options'))
const cancelButton = await findByTestId('cancelButton')
fireEvent.click(cancelButton)
expect(queryByText('Password Options Tray')).not.toBeInTheDocument()
})
})