You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

790 lines
24 KiB

  1. <template lang="pug">
  2. v-app
  3. .login(:style='`background-image: url(` + bgUrl + `);`')
  4. .login-sd
  5. .d-flex.mb-5
  6. .login-logo
  7. v-avatar(tile, size='34')
  8. v-img(:src='logoUrl')
  9. .login-title
  10. .text-h6.grey--text.text--darken-4 {{ siteTitle }}
  11. v-alert.mb-0(
  12. v-model='errorShown'
  13. transition='slide-y-reverse-transition'
  14. color='red darken-2'
  15. tile
  16. dark
  17. dense
  18. icon='mdi-alert'
  19. )
  20. .body-2 {{errorMessage}}
  21. //-------------------------------------------------
  22. //- PROVIDERS LIST
  23. //-------------------------------------------------
  24. template(v-if='screen === `login` && strategies.length > 1')
  25. .login-subtitle
  26. .text-subtitle-1 Select Authentication Provider
  27. .login-list
  28. v-list.elevation-1.radius-7(nav, light)
  29. v-list-item-group(v-model='selectedStrategyKey')
  30. v-list-item(
  31. v-for='(stg, idx) of filteredStrategies'
  32. :key='stg.key'
  33. :value='stg.key'
  34. :color='stg.strategy.color'
  35. )
  36. v-avatar.mr-3(tile, size='24', v-html='stg.strategy.icon')
  37. span.text-none {{stg.displayName}}
  38. //-------------------------------------------------
  39. //- LOGIN FORM
  40. //-------------------------------------------------
  41. template(v-if='screen === `login` && selectedStrategy.strategy.useForm')
  42. .login-subtitle
  43. .text-subtitle-1 Enter your credentials
  44. .login-form
  45. v-text-field(
  46. solo
  47. flat
  48. prepend-inner-icon='mdi-clipboard-account'
  49. background-color='white'
  50. color='blue darken-2'
  51. hide-details
  52. ref='iptEmail'
  53. v-model='username'
  54. :placeholder='isUsernameEmail ? $t(`auth:fields.email`) : $t(`auth:fields.username`)'
  55. :type='isUsernameEmail ? `email` : `text`'
  56. :autocomplete='isUsernameEmail ? `email` : `username`'
  57. light
  58. )
  59. v-text-field.mt-2(
  60. solo
  61. flat
  62. prepend-inner-icon='mdi-form-textbox-password'
  63. background-color='white'
  64. color='blue darken-2'
  65. hide-details
  66. ref='iptPassword'
  67. v-model='password'
  68. :append-icon='hidePassword ? "mdi-eye-off" : "mdi-eye"'
  69. @click:append='() => (hidePassword = !hidePassword)'
  70. :type='hidePassword ? "password" : "text"'
  71. :placeholder='$t("auth:fields.password")'
  72. autocomplete='current-password'
  73. @keyup.enter='login'
  74. light
  75. )
  76. v-btn.mt-2.text-none(
  77. width='100%'
  78. large
  79. color='blue darken-2'
  80. dark
  81. @click='login'
  82. :loading='isLoading'
  83. ) {{ $t('auth:actions.login') }}
  84. .text-center.mt-5
  85. v-btn.text-none(
  86. text
  87. rounded
  88. color='grey darken-3'
  89. @click.stop.prevent='forgotPassword'
  90. href='#forgot'
  91. ): .caption {{ $t('auth:forgotPasswordLink') }}
  92. v-btn.text-none(
  93. v-if='selectedStrategyKey === `local` && selectedStrategy.selfRegistration'
  94. color='indigo darken-2'
  95. text
  96. rounded
  97. href='/register'
  98. ): .caption {{ $t('auth:switchToRegister.link') }}
  99. //-------------------------------------------------
  100. //- FORGOT PASSWORD FORM
  101. //-------------------------------------------------
  102. template(v-if='screen === `forgot`')
  103. .login-subtitle
  104. .text-subtitle-1 Forgot your password
  105. .login-info {{ $t('auth:forgotPasswordSubtitle') }}
  106. .login-form
  107. v-text-field(
  108. solo
  109. flat
  110. prepend-inner-icon='mdi-clipboard-account'
  111. background-color='white'
  112. color='blue darken-2'
  113. hide-details
  114. ref='iptForgotPwdEmail'
  115. v-model='username'
  116. :placeholder='$t(`auth:fields.email`)'
  117. type='email'
  118. autocomplete='email'
  119. light
  120. )
  121. v-btn.mt-2.text-none(
  122. width='100%'
  123. large
  124. color='blue darken-2'
  125. dark
  126. @click='forgotPasswordSubmit'
  127. :loading='isLoading'
  128. ) {{ $t('auth:sendResetPassword') }}
  129. .text-center.mt-5
  130. v-btn.text-none(
  131. text
  132. rounded
  133. color='grey darken-3'
  134. @click.stop.prevent='screen = `login`'
  135. href='#forgot'
  136. ): .caption {{ $t('auth:forgotPasswordCancel') }}
  137. //-------------------------------------------------
  138. //- CHANGE PASSWORD FORM
  139. //-------------------------------------------------
  140. template(v-if='screen === `changePwd`')
  141. .login-subtitle
  142. .text-subtitle-1 {{ $t('auth:changePwd.subtitle') }}
  143. .login-form
  144. v-text-field.mt-2(
  145. type='password'
  146. solo
  147. flat
  148. prepend-inner-icon='mdi-form-textbox-password'
  149. background-color='white'
  150. color='blue darken-2'
  151. hide-details
  152. ref='iptNewPassword'
  153. v-model='newPassword'
  154. :placeholder='$t(`auth:changePwd.newPasswordPlaceholder`)'
  155. autocomplete='new-password'
  156. light
  157. )
  158. password-strength(slot='progress', v-model='newPassword')
  159. v-text-field.mt-2(
  160. type='password'
  161. solo
  162. flat
  163. prepend-inner-icon='mdi-form-textbox-password'
  164. background-color='white'
  165. color='blue darken-2'
  166. hide-details
  167. v-model='newPasswordVerify'
  168. :placeholder='$t(`auth:changePwd.newPasswordVerifyPlaceholder`)'
  169. autocomplete='new-password'
  170. @keyup.enter='changePassword'
  171. light
  172. )
  173. v-btn.mt-2.text-none(
  174. width='100%'
  175. large
  176. color='blue darken-2'
  177. dark
  178. @click='changePassword'
  179. :loading='isLoading'
  180. ) {{ $t('auth:changePwd.proceed') }}
  181. //-------------------------------------------------
  182. //- TFA FORM
  183. //-------------------------------------------------
  184. v-dialog(v-model='isTFAShown', max-width='500', persistent)
  185. v-card
  186. .login-tfa.text-center.pa-5.grey--text.text--darken-3
  187. img(src='_assets/svg/icon-pin-pad.svg')
  188. .subtitle-2 Enter the security code generated from your trusted device:
  189. v-text-field.login-tfa-field.mt-2(
  190. solo
  191. flat
  192. background-color='white'
  193. color='blue darken-2'
  194. hide-details
  195. ref='iptTFA'
  196. v-model='securityCode'
  197. :placeholder='$t("auth:tfa.placeholder")'
  198. autocomplete='one-time-code'
  199. @keyup.enter='verifySecurityCode(false)'
  200. light
  201. )
  202. v-btn.mt-2.text-none(
  203. width='100%'
  204. large
  205. color='blue darken-2'
  206. dark
  207. @click='verifySecurityCode(false)'
  208. :loading='isLoading'
  209. ) {{ $t('auth:tfa.verifyToken') }}
  210. //-------------------------------------------------
  211. //- SETUP TFA FORM
  212. //-------------------------------------------------
  213. v-dialog(v-model='isTFASetupShown', max-width='600', persistent)
  214. v-card
  215. .login-tfa.text-center.pa-5.grey--text.text--darken-3
  216. .subtitle-1.primary--text Your administrator has required Two-Factor Authentication (2FA) to be enabled on your account.
  217. v-divider.my-5
  218. .subtitle-2 1) Scan the QR code below from your mobile 2FA application:
  219. .caption (e.g. #[a(href='https://authy.com/', target='_blank', noopener) Authy], #[a(href='https://support.google.com/accounts/answer/1066447', target='_blank', noopener) Google Authenticator], #[a(href='https://www.microsoft.com/en-us/account/authenticator', target='_blank', noopener) Microsoft Authenticator], etc.)
  220. .login-tfa-qr.mt-5(v-if='isTFASetupShown', v-html='tfaQRImage')
  221. .subtitle-2.mt-5 2) Enter the security code generated from your trusted device:
  222. v-text-field.login-tfa-field.mt-2(
  223. solo
  224. flat
  225. background-color='white'
  226. color='blue darken-2'
  227. hide-details
  228. ref='iptTFASetup'
  229. v-model='securityCode'
  230. :placeholder='$t("auth:tfa.placeholder")'
  231. autocomplete='one-time-code'
  232. @keyup.enter='verifySecurityCode(true)'
  233. light
  234. )
  235. v-btn.mt-2.text-none(
  236. width='100%'
  237. large
  238. color='blue darken-2'
  239. dark
  240. @click='verifySecurityCode(true)'
  241. :loading='isLoading'
  242. ) {{ $t('auth:tfa.verifyToken') }}
  243. loader(v-model='isLoading', :color='loaderColor', :title='loaderTitle', :subtitle='$t(`auth:pleaseWait`)')
  244. notify(style='padding-top: 64px;')
  245. </template>
  246. <script>
  247. /* global siteConfig */
  248. // <span>Photo by <a href="https://unsplash.com/@isaacquesada?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText">Isaac Quesada</a> on <a href="/t/textures-patterns?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText">Unsplash</a></span>
  249. import _ from 'lodash'
  250. import Cookies from 'js-cookie'
  251. import gql from 'graphql-tag'
  252. import { sync } from 'vuex-pathify'
  253. export default {
  254. i18nOptions: { namespaces: 'auth' },
  255. props: {
  256. bgUrl: {
  257. type: String,
  258. default: ''
  259. },
  260. hideLocal: {
  261. type: Boolean,
  262. default: false
  263. },
  264. changePwdContinuationToken: {
  265. type: String,
  266. default: null
  267. }
  268. },
  269. data () {
  270. return {
  271. error: false,
  272. strategies: [],
  273. selectedStrategyKey: 'unselected',
  274. selectedStrategy: { key: 'unselected', strategy: { useForm: false, usernameType: 'email' } },
  275. screen: 'login',
  276. username: '',
  277. password: '',
  278. hidePassword: true,
  279. securityCode: '',
  280. continuationToken: '',
  281. isLoading: false,
  282. loaderColor: 'grey darken-4',
  283. loaderTitle: 'Working...',
  284. isShown: false,
  285. newPassword: '',
  286. newPasswordVerify: '',
  287. isTFAShown: false,
  288. isTFASetupShown: false,
  289. tfaQRImage: '',
  290. errorShown: false,
  291. errorMessage: ''
  292. }
  293. },
  294. computed: {
  295. activeModal: sync('editor/activeModal'),
  296. siteTitle () {
  297. return siteConfig.title
  298. },
  299. isSocialShown () {
  300. return this.strategies.length > 1
  301. },
  302. logoUrl () { return siteConfig.logoUrl },
  303. filteredStrategies () {
  304. const qParams = new URLSearchParams(window.location.search)
  305. if (this.hideLocal && !qParams.has('all')) {
  306. return _.reject(this.strategies, ['key', 'local'])
  307. } else {
  308. return this.strategies
  309. }
  310. },
  311. isUsernameEmail () {
  312. return this.selectedStrategy.strategy.usernameType === `email`
  313. }
  314. },
  315. watch: {
  316. filteredStrategies (newValue, oldValue) {
  317. if (_.head(newValue).strategy.useForm) {
  318. this.selectedStrategyKey = _.head(newValue).key
  319. }
  320. },
  321. selectedStrategyKey (newValue, oldValue) {
  322. this.selectedStrategy = _.find(this.strategies, ['key', newValue])
  323. if (this.screen === 'changePwd') {
  324. return
  325. }
  326. this.screen = 'login'
  327. if (!this.selectedStrategy.strategy.useForm) {
  328. this.isLoading = true
  329. window.location.assign('/login/' + newValue)
  330. } else {
  331. this.$nextTick(() => {
  332. this.$refs.iptEmail.focus()
  333. })
  334. }
  335. }
  336. },
  337. mounted () {
  338. this.isShown = true
  339. if (this.changePwdContinuationToken) {
  340. this.screen = 'changePwd'
  341. this.continuationToken = this.changePwdContinuationToken
  342. }
  343. },
  344. methods: {
  345. /**
  346. * LOGIN
  347. */
  348. async login () {
  349. this.errorShown = false
  350. if (this.username.length < 2) {
  351. this.errorMessage = this.$t('auth:invalidEmailUsername')
  352. this.errorShown = true
  353. this.$refs.iptEmail.focus()
  354. } else if (this.password.length < 2) {
  355. this.errorMessage = this.$t('auth:invalidPassword')
  356. this.errorShown = true
  357. this.$refs.iptPassword.focus()
  358. } else {
  359. this.loaderColor = 'grey darken-4'
  360. this.loaderTitle = this.$t('auth:signingIn')
  361. this.isLoading = true
  362. try {
  363. const resp = await this.$apollo.mutate({
  364. mutation: gql`
  365. mutation($username: String!, $password: String!, $strategy: String!) {
  366. authentication {
  367. login(username: $username, password: $password, strategy: $strategy) {
  368. responseResult {
  369. succeeded
  370. errorCode
  371. slug
  372. message
  373. }
  374. jwt
  375. mustChangePwd
  376. mustProvideTFA
  377. mustSetupTFA
  378. continuationToken
  379. redirect
  380. tfaQRImage
  381. }
  382. }
  383. }
  384. `,
  385. variables: {
  386. username: this.username,
  387. password: this.password,
  388. strategy: this.selectedStrategy.key
  389. }
  390. })
  391. if (_.has(resp, 'data.authentication.login')) {
  392. const respObj = _.get(resp, 'data.authentication.login', {})
  393. if (respObj.responseResult.succeeded === true) {
  394. this.handleLoginResponse(respObj)
  395. } else {
  396. throw new Error(respObj.responseResult.message)
  397. }
  398. } else {
  399. throw new Error(this.$t('auth:genericError'))
  400. }
  401. } catch (err) {
  402. console.error(err)
  403. this.$store.commit('showNotification', {
  404. style: 'red',
  405. message: err.message,
  406. icon: 'alert'
  407. })
  408. this.isLoading = false
  409. }
  410. }
  411. },
  412. /**
  413. * VERIFY TFA CODE
  414. */
  415. async verifySecurityCode (setup = false) {
  416. if (this.securityCode.length !== 6) {
  417. this.$store.commit('showNotification', {
  418. style: 'red',
  419. message: 'Enter a valid security code.',
  420. icon: 'alert'
  421. })
  422. if (setup) {
  423. this.$refs.iptTFASetup.focus()
  424. } else {
  425. this.$refs.iptTFA.focus()
  426. }
  427. } else {
  428. this.loaderColor = 'grey darken-4'
  429. this.loaderTitle = this.$t('auth:signingIn')
  430. this.isLoading = true
  431. try {
  432. const resp = await this.$apollo.mutate({
  433. mutation: gql`
  434. mutation(
  435. $continuationToken: String!
  436. $securityCode: String!
  437. $setup: Boolean
  438. ) {
  439. authentication {
  440. loginTFA(
  441. continuationToken: $continuationToken
  442. securityCode: $securityCode
  443. setup: $setup
  444. ) {
  445. responseResult {
  446. succeeded
  447. errorCode
  448. slug
  449. message
  450. }
  451. jwt
  452. mustChangePwd
  453. continuationToken
  454. redirect
  455. }
  456. }
  457. }
  458. `,
  459. variables: {
  460. continuationToken: this.continuationToken,
  461. securityCode: this.securityCode,
  462. setup
  463. }
  464. })
  465. if (_.has(resp, 'data.authentication.loginTFA')) {
  466. let respObj = _.get(resp, 'data.authentication.loginTFA', {})
  467. if (respObj.responseResult.succeeded === true) {
  468. this.handleLoginResponse(respObj)
  469. } else {
  470. if (!setup) {
  471. this.isTFAShown = false
  472. }
  473. throw new Error(respObj.responseResult.message)
  474. }
  475. } else {
  476. throw new Error(this.$t('auth:genericError'))
  477. }
  478. } catch (err) {
  479. console.error(err)
  480. this.$store.commit('showNotification', {
  481. style: 'red',
  482. message: err.message,
  483. icon: 'alert'
  484. })
  485. this.isLoading = false
  486. }
  487. }
  488. },
  489. /**
  490. * CHANGE PASSWORD
  491. */
  492. async changePassword () {
  493. this.loaderColor = 'grey darken-4'
  494. this.loaderTitle = this.$t('auth:changePwd.loading')
  495. this.isLoading = true
  496. try {
  497. const resp = await this.$apollo.mutate({
  498. mutation: gql`
  499. mutation (
  500. $continuationToken: String!
  501. $newPassword: String!
  502. ) {
  503. authentication {
  504. loginChangePassword (
  505. continuationToken: $continuationToken
  506. newPassword: $newPassword
  507. ) {
  508. responseResult {
  509. succeeded
  510. errorCode
  511. slug
  512. message
  513. }
  514. jwt
  515. continuationToken
  516. redirect
  517. }
  518. }
  519. }
  520. `,
  521. variables: {
  522. continuationToken: this.continuationToken,
  523. newPassword: this.newPassword
  524. }
  525. })
  526. if (_.has(resp, 'data.authentication.loginChangePassword')) {
  527. let respObj = _.get(resp, 'data.authentication.loginChangePassword', {})
  528. if (respObj.responseResult.succeeded === true) {
  529. this.handleLoginResponse(respObj)
  530. } else {
  531. throw new Error(respObj.responseResult.message)
  532. }
  533. } else {
  534. throw new Error(this.$t('auth:genericError'))
  535. }
  536. } catch (err) {
  537. console.error(err)
  538. this.$store.commit('showNotification', {
  539. style: 'red',
  540. message: err.message,
  541. icon: 'alert'
  542. })
  543. this.isLoading = false
  544. }
  545. },
  546. /**
  547. * SWITCH TO FORGOT PASSWORD SCREEN
  548. */
  549. forgotPassword () {
  550. this.screen = 'forgot'
  551. this.$nextTick(() => {
  552. this.$refs.iptForgotPwdEmail.focus()
  553. })
  554. },
  555. /**
  556. * FORGOT PASSWORD SUBMIT
  557. */
  558. async forgotPasswordSubmit () {
  559. this.loaderColor = 'grey darken-4'
  560. this.loaderTitle = this.$t('auth:forgotPasswordLoading')
  561. this.isLoading = true
  562. try {
  563. const resp = await this.$apollo.mutate({
  564. mutation: gql`
  565. mutation (
  566. $email: String!
  567. ) {
  568. authentication {
  569. forgotPassword (
  570. email: $email
  571. ) {
  572. responseResult {
  573. succeeded
  574. errorCode
  575. slug
  576. message
  577. }
  578. }
  579. }
  580. }
  581. `,
  582. variables: {
  583. email: this.username
  584. }
  585. })
  586. if (_.has(resp, 'data.authentication.forgotPassword.responseResult')) {
  587. let respObj = _.get(resp, 'data.authentication.forgotPassword.responseResult', {})
  588. if (respObj.succeeded === true) {
  589. this.$store.commit('showNotification', {
  590. style: 'success',
  591. message: this.$t('auth:forgotPasswordSuccess'),
  592. icon: 'email'
  593. })
  594. this.screen = 'login'
  595. } else {
  596. throw new Error(respObj.message)
  597. }
  598. } else {
  599. throw new Error(this.$t('auth:genericError'))
  600. }
  601. } catch (err) {
  602. console.error(err)
  603. this.$store.commit('showNotification', {
  604. style: 'red',
  605. message: err.message,
  606. icon: 'alert'
  607. })
  608. }
  609. this.isLoading = false
  610. },
  611. handleLoginResponse (respObj) {
  612. this.continuationToken = respObj.continuationToken
  613. if (respObj.mustChangePwd === true) {
  614. this.screen = 'changePwd'
  615. this.$nextTick(() => {
  616. this.$refs.iptNewPassword.focus()
  617. })
  618. this.isLoading = false
  619. } else if (respObj.mustProvideTFA === true) {
  620. this.securityCode = ''
  621. this.isTFAShown = true
  622. setTimeout(() => {
  623. this.$refs.iptTFA.focus()
  624. }, 500)
  625. this.isLoading = false
  626. } else if (respObj.mustSetupTFA === true) {
  627. this.securityCode = ''
  628. this.isTFASetupShown = true
  629. this.tfaQRImage = respObj.tfaQRImage
  630. setTimeout(() => {
  631. this.$refs.iptTFASetup.focus()
  632. }, 500)
  633. this.isLoading = false
  634. } else {
  635. this.loaderColor = 'green darken-1'
  636. this.loaderTitle = this.$t('auth:loginSuccess')
  637. Cookies.set('jwt', respObj.jwt, { expires: 365 })
  638. _.delay(() => {
  639. const loginRedirect = Cookies.get('loginRedirect')
  640. if (loginRedirect) {
  641. Cookies.remove('loginRedirect')
  642. window.location.replace(loginRedirect)
  643. } else if (respObj.redirect) {
  644. window.location.replace(respObj.redirect)
  645. } else {
  646. window.location.replace('/')
  647. }
  648. }, 1000)
  649. }
  650. }
  651. },
  652. apollo: {
  653. strategies: {
  654. query: gql`
  655. {
  656. authentication {
  657. activeStrategies(enabledOnly: true) {
  658. key
  659. strategy {
  660. key
  661. logo
  662. color
  663. icon
  664. useForm
  665. usernameType
  666. }
  667. displayName
  668. order
  669. selfRegistration
  670. }
  671. }
  672. }
  673. `,
  674. update: (data) => _.sortBy(data.authentication.activeStrategies, ['order']),
  675. watchLoading (isLoading) {
  676. this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'login-strategies-refresh')
  677. }
  678. }
  679. }
  680. }
  681. </script>
  682. <style lang="scss">
  683. .login {
  684. // background-image: url('/_assets/img/splash/1.jpg');
  685. background-color: mc('grey', '900');
  686. background-size: cover;
  687. background-position: center center;
  688. width: 100%;
  689. height: 100%;
  690. &-sd {
  691. background-color: rgba(255,255,255,.8);
  692. backdrop-filter: blur(10px);
  693. -webkit-backdrop-filter: blur(10px);
  694. border-left: 1px solid rgba(255,255,255,.85);
  695. border-right: 1px solid rgba(255,255,255,.85);
  696. width: 450px;
  697. height: 100%;
  698. margin-left: 5vw;
  699. @at-root .no-backdropfilter & {
  700. background-color: rgba(255,255,255,.95);
  701. }
  702. @include until($tablet) {
  703. margin-left: 0;
  704. width: 100%;
  705. }
  706. }
  707. &-logo {
  708. padding: 12px 0 0 12px;
  709. width: 58px;
  710. height: 58px;
  711. background-color: #222;
  712. margin-left: 12px;
  713. border-bottom-left-radius: 7px;
  714. border-bottom-right-radius: 7px;
  715. }
  716. &-title {
  717. height: 58px;
  718. padding-left: 12px;
  719. display: flex;
  720. align-items: center;
  721. text-shadow: .5px .5px #FFF;
  722. }
  723. &-subtitle {
  724. padding: 24px 12px 12px 12px;
  725. color: #111;
  726. font-weight: 500;
  727. text-shadow: 1px 1px rgba(255,255,255,.5);
  728. background-image: linear-gradient(to bottom, rgba(0,0,0,0), rgba(0,0,0,.15));
  729. text-align: center;
  730. border-bottom: 1px solid rgba(0,0,0,.3);
  731. }
  732. &-info {
  733. border-top: 1px solid rgba(255,255,255,.85);
  734. background-color: rgba(255,255,255,.15);
  735. border-bottom: 1px solid rgba(0,0,0,.15);
  736. padding: 12px;
  737. font-size: 13px;
  738. text-align: center;
  739. color: mc('grey', '900');
  740. }
  741. &-list {
  742. border-top: 1px solid rgba(255,255,255,.85);
  743. padding: 12px;
  744. }
  745. &-form {
  746. padding: 12px;
  747. border-top: 1px solid rgba(255,255,255,.85);
  748. }
  749. &-main {
  750. flex: 1 0 100vw;
  751. height: 100vh;
  752. }
  753. &-tfa {
  754. background-color: #EEE;
  755. border: 7px solid #FFF;
  756. &-field input {
  757. text-align: center;
  758. }
  759. &-qr {
  760. background-color: #FFF;
  761. padding: 5px;
  762. border-radius: 5px;
  763. width: 200px;
  764. height: 200px;
  765. margin: 0 auto;
  766. }
  767. }
  768. }
  769. </style>