加固后台管理安全
This commit is contained in:
@@ -2,7 +2,6 @@ import axios from 'axios'
|
||||
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
import type { ApiResponse } from '@/shared/types/types'
|
||||
import { getRefreshToken, setAuthTokens } from '@/shared/utils/authStorage'
|
||||
import type { UserStatus } from '@/shared/types/status'
|
||||
|
||||
export interface AdminRole {
|
||||
@@ -17,21 +16,19 @@ export interface AdminUser {
|
||||
nickname: string
|
||||
status: UserStatus
|
||||
support_status: 'online' | 'offline' | 'busy'
|
||||
password_must_change: boolean
|
||||
roles: AdminRole[]
|
||||
permissions: string[]
|
||||
last_login_at?: string
|
||||
}
|
||||
|
||||
export interface AdminTokenPair {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
token_type: string
|
||||
export interface AdminRefreshData {
|
||||
refreshed: boolean
|
||||
expires_in: number
|
||||
}
|
||||
|
||||
export interface AdminLoginData {
|
||||
admin: AdminUser
|
||||
tokens: AdminTokenPair
|
||||
}
|
||||
|
||||
export interface AdminCaptcha {
|
||||
@@ -82,13 +79,10 @@ export async function updateSupportStatus(status: 'online' | 'offline' | 'busy')
|
||||
|
||||
/** Manually refresh admin token (uses raw axios to avoid interceptor recursion) */
|
||||
export async function refreshAdminSession() {
|
||||
const refreshToken = getRefreshToken('admin')
|
||||
if (!refreshToken) throw new Error('no refresh token')
|
||||
const { data } = await axios.post<ApiResponse<AdminTokenPair>>(
|
||||
const { data } = await axios.post<ApiResponse<AdminRefreshData>>(
|
||||
'/api/admin/auth/refresh',
|
||||
{ refresh_token: refreshToken },
|
||||
{ timeout: 10000 }
|
||||
{},
|
||||
{ timeout: 10000, withCredentials: true }
|
||||
)
|
||||
setAuthTokens('admin', data.data)
|
||||
return data.data
|
||||
}
|
||||
|
||||
@@ -36,6 +36,10 @@ export interface ChangePasswordRequest {
|
||||
new_password: string
|
||||
}
|
||||
|
||||
export interface ResetPasswordRequest {
|
||||
new_password: string
|
||||
}
|
||||
|
||||
export async function fetchAdminMgrUsers(page = 1, pageSize = 20) {
|
||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminMgrUser>>>(
|
||||
'/admin/admin-users',
|
||||
@@ -78,7 +82,15 @@ export async function assignAdminRoles(id: number, roleIds: number[]) {
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function changeAdminPassword(id: number, req: ChangePasswordRequest) {
|
||||
export async function changeAdminPassword(req: ChangePasswordRequest) {
|
||||
const { data } = await apiClient.put<ApiResponse<{ updated: boolean }>>(
|
||||
'/admin/admin-users/me/password',
|
||||
req
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function resetAdminPassword(id: number, req: ResetPasswordRequest) {
|
||||
const { data } = await apiClient.put<ApiResponse<{ updated: boolean }>>(
|
||||
`/admin/admin-users/${id}/password`,
|
||||
req
|
||||
|
||||
@@ -19,7 +19,7 @@ export {
|
||||
fetchAdminMe,
|
||||
updateSupportStatus,
|
||||
type AdminUser,
|
||||
type AdminTokenPair,
|
||||
type AdminRefreshData,
|
||||
type AdminLoginData,
|
||||
type AdminCaptcha,
|
||||
} from './api/adminAuth'
|
||||
|
||||
@@ -3,19 +3,20 @@ import { readError } from '@/shared/utils/error'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Lock, User } from '@element-plus/icons-vue'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { fetchAdminCaptcha, type AdminCaptcha } from '@/features/admin'
|
||||
import { useAdminSessionStore } from '@/stores/adminSession'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const adminSession = useAdminSessionStore()
|
||||
const loading = ref(false)
|
||||
const captchaLoading = ref(false)
|
||||
const captcha = ref<AdminCaptcha | null>(null)
|
||||
const form = reactive({
|
||||
username: 'admin',
|
||||
password: 'admin123456',
|
||||
username: '',
|
||||
password: '',
|
||||
captchaCode: '',
|
||||
})
|
||||
|
||||
@@ -43,7 +44,8 @@ async function handleLogin() {
|
||||
form.captchaCode
|
||||
)
|
||||
ElMessage.success('后台登录成功')
|
||||
await router.push('/admin/dashboard')
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : ''
|
||||
await router.push(redirect.startsWith('/admin') ? redirect : '/admin/dashboard')
|
||||
} catch (error) {
|
||||
ElMessage.error(readError(error, '后台登录失败'))
|
||||
await loadCaptcha()
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ref } from 'vue'
|
||||
import {
|
||||
fetchAdminMgrUsers,
|
||||
deleteAdminMgrUser,
|
||||
changeAdminPassword,
|
||||
resetAdminPassword,
|
||||
type AdminMgrUser,
|
||||
} from '@/features/admin/api/adminMgr'
|
||||
import { useAdminPaginatedTable } from '@/features/admin/composables/useAdminPaginatedTable'
|
||||
@@ -22,7 +22,7 @@ const showRolesDialog = ref(false)
|
||||
const rolesAdmin = ref<AdminMgrUser | null>(null)
|
||||
const showPasswordDialog = ref(false)
|
||||
const passwordAdmin = ref<AdminMgrUser | null>(null)
|
||||
const passwordForm = ref({ old_password: '', new_password: '' })
|
||||
const passwordForm = ref({ new_password: '' })
|
||||
const passwordSubmitting = ref(false)
|
||||
|
||||
const {
|
||||
@@ -53,7 +53,7 @@ function openRoles(row: AdminMgrUser) {
|
||||
|
||||
function openPassword(row: AdminMgrUser) {
|
||||
passwordAdmin.value = row
|
||||
passwordForm.value = { old_password: '', new_password: '' }
|
||||
passwordForm.value = { new_password: '' }
|
||||
showPasswordDialog.value = true
|
||||
}
|
||||
|
||||
@@ -78,17 +78,17 @@ async function handleDelete(row: AdminMgrUser) {
|
||||
|
||||
async function handleChangePassword() {
|
||||
if (!passwordAdmin.value) return
|
||||
if (!passwordForm.value.old_password || !passwordForm.value.new_password) {
|
||||
ElMessage.warning('请填写完整')
|
||||
if (!passwordForm.value.new_password) {
|
||||
ElMessage.warning('请填写新密码')
|
||||
return
|
||||
}
|
||||
passwordSubmitting.value = true
|
||||
try {
|
||||
await changeAdminPassword(passwordAdmin.value.id, passwordForm.value)
|
||||
ElMessage.success('密码已修改')
|
||||
await resetAdminPassword(passwordAdmin.value.id, passwordForm.value)
|
||||
ElMessage.success('密码已重置,目标账号需要重新登录')
|
||||
showPasswordDialog.value = false
|
||||
} catch (error) {
|
||||
ElMessage.error(readError(error, '修改失败'))
|
||||
ElMessage.error(readError(error, '重置失败'))
|
||||
} finally {
|
||||
passwordSubmitting.value = false
|
||||
}
|
||||
@@ -169,35 +169,27 @@ const statusLabel: Record<string, string> = {
|
||||
<!-- 角色分配对话框 -->
|
||||
<AssignRolesDialog v-model="showRolesDialog" :admin="rolesAdmin" @saved="loadAdmins" />
|
||||
|
||||
<!-- 修改密码对话框 -->
|
||||
<!-- 重置密码对话框 -->
|
||||
<el-dialog
|
||||
:model-value="showPasswordDialog"
|
||||
:title="`修改密码 - ${passwordAdmin?.username || ''}`"
|
||||
:title="`重置密码 - ${passwordAdmin?.username || ''}`"
|
||||
width="460px"
|
||||
@update:model-value="showPasswordDialog = $event"
|
||||
>
|
||||
<div class="dialog-body">
|
||||
<el-form-item label="原密码" class="full-control">
|
||||
<el-input
|
||||
v-model="passwordForm.old_password"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="请输入原密码"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码" class="full-control">
|
||||
<el-input
|
||||
v-model="passwordForm.new_password"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="请输入新密码(至少6位)"
|
||||
placeholder="至少 8 位,包含字母和数字"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="showPasswordDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="passwordSubmitting" @click="handleChangePassword"
|
||||
>确认修改</el-button
|
||||
>确认重置</el-button
|
||||
>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@@ -27,17 +27,24 @@ import {
|
||||
} from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElSubMenu } from 'element-plus'
|
||||
import type { Component } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { logoutAdmin, updateSupportStatus } from '@/features/admin'
|
||||
import { changeAdminPassword, logoutAdmin, updateSupportStatus } from '@/features/admin'
|
||||
import { useAdminSessionStore } from '@/stores/adminSession'
|
||||
import { readError } from '@/shared/utils/error'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const adminSession = useAdminSessionStore()
|
||||
const isCollapsed = ref(false)
|
||||
const updatingStatus = ref(false)
|
||||
const passwordSubmitting = ref(false)
|
||||
const passwordForm = reactive({
|
||||
old_password: '',
|
||||
new_password: '',
|
||||
confirm_password: '',
|
||||
})
|
||||
|
||||
interface NavItem {
|
||||
label: string
|
||||
@@ -231,6 +238,39 @@ async function handleLogout() {
|
||||
ElMessage.success('已退出后台')
|
||||
await router.push('/admin/login')
|
||||
}
|
||||
|
||||
function passwordStrongEnough(value: string) {
|
||||
return value.length >= 8 && /[A-Za-z]/.test(value) && /\d/.test(value)
|
||||
}
|
||||
|
||||
async function handleForcedPasswordChange() {
|
||||
if (!passwordForm.old_password || !passwordForm.new_password) {
|
||||
ElMessage.error('请输入原密码和新密码')
|
||||
return
|
||||
}
|
||||
if (!passwordStrongEnough(passwordForm.new_password)) {
|
||||
ElMessage.error('新密码至少 8 位且需包含字母和数字')
|
||||
return
|
||||
}
|
||||
if (passwordForm.new_password !== passwordForm.confirm_password) {
|
||||
ElMessage.error('两次输入的新密码不一致')
|
||||
return
|
||||
}
|
||||
passwordSubmitting.value = true
|
||||
try {
|
||||
await changeAdminPassword({
|
||||
old_password: passwordForm.old_password,
|
||||
new_password: passwordForm.new_password,
|
||||
})
|
||||
ElMessage.success('密码已修改,请重新登录')
|
||||
adminSession.logout()
|
||||
await router.replace('/admin/login')
|
||||
} catch (error) {
|
||||
ElMessage.error(readError(error, '密码修改失败'))
|
||||
} finally {
|
||||
passwordSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -332,6 +372,51 @@ async function handleLogout() {
|
||||
<slot />
|
||||
</main>
|
||||
</section>
|
||||
|
||||
<el-dialog
|
||||
:model-value="adminSession.passwordMustChange"
|
||||
title="修改初始密码"
|
||||
width="420px"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="false"
|
||||
:show-close="false"
|
||||
append-to-body
|
||||
class="force-password-dialog"
|
||||
>
|
||||
<el-form class="force-password-form" label-position="top" @submit.prevent>
|
||||
<el-form-item label="原密码">
|
||||
<el-input
|
||||
v-model="passwordForm.old_password"
|
||||
type="password"
|
||||
show-password
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码">
|
||||
<el-input
|
||||
v-model="passwordForm.new_password"
|
||||
type="password"
|
||||
show-password
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="确认新密码">
|
||||
<el-input
|
||||
v-model="passwordForm.confirm_password"
|
||||
type="password"
|
||||
show-password
|
||||
autocomplete="new-password"
|
||||
@keyup.enter="handleForcedPasswordChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button :disabled="passwordSubmitting" @click="handleLogout">退出登录</el-button>
|
||||
<el-button type="primary" :loading="passwordSubmitting" @click="handleForcedPasswordChange">
|
||||
确认修改
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -572,4 +657,13 @@ async function handleLogout() {
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
:global(.force-password-dialog) {
|
||||
max-width: calc(100vw - 32px);
|
||||
}
|
||||
|
||||
.force-password-form {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -77,19 +77,28 @@ router.beforeEach(async to => {
|
||||
|
||||
const adminSession = useAdminSessionStore()
|
||||
adminSession.syncFromStorage()
|
||||
if (to.path === '/admin/login' && adminSession.token) {
|
||||
return '/admin/dashboard'
|
||||
if (to.path === '/admin/login') {
|
||||
if (!adminSession.hasSessionHint) return true
|
||||
try {
|
||||
await adminSession.loadMe()
|
||||
return '/admin/dashboard'
|
||||
} catch {
|
||||
adminSession.logout()
|
||||
return true
|
||||
}
|
||||
}
|
||||
if (to.meta.requiresAdmin) {
|
||||
if (!adminSession.token) {
|
||||
return '/admin/login'
|
||||
}
|
||||
if (adminSession.permissions.length === 0) {
|
||||
try {
|
||||
try {
|
||||
if (
|
||||
!adminSession.hasSessionHint ||
|
||||
adminSession.permissions.length === 0 ||
|
||||
adminSession.passwordMustChange
|
||||
) {
|
||||
await adminSession.loadMe()
|
||||
} catch {
|
||||
// 权限加载失败,仍然允许访问(降级为无权限状态)
|
||||
}
|
||||
} catch {
|
||||
adminSession.logout()
|
||||
return { path: '/admin/login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
}
|
||||
return true
|
||||
|
||||
@@ -100,6 +100,7 @@ import type { ApiResponse } from '@/shared/types/types'
|
||||
export const apiClient = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 30000, // 增加到 30 秒,避免大文件上传超时
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
@@ -149,11 +150,18 @@ function rejectPendingRequests(scope: AuthScope, error: unknown) {
|
||||
}
|
||||
|
||||
export async function refreshAccessToken(scope: AuthScope): Promise<string> {
|
||||
if (scope === 'admin') {
|
||||
await axios.post('/api/admin/auth/refresh', {}, { timeout: 10000, withCredentials: true })
|
||||
return ''
|
||||
}
|
||||
const refreshToken = getRefreshToken(scope)
|
||||
if (!refreshToken) throw new Error('no refresh token')
|
||||
|
||||
const endpoint = scope === 'admin' ? '/api/admin/auth/refresh' : '/api/auth/refresh'
|
||||
const { data } = await axios.post(endpoint, { refresh_token: refreshToken }, { timeout: 10000 })
|
||||
const { data } = await axios.post(
|
||||
'/api/auth/refresh',
|
||||
{ refresh_token: refreshToken },
|
||||
{ timeout: 10000 }
|
||||
)
|
||||
const tokens = {
|
||||
access_token: data.data.access_token,
|
||||
refresh_token: data.data.refresh_token,
|
||||
@@ -239,7 +247,9 @@ apiClient.interceptors.response.use(
|
||||
state.pendingRequests.push({ resolve, reject })
|
||||
}).then(newToken => {
|
||||
originalRequest._retry = true
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||
if (newToken) {
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||
}
|
||||
return apiClient(originalRequest)
|
||||
})
|
||||
}
|
||||
@@ -249,7 +259,9 @@ apiClient.interceptors.response.use(
|
||||
const newToken = await refreshAccessToken(scope)
|
||||
resolvePendingRequests(scope, newToken)
|
||||
originalRequest._retry = true
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||
if (newToken) {
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||
}
|
||||
return apiClient(originalRequest)
|
||||
} catch (refreshError) {
|
||||
rejectPendingRequests(scope, refreshError)
|
||||
|
||||
@@ -14,7 +14,7 @@ const userKeys = {
|
||||
const adminKeys = {
|
||||
accessToken: 'admin_access_token',
|
||||
refreshToken: 'admin_refresh_token',
|
||||
profile: ['admin_id', 'admin_username'],
|
||||
profile: ['admin_id', 'admin_username', 'admin_support_status', 'admin_password_must_change'],
|
||||
}
|
||||
|
||||
function keysFor(scope: AuthScope) {
|
||||
@@ -22,15 +22,23 @@ function keysFor(scope: AuthScope) {
|
||||
}
|
||||
|
||||
export function getAccessToken(scope: AuthScope) {
|
||||
if (scope === 'admin') return ''
|
||||
return localStorage.getItem(keysFor(scope).accessToken) || ''
|
||||
}
|
||||
|
||||
export function getRefreshToken(scope: AuthScope) {
|
||||
if (scope === 'admin') return ''
|
||||
return localStorage.getItem(keysFor(scope).refreshToken) || ''
|
||||
}
|
||||
|
||||
export function setAuthTokens(scope: AuthScope, tokens: AuthTokenPair) {
|
||||
const keys = keysFor(scope)
|
||||
if (scope === 'admin') {
|
||||
localStorage.removeItem(keys.accessToken)
|
||||
localStorage.removeItem(keys.refreshToken)
|
||||
notifyAuthStorageChanged(scope)
|
||||
return
|
||||
}
|
||||
localStorage.setItem(keys.accessToken, tokens.access_token)
|
||||
localStorage.setItem(keys.refreshToken, tokens.refresh_token)
|
||||
notifyAuthStorageChanged(scope)
|
||||
|
||||
@@ -6,17 +6,12 @@ import {
|
||||
type AdminRole,
|
||||
type AdminUser,
|
||||
} from '@/features/admin/api/adminAuth'
|
||||
import {
|
||||
clearAuthStorage,
|
||||
getAccessToken,
|
||||
getRefreshToken,
|
||||
setAuthTokens,
|
||||
} from '@/shared/utils/authStorage'
|
||||
import { clearAuthStorage } from '@/shared/utils/authStorage'
|
||||
|
||||
export const useAdminSessionStore = defineStore('adminSession', {
|
||||
state: () => ({
|
||||
token: getAccessToken('admin'),
|
||||
refreshToken: getRefreshToken('admin'),
|
||||
token: '',
|
||||
refreshToken: '',
|
||||
adminId: Number(localStorage.getItem('admin_id') || 0),
|
||||
username: localStorage.getItem('admin_username') || '',
|
||||
nickname: '',
|
||||
@@ -24,10 +19,12 @@ export const useAdminSessionStore = defineStore('adminSession', {
|
||||
| 'online'
|
||||
| 'offline'
|
||||
| 'busy',
|
||||
passwordMustChange: localStorage.getItem('admin_password_must_change') === 'true',
|
||||
roles: [] as AdminRole[],
|
||||
permissions: [] as string[],
|
||||
}),
|
||||
getters: {
|
||||
hasSessionHint: state => state.adminId > 0 || state.username !== '',
|
||||
hasPermission: state => {
|
||||
return (code: string) => state.permissions.includes(code) || state.permissions.includes('*')
|
||||
},
|
||||
@@ -41,7 +38,7 @@ export const useAdminSessionStore = defineStore('adminSession', {
|
||||
actions: {
|
||||
async login(username: string, password: string, captchaId: string, captchaCode: string) {
|
||||
const result = await loginAdmin(username, password, captchaId, captchaCode)
|
||||
this.applySession(result.admin, result.tokens.access_token, result.tokens.refresh_token)
|
||||
this.applySession(result.admin)
|
||||
return result
|
||||
},
|
||||
async loadMe() {
|
||||
@@ -56,28 +53,27 @@ export const useAdminSessionStore = defineStore('adminSession', {
|
||||
this.username = ''
|
||||
this.nickname = ''
|
||||
this.supportStatus = 'offline'
|
||||
this.passwordMustChange = false
|
||||
this.roles = []
|
||||
this.permissions = []
|
||||
clearAuthStorage('admin')
|
||||
localStorage.removeItem('admin_support_status')
|
||||
localStorage.removeItem('admin_password_must_change')
|
||||
},
|
||||
syncFromStorage() {
|
||||
this.token = getAccessToken('admin')
|
||||
this.refreshToken = getRefreshToken('admin')
|
||||
this.token = ''
|
||||
this.refreshToken = ''
|
||||
this.adminId = Number(localStorage.getItem('admin_id') || 0)
|
||||
this.username = localStorage.getItem('admin_username') || ''
|
||||
this.supportStatus = (localStorage.getItem('admin_support_status') || 'offline') as
|
||||
| 'online'
|
||||
| 'offline'
|
||||
| 'busy'
|
||||
this.passwordMustChange = localStorage.getItem('admin_password_must_change') === 'true'
|
||||
},
|
||||
applySession(admin: AdminUser, accessToken: string, refreshToken: string) {
|
||||
this.token = accessToken
|
||||
this.refreshToken = refreshToken
|
||||
setAuthTokens('admin', {
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
})
|
||||
applySession(admin: AdminUser) {
|
||||
this.token = ''
|
||||
this.refreshToken = ''
|
||||
this.applyAdmin(admin)
|
||||
},
|
||||
applyAdmin(admin: AdminUser) {
|
||||
@@ -85,11 +81,13 @@ export const useAdminSessionStore = defineStore('adminSession', {
|
||||
this.username = admin.username
|
||||
this.nickname = admin.nickname
|
||||
this.supportStatus = admin.support_status || 'offline'
|
||||
this.passwordMustChange = admin.password_must_change
|
||||
this.roles = admin.roles || []
|
||||
this.permissions = admin.permissions || []
|
||||
localStorage.setItem('admin_id', String(admin.id))
|
||||
localStorage.setItem('admin_username', admin.username)
|
||||
localStorage.setItem('admin_support_status', admin.support_status || 'offline')
|
||||
localStorage.setItem('admin_password_must_change', String(admin.password_must_change))
|
||||
},
|
||||
setSupportStatus(status: 'online' | 'offline' | 'busy') {
|
||||
this.supportStatus = status
|
||||
|
||||
Reference in New Issue
Block a user