feat: 访问限制中新增身份验证设置
This commit is contained in:
parent
aa8e68a688
commit
7a0f15b4e4
@ -312,6 +312,20 @@ class ApplicationSerializer(serializers.Serializer):
|
|||||||
if 'show_source' in instance and instance.get('show_source') is not None:
|
if 'show_source' in instance and instance.get('show_source') is not None:
|
||||||
application_access_token.show_source = instance.get('show_source')
|
application_access_token.show_source = instance.get('show_source')
|
||||||
application_access_token.save()
|
application_access_token.save()
|
||||||
|
application_setting_model = DBModelManage.get_model('application_setting')
|
||||||
|
X_PACK_LICENSE_IS_VALID = (settings.XPACK_LICENSE_IS_VALID if hasattr(settings,
|
||||||
|
'XPACK_LICENSE_IS_VALID') else False)
|
||||||
|
if application_setting_model is not None and X_PACK_LICENSE_IS_VALID:
|
||||||
|
application_setting, _ = application_setting_model.objects.get_or_create(
|
||||||
|
application_id=self.data.get('application_id'))
|
||||||
|
if application_setting is not None:
|
||||||
|
application_setting.authentication = instance.get('authentication')
|
||||||
|
application_setting.authentication_value = {
|
||||||
|
"type": "password",
|
||||||
|
"value": instance.get('authentication_value')
|
||||||
|
}
|
||||||
|
application_setting.save()
|
||||||
|
|
||||||
get_application_access_token(application_access_token.access_token, False)
|
get_application_access_token(application_access_token.access_token, False)
|
||||||
return self.one(with_valid=False)
|
return self.one(with_valid=False)
|
||||||
|
|
||||||
@ -734,7 +748,8 @@ class ApplicationSerializer(serializers.Serializer):
|
|||||||
'draggable': application_setting.draggable,
|
'draggable': application_setting.draggable,
|
||||||
'show_guide': application_setting.show_guide,
|
'show_guide': application_setting.show_guide,
|
||||||
'avatar': application_setting.avatar,
|
'avatar': application_setting.avatar,
|
||||||
'float_icon': application_setting.float_icon}
|
'float_icon': application_setting.float_icon,
|
||||||
|
'authentication': application_setting.authentication}
|
||||||
return ApplicationSerializer.Query.reset_application(
|
return ApplicationSerializer.Query.reset_application(
|
||||||
{**ApplicationSerializer.ApplicationModel(application).data,
|
{**ApplicationSerializer.ApplicationModel(application).data,
|
||||||
'stt_model_id': application.stt_model_id,
|
'stt_model_id': application.stt_model_id,
|
||||||
|
|||||||
@ -387,6 +387,15 @@ const updatePlatformStatus: (application_id: string, data: any) => Promise<Resul
|
|||||||
) => {
|
) => {
|
||||||
return post(`/platform/${application_id}/status`, data)
|
return post(`/platform/${application_id}/status`, data)
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 验证密码
|
||||||
|
*/
|
||||||
|
const validatePassword: (application_id: string, password: string) => Promise<Result<any>> = (
|
||||||
|
application_id,
|
||||||
|
password
|
||||||
|
) => {
|
||||||
|
return get(`/application/${application_id}/auth/${password}`, undefined)
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
getAllAppilcation,
|
getAllAppilcation,
|
||||||
@ -419,5 +428,6 @@ export default {
|
|||||||
getPlatformStatus,
|
getPlatformStatus,
|
||||||
getPlatformConfig,
|
getPlatformConfig,
|
||||||
updatePlatformConfig,
|
updatePlatformConfig,
|
||||||
updatePlatformStatus
|
updatePlatformStatus,
|
||||||
|
validatePassword
|
||||||
}
|
}
|
||||||
|
|||||||
@ -65,6 +65,8 @@ export default {
|
|||||||
dialogTitle: 'Access Restrictions',
|
dialogTitle: 'Access Restrictions',
|
||||||
showSourceLabel: 'Show Source',
|
showSourceLabel: 'Show Source',
|
||||||
clientQueryLimitLabel: 'Each Client Query Limit',
|
clientQueryLimitLabel: 'Each Client Query Limit',
|
||||||
|
authentication: 'Authentication',
|
||||||
|
authenticationValue: 'Authentication Password',
|
||||||
timesDays: 'Times/Day',
|
timesDays: 'Times/Day',
|
||||||
whitelistLabel: 'Whitelist',
|
whitelistLabel: 'Whitelist',
|
||||||
whitelistPlaceholder:
|
whitelistPlaceholder:
|
||||||
|
|||||||
@ -65,6 +65,8 @@ export default {
|
|||||||
showSourceLabel: '显示知识来源',
|
showSourceLabel: '显示知识来源',
|
||||||
clientQueryLimitLabel: '每个客户端提问限制',
|
clientQueryLimitLabel: '每个客户端提问限制',
|
||||||
timesDays: '次/天',
|
timesDays: '次/天',
|
||||||
|
authentication: '身份验证',
|
||||||
|
authenticationValue: '验证密码',
|
||||||
whitelistLabel: '白名单',
|
whitelistLabel: '白名单',
|
||||||
whitelistPlaceholder:
|
whitelistPlaceholder:
|
||||||
'请输入允许嵌入第三方的源地址,一行一个,如:\nhttp://127.0.0.1:5678\nhttps://dataease.io',
|
'请输入允许嵌入第三方的源地址,一行一个,如:\nhttp://127.0.0.1:5678\nhttps://dataease.io',
|
||||||
|
|||||||
@ -119,6 +119,18 @@ const useApplicationStore = defineStore({
|
|||||||
reject(error)
|
reject(error)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
async validatePassword(id: string, password: string, loading?: Ref<boolean>) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
applicationApi
|
||||||
|
.validatePassword(id, password)
|
||||||
|
.then((data) => {
|
||||||
|
resolve(data)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
reject(error)
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@ -27,6 +27,30 @@
|
|||||||
$t('views.applicationOverview.appInfo.LimitDialog.timesDays')
|
$t('views.applicationOverview.appInfo.LimitDialog.timesDays')
|
||||||
}}</span>
|
}}</span>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<!-- 身份验证 -->
|
||||||
|
<el-form-item
|
||||||
|
:label="$t('views.applicationOverview.appInfo.LimitDialog.authentication')"
|
||||||
|
v-hasPermission="new ComplexPermission([], ['x-pack'], 'OR')"
|
||||||
|
>
|
||||||
|
<el-switch size="small" v-model="form.authentication"></el-switch>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item
|
||||||
|
v-if="form.authentication"
|
||||||
|
:label="$t('views.applicationOverview.appInfo.LimitDialog.authenticationValue')"
|
||||||
|
v-hasPermission="new ComplexPermission([], ['x-pack'], 'OR')"
|
||||||
|
>
|
||||||
|
<el-input
|
||||||
|
v-model="form.authentication_value"
|
||||||
|
readonly
|
||||||
|
style="width: 300px; margin-right: 10px"
|
||||||
|
></el-input>
|
||||||
|
<el-button type="primary" text @click="copyClick(form.authentication_value)">
|
||||||
|
<AppIcon iconName="app-copy"></AppIcon>
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="refreshAuthentication" type="primary" text style="margin-left: 1px">
|
||||||
|
<el-icon><RefreshRight /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item
|
<el-form-item
|
||||||
:label="$t('views.applicationOverview.appInfo.LimitDialog.whitelistLabel')"
|
:label="$t('views.applicationOverview.appInfo.LimitDialog.whitelistLabel')"
|
||||||
@click.prevent
|
@click.prevent
|
||||||
@ -61,6 +85,8 @@ import type { FormInstance, FormRules } from 'element-plus'
|
|||||||
import applicationApi from '@/api/application'
|
import applicationApi from '@/api/application'
|
||||||
import { MsgSuccess, MsgConfirm } from '@/utils/message'
|
import { MsgSuccess, MsgConfirm } from '@/utils/message'
|
||||||
import { t } from '@/locales'
|
import { t } from '@/locales'
|
||||||
|
import { copyClick } from '@/utils/clipboard'
|
||||||
|
import { ComplexPermission } from '@/utils/permission/type'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const {
|
const {
|
||||||
@ -73,7 +99,9 @@ const limitFormRef = ref()
|
|||||||
const form = ref<any>({
|
const form = ref<any>({
|
||||||
access_num: 0,
|
access_num: 0,
|
||||||
white_active: true,
|
white_active: true,
|
||||||
white_list: ''
|
white_list: '',
|
||||||
|
authentication_value: '',
|
||||||
|
authentication: false
|
||||||
})
|
})
|
||||||
|
|
||||||
const dialogVisible = ref<boolean>(false)
|
const dialogVisible = ref<boolean>(false)
|
||||||
@ -93,6 +121,8 @@ const open = (data: any) => {
|
|||||||
form.value.access_num = data.access_num
|
form.value.access_num = data.access_num
|
||||||
form.value.white_active = data.white_active
|
form.value.white_active = data.white_active
|
||||||
form.value.white_list = data.white_list?.length ? data.white_list?.join('\n') : ''
|
form.value.white_list = data.white_list?.length ? data.white_list?.join('\n') : ''
|
||||||
|
form.value.authentication_value = data.authentication_value
|
||||||
|
form.value.authentication = data.authentication
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -103,7 +133,9 @@ const submit = async (formEl: FormInstance | undefined) => {
|
|||||||
const obj = {
|
const obj = {
|
||||||
white_list: form.value.white_list ? form.value.white_list.split('\n') : [],
|
white_list: form.value.white_list ? form.value.white_list.split('\n') : [],
|
||||||
white_active: form.value.white_active,
|
white_active: form.value.white_active,
|
||||||
access_num: form.value.access_num
|
access_num: form.value.access_num,
|
||||||
|
authentication: form.value.authentication,
|
||||||
|
authentication_value: form.value.authentication_value
|
||||||
}
|
}
|
||||||
applicationApi.putAccessToken(id as string, obj, loading).then((res) => {
|
applicationApi.putAccessToken(id as string, obj, loading).then((res) => {
|
||||||
emit('refresh')
|
emit('refresh')
|
||||||
@ -114,6 +146,17 @@ const submit = async (formEl: FormInstance | undefined) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
function generateAuthenticationValue(length: number = 10) {
|
||||||
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||||
|
const randomValues = new Uint8Array(length)
|
||||||
|
window.crypto.getRandomValues(randomValues)
|
||||||
|
return Array.from(randomValues)
|
||||||
|
.map((value) => chars[value % chars.length])
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
function refreshAuthentication() {
|
||||||
|
form.value.authentication_value = generateAuthenticationValue()
|
||||||
|
}
|
||||||
|
|
||||||
defineExpose({ open })
|
defineExpose({ open })
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,5 +1,33 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="chat-embed layout-bg" v-loading="loading">
|
<div class="chat-embed layout-bg" v-loading="loading">
|
||||||
|
<el-dialog
|
||||||
|
v-model="isPasswordDialogVisible"
|
||||||
|
width="480px"
|
||||||
|
height="236px"
|
||||||
|
title="输入密码打开链接"
|
||||||
|
custom-class="no-close-button"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
:close-on-press-escape="false"
|
||||||
|
:show-close="false"
|
||||||
|
center
|
||||||
|
:modal="true"
|
||||||
|
>
|
||||||
|
<el-input
|
||||||
|
style="width: 400px; height: 40px"
|
||||||
|
v-model="password"
|
||||||
|
:placeholder="$t('login.ldap.passwordPlaceholder')"
|
||||||
|
show-password
|
||||||
|
/>
|
||||||
|
<span class="input-error" v-if="passwordError">{{ passwordError }}</span>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
@click="validatePassword"
|
||||||
|
style="width: 400px; height: 40px; margin-top: 24px"
|
||||||
|
>确定</el-button
|
||||||
|
>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<div v-if="isAuthenticated">
|
||||||
<div class="chat-embed__header" :class="!isDefaultTheme ? 'custom-header' : ''">
|
<div class="chat-embed__header" :class="!isDefaultTheme ? 'custom-header' : ''">
|
||||||
<div class="chat-width flex align-center">
|
<div class="chat-width flex align-center">
|
||||||
<div class="mr-12 ml-24 flex">
|
<div class="mr-12 ml-24 flex">
|
||||||
@ -97,6 +125,7 @@
|
|||||||
</el-collapse-transition>
|
</el-collapse-transition>
|
||||||
<div class="chat-popover-mask" v-show="show"></div>
|
<div class="chat-popover-mask" v-show="show"></div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, reactive, nextTick, computed } from 'vue'
|
import { ref, onMounted, reactive, nextTick, computed } from 'vue'
|
||||||
@ -121,6 +150,10 @@ const applicationDetail = ref<any>({})
|
|||||||
const applicationAvailable = ref<boolean>(true)
|
const applicationAvailable = ref<boolean>(true)
|
||||||
const chatLogeData = ref<any[]>([])
|
const chatLogeData = ref<any[]>([])
|
||||||
const show = ref(false)
|
const show = ref(false)
|
||||||
|
const isPasswordDialogVisible = ref(false)
|
||||||
|
const password = ref('')
|
||||||
|
const passwordError = ref('')
|
||||||
|
const isAuthenticated = ref(false)
|
||||||
|
|
||||||
const paginationConfig = reactive({
|
const paginationConfig = reactive({
|
||||||
current_page: 1,
|
current_page: 1,
|
||||||
@ -171,6 +204,20 @@ function newChat() {
|
|||||||
currentRecordList.value = []
|
currentRecordList.value = []
|
||||||
currentChatId.value = 'new'
|
currentChatId.value = 'new'
|
||||||
}
|
}
|
||||||
|
function validatePassword() {
|
||||||
|
if (!password.value) {
|
||||||
|
passwordError.value = '密码不能为空'
|
||||||
|
return // 终止后续执行
|
||||||
|
}
|
||||||
|
application.validatePassword(applicationDetail?.value.id, password.value).then((res: any) => {
|
||||||
|
if (res?.data.is_valid) {
|
||||||
|
isAuthenticated.value = true
|
||||||
|
isPasswordDialogVisible.value = false
|
||||||
|
} else {
|
||||||
|
passwordError.value = '密码错误'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function getAccessToken(token: string) {
|
function getAccessToken(token: string) {
|
||||||
application
|
application
|
||||||
@ -189,6 +236,12 @@ function getAppProfile() {
|
|||||||
.asyncGetAppProfile(loading)
|
.asyncGetAppProfile(loading)
|
||||||
.then((res: any) => {
|
.then((res: any) => {
|
||||||
applicationDetail.value = res.data
|
applicationDetail.value = res.data
|
||||||
|
if (user.isEnterprise()) {
|
||||||
|
isPasswordDialogVisible.value = applicationDetail?.value.authentication
|
||||||
|
}
|
||||||
|
if (!isPasswordDialogVisible.value) {
|
||||||
|
isAuthenticated.value = true
|
||||||
|
}
|
||||||
if (res.data?.show_history || !user.isEnterprise()) {
|
if (res.data?.show_history || !user.isEnterprise()) {
|
||||||
getChatLog(applicationDetail.value.id)
|
getChatLog(applicationDetail.value.id)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,33 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="chat-pc layout-bg" :class="classObj" v-loading="loading">
|
<div class="chat-pc layout-bg" :class="classObj" v-loading="loading">
|
||||||
|
<el-dialog
|
||||||
|
v-model="isPasswordDialogVisible"
|
||||||
|
width="480px"
|
||||||
|
height="236px"
|
||||||
|
title="输入密码打开链接"
|
||||||
|
custom-class="no-close-button"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
:close-on-press-escape="false"
|
||||||
|
:show-close="false"
|
||||||
|
center
|
||||||
|
:modal="true"
|
||||||
|
>
|
||||||
|
<el-input
|
||||||
|
style="width: 400px; height: 40px"
|
||||||
|
v-model="password"
|
||||||
|
:placeholder="$t('login.ldap.passwordPlaceholder')"
|
||||||
|
show-password
|
||||||
|
/>
|
||||||
|
<span class="input-error" v-if="passwordError">{{ passwordError }}</span>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
@click="validatePassword"
|
||||||
|
style="width: 400px; height: 40px; margin-top: 24px"
|
||||||
|
>确定</el-button
|
||||||
|
>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<div v-if="isAuthenticated">
|
||||||
<div class="chat-pc__header" :class="!isDefaultTheme ? 'custom-header' : ''">
|
<div class="chat-pc__header" :class="!isDefaultTheme ? 'custom-header' : ''">
|
||||||
<div class="flex align-center">
|
<div class="flex align-center">
|
||||||
<div class="mr-12 ml-24 flex">
|
<div class="mr-12 ml-24 flex">
|
||||||
@ -19,7 +47,6 @@
|
|||||||
:size="32"
|
:size="32"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h4>{{ applicationDetail?.name }}</h4>
|
<h4>{{ applicationDetail?.name }}</h4>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -100,7 +127,6 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="right-height">
|
<div class="right-height">
|
||||||
<!-- 对话 -->
|
|
||||||
<AiChat
|
<AiChat
|
||||||
ref="AiChatRef"
|
ref="AiChatRef"
|
||||||
v-model:data="applicationDetail"
|
v-model:data="applicationDetail"
|
||||||
@ -115,14 +141,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="collapse">
|
<div class="collapse">
|
||||||
<el-button @click="isCollapse = !isCollapse">
|
<el-button @click="isCollapse = !isCollapse">
|
||||||
<el-icon> <component :is="isCollapse ? 'Fold' : 'Expand'" /></el-icon>
|
<el-icon> <component :is="isCollapse ? 'Fold' : 'Expand'" /></el-icon>
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, ref, onMounted, nextTick, computed } from 'vue'
|
import { reactive, ref, onMounted, nextTick, computed } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
@ -130,8 +157,11 @@ import { marked } from 'marked'
|
|||||||
import { saveAs } from 'file-saver'
|
import { saveAs } from 'file-saver'
|
||||||
import { isAppIcon } from '@/utils/application'
|
import { isAppIcon } from '@/utils/application'
|
||||||
import useStore from '@/stores'
|
import useStore from '@/stores'
|
||||||
|
|
||||||
import useResize from '@/layout/hooks/useResize'
|
import useResize from '@/layout/hooks/useResize'
|
||||||
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
|
import { t } from '@/locales'
|
||||||
|
import authApi from '@/api/auth-setting'
|
||||||
|
import { MsgSuccess } from '@/utils/message'
|
||||||
useResize()
|
useResize()
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@ -147,6 +177,10 @@ const isDefaultTheme = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const isCollapse = ref(false)
|
const isCollapse = ref(false)
|
||||||
|
const isPasswordDialogVisible = ref(false)
|
||||||
|
const password = ref('')
|
||||||
|
const passwordError = ref('')
|
||||||
|
const isAuthenticated = ref(false)
|
||||||
|
|
||||||
const classObj = computed(() => {
|
const classObj = computed(() => {
|
||||||
return {
|
return {
|
||||||
@ -225,6 +259,12 @@ function getAppProfile() {
|
|||||||
.asyncGetAppProfile(loading)
|
.asyncGetAppProfile(loading)
|
||||||
.then((res: any) => {
|
.then((res: any) => {
|
||||||
applicationDetail.value = res.data
|
applicationDetail.value = res.data
|
||||||
|
if (user.isEnterprise()) {
|
||||||
|
isPasswordDialogVisible.value = applicationDetail?.value.authentication
|
||||||
|
}
|
||||||
|
if (!isPasswordDialogVisible.value) {
|
||||||
|
isAuthenticated.value = true
|
||||||
|
}
|
||||||
if (res.data?.show_history || !user.isEnterprise()) {
|
if (res.data?.show_history || !user.isEnterprise()) {
|
||||||
getChatLog(applicationDetail.value.id)
|
getChatLog(applicationDetail.value.id)
|
||||||
}
|
}
|
||||||
@ -336,6 +376,21 @@ async function exportHTML(): Promise<void> {
|
|||||||
saveAs(blob, suggestedName)
|
saveAs(blob, suggestedName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validatePassword() {
|
||||||
|
if (!password.value) {
|
||||||
|
passwordError.value = '密码不能为空'
|
||||||
|
return // 终止后续执行
|
||||||
|
}
|
||||||
|
application.validatePassword(applicationDetail?.value.id, password.value).then((res: any) => {
|
||||||
|
if (res?.data.is_valid) {
|
||||||
|
isAuthenticated.value = true
|
||||||
|
isPasswordDialogVisible.value = false
|
||||||
|
} else {
|
||||||
|
passwordError.value = '密码错误'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
user.changeUserType(2)
|
user.changeUserType(2)
|
||||||
getAccessToken(accessToken)
|
getAccessToken(accessToken)
|
||||||
@ -455,4 +510,8 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.input-error {
|
||||||
|
color: red;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user