Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions src/components/Apps/AccountCreateUpdateForm/OTPSecretInput.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<template>
<div class="otp-secret-input">
<el-input
v-model.trim="curValue"
:disabled="disabled"
:placeholder="placeholder"
@input="handleInput"
>
<template slot="append">
<el-button
:disabled="disabled"
:loading="loading"
type="text"
@click="generateOTPSecret"
>
{{ $t('Generate') }}
</el-button>
</template>
</el-input>
<div v-if="generatedOTPCode" class="otp-secret-input__code">
<span class="otp-secret-input__code-label">{{ $t('CurrentOTPCode') }}:</span>
<span class="otp-secret-input__code-value">{{ generatedOTPCode }}</span>
<el-button type="text" @click="copyOTPCode">
{{ $t('Copy') }}
</el-button>
</div>
</div>
</template>

<script>
import { copy } from '@/utils/common/index'

export default {
name: 'OTPSecretInput',
props: {
value: {
type: String,
default: ''
},
disabled: {
type: Boolean,
default: false
},
placeholder: {
type: String,
default: ''
}
},
data() {
return {
loading: false,
curValue: this.value,
generatedOTPCode: ''
}
},
watch: {
value(val) {
this.curValue = val
}
},
methods: {
handleInput(value) {
this.generatedOTPCode = ''
this.$emit('input', value)
},
async generateOTPSecret() {
this.loading = true
try {
const data = await this.$axios.post('/api/v1/accounts/accounts/generate-otp/')
this.curValue = data.otp_secret_key || ''
this.generatedOTPCode = data.otp_code || ''
this.$emit('input', this.curValue)
} finally {
this.loading = false
}
},
copyOTPCode() {
if (!this.generatedOTPCode) {
return
}
copy(this.generatedOTPCode)
this.$message.success(this.$tc('CopySuccess'))
}
}
}
</script>

<style lang="scss" scoped>
.otp-secret-input {
&__code {
margin-top: 8px;
color: var(--color-text-secondary);
display: flex;
align-items: center;
gap: 8px;
line-height: 1.4;
}

&__code-label {
white-space: nowrap;
}

&__code-value {
color: var(--color-text-primary);
font-family: monospace;
letter-spacing: 1px;
}
}
</style>
12 changes: 12 additions & 0 deletions src/components/Apps/AccountCreateUpdateForm/const.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Select2 from '@/components/Form/FormFields/Select2.vue'
import AssetSelect from '@/components/Apps/AssetSelect/index.vue'
import { Required, RequiredChange } from '@/components/Form/DataForm/rules'
import AutomationParamsForm from '@/views/assets/Platform/AutomationParamsSetting.vue'
import OTPSecretInput from '@/components/Apps/AccountCreateUpdateForm/OTPSecretInput.vue'

export const accountFieldsMeta = (vm) => {
const defaultPrivilegedAccounts = ['root', 'administrator']
Expand Down Expand Up @@ -215,6 +216,17 @@ export const accountFieldsMeta = (vm) => {
},
hidden: (formValue) => formValue.secret_type !== 'api_key' || vm.addTemplate
},
otp_secret_key: {
label: vm.$t('OTPSecretKey'),
component: OTPSecretInput,
helpText: vm.$t('OTPSecretKeyHelpText'),
el: {
get disabled() {
return vm.isDisabled
}
},
hidden: () => vm.addTemplate
},
secret_type: {
type: 'radio-group',
options: [],
Expand Down
4 changes: 4 additions & 0 deletions src/components/Apps/AccountCreateUpdateForm/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export default {
'access_key',
'passphrase',
'api_key',
'otp_secret_key',
'secret_reset'
]
],
Expand Down Expand Up @@ -160,6 +161,9 @@ export default {
const secretType = form.secret_type || 'password'
form.secret = form[secretType]
form.secret = this.encryptPassword ? encryptPassword(form.secret) : form.secret
if (form.otp_secret_key) {
form.otp_secret_key = encryptPassword(form.otp_secret_key)
}

// 如果不删除会明文显示
delete form[secretType]
Expand Down
8 changes: 8 additions & 0 deletions src/components/Apps/AccountListTable/AccountList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@
:url="secretUrl"
:visible.sync="showViewSecretDialog"
/>
<ViewOTPCode
v-if="showViewOTPCodeDialog"
:account="account"
:visible.sync="showViewOTPCodeDialog"
/>
<UpdateSecretInfo
v-if="showUpdateSecretDialog"
:account="account"
Expand Down Expand Up @@ -60,6 +65,7 @@ import {
SecretViewerFormatter
} from '@/components/Table/TableFormatters'
import ViewSecret from './ViewSecret.vue'
import ViewOTPCode from './ViewOTPCode.vue'
import UpdateSecretInfo from './UpdateSecretInfo.vue'
import ResultDialog from './BulkCreateResultDialog.vue'
import AccountCreateUpdate from './AccountCreateUpdate.vue'
Expand All @@ -71,6 +77,7 @@ export default {
name: 'AccountListTable',
components: {
ViewSecret,
ViewOTPCode,
ResultDialog,
DrawerListTable,
UpdateSecretInfo,
Expand Down Expand Up @@ -157,6 +164,7 @@ export default {
currentAccountColumn: {},
showPasswordHistoryDialog: false,
showViewSecretDialog: false,
showViewOTPCodeDialog: false,
showUpdateSecretDialog: false,
showResultDialog: false,
showAddDialog: false,
Expand Down
204 changes: 204 additions & 0 deletions src/components/Apps/AccountListTable/ViewOTPCode.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
<template>
<Dialog
:destroy-on-close="true"
:show-cancel="false"
:title="$tc('OTPCode')"
:visible.sync="showDialog"
:width="'40'"
v-bind="$attrs"
@confirm="showDialog = false"
v-on="$listeners"
>
<el-form label-position="right" label-width="130px">
<el-form-item :label="$tc('Name')">
<span>{{ account.name || '-' }}</span>
</el-form-item>
<el-form-item :label="$tc('Username')">
<span>{{ account.username || '-' }}</span>
</el-form-item>
<el-form-item :label="$tc('CurrentOTPCode')">
<div class="otp-panel">
<div class="otp-panel__header">
<span class="otp-panel__title">{{ account.asset?.name || '-' }}</span>
</div>
<div class="otp-panel__code" @click="copyCurrentOTPCode">{{ currentOTPCode || '------' }}</div>
<div class="otp-panel__footer">
<span class="otp-panel__footer-text">{{ account.username || '-' }}</span>
<span class="otp-panel__countdown">{{ secondsRemaining }}s</span>
</div>
</div>
</el-form-item>
</el-form>
</Dialog>
</template>

<script>
import Dialog from '@/components/Dialog/index.vue'
import { copy } from '@/utils/common/index'
import { mapGetters } from 'vuex'

const OTP_PERIOD = 30

export default {
name: 'ViewOTPCode',
components: {
Dialog
},
props: {
account: {
type: Object,
default: () => ({})
},
visible: {
type: Boolean,
default: false
}
},
data() {
return {
showDialog: false,
currentOTPCode: '',
loading: false,
secondsRemaining: OTP_PERIOD,
countdownTimer: null
}
},
computed: {
...mapGetters({
publicSettings: 'publicSettings'
})
},
mounted() {
this.showDialog = this.visible
if (!this.publicSettings.SECURITY_ACCOUNT_SECRET_READ) {
this.$message.warning(this.$tc('AccountSecretReadDisabled'))
this.$emit('update:visible', false)
return
}
this.syncCountdown()
this.startCountdown()
this.loadCurrentOTPCode({ disableFlashErrorMsg: true })
},
beforeDestroy() {
if (this.countdownTimer) {
clearInterval(this.countdownTimer)
this.countdownTimer = null
}
},
methods: {
syncCountdown() {
const timestamp = Math.floor(Date.now() / 1000)
const mod = timestamp % OTP_PERIOD
this.secondsRemaining = mod === 0 ? OTP_PERIOD : OTP_PERIOD - mod
},
startCountdown() {
if (this.countdownTimer) {
clearInterval(this.countdownTimer)
}
this.countdownTimer = setInterval(() => {
const previous = this.secondsRemaining
this.syncCountdown()
if (this.secondsRemaining === OTP_PERIOD && previous !== OTP_PERIOD) {
this.loadCurrentOTPCode({ disableFlashErrorMsg: true })
}
}, 1000)
},
loadCurrentOTPCode(options = {}) {
this.loading = true
return this.$axios
// eslint-disable-next-line spellcheck/spell-checker
.get(`/api/v1/accounts/account-secrets/${this.account.id}/otp-code/`, options)
.then(res => {
this.currentOTPCode = res?.otp_code || ''
this.syncCountdown()
})
.finally(() => {
this.loading = false
})
},
copyCurrentOTPCode() {
if (!this.currentOTPCode) return
copy(this.currentOTPCode)
}
}
}
</script>

<style lang="scss" scoped>
.el-form-item {
border-bottom: 1px solid #ebeef5;
padding: 5px 0;
margin-bottom: 0;

&:last-child {
border-bottom: none;
}

::v-deep .el-form-item__label {
display: flex;
align-items: center;
justify-content: flex-start;
padding-right: 20px;
line-height: 30px;
word-break: keep-all;
overflow-wrap: break-word;
white-space: normal;
}

::v-deep .el-form-item__content {
line-height: 30px;
}
}

.otp-panel {
border: 1px solid #dfe4ec;
border-radius: 4px;
background: #f5f7fa;
padding: 12px 16px;
}

.otp-panel__header {
display: flex;
align-items: center;
gap: 12px;
}

.otp-panel__title {
color: #606266;
font-size: 13px;
line-height: 1.2;
word-break: break-all;
}

.otp-panel__code {
margin: 10px 0 6px;
color: var(--color-info);
font-size: 42px;
line-height: 1;
font-weight: 600;
letter-spacing: 2px;
font-family: Monaco, Menlo, Consolas, monospace;
cursor: pointer;
user-select: none;
}

.otp-panel__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: #909399;
font-size: 12px;
line-height: 1.2;
}

.otp-panel__footer-text {
word-break: break-all;
}

.otp-panel__countdown {
color: #606266;
font-weight: 500;
flex-shrink: 0;
}
</style>
Loading
Loading