feat: permissions CRUD browser integration + E2E enhancements
Backend: - permission_handler: 完善权限 CRUD 接口(列表/创建/更新/删除) - auth_handler: 修复认证处理逻辑 - router: 新增权限管理路由 - handler_test: 新增权限 handler 测试覆盖 Frontend: - permissions.ts/test.ts: 权限服务层完整实现 - profile/settings/service_tests: 服务适配器修正 - client.ts: HTTP 客户端健壮性增强 - vite.config.js: 构建配置优化 - E2E 脚本: run-playwright-cdp-e2e 大幅增强(权限流程覆盖) Docs: - REAL_PROJECT_STATUS: 状态更新 - PRODUCTION_CHECKLIST/QUALITY_STANDARD/TECHNICAL_GUIDE/PROJECT_EXPERIENCE_SUMMARY: 团队规范完善 - plans/2026-04-23: 权限浏览器 CRUD 设计方案 验证: go build 0错误
This commit is contained in:
@@ -104,18 +104,19 @@ function Get-BrowserArguments {
|
||||
$arguments = @(
|
||||
"--remote-debugging-port=$Port",
|
||||
"--user-data-dir=$ProfileDir",
|
||||
'--no-sandbox'
|
||||
'--no-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-background-networking',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--disable-sync',
|
||||
'--disable-gpu'
|
||||
)
|
||||
|
||||
if (Test-HeadlessShellBrowser -BrowserPath $BrowserPath) {
|
||||
$arguments += '--single-process'
|
||||
} else {
|
||||
$arguments += @(
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-background-networking',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--disable-sync',
|
||||
'--headless=new'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -103,6 +103,28 @@ function Wait-UrlReady {
|
||||
throw "$Label did not become ready: $Url"
|
||||
}
|
||||
|
||||
function Sync-AdminBootstrapExpectation {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$BackendBaseUrl
|
||||
)
|
||||
|
||||
$capabilitiesUrl = "$BackendBaseUrl/api/v1/auth/capabilities"
|
||||
$response = Invoke-RestMethod -Uri $capabilitiesUrl -Method Get -TimeoutSec 15
|
||||
$requiresBootstrap = $false
|
||||
|
||||
if ($response -and $response.data -and $null -ne $response.data.admin_bootstrap_required) {
|
||||
$requiresBootstrap = [bool]$response.data.admin_bootstrap_required
|
||||
}
|
||||
|
||||
if ($requiresBootstrap) {
|
||||
$env:E2E_EXPECT_ADMIN_BOOTSTRAP = '1'
|
||||
} else {
|
||||
Remove-Item Env:E2E_EXPECT_ADMIN_BOOTSTRAP -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Write-Host "playwright e2e admin bootstrap expected: $requiresBootstrap"
|
||||
}
|
||||
|
||||
function Start-ManagedProcess {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Name,
|
||||
@@ -280,7 +302,6 @@ try {
|
||||
$env:E2E_LOGIN_PASSWORD = $AdminPassword
|
||||
$env:E2E_LOGIN_EMAIL = $AdminEmail
|
||||
$env:E2E_BOOTSTRAP_SECRET = $bootstrapSecret
|
||||
$env:E2E_EXPECT_ADMIN_BOOTSTRAP = '1'
|
||||
$env:E2E_EXTERNAL_WEB_SERVER = '1'
|
||||
$env:E2E_BASE_URL = $frontendBaseUrl
|
||||
$env:E2E_API_BASE_URL = "$backendBaseUrl/api/v1"
|
||||
@@ -289,7 +310,7 @@ try {
|
||||
Push-Location $frontendRoot
|
||||
try {
|
||||
$lastError = $null
|
||||
$suiteAttempts = 2
|
||||
$suiteAttempts = 3
|
||||
if ($env:E2E_SUITE_ATTEMPTS) {
|
||||
$parsedSuiteAttempts = 0
|
||||
if ([int]::TryParse($env:E2E_SUITE_ATTEMPTS, [ref]$parsedSuiteAttempts) -and $parsedSuiteAttempts -gt 0) {
|
||||
@@ -299,6 +320,7 @@ try {
|
||||
|
||||
for ($attempt = 1; $attempt -le $suiteAttempts; $attempt++) {
|
||||
try {
|
||||
Sync-AdminBootstrapExpectation -BackendBaseUrl $backendBaseUrl
|
||||
& (Join-Path $PSScriptRoot 'run-cdp-smoke.ps1') `
|
||||
-Port $BrowserPort `
|
||||
-Command @('node', './scripts/run-playwright-cdp-e2e.mjs')
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -269,6 +269,31 @@ describe('http client', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the current non-expired access token even when another refresh is still in flight', async () => {
|
||||
const fetchMock = vi.mocked(fetch)
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data: { ok: true },
|
||||
}),
|
||||
)
|
||||
|
||||
const { get, setAccessToken, setRefreshPromise, startRefreshing } = await loadModules()
|
||||
setAccessToken('still-valid-access-token', 3600)
|
||||
startRefreshing()
|
||||
setRefreshPromise(new Promise(() => {}))
|
||||
|
||||
const requestPromise = get('/protected')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({
|
||||
Authorization: 'Bearer still-valid-access-token',
|
||||
})
|
||||
await expect(requestPromise).resolves.toEqual({ ok: true })
|
||||
})
|
||||
|
||||
it('clears the local session when refresh fails before the business request is sent', async () => {
|
||||
const fetchMock = vi.mocked(fetch)
|
||||
fetchMock.mockResolvedValueOnce(new Response(null, { status: 401 }))
|
||||
|
||||
@@ -188,6 +188,10 @@ async function resolveAuthorizationHeader(auth: boolean): Promise<string | null>
|
||||
}
|
||||
|
||||
let token = getAccessToken()
|
||||
if (token && !isAccessTokenExpired()) {
|
||||
return token
|
||||
}
|
||||
|
||||
if (isRefreshing()) {
|
||||
const promise = getRefreshPromise()
|
||||
if (promise) {
|
||||
|
||||
@@ -22,7 +22,7 @@ describe('permissions service', () => {
|
||||
|
||||
it('gets permission tree', async () => {
|
||||
const mockTree = [
|
||||
{ id: 1, name: 'dashboard', children: [{ id: 2, name: 'view' }] },
|
||||
{ id: 1, name: 'dashboard', type: 0, children: [{ id: 2, name: 'view', type: 2 }] },
|
||||
]
|
||||
getMock.mockResolvedValue(mockTree)
|
||||
|
||||
@@ -30,13 +30,15 @@ describe('permissions service', () => {
|
||||
const result = await getPermissionTree()
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('/permissions/tree')
|
||||
expect(result).toEqual(mockTree)
|
||||
expect(result).toEqual([
|
||||
{ id: 1, name: 'dashboard', type: 'menu', children: [{ id: 2, name: 'view', type: 'api' }] },
|
||||
])
|
||||
})
|
||||
|
||||
it('lists all permissions', async () => {
|
||||
const mockPermissions = [
|
||||
{ id: 1, name: 'view dashboard', code: 'dashboard:view' },
|
||||
{ id: 2, name: 'edit dashboard', code: 'dashboard:edit' },
|
||||
{ id: 1, name: 'view dashboard', code: 'dashboard:view', type: 0 },
|
||||
{ id: 2, name: 'edit dashboard', code: 'dashboard:edit', type: 1 },
|
||||
]
|
||||
getMock.mockResolvedValue(mockPermissions)
|
||||
|
||||
@@ -44,40 +46,46 @@ describe('permissions service', () => {
|
||||
const result = await listPermissions()
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('/permissions')
|
||||
expect(result).toEqual(mockPermissions)
|
||||
expect(result).toEqual([
|
||||
{ id: 1, name: 'view dashboard', code: 'dashboard:view', type: 'menu' },
|
||||
{ id: 2, name: 'edit dashboard', code: 'dashboard:edit', type: 'button' },
|
||||
])
|
||||
})
|
||||
|
||||
it('gets a single permission', async () => {
|
||||
getMock.mockResolvedValue({ id: 5, name: 'view users', code: 'users:view' })
|
||||
getMock.mockResolvedValue({ id: 5, name: 'view users', code: 'users:view', type: 2 })
|
||||
|
||||
const { getPermission } = await import('./permissions')
|
||||
const result = await getPermission(5)
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('/permissions/5')
|
||||
expect(result).toEqual({ id: 5, name: 'view users', code: 'users:view' })
|
||||
expect(result).toEqual({ id: 5, name: 'view users', code: 'users:view', type: 'api' })
|
||||
})
|
||||
|
||||
it('creates a permission', async () => {
|
||||
const newPermission = { name: 'new permission', code: 'new:code', type: 'button' as const }
|
||||
const created = { id: 10, ...newPermission }
|
||||
const created = { id: 10, ...newPermission, type: 1 }
|
||||
postMock.mockResolvedValue(created)
|
||||
|
||||
const { createPermission } = await import('./permissions')
|
||||
const result = await createPermission(newPermission)
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith('/permissions', newPermission)
|
||||
expect(result).toEqual(created)
|
||||
expect(postMock).toHaveBeenCalledWith('/permissions', {
|
||||
...newPermission,
|
||||
type: 1,
|
||||
})
|
||||
expect(result).toEqual({ id: 10, name: 'new permission', code: 'new:code', type: 'button' })
|
||||
})
|
||||
|
||||
it('updates a permission', async () => {
|
||||
const updateData = { name: 'updated name' }
|
||||
putMock.mockResolvedValue({ id: 3, ...updateData })
|
||||
putMock.mockResolvedValue({ id: 3, ...updateData, type: 0 })
|
||||
|
||||
const { updatePermission } = await import('./permissions')
|
||||
const result = await updatePermission(3, updateData)
|
||||
|
||||
expect(putMock).toHaveBeenCalledWith('/permissions/3', updateData)
|
||||
expect(result).toEqual({ id: 3, name: 'updated name' })
|
||||
expect(result).toEqual({ id: 3, name: 'updated name', type: 'menu' })
|
||||
})
|
||||
|
||||
it('deletes a permission', async () => {
|
||||
|
||||
@@ -5,14 +5,58 @@
|
||||
*/
|
||||
|
||||
import { get, post, put, del } from '@/lib/http/client'
|
||||
import type { Permission, CreatePermissionRequest, UpdatePermissionRequest } from '@/types/permission'
|
||||
import type {
|
||||
Permission,
|
||||
CreatePermissionRequest,
|
||||
UpdatePermissionRequest,
|
||||
PermissionType,
|
||||
} from '@/types/permission'
|
||||
|
||||
type RawPermissionType = 0 | 1 | 2
|
||||
|
||||
interface RawPermission extends Omit<Permission, 'type' | 'children'> {
|
||||
type: RawPermissionType
|
||||
children?: RawPermission[]
|
||||
}
|
||||
|
||||
function normalizePermissionType(type: RawPermissionType): PermissionType {
|
||||
switch (type) {
|
||||
case 0:
|
||||
return 'menu'
|
||||
case 1:
|
||||
return 'button'
|
||||
case 2:
|
||||
return 'api'
|
||||
default:
|
||||
return 'api'
|
||||
}
|
||||
}
|
||||
|
||||
function serializePermissionType(type: PermissionType): RawPermissionType {
|
||||
switch (type) {
|
||||
case 'menu':
|
||||
return 0
|
||||
case 'button':
|
||||
return 1
|
||||
case 'api':
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePermission(permission: RawPermission): Permission {
|
||||
return {
|
||||
...permission,
|
||||
type: normalizePermissionType(permission.type),
|
||||
children: permission.children?.map(normalizePermission),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取权限树
|
||||
* GET /api/v1/permissions/tree
|
||||
*/
|
||||
export function getPermissionTree(): Promise<Permission[]> {
|
||||
return get<Permission[]>('/permissions/tree')
|
||||
return get<RawPermission[]>('/permissions/tree').then((permissions) => permissions.map(normalizePermission))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -20,7 +64,7 @@ export function getPermissionTree(): Promise<Permission[]> {
|
||||
* GET /api/v1/permissions
|
||||
*/
|
||||
export function listPermissions(): Promise<Permission[]> {
|
||||
return get<Permission[]>('/permissions')
|
||||
return get<RawPermission[]>('/permissions').then((permissions) => permissions.map(normalizePermission))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -28,7 +72,7 @@ export function listPermissions(): Promise<Permission[]> {
|
||||
* GET /api/v1/permissions/:id
|
||||
*/
|
||||
export function getPermission(id: number): Promise<Permission> {
|
||||
return get<Permission>(`/permissions/${id}`)
|
||||
return get<RawPermission>(`/permissions/${id}`).then(normalizePermission)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,7 +80,10 @@ export function getPermission(id: number): Promise<Permission> {
|
||||
* POST /api/v1/permissions
|
||||
*/
|
||||
export function createPermission(data: CreatePermissionRequest): Promise<Permission> {
|
||||
return post<Permission>('/permissions', data)
|
||||
return post<RawPermission>('/permissions', {
|
||||
...data,
|
||||
type: serializePermissionType(data.type),
|
||||
}).then(normalizePermission)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,7 +91,7 @@ export function createPermission(data: CreatePermissionRequest): Promise<Permiss
|
||||
* PUT /api/v1/permissions/:id
|
||||
*/
|
||||
export function updatePermission(id: number, data: UpdatePermissionRequest): Promise<Permission> {
|
||||
return put<Permission>(`/permissions/${id}`, data)
|
||||
return put<RawPermission>(`/permissions/${id}`, data).then(normalizePermission)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -76,9 +76,8 @@ describe('profile service', () => {
|
||||
})
|
||||
|
||||
expect(putMock).toHaveBeenCalledWith('/users/1/password', {
|
||||
current_password: 'OldPass123',
|
||||
old_password: 'OldPass123',
|
||||
new_password: 'NewPass123',
|
||||
confirm_password: 'NewPass123',
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -50,7 +50,10 @@ export function uploadAvatar(userId: number, file: File): Promise<AvatarUploadRe
|
||||
}
|
||||
|
||||
export function updatePassword(userId: number, data: UpdatePasswordRequest): Promise<void> {
|
||||
return put<void>(`/users/${userId}/password`, data)
|
||||
return put<void>(`/users/${userId}/password`, {
|
||||
old_password: data.current_password,
|
||||
new_password: data.new_password,
|
||||
})
|
||||
}
|
||||
|
||||
export function getTOTPStatus(): Promise<TOTPStatusResponse> {
|
||||
|
||||
@@ -74,10 +74,22 @@ describe('additional service adapters', () => {
|
||||
.mockResolvedValueOnce([{ id: 9 }, { id: 11 }])
|
||||
.mockResolvedValueOnce({ items: [], total: 0, page: 1, page_size: 20 })
|
||||
.mockResolvedValueOnce({ id: 3 })
|
||||
.mockResolvedValueOnce([{ id: 1, name: 'menu:view' }])
|
||||
.mockResolvedValueOnce([{ id: 2, name: 'menu:edit' }])
|
||||
.mockResolvedValueOnce([{ id: 1, name: 'menu:view', type: 0 }])
|
||||
.mockResolvedValueOnce([{ id: 2, name: 'menu:edit', type: 1 }])
|
||||
.mockResolvedValueOnce({ total_users: 10 })
|
||||
.mockResolvedValueOnce({ active_users: 8 })
|
||||
postMock.mockImplementation(async (url: string, payload: Record<string, unknown>) => {
|
||||
if (url === '/permissions') {
|
||||
return { id: 6, ...payload }
|
||||
}
|
||||
return { id: 5, ...payload }
|
||||
})
|
||||
putMock.mockImplementation(async (url: string, payload: Record<string, unknown>) => {
|
||||
if (url === '/permissions/6') {
|
||||
return { id: 6, ...payload, type: 0 }
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const {
|
||||
listRoles,
|
||||
@@ -156,7 +168,7 @@ describe('additional service adapters', () => {
|
||||
expect(postMock).toHaveBeenCalledWith('/permissions', {
|
||||
name: 'view dashboard',
|
||||
code: 'dashboard:view',
|
||||
type: 'menu',
|
||||
type: 0,
|
||||
})
|
||||
|
||||
await updatePermission(6, { name: 'updated permission' })
|
||||
@@ -243,9 +255,8 @@ describe('additional service adapters', () => {
|
||||
confirm_password: 'NewPass123',
|
||||
})
|
||||
expect(putMock).toHaveBeenCalledWith('/users/1/password', {
|
||||
current_password: 'CurrentPass123',
|
||||
old_password: 'CurrentPass123',
|
||||
new_password: 'NewPass123',
|
||||
confirm_password: 'NewPass123',
|
||||
})
|
||||
|
||||
await expect(getTOTPStatus()).resolves.toEqual({ totp_enabled: true })
|
||||
|
||||
@@ -80,7 +80,26 @@ describe('permissions service', () => {
|
||||
|
||||
it('gets permission tree', async () => {
|
||||
const mockPermissions = [
|
||||
{ id: 1, name: 'Users', code: 'users', children: [{ id: 2, name: 'View', code: 'users:view' }] },
|
||||
{
|
||||
id: 1,
|
||||
name: 'Users',
|
||||
code: 'users',
|
||||
type: 0,
|
||||
children: [
|
||||
{ id: 2, name: 'View', code: 'users:view', type: 2 },
|
||||
],
|
||||
},
|
||||
]
|
||||
const expectedPermissions = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Users',
|
||||
code: 'users',
|
||||
type: 'menu',
|
||||
children: [
|
||||
{ id: 2, name: 'View', code: 'users:view', type: 'api', children: undefined },
|
||||
],
|
||||
},
|
||||
]
|
||||
getMock.mockResolvedValue(mockPermissions)
|
||||
|
||||
@@ -88,7 +107,7 @@ describe('permissions service', () => {
|
||||
const result = await getPermissionTree()
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('/permissions/tree')
|
||||
expect(result).toEqual(mockPermissions)
|
||||
expect(result).toEqual(expectedPermissions)
|
||||
expect(result[0].children?.[0]?.name).toBe('View')
|
||||
})
|
||||
|
||||
@@ -119,14 +138,15 @@ describe('permissions service', () => {
|
||||
|
||||
it('creates a permission', async () => {
|
||||
const newPermission = { name: 'Test', code: 'test', type: 'button' as const }
|
||||
const createdPermission = { id: 10, ...newPermission }
|
||||
const createdPermission = { id: 10, ...newPermission, type: 1 }
|
||||
postMock.mockResolvedValue(createdPermission)
|
||||
|
||||
const { createPermission } = await import('./permissions')
|
||||
const result = await createPermission(newPermission)
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith('/permissions', newPermission)
|
||||
expect(postMock).toHaveBeenCalledWith('/permissions', { ...newPermission, type: 1 })
|
||||
expect(result.id).toBe(10)
|
||||
expect(result.type).toBe('button')
|
||||
})
|
||||
|
||||
it('updates a permission', async () => {
|
||||
|
||||
@@ -13,37 +13,35 @@ describe('settings service', () => {
|
||||
|
||||
it('gets system settings', async () => {
|
||||
const mockSettings = {
|
||||
data: {
|
||||
system: {
|
||||
name: 'UserSystem',
|
||||
version: '1.0.0',
|
||||
environment: 'production',
|
||||
description: 'User management system',
|
||||
},
|
||||
security: {
|
||||
password_min_length: 8,
|
||||
password_require_uppercase: true,
|
||||
password_require_lowercase: true,
|
||||
password_require_numbers: true,
|
||||
password_require_symbols: true,
|
||||
password_history: 5,
|
||||
totp_enabled: true,
|
||||
login_fail_lock: true,
|
||||
login_fail_threshold: 5,
|
||||
login_fail_duration: 30,
|
||||
session_timeout: 3600,
|
||||
device_trust_duration: 2592000,
|
||||
},
|
||||
features: {
|
||||
email_verification: true,
|
||||
phone_verification: false,
|
||||
oauth_providers: ['google', 'github'],
|
||||
sso_enabled: false,
|
||||
operation_log_enabled: true,
|
||||
login_log_enabled: true,
|
||||
data_export_enabled: true,
|
||||
data_import_enabled: true,
|
||||
},
|
||||
system: {
|
||||
name: 'UserSystem',
|
||||
version: '1.0.0',
|
||||
environment: 'production',
|
||||
description: 'User management system',
|
||||
},
|
||||
security: {
|
||||
password_min_length: 8,
|
||||
password_require_uppercase: true,
|
||||
password_require_lowercase: true,
|
||||
password_require_numbers: true,
|
||||
password_require_symbols: true,
|
||||
password_history: 5,
|
||||
totp_enabled: true,
|
||||
login_fail_lock: true,
|
||||
login_fail_threshold: 5,
|
||||
login_fail_duration: 30,
|
||||
session_timeout: 3600,
|
||||
device_trust_duration: 2592000,
|
||||
},
|
||||
features: {
|
||||
email_verification: true,
|
||||
phone_verification: false,
|
||||
oauth_providers: ['google', 'github'],
|
||||
sso_enabled: false,
|
||||
operation_log_enabled: true,
|
||||
login_log_enabled: true,
|
||||
data_export_enabled: true,
|
||||
data_import_enabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -53,6 +51,6 @@ describe('settings service', () => {
|
||||
const result = await getSettings()
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('/admin/settings')
|
||||
expect(result).toEqual(mockSettings.data)
|
||||
expect(result).toEqual(mockSettings)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -45,14 +45,10 @@ export interface SystemSettings {
|
||||
features: FeaturesInfo
|
||||
}
|
||||
|
||||
interface SettingsResponse {
|
||||
data: SystemSettings
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统设置
|
||||
* GET /api/v1/admin/settings
|
||||
*/
|
||||
export function getSettings(): Promise<SystemSettings> {
|
||||
return get<SettingsResponse>('/admin/settings').then(res => res.data)
|
||||
return get<SystemSettings>('/admin/settings')
|
||||
}
|
||||
|
||||
@@ -8,11 +8,7 @@ const apiProxyTarget = process.env.VITE_API_PROXY_TARGET || 'http://127.0.0.1:80
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: 'index.html',
|
||||
},
|
||||
},
|
||||
root: __dirname,
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
|
||||
Reference in New Issue
Block a user