Add QR for Mobile Login link to /profile routes

closes USERS-458
flag=mobile_qr_login

This commit changes how we're adding the link to render the mobile
QR code modal. We're going to be using the profile tab approach
instead of adding it directly to the global profile tray. This
will allow it to exist not only in the global profile tray, but
the user profile, and even the global mobile nav. The QR for mobile
link tab will show up for any active path under '/profile'

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')

With the :mobile_qr_login feature flag enabled:

*Web View*
 - Navigate to '/'
 - Open the profile tray and click 'QR for Mobile Login'
 - Ensure the QR code image is generated with the title and expire tag
 - Navigate to '/profile'
 - Click on 'QR for Mobile Login' on the left hand side section tabs
 - Ensure the QR code image is generated with the title and expire tag

*Mobile Web View*
 - Shrink browser horizontally until Canvas web view is rendered
 - Click on the down carrot menu at the top of the global nav
 - Click on 'QR for Mobile Login' option at the bottom
 - Ensure the QR code image is generated with the title and expire tag
 - Click on the hamburger menu in the top left
 - Select the Account drop-down
 - Click on 'QR for Mobile Login'
 - Ensure the QR code image is generated with the title and expire tag

- 'QR for Mobile Login' link tab should only appear for paths
  under '/profile'

With the :mobile_qr_login feature flag disabled:

 - Verify the 'QR for Mobile Login' link tab does not appear
   in any of the above locations previously tested
 - Hitting the '/profile/qr_for_Mobile' path directly renders
   a 404

Change-Id: If69d5f3a7526f7aa84cfae22d0747e5afd2d617b
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/232750
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Reviewed-by: Charley Kline <ckline@instructure.com>
QA-Review: Charley Kline <ckline@instructure.com>
Product-Review: Peyton Craighill <pcraighill@instructure.com>
This commit is contained in:
August Thornton 2020-04-02 17:07:04 -06:00
parent d34f0c961b
commit 2a8b3c95a4
16 changed files with 444 additions and 415 deletions

View File

@ -176,7 +176,6 @@ class ApplicationController < ActionController::Base
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),
show_qr_login: Object.const_defined?("InstructureMiscPlugin") && !!@domain_root_account&.feature_enabled?(:mobile_qr_login),
responsive_2020_03: !!@domain_root_account&.feature_enabled?(:responsive_2020_03),
responsive_2020_04: !!@domain_root_account&.feature_enabled?(:responsive_2020_04),
product_tours: !!@domain_root_account&.feature_enabled?(:product_tours),

View File

@ -138,7 +138,7 @@
#
class ProfileController < ApplicationController
before_action :require_registered_user, :except => [:show, :settings, :communication, :communication_update]
before_action :require_user, :only => [:settings, :communication, :communication_update]
before_action :require_user, :only => [:settings, :communication, :communication_update, :qr_mobile_login]
before_action :require_user_for_private_profile, :only => :show
before_action :reject_student_view_student
before_action :require_password_session, :only => [:communication, :communication_update, :update]
@ -490,4 +490,19 @@ class ProfileController < ApplicationController
})
render :content_shares
end
def qr_mobile_login
raise not_found unless @domain_root_account&.feature_enabled?(:mobile_qr_login)
@user ||= @current_user
set_active_tab 'qr_mobile_login'
@context = @user.profile if @user == @current_user
add_crumb(@user.short_name, profile_path)
add_crumb(t('crumbs.mobile_qr_login', "QR for Mobile Login"))
js_bundle :qr_mobile_login
render html: '', layout: true
end
end

View File

@ -16,59 +16,56 @@
# with this program. If not, see <http://www.gnu.org/licenses/>.
module SectionTabHelper
PERMISSIONS_TO_PRECALCULATE = [
:create_conferences,
:create_forum,
:manage_admin_users,
:manage_assignments,
:manage_content,
:manage_files,
:manage_grades,
:manage_students,
:moderate_forum,
:post_to_forum,
:read_announcements,
:read_course_content,
:read_forum,
:read_roster,
:view_all_grades
PERMISSIONS_TO_PRECALCULATE = %i[
create_conferences
create_forum
manage_admin_users
manage_assignments
manage_content
manage_files
manage_grades
manage_students
moderate_forum
post_to_forum
read_announcements
read_course_content
read_forum
read_roster
view_all_grades
].freeze
def available_section_tabs
@available_section_tabs ||= AvailableSectionTabs.new(
@context, @current_user, @domain_root_account, session
).to_a
@available_section_tabs ||=
AvailableSectionTabs.new(@context, @current_user, @domain_root_account, session).to_a
end
def nav_name
if active_path?('/courses')
I18n.t('Courses Navigation Menu')
elsif active_path?('/profile')
I18n.t('Account Navigation Menu')
I18n.t('Account Navigation Menu')
elsif active_path?('/accounts')
I18n.t('Admin Navigation Menu')
elsif active_path?('/groups')
I18n.t('Groups Navigation Menu')
I18n.t('Groups Navigation Menu')
else
I18n.t('Context Navigation Menu')
I18n.t('Context Navigation Menu')
end
end
def section_tabs
@section_tabs ||= begin
if @context && available_section_tabs.any?
content_tag(:nav, {
:role => 'navigation',
:'aria-label' => nav_name
}) do
concat(content_tag(:ul, id: 'section-tabs') do
available_section_tabs.map do |tab|
section_tab_tag(tab, @context, get_active_tab)
end
end)
@section_tabs ||=
begin
if @context && available_section_tabs.any?
content_tag(:nav, { role: 'navigation', 'aria-label': nav_name }) do
concat(
content_tag(:ul, id: 'section-tabs') do
available_section_tabs.map { |tab| section_tab_tag(tab, @context, get_active_tab) }
end
)
end
end
end
end
raw(@section_tabs)
end
@ -77,7 +74,9 @@ module SectionTabHelper
end
class AvailableSectionTabs
def initialize(context, current_user, domain_root_account, session, precalculated_permissions=nil)
def initialize(
context, current_user, domain_root_account, session, precalculated_permissions = nil
)
@context = context
@current_user = current_user
@domain_root_account = domain_root_account
@ -90,32 +89,39 @@ module SectionTabHelper
return [] unless context.respond_to?(:tabs_available)
Rails.cache.fetch(cache_key, expires_in: 1.hour) do
new_collaborations_enabled = context.feature_enabled?(:new_collaborations) if context.respond_to?(:feature_enabled?)
if context.respond_to?(:feature_enabled?)
new_collaborations_enabled = context.feature_enabled?(:new_collaborations)
end
context.tabs_available(current_user, {
session: session,
root_account: domain_root_account,
precalculated_permissions: @precalculated_permissions
}).select { |tab|
tab_has_required_attributes?(tab)
}.reject { |tab|
context.tabs_available(
current_user,
{
session: session,
root_account: domain_root_account,
precalculated_permissions: @precalculated_permissions
}
).select { |tab| tab_has_required_attributes?(tab) }.reject do |tab|
if tab_is?(tab, 'TAB_COLLABORATIONS')
new_collaborations_enabled ||
!Collaboration.any_collaborations_configured?(@context)
new_collaborations_enabled || !Collaboration.any_collaborations_configured?(@context)
elsif tab_is?(tab, 'TAB_COLLABORATIONS_NEW')
!new_collaborations_enabled
elsif tab_is?(tab, 'TAB_CONFERENCES')
!WebConference.config
end
}
end
end
end
private
def cache_key
[ context, current_user, domain_root_account,
[
context,
current_user,
domain_root_account,
Lti::NavigationCache.new(domain_root_account),
"section_tabs_hash", I18n.locale
'section_tabs_hash',
I18n.locale
].cache_key
end
@ -124,8 +130,7 @@ module SectionTabHelper
end
def tab_is?(tab, const_name)
context.class.const_defined?(const_name) &&
tab[:id] == context.class.const_get(const_name)
context.class.const_defined?(const_name) && tab[:id] == context.class.const_get(const_name)
end
end
@ -134,13 +139,13 @@ module SectionTabHelper
include ActionView::Helpers::TagHelper
include ActionView::Helpers::TextHelper
def initialize(tab, context, active_tab=nil)
def initialize(tab, context, active_tab = nil)
@tab = SectionTabPresenter.new(tab, context)
@active_tab = active_tab
end
def a_classes
[ @tab.css_class.downcase.replace_whitespace('-') ].tap do |a|
[@tab.css_class.downcase.replace_whitespace('-')].tap do |a|
a << 'active' if @tab.active?(@active_tab)
end
end
@ -158,9 +163,9 @@ module SectionTabHelper
def a_aria_label
return unless @tab.hide? || @tab.unused?
if @tab.hide?
I18n.t('%{label}. Disabled. Not visible to students', {label: @tab.label})
I18n.t('%{label}. Disabled. Not visible to students', { label: @tab.label })
else
I18n.t('%{label}. No content. Not visible to students', {label: @tab.label})
I18n.t('%{label}. No content. Not visible to students', { label: @tab.label })
end
end
@ -169,13 +174,15 @@ module SectionTabHelper
end
def a_attributes
{ href: @tab.path,
{
href: @tab.path,
title: a_title,
'aria-label': a_aria_label,
'aria-current': a_aria_current_page,
class: a_classes }.tap do |h|
h[:target] = @tab.target if @tab.target?
h['data-tooltip'] = '' if @tab.hide? || @tab.unused?
class: a_classes
}.tap do |h|
h[:target] = @tab.target if @tab.target?
h['data-tooltip'] = '' if @tab.hide? || @tab.unused?
end
end
@ -187,9 +194,7 @@ module SectionTabHelper
end
def li_classes
[ 'section' ].tap do |a|
a << 'section-hidden' if @tab.hide? || @tab.unused?
end
%w[section].tap { |a| a << 'section-hidden' if @tab.hide? || @tab.unused? }
end
def indicate_hidden
@ -198,9 +203,7 @@ module SectionTabHelper
end
def to_html
content_tag(:li, a_tag, {
class: li_classes
})
content_tag(:li, a_tag, { class: li_classes })
end
end
end

View File

@ -0,0 +1,36 @@
/*
* 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 ReactDOM from 'react-dom'
import ready from '@instructure/ready'
import {QRMobileLogin} from '../profiles/QRMobileLogin'
ready(() => {
let container
container = document.createElement('div')
container.setAttribute('id', 'qr_login_container')
const content = document.querySelector('#content')
content.appendChild(container)
container = document.querySelector('#qr_login_container')
if (container) {
ReactDOM.render(<QRMobileLogin />, container)
}
})

View File

@ -296,7 +296,6 @@ 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':

View File

@ -24,7 +24,6 @@ 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
@ -63,19 +62,7 @@ ProfileTab.propTypes = {
}
export default function ProfileTray(props) {
const {
userDisplayName,
userAvatarURL,
loaded,
userPronouns,
tabs,
counts,
showQRLoginLink
} = props
function onOpenQRLoginModal() {
showQRLoginModal()
}
const {userDisplayName, userAvatarURL, loaded, userPronouns, tabs, counts} = props
return (
<View as="div" padding="medium">
@ -106,22 +93,12 @@ export default function ProfileTray(props) {
{loaded ? (
tabs.map(tab => <ProfileTab key={tab.id} {...tab} counts={counts} />)
) : (
<List.Item>
<List.Item key="loading">
<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>
)
@ -133,6 +110,5 @@ ProfileTray.propTypes = {
loaded: bool.isRequired,
userPronouns: string,
tabs: arrayOf(shape(ProfileTab.propTypes)).isRequired,
counts: object.isRequired,
showQRLoginLink: bool.isRequired
counts: object.isRequired
}

View File

@ -1,171 +0,0 @@
/* 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 MockDate from 'mockdate'
import moment from 'moment'
import {showQRLoginModal, QRLoginModal, killQRLoginModal} from '../QRLoginModal'
import {render, fireEvent, act} from '@testing-library/react'
import {getByText as domGetByText} from '@testing-library/dom'
// a fake QR code image, and then a another one after generating a new code
const loginImageJsons = [
{png: 'R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='},
{png: 'R0lGODlhAQABZZZZZCH5BAEKAAEALZZZZZABAAEAAAICTAEAOn=='}
]
const route = '/canvas/login.png'
const QRModalStub = () => <span>hi there</span>
// used for when we want fetchMock not to ever respond
const doNotRespond = Function.prototype
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', () => {
describe('before the API call responds', () => {
const handleDismiss = jest.fn()
beforeEach(() => {
fetchMock.post(route, doNotRespond, {overwriteRoutes: true})
})
afterEach(() => {
handleDismiss.mockClear()
fetchMock.restore()
})
it('renders the modal', () => {
const {getByText} = render(<QRLoginModal onDismiss={handleDismiss} />)
expect(getByText(/To log in to your Canvas account/)).toBeInTheDocument()
})
it('renders a spinner', () => {
const {getByTestId} = render(<QRLoginModal onDismiss={handleDismiss} />)
expect(getByTestId('qr-code-spinner')).toBeInTheDocument()
})
it('dismisses the modal when "Done" button is clicked', () => {
const {getByTestId} = render(<QRLoginModal onDismiss={handleDismiss} />)
const closeButton = getByTestId('qr-close-button')
fireEvent.click(closeButton)
expect(handleDismiss).toHaveBeenCalled()
})
it('dismisses the modal 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()
})
})
describe('after the API call responds', () => {
beforeEach(() => {
jest.useFakeTimers()
fetchMock
.postOnce(route, loginImageJsons[0], {overwriteRoutes: true})
.postOnce(route, loginImageJsons[1], {overwriteRoutes: false})
})
afterEach(() => {
fetchMock.restore()
MockDate.reset()
})
// advances both global time and the jest timers by the given time duration
function advance(...args) {
const delay = moment.duration(...args).asMilliseconds()
act(() => {
const now = Date.now()
MockDate.set(now + delay)
jest.advanceTimersByTime(delay)
})
}
it('renders the image in the response, and the right expiration time', async () => {
const {findByTestId, getByText} = render(<QRLoginModal onDismiss={Function.prototype} />)
const image = await findByTestId('qr-code-image')
expect(image.src).toBe(`data:image/png;base64, ${loginImageJsons[0].png}`)
expect(getByText(/expires in 10 minutes/i)).toBeInTheDocument()
})
it('updates the expiration as time elapses', async () => {
const {findByText} = render(<QRLoginModal onDismiss={Function.prototype} />)
await findByText(/expires in 10 minutes/i)
advance(1, 'minute')
const expiresIn = await findByText(/expires in 9 minutes/i)
expect(expiresIn).toBeInTheDocument()
})
it('shows the right thing when the token has expired', async () => {
const refreshInterval = moment.duration(15, 'minutes')
const pollInterval = moment.duration(3, 'minutes')
const {findByText} = render(
<QRLoginModal
onDismiss={Function.prototype}
refreshInterval={refreshInterval}
pollInterval={pollInterval}
/>
)
await findByText(/expires in 10 minutes/)
advance(11, 'minutes') // code is only good for 10
const expiresIn = await findByText(/code has expired/i)
expect(expiresIn).toBeInTheDocument()
})
it('refreshes the code at the right time', async () => {
const refreshInterval = moment.duration(2, 'minutes')
const {findByText, findByTestId} = render(
<QRLoginModal onDismiss={Function.prototype} refreshInterval={refreshInterval} />
)
const image = await findByTestId('qr-code-image')
expect(image.src).toBe(`data:image/png;base64, ${loginImageJsons[0].png}`)
expect(fetchMock.calls(route)).toHaveLength(1)
advance(1, 'minute')
await findByText(/expires in 9 minutes/)
advance(1, 'minute')
await findByText(/expires in 10 minutes/)
expect(fetchMock.calls(route)).toHaveLength(2)
expect(image.src).toBe(`data:image/png;base64, ${loginImageJsons[1].png}`)
})
})
})
})

View File

@ -16,43 +16,37 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import I18n from 'i18n!QRLoginModal'
import I18n from 'i18n!QRMobileLogin'
import React, {useState, useEffect} from 'react'
import ReactDOM from 'react-dom'
import {showFlashAlert} from 'jsx/shared/FlashAlert'
import doFetchApi from 'jsx/shared/effects/doFetchApi'
import Modal from '../../shared/components/InstuiModal'
import {Flex} from '@instructure/ui-flex'
import {Heading} from '@instructure/ui-heading'
import {Img} from '@instructure/ui-img'
import {Button} from '@instructure/ui-buttons'
import {View} from '@instructure/ui-view'
import {Text} from '@instructure/ui-text'
import {Spinner} from '@instructure/ui-spinner'
import {Text} from '@instructure/ui-text'
import {View} from '@instructure/ui-view'
import moment from 'moment'
import {func, object} from 'prop-types'
import {object} from 'prop-types'
const REFRESH_INTERVAL = moment.duration(9.75, 'minutes') // 9 min 45 sec
const POLL_INTERVAL = moment.duration(5, 'seconds')
const QR_CODE_LIFETIME = moment.duration(10, 'minutes')
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, refreshInterval, pollInterval}) {
export function QRMobileLogin({refreshInterval, pollInterval}) {
const [imagePng, setImagePng] = useState(null)
const [validFor, setValidFor] = useState(null)
function renderQRCode() {
const body = imagePng ? (
<span className="fs-exclude">
<Img data-testid="qr-code-image" src={`data:image/png;base64, ${imagePng}`} />
<Img
alt={I18n.t('QR Code Image')}
constrain="contain"
data-testid="qr-code-image"
src={`data:image/png;base64, ${imagePng}`}
/>
</span>
) : (
<Spinner
@ -61,7 +55,7 @@ export function QRLoginModal({onDismiss, refreshInterval, pollInterval}) {
/>
)
return (
<View display="block" textAlign="center" padding="small 0 0">
<View display="block" textAlign="center" padding="small xx-large">
{body}
</View>
)
@ -95,7 +89,6 @@ export function QRLoginModal({onDismiss, refreshInterval, pollInterval}) {
message: I18n.t('An error occurred while retrieving your QR Code'),
err
})
onDismiss()
} finally {
isFetching = false
}
@ -117,49 +110,46 @@ export function QRLoginModal({onDismiss, refreshInterval, pollInterval}) {
useEffect(startTimedEvents, [])
return (
<Modal onDismiss={onDismiss} open label={I18n.t('QR for Mobile Login')} size="small">
<Modal.Body>
<View display="block">
{I18n.t(
'To log in to your Canvas account when youre on the go, scan this QR code from any Canvas mobile app.'
<Flex direction="column" justifyItems="center" margin="none medium">
<Flex.Item margin="xx-small" padding="xx-small">
<Heading level="h1">{I18n.t('QR for Mobile Login')}</Heading>
</Flex.Item>
<Flex.Item>
<View {...flexViewProps}>
<View display="block">
{I18n.t(
'To log in to your Canvas account when youre on the go, scan this QR code from any Canvas mobile app.'
)}
</View>
{renderQRCode()}
{validFor && (
<Text weight="light" size="small">
{validFor}
</Text>
)}
</View>
{renderQRCode()}
{validFor && (
<Text weight="light" size="small">
{validFor}
</Text>
)}
</Modal.Body>
<Modal.Footer>
<Button data-testid="qr-close-button" variant="primary" onClick={onDismiss}>
{I18n.t('Done')}
</Button>
</Modal.Footer>
</Modal>
</Flex.Item>
</Flex>
)
}
QRLoginModal.propTypes = {
onDismiss: func,
const flexViewProps = {
borderColor: 'primary',
borderWidth: 'small',
borderRadius: 'medium',
padding: 'medium',
margin: 'medium small',
maxWidth: '30rem',
as: 'div'
}
QRMobileLogin.propTypes = {
refreshInterval: object,
pollInterval: object
}
QRLoginModal.defaultProps = {
onDismiss: killQRLoginModal,
QRMobileLogin.defaultProps = {
refreshInterval: REFRESH_INTERVAL,
pollInterval: POLL_INTERVAL
}
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 {...modalProps} />, modalContainer)
}

View File

@ -0,0 +1,120 @@
/* 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 MockDate from 'mockdate'
import fetchMock from 'fetch-mock'
import moment from 'moment'
import {render, act} from '@testing-library/react'
import {QRMobileLogin} from '../QRMobileLogin'
// a fake QR code image, and then a another one after generating a new code
const loginImageJsons = [
{png: 'R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='},
{png: 'R0lGODlhAQABZZZZZCH5BAEKAAEALZZZZZABAAEAAAICTAEAOn=='}
]
const route = '/canvas/login.png'
// used for when we want fetchMock not to ever respond
const doNotRespond = Function.prototype
describe('QRMobileLogin', () => {
describe('before the API call responds', () => {
beforeEach(() => {
fetchMock.post(route, doNotRespond, {overwriteRoutes: true})
})
afterEach(() => fetchMock.restore())
it('renders component', () => {
const {getByText} = render(<QRMobileLogin />)
expect(getByText(/QR for Mobile Login/)).toBeVisible()
expect(getByText(/To log in to your Canvas account/)).toBeVisible()
})
it('renders a spinner while fetching QR image', () => {
const {getByTestId} = render(<QRMobileLogin />)
expect(getByTestId('qr-code-spinner')).toBeInTheDocument()
})
})
describe('after the API call responds', () => {
beforeEach(() => {
jest.useFakeTimers()
fetchMock
.postOnce(route, loginImageJsons[0], {overwriteRoutes: true})
.postOnce(route, loginImageJsons[1], {overwriteRoutes: false})
})
afterEach(() => {
fetchMock.restore()
MockDate.reset()
})
// advances both global time and the jest timers by the given time duration
function advance(...args) {
const delay = moment.duration(...args).asMilliseconds()
act(() => {
const now = Date.now()
MockDate.set(now + delay)
jest.advanceTimersByTime(delay)
})
}
it('renders the image in the response, and the right expiration time', async () => {
const {findByTestId, getByText} = render(<QRMobileLogin onDismiss={Function.prototype} />)
const image = await findByTestId('qr-code-image')
expect(image.src).toBe(`data:image/png;base64, ${loginImageJsons[0].png}`)
expect(getByText(/expires in 10 minutes/i)).toBeInTheDocument()
})
it('updates the expiration as time elapses', async () => {
const {findByText} = render(<QRMobileLogin />)
await findByText(/expires in 10 minutes/i)
advance(1, 'minute')
const expiresIn = await findByText(/expires in 9 minutes/i)
expect(expiresIn).toBeInTheDocument()
})
it('shows the right thing when the token has expired', async () => {
const refreshInterval = moment.duration(15, 'minutes')
const pollInterval = moment.duration(3, 'minutes')
const {findByText} = render(
<QRMobileLogin refreshInterval={refreshInterval} pollInterval={pollInterval} />
)
await findByText(/expires in 10 minutes/)
advance(11, 'minutes') // code is only good for 10
const expiresIn = await findByText(/code has expired/i)
expect(expiresIn).toBeInTheDocument()
})
it('refreshes the code at the right time', async () => {
const refreshInterval = moment.duration(2, 'minutes')
const {findByText, findByTestId} = render(<QRMobileLogin refreshInterval={refreshInterval} />)
const image = await findByTestId('qr-code-image')
expect(image.src).toBe(`data:image/png;base64, ${loginImageJsons[0].png}`)
expect(fetchMock.calls(route)).toHaveLength(1)
advance(1, 'minute')
await findByText(/expires in 9 minutes/)
advance(1, 'minute')
await findByText(/expires in 10 minutes/)
expect(fetchMock.calls(route)).toHaveLength(2)
expect(image.src).toBe(`data:image/png;base64, ${loginImageJsons[1].png}`)
})
})
})

View File

@ -19,26 +19,37 @@
class UserProfile < ActiveRecord::Base
belongs_to :user
delegate :short_name, :name, :asset_string, :opaque_identifier, :to => :user
delegate :short_name, :name, :asset_string, :opaque_identifier, to: :user
has_many :links, :class_name => 'UserProfileLink', :dependent => :destroy
has_many :links, inverse_of: :user_profile, class_name: 'UserProfileLink', dependent: :destroy
validates_length_of :title, :maximum => maximum_string_length, :allow_blank => true
validates :title,
length: {
maximum: maximum_string_length, too_long: '%{count} characters is the maximum allowed'
},
allow_blank: true
TAB_PROFILE, TAB_COMMUNICATION_PREFERENCES, TAB_FILES, TAB_EPORTFOLIOS,
TAB_PROFILE_SETTINGS, TAB_OBSERVEES, TAB_CONTENT_SHARES = *0..10
TAB_PROFILE,
TAB_COMMUNICATION_PREFERENCES,
TAB_FILES,
TAB_EPORTFOLIOS,
TAB_PROFILE_SETTINGS,
TAB_OBSERVEES,
TAB_QR_MOBILE_LOGIN,
TAB_CONTENT_SHARES =
*0..10
BASE_TABS = [
{
id: TAB_COMMUNICATION_PREFERENCES,
label: -> { I18n.t('#user_profile.tabs.notifications', "Notifications") },
label: -> { I18n.t('#user_profile.tabs.notifications', 'Notifications') },
css_class: 'notifications',
href: :communication_profile_path,
no_args: true
}.freeze,
{
id: TAB_FILES,
label: -> { I18n.t('#tabs.files', "Files") },
label: -> { I18n.t('#tabs.files', 'Files') },
css_class: 'files',
href: :files_path,
no_args: true
@ -61,84 +72,104 @@ class UserProfile < ActiveRecord::Base
can :view_lti_tool
end
def tabs_available(user=nil, opts={})
@tabs ||= begin
tabs = BASE_TABS.map do |tab|
new_tab = tab.dup
new_tab[:label] = tab[:label].call
new_tab
def tabs_available(user = nil, opts = {})
@tabs ||=
begin
tabs =
BASE_TABS.map do |tab|
new_tab = tab.dup
new_tab[:label] = tab[:label].call
new_tab
end
insert_profile_tab(tabs, user, opts)
insert_eportfolios_tab(tabs, user)
insert_content_shares_tab(tabs, user, opts)
insert_lti_tool_tabs(tabs, user, opts) if user && opts[:root_account]
tabs = tabs.slice(0, 2) if user&.fake_student?
insert_observer_tabs(tabs, user)
insert_qr_mobile_login_tab(tabs, user, opts)
tabs
end
insert_profile_tab(tabs, user, opts)
insert_eportfolios_tab(tabs, user)
insert_content_shares_tab(tabs, user, opts)
insert_lti_tool_tabs(tabs, user, opts) if user && opts[:root_account]
tabs = tabs.slice(0,2) if user&.fake_student?
insert_observer_tabs(tabs, user)
tabs
end
end
private
def insert_profile_tab(tabs, user, opts)
if user && opts[:root_account] && opts[:root_account].enable_profiles?
tabs.insert 1, {
id: TAB_PROFILE,
label: I18n.t('#user_profile.tabs.profile', "Profile"),
css_class: 'profile',
href: :profile_path,
no_args: true
}
tabs.insert 1,
{
id: TAB_PROFILE,
label: I18n.t('#user_profile.tabs.profile', 'Profile'),
css_class: 'profile',
href: :profile_path,
no_args: true
}
end
end
def insert_eportfolios_tab(tabs, user)
if user.eportfolios_enabled?
tabs << {
id: TAB_EPORTFOLIOS,
label: I18n.t('#tabs.eportfolios', "ePortfolios"),
css_class: 'eportfolios',
href: :dashboard_eportfolios_path,
no_args: true
}
tabs <<
{
id: TAB_EPORTFOLIOS,
label: I18n.t('#tabs.eportfolios', 'ePortfolios'),
css_class: 'eportfolios',
href: :dashboard_eportfolios_path,
no_args: true
}
end
end
def insert_content_shares_tab(tabs, user, opts)
if user && opts[:root_account]&.feature_enabled?(:direct_share) && user.can_content_share?
tabs << {
id: TAB_CONTENT_SHARES,
label: I18n.t("Shared Content"),
css_class: 'content_shares',
href: :content_shares_profile_path,
no_args: true
}
tabs <<
{
id: TAB_CONTENT_SHARES,
label: I18n.t('Shared Content'),
css_class: 'content_shares',
href: :content_shares_profile_path,
no_args: true
}
end
end
def insert_lti_tool_tabs(tabs, user, opts)
tools = opts[:root_account].context_external_tools.active.having_setting('user_navigation').
select { |t| t.permission_given?(:user_navigation, user, opts[:root_account]) }
tools =
opts[:root_account].context_external_tools.active.having_setting('user_navigation')
.select { |t| t.permission_given?(:user_navigation, user, opts[:root_account]) }
tabs.concat(
Lti::ExternalToolTab.new(user, :user_navigation, tools, opts[:language]).
tabs.
find_all { |tab| should_keep_tab?(tab, user, opts[:root_account]) }
Lti::ExternalToolTab.new(user, :user_navigation, tools, opts[:language]).tabs
.find_all { |tab| show_lti_tab?(tab, user, opts[:root_account]) }
)
end
def should_keep_tab?(tab, user, account)
def show_lti_tab?(tab, user, account)
tab[:visibility] != 'admins' || self.grants_right?(user, account, :view_lti_tool)
end
def insert_observer_tabs(tabs, user)
if user&.as_observer_observation_links&.active&.exists?
tabs << {
id: TAB_OBSERVEES,
label: I18n.t('#tabs.observees', 'Observing'),
css_class: 'observees',
href: :observees_profile_path,
no_args: true
}
tabs <<
{
id: TAB_OBSERVEES,
label: I18n.t('#tabs.observees', 'Observing'),
css_class: 'observees',
href: :observees_profile_path,
no_args: true
}
end
end
def insert_qr_mobile_login_tab(tabs, user, opts)
if user && opts[:root_account]&.feature_enabled?(:mobile_qr_login)
tabs <<
{
id: TAB_QR_MOBILE_LOGIN,
label: I18n.t('#tabs.qr_mobile_login', 'QR for Mobile Login'),
css_class: 'qr_mobile_login',
href: :qr_mobile_login_path,
no_args: true
}
end
end
end

View File

@ -51,6 +51,12 @@ class SectionTabPresenter
end
def to_h
{ css_class: tab.css_class, icon: tab.icon, hidden: hide? || unused?, path: path, label: tab.label }
{
css_class: tab.css_class,
icon: tab.icon,
hidden: hide? || unused?,
path: path,
label: tab.label
}
end
end

View File

@ -860,6 +860,7 @@ CanvasRails::Application.routes.draw do
scope '/profile' do
post 'toggle_disable_inbox' => 'profile#toggle_disable_inbox'
get 'profile_pictures' => 'profile#profile_pics', as: :profile_pics
get 'qr_mobile_login' => 'profile#qr_mobile_login', as: :qr_mobile_login
delete 'user_services/:id' => 'users#delete_user_service', as: :profile_user_service
post 'user_services' => 'users#create_user_service', as: :profile_create_user_service
end

View File

@ -186,35 +186,6 @@ RSpec.describe ApplicationController do
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
context "responsive_2020_03" do
before(:each) do
controller.instance_variable_set(:@domain_root_account, Account.default)

View File

@ -300,4 +300,35 @@ describe ProfileController do
end
end
end
describe "GET #qr_mobile_login" do
subject(:response) { get :qr_mobile_login }
context "mobile_qr_login flag is enabled" do
before :once do
Account.default.enable_feature! :mobile_qr_login
end
it "should render empty html layout" do
user_session(@user)
expect(response).to render_template "layouts/application"
expect(response.body).to eq ""
end
it "should redirect to login if no active session" do
expect(response).to redirect_to "/login"
end
end
context "mobile_qr_login flag is disabled" do
before :once do
Account.default.disable_feature! :mobile_qr_login
end
it "should 404" do
user_session(@user)
expect(response).to be_not_found
end
end
end
end

View File

@ -210,5 +210,27 @@ describe UserProfile do
end
end
end
describe "QR mobile login" do
before :once do
user_factory(active_all: true)
end
context "mobile_qr_login flag is enabled" do
it "should show the QR mobile login tab" do
account.enable_feature! :mobile_qr_login
tabs = @user.profile.tabs_available(@user, :root_account => account)
expect(tabs.map { |t| t[:id] }).to include UserProfile::TAB_QR_MOBILE_LOGIN
end
end
context "mobile_qr_login flag is disabled" do
it "should not show the QR mobile login tab" do
account.disable_feature! :mobile_qr_login
tabs = @user.profile.tabs_available(@user, :root_account => account)
expect(tabs.map { |t| t[:id] }).not_to include UserProfile::TAB_QR_MOBILE_LOGIN
end
end
end
end
end

View File

@ -14,6 +14,7 @@
#
# 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/>.
require_relative '../common'
describe 'QR for mobile login' do
@ -56,19 +57,18 @@ describe 'QR for mobile login' do
it 'should bring up modal with generated QR code' do
get '/'
f('#global_nav_profile_link').click
find_button('QR for Mobile Login').click
fln('QR for Mobile Login').click
qr_code = f("img[data-testid='qr-code-image']")
check_base64_encoded_png_image(qr_code)
end
end
# TODO: USERS-458 will make this available
# context 'from profile settings' do
# it 'should bring up modal with generated QR code' do
# get '/profile'
# find_button('QR for Mobile Login').click
# qr_code = f("img[data-testid='qr-code-image']")
# check_base64_encoded_png_image(qr_code)
# end
# end
context 'from user profile' do
it 'should bring up modal with generated QR code' do
get '/profile'
fln('QR for Mobile Login').click
qr_code = f("img[data-testid='qr-code-image']")
check_base64_encoded_png_image(qr_code)
end
end
end