Skip to content

Commit

Permalink
Add QR code login for mobile to the profile tray
Browse files Browse the repository at this point in the history
Closes USERS-433
Refs USERS-438
Refs UXS-30
flag=mobile_qr_login

Adds an entry to the left nav "Profile" tray that allows a user to pull
up a modal that displays QR code to scan on their phone to log into the
mobile app.

Test Plan:
 - Ensure your version of canvas has an up to date
   instructure_misc_plugin
 - create a developer key
 - add https://sso.canvaslms.com/canvas/login as its only redirect URI
 - In a rails console
   - a = Account.default
   - a.settings[:ios_mobile_sso_developer_key_id] = <dev key global id>
   - a.save!
   - a.account_domains.create!(name: 'sso.canvaslms.com')
 - In canvas, ensure the QR for Mobile Login release flag is on
 - Open the profile tray and click "QR for Mobile Login"
 - Ensure the modal dialog shows the QR code
 - Ensure the modal dialog can then be dismissed

Change-Id: I92b3c4869530207b9274e9e207828be8de111672
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/230318
Reviewed-by: Spencer Olson <[email protected]>
Reviewed-by: Cody Cutrer <[email protected]>
QA-Review: Pat Renner <[email protected]>
Tested-by: Service Cloud Jenkins <[email protected]>
Product-Review: Kevin Dougherty <[email protected]>
  • Loading branch information
ktgeek authored and cvkline committed Mar 19, 2020
1 parent 060c297 commit 1a6a69a
Show file tree
Hide file tree
Showing 8 changed files with 271 additions and 5 deletions.
3 changes: 2 additions & 1 deletion app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,8 @@ def js_env(hash = {}, overwrite = false)
assignment_bulk_edit: Account.site_admin.feature_enabled?(:assignment_bulk_edit),
la_620_old_rce_init_fix: Account.site_admin.feature_enabled?(:la_620_old_rce_init_fix),
cc_in_rce_video_tray: Account.site_admin.feature_enabled?(:cc_in_rce_video_tray),
featured_help_links: Account.site_admin.feature_enabled?(:featured_help_links)
featured_help_links: Account.site_admin.feature_enabled?(:featured_help_links),
show_qr_login: Object.const_defined?("InstructureMiscPlugin") && !!@domain_root_account&.feature_enabled?(:mobile_qr_login)
}
}
@js_env[:current_user] = @current_user ? Rails.cache.fetch(['user_display_json', @current_user].cache_key, :expires_in => 1.hour) { user_display_json(@current_user, :profile, [:avatar_is_fallback]) } : {}
Expand Down
1 change: 1 addition & 0 deletions app/jsx/navigation_header/Navigation.js
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ export default class Navigation extends React.Component {
loaded={this.state.profileAreLoaded}
tabs={this.state.profile}
counts={{unreadShares: this.state.unreadSharesCount}}
showQRLoginLink={window.ENV.FEATURES.show_qr_login}
/>
)
case 'help':
Expand Down
31 changes: 28 additions & 3 deletions app/jsx/navigation_header/trays/ProfileTray.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {Link} from '@instructure/ui-link'
import {View} from '@instructure/ui-layout'
import LogoutButton from '../LogoutButton'
import {AccessibleContent} from '@instructure/ui-a11y'
import {showQRLoginModal} from './QRLoginModal'

// Trying to keep this as generalized as possible, but it's still a bit
// gross matching on the id of the tray tabs given to us by Rails
Expand Down Expand Up @@ -62,7 +63,20 @@ ProfileTab.propTypes = {
}

export default function ProfileTray(props) {
const {userDisplayName, userAvatarURL, loaded, userPronouns, tabs, counts} = props
const {
userDisplayName,
userAvatarURL,
loaded,
userPronouns,
tabs,
counts,
showQRLoginLink
} = props

function onOpenQRLoginModal() {
showQRLoginModal()
}

return (
<View as="div" padding="medium">
<View textAlign="center">
Expand Down Expand Up @@ -91,12 +105,22 @@ export default function ProfileTray(props) {
{loaded ? (
tabs.map(tab => <ProfileTab key={tab.id} {...tab} counts={counts} />)
) : (
<List.Item key="loading">
<List.Item>
<div style={{textAlign: 'center'}}>
<Spinner margin="medium" renderTitle="Loading" />
</div>
</List.Item>
)}

{showQRLoginLink && loaded && (
<List.Item>
<View as="div" margin="small 0">
<Link isWithinText={false} onClick={onOpenQRLoginModal}>
{I18n.t('QR for Mobile Login')}
</Link>
</View>
</List.Item>
)}
</List>
</View>
)
Expand All @@ -108,5 +132,6 @@ ProfileTray.propTypes = {
loaded: bool.isRequired,
userPronouns: string,
tabs: arrayOf(shape(ProfileTab.propTypes)).isRequired,
counts: object.isRequired
counts: object.isRequired,
showQRLoginLink: bool.isRequired
}
108 changes: 108 additions & 0 deletions app/jsx/navigation_header/trays/QRLoginModal.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/*
* Copyright (C) 2020 - 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 I18n from 'i18n!QRLoginModal'
import React, {useCallback, useState} from 'react'
import ReactDOM from 'react-dom'
import useFetchApi from 'jsx/shared/effects/useFetchApi'
import {showFlashAlert} from 'jsx/shared/FlashAlert'

import Modal from '../../shared/components/InstuiModal'
import {Img} from '@instructure/ui-img'
import {Button} from '@instructure/ui-buttons'
import {View} from '@instructure/ui-view'
import {Spinner} from '@instructure/ui-spinner'
import {func} from 'prop-types'

let modalContainer

// exported for tests only
export function killQRLoginModal() {
if (modalContainer) ReactDOM.unmountComponentAtNode(modalContainer)
modalContainer.remove()
modalContainer = undefined
}

// exported for tests only
export function QRLoginModal({onDismiss}) {
const [image, setImage] = useState(null)

function fetchError(e) {
showFlashAlert({
message: I18n.t('An error occurred while retrieving your QR Code'),
err: e
})
killQRLoginModal()
}

useFetchApi({
path: 'canvas/login.png',
fetchOpts: {method: 'POST'},
success: useCallback(r => setImage(r), []),
error: useCallback(fetchError, [])
})

function renderQRCode() {
const body = image ? (
<Img data-testid="qr-code-image" src={`data:image/png;base64, ${image.png}`} />
) : (
<Spinner
data-testid="qr-code-spinner"
renderTitle={I18n.t('Waiting for your QR Code to load')}
/>
)
return (
<View display="block" textAlign="center" padding="small 0 0">
{body}
</View>
)
}

return (
<Modal onDismiss={onDismiss} open label={I18n.t('QR for Mobile Login')} size="small">
<Modal.Body>
<View display="block">
{I18n.t(
"Scan this QR code from any Canvas mobile app to access your Canvas account when you're on the go."
)}
</View>
{renderQRCode()}
</Modal.Body>
<Modal.Footer>
<Button data-testid="qr-close-button" variant="primary" onClick={onDismiss}>
{I18n.t('Done')}
</Button>
</Modal.Footer>
</Modal>
)
}

QRLoginModal.propTypes = {
onDismiss: func.isRequired
}

export function showQRLoginModal(props = {}) {
if (modalContainer) return // Modal is already up
const {QRModal, ...modalProps} = props
modalContainer = document.createElement('div')
modalContainer.setAttribute('id', 'qr_login_modal_container')
document.body.appendChild(modalContainer)

const Component = QRModal || QRLoginModal
ReactDOM.render(<Component onDismiss={killQRLoginModal} {...modalProps} />, modalContainer)
}
92 changes: 92 additions & 0 deletions app/jsx/navigation_header/trays/__tests__/QRLoginModal.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/* Copyright (C) 2020 - 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 fetchMock from 'fetch-mock'
import {showQRLoginModal, QRLoginModal, killQRLoginModal} from '../QRLoginModal'
import {render, fireEvent} from '@testing-library/react'
import {getByText as domGetByText} from '@testing-library/dom'

const loginImageJson = {
png: 'R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='
}

const QRModalStub = () => <span>hi there</span>

describe('Navigation Header > Trays', () => {
describe('showQRLoginModal function', () => {
it('renders the modal component inside the div', () => {
showQRLoginModal({QRModal: QRModalStub})
const container = document.querySelector('div#qr_login_modal_container')
expect(domGetByText(container, 'hi there')).toBeInTheDocument()
})

it('removes the div when the modal is closed', () => {
showQRLoginModal({QRModal: QRModalStub})
killQRLoginModal()
const container = document.querySelector('div#qr_login_modal_container')
expect(container).toBeNull()
})

it('does not render multiple divs if called more than once', () => {
showQRLoginModal({QRModal: QRModalStub})
showQRLoginModal({QRModal: QRModalStub})
showQRLoginModal({QRModal: QRModalStub})
const containers = document.querySelectorAll('div#qr_login_modal_container')
expect(containers.length).toBe(1)
})
})

describe('QRLoginModal component', () => {
const handleDismiss = jest.fn()

beforeEach(handleDismiss.mockClear)

afterEach(fetchMock.restore)

it('renders the dialog', () => {
const {getByText} = render(<QRLoginModal onDismiss={handleDismiss} />)
expect(getByText(/Scan this QR code/)).toBeInTheDocument()
})

it('renders a spinner before the API call has completed', () => {
const {getByTestId} = render(<QRLoginModal onDismiss={handleDismiss} />)
expect(getByTestId('qr-code-spinner')).toBeInTheDocument()
})

it('renders the image returned by the API call', async () => {
fetchMock.post('/canvas/login.png', loginImageJson)
const {findByTestId} = render(<QRLoginModal onDismiss={handleDismiss} />)
const image = await findByTestId('qr-code-image')
expect(image.src).toBe(`data:image/png;base64, ${loginImageJson.png}`)
})

it('kills the modal off when "Done" button is clicked', () => {
const {getByTestId} = render(<QRLoginModal onDismiss={handleDismiss} />)
const closeButton = getByTestId('qr-close-button')
fireEvent.click(closeButton)
expect(handleDismiss).toHaveBeenCalled()
})

it('kills the modal off when the modal dismiss X is clicked', () => {
const {getByTestId} = render(<QRLoginModal onDismiss={handleDismiss} />)
const closeButton = getByTestId('instui-modal-close').querySelector('button')
fireEvent.click(closeButton)
expect(handleDismiss).toHaveBeenCalled()
})
})
})
7 changes: 6 additions & 1 deletion app/jsx/shared/components/InstuiModal.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,12 @@ export default function CanvasInstUIModal({
onDismiss={onDismiss}
>
<Modal.Header>
<CloseButton placement="end" offset="medium" onClick={onDismiss}>
<CloseButton
data-testid="instui-modal-close"
placement="end"
offset="medium"
onClick={onDismiss}
>
{closeButtonLabel || I18n.t('Close')}
</CloseButton>
<Heading>{label}</Heading>
Expand Down
5 changes: 5 additions & 0 deletions config/feature_flags/covid.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
mobile_qr_login:
state: hidden
applies_to: RootAccount
display_name: "QR for Mobile Login"
description: "Include a link to show a QR code to easy mobile login on the profile page."
29 changes: 29 additions & 0 deletions spec/controllers/application_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,35 @@
expect(controller.js_env[:SETTINGS][:open_registration]).to be_truthy
end

context "show_qr_login (QR for Mobile Login)" do
before(:each) do
allow(Object).to receive(:const_defined?).and_call_original
controller.instance_variable_set(:@domain_root_account, Account.default)
end

it 'is false if InstructureMiscPlugin is not defined and the feature flag is off' do
allow(Object).to receive(:const_defined?).with("InstructureMiscPlugin").and_return(false).once
expect(controller.js_env[:FEATURES][:show_qr_login]).to be_falsey
end

it 'is false if InstructureMiscPlugin is defined and the feature flag is off' do
allow(Object).to receive(:const_defined?).with("InstructureMiscPlugin").and_return(true).once
expect(controller.js_env[:FEATURES][:show_qr_login]).to be_falsey
end

it 'is false if InstructureMiscPlugin is not defined and the feature flag is on' do
Account.default.enable_feature!(:mobile_qr_login)
allow(Object).to receive(:const_defined?).with("InstructureMiscPlugin").and_return(false).once
expect(controller.js_env[:FEATURES][:show_qr_login]).to be_falsey
end

it 'is true if InstructureMiscPlugin is defined and the feature flag is on' do
Account.default.enable_feature!(:mobile_qr_login)
allow(Object).to receive(:const_defined?).with("InstructureMiscPlugin").and_return(true).once
expect(controller.js_env[:FEATURES][:show_qr_login]).to be_truthy
end
end

it 'sets LTI_LAUNCH_FRAME_ALLOWANCES' do
expect(@controller.js_env[:LTI_LAUNCH_FRAME_ALLOWANCES]).to match_array [
"geolocation *",
Expand Down

0 comments on commit 1a6a69a

Please sign in to comment.