feat(plugin-multi-tenant): improves tenant assignment flow (#13881)
### Improved tenant assignment flow This PR improves the tenant assignment flow. I know a lot of users liked the previous flow where the field was not injected into the document. But the original flow, confused many of users because the tenant filter (top left) was being used to set the tenant on the document _and_ filter the list view. This change shown below is aiming to solve both of those groups with a slightly different approach. As always, feedback is welcome while we try to really make this plugin work for everyone. https://github.com/user-attachments/assets/ceee8b3a-c5f5-40e9-8648-f583e2412199 Added 2 new localization strings: ``` // shown in the 3 dot menu 'assign-tenant-button-label': 'Assign Tenant', // shown when needing to assign a tenant to a NEW document 'assign-tenant-modal-title': 'Assign "{{title}}"', ``` Removed 2 localization strings: ``` 'confirm-modal-tenant-switch--body', 'confirm-modal-tenant-switch--heading' ```
This commit is contained in:
@@ -2,9 +2,6 @@ import type { BrowserContext, Page } from '@playwright/test'
|
||||
import type { TypeWithID } from 'payload'
|
||||
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { devUser } from 'credentials.js'
|
||||
import { openDocControls } from 'helpers/e2e/openDocControls.js'
|
||||
import { openNav } from 'helpers/e2e/toggleNav.js'
|
||||
import path from 'path'
|
||||
import { wait } from 'payload/shared'
|
||||
import { fileURLToPath } from 'url'
|
||||
@@ -12,8 +9,8 @@ import { fileURLToPath } from 'url'
|
||||
import type { PayloadTestSDK } from '../helpers/sdk/index.js'
|
||||
import type { Config, ReadOnlyCollection, RestrictedVersion } from './payload-types.js'
|
||||
|
||||
import { devUser } from '../credentials.js'
|
||||
import {
|
||||
closeNav,
|
||||
ensureCompilationIsDone,
|
||||
exactText,
|
||||
initPageConsoleErrorCatch,
|
||||
@@ -21,6 +18,8 @@ import {
|
||||
} from '../helpers.js'
|
||||
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
|
||||
import { login } from '../helpers/e2e/auth/login.js'
|
||||
import { openDocControls } from '../helpers/e2e/openDocControls.js'
|
||||
import { closeNav, openNav } from '../helpers/e2e/toggleNav.js'
|
||||
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
|
||||
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js'
|
||||
import {
|
||||
|
||||
@@ -7,15 +7,12 @@ import type {
|
||||
} from '@playwright/test'
|
||||
import type { Config } from 'payload'
|
||||
|
||||
import { formatAdminURL } from '@payloadcms/ui/shared'
|
||||
import { expect } from '@playwright/test'
|
||||
import { defaults } from 'payload'
|
||||
import { wait } from 'payload/shared'
|
||||
import shelljs from 'shelljs'
|
||||
import { setTimeout } from 'timers/promises'
|
||||
|
||||
import { devUser } from './credentials.js'
|
||||
import { openNav } from './helpers/e2e/toggleNav.js'
|
||||
import { POLL_TOPASS_TIMEOUT } from './playwright.config.js'
|
||||
|
||||
export type AdminRoutes = NonNullable<Config['admin']>['routes']
|
||||
@@ -220,14 +217,6 @@ export async function openCreateDocDrawer(page: Page, fieldSelector: string): Pr
|
||||
await wait(500) // wait for drawer form state to initialize
|
||||
}
|
||||
|
||||
export async function closeNav(page: Page): Promise<void> {
|
||||
if (!(await page.locator('.template-default.template-default--nav-open').isVisible())) {
|
||||
return
|
||||
}
|
||||
await page.locator('.nav-toggler >> visible=true').click()
|
||||
await expect(page.locator('.template-default.template-default--nav-open')).toBeHidden()
|
||||
}
|
||||
|
||||
export async function openLocaleSelector(page: Page): Promise<void> {
|
||||
const button = page.locator('.localizer button.popup-button')
|
||||
const popup = page.locator('.localizer .popup.popup--active')
|
||||
|
||||
@@ -13,7 +13,7 @@ export async function assertToastErrors({
|
||||
}): Promise<void> {
|
||||
const isSingleError = errors.length === 1
|
||||
const message = isSingleError
|
||||
? 'The following field is invalid:'
|
||||
? 'The following field is invalid: '
|
||||
: `The following fields are invalid (${errors.length}):`
|
||||
|
||||
// Check the intro message text
|
||||
|
||||
@@ -24,3 +24,17 @@ export async function openNav(page: Page): Promise<void> {
|
||||
await expect(page.locator('.nav--nav-animate[inert], .nav--nav-hydrated[inert]')).toBeHidden()
|
||||
await expect(page.locator('.template-default.template-default--nav-open')).toBeVisible()
|
||||
}
|
||||
|
||||
export async function closeNav(page: Page): Promise<void> {
|
||||
// wait for the preferences/media queries to either open or close the nav
|
||||
await expect(page.locator('.template-default--nav-hydrated')).toBeVisible()
|
||||
|
||||
// check to see if the nav is already closed and if so, return early
|
||||
if (!(await page.locator('.template-default.template-default--nav-open').isVisible())) {
|
||||
return
|
||||
}
|
||||
|
||||
// playwright: get first element with .nav-toggler which is VISIBLE (not hidden), could be 2 elements with .nav-toggler on mobile and desktop but only one is visible
|
||||
await page.locator('.nav-toggler >> visible=true').click()
|
||||
await expect(page.locator('.template-default.template-default--nav-open')).toBeHidden()
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ export default buildConfigWithDefaults({
|
||||
onInit: seed,
|
||||
plugins: [
|
||||
multiTenantPlugin<ConfigType>({
|
||||
debug: true,
|
||||
// debug: true,
|
||||
userHasAccessToAllTenants: (user) => Boolean(user.roles?.includes('admin')),
|
||||
useTenantsCollectionAccess: false,
|
||||
tenantField: {
|
||||
@@ -52,9 +52,9 @@ export default buildConfigWithDefaults({
|
||||
i18n: {
|
||||
translations: {
|
||||
en: {
|
||||
'field-assignedTenant-label': 'Currently Assigned Site',
|
||||
'nav-tenantSelector-label': 'Filter By Site',
|
||||
'confirm-modal-tenant-switch--heading': 'Confirm Site Change',
|
||||
'field-assignedTenant-label': 'Site',
|
||||
'nav-tenantSelector-label': 'Filter by Site',
|
||||
'assign-tenant-button-label': 'Assign Site',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ import * as path from 'path'
|
||||
import { wait } from 'payload/shared'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
import type { PayloadTestSDK } from '../helpers/sdk/index.js'
|
||||
import type { Config } from './payload-types.js'
|
||||
|
||||
import { ensureCompilationIsDone, initPageConsoleErrorCatch, saveDocAndAssert } from '../helpers.js'
|
||||
@@ -18,7 +19,7 @@ import {
|
||||
getSelectInputValue,
|
||||
selectInput,
|
||||
} from '../helpers/e2e/selectInput.js'
|
||||
import { openNav } from '../helpers/e2e/toggleNav.js'
|
||||
import { closeNav, openNav } from '../helpers/e2e/toggleNav.js'
|
||||
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
|
||||
import { reInitializeDB } from '../helpers/reInitializeDB.js'
|
||||
import { TEST_TIMEOUT_LONG } from '../playwright.config.js'
|
||||
@@ -37,16 +38,19 @@ test.describe('Multi Tenant', () => {
|
||||
let menuItemsURL: AdminUrlUtil
|
||||
let usersURL: AdminUrlUtil
|
||||
let tenantsURL: AdminUrlUtil
|
||||
let payload: PayloadTestSDK<Config>
|
||||
|
||||
test.beforeAll(async ({ browser }, testInfo) => {
|
||||
testInfo.setTimeout(TEST_TIMEOUT_LONG)
|
||||
|
||||
const { serverURL: serverFromInit, payload } = await initPayloadE2ENoConfig<Config>({ dirname })
|
||||
const { serverURL: serverFromInit, payload: payloadFromInit } =
|
||||
await initPayloadE2ENoConfig<Config>({ dirname })
|
||||
serverURL = serverFromInit
|
||||
globalMenuURL = new AdminUrlUtil(serverURL, menuSlug)
|
||||
menuItemsURL = new AdminUrlUtil(serverURL, menuItemsSlug)
|
||||
usersURL = new AdminUrlUtil(serverURL, usersSlug)
|
||||
tenantsURL = new AdminUrlUtil(serverURL, tenantsSlug)
|
||||
payload = payloadFromInit
|
||||
autosaveGlobalURL = new AdminUrlUtil(serverURL, autosaveGlobalSlug)
|
||||
|
||||
const context = await browser.newContext()
|
||||
@@ -72,7 +76,7 @@ test.describe('Multi Tenant', () => {
|
||||
data: credentials.admin,
|
||||
})
|
||||
|
||||
await clearGlobalTenant({ page })
|
||||
await clearTenantFilter({ page })
|
||||
|
||||
await page.goto(tenantsURL.list)
|
||||
|
||||
@@ -99,8 +103,8 @@ test.describe('Multi Tenant', () => {
|
||||
data: credentials.admin,
|
||||
})
|
||||
|
||||
await page.goto(tenantsURL.list)
|
||||
await selectTenant({
|
||||
await setTenantFilter({
|
||||
urlUtil: tenantsURL,
|
||||
page,
|
||||
tenant: 'Blue Dog',
|
||||
})
|
||||
@@ -127,7 +131,7 @@ test.describe('Multi Tenant', () => {
|
||||
})
|
||||
|
||||
await page.goto(menuItemsURL.list)
|
||||
await clearGlobalTenant({ page })
|
||||
await clearTenantFilter({ page })
|
||||
|
||||
await expect(
|
||||
page.locator('.collection-list .table .cell-name', {
|
||||
@@ -147,8 +151,8 @@ test.describe('Multi Tenant', () => {
|
||||
data: credentials.admin,
|
||||
})
|
||||
|
||||
await page.goto(menuItemsURL.list)
|
||||
await selectTenant({
|
||||
await setTenantFilter({
|
||||
urlUtil: menuItemsURL,
|
||||
page,
|
||||
tenant: 'Blue Dog',
|
||||
})
|
||||
@@ -172,7 +176,7 @@ test.describe('Multi Tenant', () => {
|
||||
})
|
||||
|
||||
await page.goto(menuItemsURL.list)
|
||||
await clearGlobalTenant({ page })
|
||||
await clearTenantFilter({ page })
|
||||
|
||||
await expect(
|
||||
page.locator('.collection-list .table .cell-name', {
|
||||
@@ -188,7 +192,7 @@ test.describe('Multi Tenant', () => {
|
||||
})
|
||||
|
||||
await page.goto(menuItemsURL.list)
|
||||
await clearGlobalTenant({ page })
|
||||
await clearTenantFilter({ page })
|
||||
|
||||
await expect(
|
||||
page.locator('.collection-list .table .cell-name', {
|
||||
@@ -207,7 +211,7 @@ test.describe('Multi Tenant', () => {
|
||||
})
|
||||
|
||||
await page.goto(usersURL.list)
|
||||
await clearGlobalTenant({ page })
|
||||
await clearTenantFilter({ page })
|
||||
|
||||
await expect(
|
||||
page.locator('.collection-list .table .cell-email', {
|
||||
@@ -233,8 +237,8 @@ test.describe('Multi Tenant', () => {
|
||||
data: credentials.admin,
|
||||
})
|
||||
|
||||
await page.goto(usersURL.list)
|
||||
await selectTenant({
|
||||
await setTenantFilter({
|
||||
urlUtil: usersURL,
|
||||
page,
|
||||
tenant: 'Blue Dog',
|
||||
})
|
||||
@@ -267,7 +271,7 @@ test.describe('Multi Tenant', () => {
|
||||
})
|
||||
|
||||
await page.goto(menuItemsURL.list)
|
||||
await clearGlobalTenant({ page })
|
||||
await clearTenantFilter({ page })
|
||||
|
||||
await goToListDoc({
|
||||
page,
|
||||
@@ -287,7 +291,7 @@ test.describe('Multi Tenant', () => {
|
||||
.toBe('Blue Dog')
|
||||
})
|
||||
|
||||
test('should prompt for confirmation upon tenant switching', async () => {
|
||||
test('should allow tenant switching cancellation', async () => {
|
||||
await loginClientSide({
|
||||
page,
|
||||
serverURL,
|
||||
@@ -295,7 +299,7 @@ test.describe('Multi Tenant', () => {
|
||||
})
|
||||
|
||||
await page.goto(menuItemsURL.list)
|
||||
await clearGlobalTenant({ page })
|
||||
await clearTenantFilter({ page })
|
||||
|
||||
await goToListDoc({
|
||||
page,
|
||||
@@ -307,14 +311,46 @@ test.describe('Multi Tenant', () => {
|
||||
await selectDocumentTenant({
|
||||
page,
|
||||
tenant: 'Steel Cat',
|
||||
action: 'cancel',
|
||||
payload,
|
||||
})
|
||||
|
||||
const confirmationModal = page.locator('#confirm-switch-tenant')
|
||||
await expect(confirmationModal).toBeVisible()
|
||||
await expect(
|
||||
confirmationModal.getByText('You are about to change ownership from Blue Dog to Steel Cat'),
|
||||
).toBeVisible()
|
||||
await expect(page.locator('#action-save')).toBeDisabled()
|
||||
|
||||
await page.goto(menuItemsURL.list)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await getSelectedTenantFilterName({ page, payload })
|
||||
})
|
||||
.toBe('Blue Dog')
|
||||
})
|
||||
|
||||
test('should allow tenant switching confirmation', async () => {
|
||||
await loginClientSide({
|
||||
page,
|
||||
serverURL,
|
||||
data: credentials.admin,
|
||||
})
|
||||
|
||||
await page.goto(menuItemsURL.list)
|
||||
await clearTenantFilter({ page })
|
||||
|
||||
await goToListDoc({
|
||||
page,
|
||||
cellClass: '.cell-name',
|
||||
textToMatch: 'Spicy Mac',
|
||||
urlUtil: menuItemsURL,
|
||||
})
|
||||
|
||||
await selectDocumentTenant({
|
||||
page,
|
||||
payload,
|
||||
tenant: 'Steel Cat',
|
||||
})
|
||||
|
||||
await saveDocAndAssert(page)
|
||||
})
|
||||
|
||||
test('should filter internal links in Lexical editor', async () => {
|
||||
await loginClientSide({
|
||||
page,
|
||||
@@ -324,6 +360,7 @@ test.describe('Multi Tenant', () => {
|
||||
await page.goto(menuItemsURL.create)
|
||||
await selectDocumentTenant({
|
||||
page,
|
||||
payload,
|
||||
tenant: 'Blue Dog',
|
||||
})
|
||||
const editor = page.locator('[data-lexical-editor="true"]')
|
||||
@@ -364,8 +401,8 @@ test.describe('Multi Tenant', () => {
|
||||
serverURL,
|
||||
data: credentials.admin,
|
||||
})
|
||||
await page.goto(tenantsURL.list)
|
||||
await selectTenant({
|
||||
await setTenantFilter({
|
||||
urlUtil: tenantsURL,
|
||||
page,
|
||||
tenant: 'Blue Dog',
|
||||
})
|
||||
@@ -381,8 +418,8 @@ test.describe('Multi Tenant', () => {
|
||||
data: credentials.admin,
|
||||
})
|
||||
|
||||
await page.goto(tenantsURL.list)
|
||||
await selectTenant({
|
||||
await setTenantFilter({
|
||||
urlUtil: tenantsURL,
|
||||
page,
|
||||
tenant: 'Blue Dog',
|
||||
})
|
||||
@@ -391,7 +428,7 @@ test.describe('Multi Tenant', () => {
|
||||
|
||||
// Attempt to switch tenants with unsaved changes
|
||||
await page.fill('#field-title', 'New Global Menu Name')
|
||||
await selectTenant({
|
||||
await switchGlobalDocTenant({
|
||||
page,
|
||||
tenant: 'Steel Cat',
|
||||
})
|
||||
@@ -424,15 +461,25 @@ test.describe('Multi Tenant', () => {
|
||||
data: credentials.admin,
|
||||
})
|
||||
await page.goto(tenantsURL.list)
|
||||
await clearGlobalTenant({ page })
|
||||
await clearTenantFilter({ page })
|
||||
await page.goto(autosaveGlobalURL.list)
|
||||
await expect(page.locator('.doc-header__title')).toBeVisible()
|
||||
const globalTenant = await getGlobalTenant({ page })
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await getDocumentTenant({ page })
|
||||
})
|
||||
.toBe(globalTenant)
|
||||
const docID = (await page.locator('.render-title').getAttribute('data-doc-id')) as string
|
||||
await expect.poll(() => docID).not.toBeUndefined()
|
||||
const globalTenant = await getSelectedTenantFilterName({ page, payload })
|
||||
const autosaveGlobal = await payload.find({
|
||||
collection: autosaveGlobalSlug,
|
||||
where: {
|
||||
id: {
|
||||
equals: docID,
|
||||
},
|
||||
'tenant.name': {
|
||||
equals: globalTenant,
|
||||
},
|
||||
},
|
||||
})
|
||||
await expect.poll(() => autosaveGlobal?.totalDocs).toBe(1)
|
||||
await expect.poll(() => autosaveGlobal?.docs?.[0]?.tenant).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -515,7 +562,7 @@ test.describe('Multi Tenant', () => {
|
||||
})
|
||||
|
||||
await page.goto(tenantsURL.list)
|
||||
await clearGlobalTenant({ page })
|
||||
await clearTenantFilter({ page })
|
||||
|
||||
await expect(
|
||||
page.locator('.collection-list .table .cell-name', {
|
||||
@@ -602,24 +649,6 @@ test.describe('Multi Tenant', () => {
|
||||
/**
|
||||
* Helper Functions
|
||||
*/
|
||||
|
||||
async function getGlobalTenant({ page }: { page: Page }): Promise<string | undefined> {
|
||||
await openNav(page)
|
||||
return await getSelectInputValue<false>({
|
||||
selectLocator: page.locator('.tenant-selector'),
|
||||
multiSelect: false,
|
||||
})
|
||||
}
|
||||
|
||||
async function getDocumentTenant({ page }: { page: Page }): Promise<string | undefined> {
|
||||
await openNav(page)
|
||||
return await getSelectInputValue<false>({
|
||||
selectLocator: page.locator('#field-tenant'),
|
||||
multiSelect: false,
|
||||
valueLabelClass: '.relationship--single-value',
|
||||
})
|
||||
}
|
||||
|
||||
async function getTenantOptions({ page }: { page: Page }): Promise<string[]> {
|
||||
await openNav(page)
|
||||
return await getSelectInputOptions({
|
||||
@@ -627,33 +656,124 @@ async function getTenantOptions({ page }: { page: Page }): Promise<string[]> {
|
||||
})
|
||||
}
|
||||
|
||||
async function openAssignTenantModal({
|
||||
page,
|
||||
payload,
|
||||
}: {
|
||||
page: Page
|
||||
payload: PayloadTestSDK<Config>
|
||||
}): Promise<void> {
|
||||
const assignTenantModal = page.locator('#assign-tenant-field-modal')
|
||||
|
||||
const globalTenant = await getSelectedTenantFilterName({ page, payload })
|
||||
if (!globalTenant) {
|
||||
await expect(assignTenantModal).toBeVisible()
|
||||
return
|
||||
}
|
||||
|
||||
// Open the assign tenant modal
|
||||
const docControlsPopup = page.locator('.doc-controls__popup')
|
||||
const docControlsButton = docControlsPopup.locator('.popup-button')
|
||||
await expect(docControlsButton).toBeVisible()
|
||||
await docControlsButton.click()
|
||||
|
||||
const assignTenantButtonLocator = docControlsPopup.locator('button', { hasText: 'Assign Site' })
|
||||
await expect(assignTenantButtonLocator).toBeVisible()
|
||||
await assignTenantButtonLocator.click()
|
||||
|
||||
await expect(assignTenantModal).toBeVisible()
|
||||
}
|
||||
|
||||
async function selectDocumentTenant({
|
||||
page,
|
||||
tenant,
|
||||
action = 'confirm',
|
||||
payload,
|
||||
}: {
|
||||
action?: 'cancel' | 'confirm'
|
||||
page: Page
|
||||
payload: PayloadTestSDK<Config>
|
||||
tenant: string
|
||||
}): Promise<void> {
|
||||
await closeNav(page)
|
||||
await openAssignTenantModal({ page, payload })
|
||||
await selectInput({
|
||||
selectLocator: page.locator('.tenantField'),
|
||||
option: tenant,
|
||||
multiSelect: false,
|
||||
})
|
||||
|
||||
const assignTenantModal = page.locator('#assign-tenant-field-modal')
|
||||
if (action === 'confirm') {
|
||||
await assignTenantModal.locator('button', { hasText: 'Confirm' }).click()
|
||||
await expect(assignTenantModal).toBeHidden()
|
||||
} else {
|
||||
await assignTenantModal.locator('button', { hasText: 'Cancel' }).click()
|
||||
await expect(assignTenantModal).toBeHidden()
|
||||
}
|
||||
}
|
||||
|
||||
async function getSelectedTenantFilterName({
|
||||
page,
|
||||
payload,
|
||||
}: {
|
||||
page: Page
|
||||
payload: PayloadTestSDK<Config>
|
||||
}): Promise<string | undefined> {
|
||||
const cookies = await page.context().cookies()
|
||||
const tenantIDFromCookie = cookies.find((c) => c.name === 'payload-tenant')?.value
|
||||
if (tenantIDFromCookie) {
|
||||
const tenant = await payload.find({
|
||||
collection: 'tenants',
|
||||
where: {
|
||||
id: {
|
||||
equals: tenantIDFromCookie,
|
||||
},
|
||||
},
|
||||
})
|
||||
return tenant?.docs?.[0]?.name || undefined
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function setTenantFilter({
|
||||
page,
|
||||
tenant,
|
||||
urlUtil,
|
||||
}: {
|
||||
page: Page
|
||||
tenant: string
|
||||
urlUtil: AdminUrlUtil
|
||||
}): Promise<void> {
|
||||
await page.goto(urlUtil.list)
|
||||
await openNav(page)
|
||||
await selectInput({
|
||||
selectLocator: page.locator('.tenant-selector'),
|
||||
option: tenant,
|
||||
multiSelect: false,
|
||||
})
|
||||
}
|
||||
|
||||
async function switchGlobalDocTenant({
|
||||
page,
|
||||
tenant,
|
||||
}: {
|
||||
page: Page
|
||||
tenant: string
|
||||
}): Promise<void> {
|
||||
await openNav(page)
|
||||
return selectInput({
|
||||
selectLocator: page.locator('.tenantField'),
|
||||
option: tenant,
|
||||
multiSelect: false,
|
||||
})
|
||||
}
|
||||
|
||||
async function selectTenant({ page, tenant }: { page: Page; tenant: string }): Promise<void> {
|
||||
await openNav(page)
|
||||
return selectInput({
|
||||
await selectInput({
|
||||
selectLocator: page.locator('.tenant-selector'),
|
||||
option: tenant,
|
||||
multiSelect: false,
|
||||
})
|
||||
}
|
||||
|
||||
async function clearGlobalTenant({ page }: { page: Page }): Promise<void> {
|
||||
async function clearTenantFilter({ page }: { page: Page }): Promise<void> {
|
||||
await openNav(page)
|
||||
return clearSelectInput({
|
||||
await clearSelectInput({
|
||||
selectLocator: page.locator('.tenant-selector'),
|
||||
})
|
||||
await closeNav(page)
|
||||
}
|
||||
|
||||
@@ -183,7 +183,7 @@ export interface FoodItem {
|
||||
root: {
|
||||
type: string;
|
||||
children: {
|
||||
type: string;
|
||||
type: any;
|
||||
version: number;
|
||||
[k: string]: unknown;
|
||||
}[];
|
||||
|
||||
Reference in New Issue
Block a user