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:
parent
d34f0c961b
commit
2a8b3c95a4
|
@ -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),
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
})
|
|
@ -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':
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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}`)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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 you’re 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 you’re 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)
|
||||
}
|
|
@ -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}`)
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue