TwoFactorModal.vue 3.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126
  1. <script setup>
  2. import { ref, watch } from 'vue';
  3. import { useI18n } from 'vue-i18n';
  4. import { message } from 'ant-design-vue';
  5. import * as OTPAuth from 'otpauth';
  6. import { ClipboardManager } from '@/utils';
  7. const { t } = useI18n();
  8. const props = defineProps({
  9. open: { type: Boolean, default: false },
  10. title: { type: String, default: '' },
  11. description: { type: String, default: '' },
  12. token: { type: String, default: '' },
  13. type: { type: String, default: 'set', validator: (v) => ['set', 'confirm'].includes(v) },
  14. });
  15. const emit = defineEmits(['update:open', 'confirm']);
  16. const enteredCode = ref('');
  17. const qrValue = ref('');
  18. let totp = null;
  19. function buildTotp() {
  20. totp = new OTPAuth.TOTP({
  21. issuer: '3x-ui',
  22. label: 'Administrator',
  23. algorithm: 'SHA1',
  24. digits: 6,
  25. period: 30,
  26. secret: props.token,
  27. });
  28. qrValue.value = totp.toString();
  29. }
  30. watch(() => props.open, (next) => {
  31. if (!next) return;
  32. enteredCode.value = '';
  33. totp = null;
  34. qrValue.value = '';
  35. if (props.token) {
  36. buildTotp();
  37. }
  38. });
  39. function close(success, code = '') {
  40. emit('confirm', success, code);
  41. emit('update:open', false);
  42. enteredCode.value = '';
  43. }
  44. function onOk() {
  45. if (props.type === 'confirm' && !props.token) {
  46. close(true, enteredCode.value);
  47. return;
  48. }
  49. if (!totp) return;
  50. if (totp.generate() === enteredCode.value) {
  51. close(true);
  52. } else {
  53. message.error(t('pages.settings.security.twoFactorModalError'));
  54. }
  55. }
  56. function onCancel() {
  57. close(false);
  58. }
  59. async function copyToken() {
  60. const ok = await ClipboardManager.copyText(props.token);
  61. if (ok) message.success(t('copied'));
  62. }
  63. </script>
  64. <template>
  65. <a-modal :open="open" :title="title" :closable="true" @cancel="onCancel">
  66. <template v-if="type === 'set'">
  67. <p>{{ t('pages.settings.security.twoFactorModalSteps') }}</p>
  68. <a-divider />
  69. <p>{{ t('pages.settings.security.twoFactorModalFirstStep') }}</p>
  70. <div class="qr-wrap">
  71. <a-qrcode class="qr-code" :value="qrValue" :size="180" type="svg" :bordered="false"
  72. error-level="L" :title="t('copy')" @click="copyToken" />
  73. <span class="qr-token">{{ token }}</span>
  74. </div>
  75. <a-divider />
  76. <p>{{ t('pages.settings.security.twoFactorModalSecondStep') }}</p>
  77. <a-input v-model:value="enteredCode" :style="{ width: '100%' }" />
  78. </template>
  79. <template v-else>
  80. <p>{{ description }}</p>
  81. <a-input v-model:value="enteredCode" :style="{ width: '100%' }" />
  82. </template>
  83. <template #footer>
  84. <a-button @click="onCancel">{{ t('cancel') }}</a-button>
  85. <a-button type="primary" :disabled="enteredCode.length < 6" @click="onOk">{{ t('confirm') }}</a-button>
  86. </template>
  87. </a-modal>
  88. </template>
  89. <style scoped>
  90. .qr-wrap {
  91. display: flex;
  92. flex-direction: column;
  93. align-items: center;
  94. gap: 12px;
  95. }
  96. .qr-code {
  97. cursor: pointer;
  98. padding: 0 !important;
  99. background: #fff;
  100. border-radius: 6px;
  101. }
  102. .qr-token {
  103. font-size: 12px;
  104. font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
  105. word-break: break-all;
  106. text-align: center;
  107. }
  108. </style>