test(e2e): Add Admin UI test for OIDC auth method (#2638)
* test(e2e): Add managed group check in LDAP test * test(e2e): Add oidc test * CR: Suppress warning * CR: Use toHaveText
This commit is contained in:
1 parent
ed569306bb
commit
ca4aad0a2b
5 files changed
+530
-11
No files matched your search
@@ -33,6 +33,97 @@ export class AuthMethodsPage extends BaseResourcePage {
|
||||
return authMethodName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new OIDC Auth Method. Assumes you have selected the desired scope.
|
||||
* @param {string} issuer OIDC issuer
|
||||
* @param {string} clientId OIDC client ID
|
||||
* @param {string} clientSecret OIDC client secret
|
||||
* @returns Name of the auth method
|
||||
*/
|
||||
async createOidcAuthMethod(issuer, clientId, clientSecret, boundaryAddr) {
|
||||
const authMethodName = 'Auth Method ' + nanoid();
|
||||
await this.page
|
||||
.getByRole('navigation', { name: 'IAM' })
|
||||
.getByRole('link', { name: 'Auth Methods' })
|
||||
.click();
|
||||
await this.page.getByRole('button', { name: 'New' }).click();
|
||||
await this.page.getByRole('link', { name: 'OIDC' }).click();
|
||||
await this.page.getByLabel('Name').fill(authMethodName);
|
||||
await this.page.getByLabel('Description').fill('OIDC Auth Method');
|
||||
await this.page.getByLabel('Issuer').fill(issuer);
|
||||
await this.page.getByLabel('Client ID').fill(clientId);
|
||||
await this.page.getByLabel('Client Secret').fill(clientSecret);
|
||||
await this.page
|
||||
.getByRole('group', { name: 'Signing Algorithms' })
|
||||
.getByRole('combobox')
|
||||
.selectOption('RS256');
|
||||
await this.page
|
||||
.getByRole('group', { name: 'Signing Algorithms' })
|
||||
.getByRole('button', { name: 'Add' })
|
||||
.click();
|
||||
|
||||
await this.page
|
||||
.getByRole('group', { name: 'Claims Scopes' })
|
||||
.getByRole('textbox')
|
||||
.last()
|
||||
.fill('groups');
|
||||
await this.page
|
||||
.getByRole('group', { name: 'Claims Scopes' })
|
||||
.getByRole('button', { name: 'Add' })
|
||||
.click();
|
||||
await this.page
|
||||
.getByRole('group', { name: 'Claims Scopes' })
|
||||
.getByRole('textbox')
|
||||
.last()
|
||||
.fill('user');
|
||||
await this.page
|
||||
.getByRole('group', { name: 'Claims Scopes' })
|
||||
.getByRole('button', { name: 'Add' })
|
||||
.click();
|
||||
|
||||
await this.page
|
||||
.getByRole('group', { name: 'Account Claim Maps' })
|
||||
.getByLabel('From Claim')
|
||||
.last()
|
||||
.fill('username');
|
||||
await this.page
|
||||
.getByRole('group', { name: 'Account Claim Maps' })
|
||||
.getByLabel('To Claim')
|
||||
.last()
|
||||
.selectOption('name');
|
||||
await this.page
|
||||
.getByRole('group', { name: 'Account Claim Maps' })
|
||||
.getByRole('button', { name: 'Add' })
|
||||
.click();
|
||||
await this.page
|
||||
.getByRole('group', { name: 'Account Claim Maps' })
|
||||
.getByLabel('From Claim')
|
||||
.last()
|
||||
.fill('email');
|
||||
await this.page
|
||||
.getByRole('group', { name: 'Account Claim Maps' })
|
||||
.getByLabel('To Claim')
|
||||
.last()
|
||||
.selectOption('email');
|
||||
await this.page
|
||||
.getByRole('group', { name: 'Account Claim Maps' })
|
||||
.getByRole('button', { name: 'Add' })
|
||||
.click();
|
||||
|
||||
await this.page.getByLabel('Maximum Age').fill('20');
|
||||
await this.page.getByLabel('API URL Prefix').fill(boundaryAddr);
|
||||
|
||||
await this.page.getByRole('button', { name: 'Save' }).click();
|
||||
await this.dismissSuccessAlert();
|
||||
await expect(
|
||||
this.page
|
||||
.getByRole('navigation', { name: 'breadcrumbs' })
|
||||
.getByText(authMethodName),
|
||||
).toBeVisible();
|
||||
|
||||
return authMethodName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the first available auth method primary.
|
||||
* Assumes you have created new auth method.
|
||||
|
||||
@@ -106,8 +106,8 @@ test('Set up LDAP auth method @ce @ent @docker', async ({
|
||||
).toBeVisible();
|
||||
|
||||
// Change state to active-public
|
||||
page.getByTitle('Inactive').click();
|
||||
page.getByText('Public').click();
|
||||
await page.getByTitle('Inactive').click();
|
||||
await page.getByText('Public').click();
|
||||
await expect(
|
||||
page.getByRole('alert').getByText('Success', { exact: true }),
|
||||
).toBeVisible();
|
||||
@@ -190,8 +190,13 @@ test('Set up LDAP auth method @ce @ent @docker', async ({
|
||||
.click();
|
||||
await page.getByRole('link', { name: ldapAuthMethodName }).click();
|
||||
await page.getByRole('link', { name: 'Accounts' }).click();
|
||||
await expect(
|
||||
page
|
||||
.getByRole('navigation', { name: 'breadcrumbs' })
|
||||
.getByText('Accounts'),
|
||||
).toBeVisible();
|
||||
|
||||
const headersCount = await page
|
||||
let headersCount = await page
|
||||
.getByRole('table')
|
||||
.getByRole('columnheader')
|
||||
.count();
|
||||
@@ -211,21 +216,61 @@ test('Set up LDAP auth method @ce @ent @docker', async ({
|
||||
}
|
||||
|
||||
expect(
|
||||
await page
|
||||
page
|
||||
.getByRole('cell', { name: ldapAccountName })
|
||||
.locator('..')
|
||||
.getByRole('cell')
|
||||
.nth(fullNameIndex)
|
||||
.innerText(),
|
||||
).toBe(ldapUserName);
|
||||
.nth(fullNameIndex),
|
||||
).toHaveText(ldapUserName);
|
||||
expect(
|
||||
await page
|
||||
page
|
||||
.getByRole('cell', { name: ldapAccountName })
|
||||
.locator('..')
|
||||
.getByRole('cell')
|
||||
.nth(emailIndex)
|
||||
.innerText(),
|
||||
).toBe(ldapUserName + '@mail.com');
|
||||
.nth(emailIndex),
|
||||
).toHaveText(ldapUserName + '@mail.com');
|
||||
|
||||
// View the Managed Group
|
||||
await page.getByRole('link', { name: 'Managed Groups' }).click();
|
||||
await page.getByRole('link', { name: ldapManagedGroupName }).click();
|
||||
await page.getByRole('link', { name: 'Members' }).click();
|
||||
await expect(
|
||||
page
|
||||
.getByRole('navigation', { name: 'breadcrumbs' })
|
||||
.getByText('Members'),
|
||||
).toBeVisible();
|
||||
|
||||
headersCount = await page
|
||||
.getByRole('table')
|
||||
.getByRole('columnheader')
|
||||
.count();
|
||||
for (let i = 0; i < headersCount; i++) {
|
||||
const header = await page
|
||||
.getByRole('table')
|
||||
.getByRole('columnheader')
|
||||
.nth(i)
|
||||
.innerText();
|
||||
if (header == 'Full Name') {
|
||||
fullNameIndex = i;
|
||||
} else if (header == 'Email') {
|
||||
emailIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
page
|
||||
.getByRole('cell', { name: ldapAccountName })
|
||||
.locator('..')
|
||||
.getByRole('cell')
|
||||
.nth(fullNameIndex),
|
||||
).toHaveText(ldapUserName);
|
||||
expect(
|
||||
page
|
||||
.getByRole('cell', { name: ldapAccountName })
|
||||
.locator('..')
|
||||
.getByRole('cell')
|
||||
.nth(emailIndex),
|
||||
).toHaveText(ldapUserName + '@mail.com');
|
||||
|
||||
// View the User account and verify attributes
|
||||
await page
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { test } from '../../global-setup.js';
|
||||
import { expect } from '@playwright/test';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
import * as boundaryCli from '../../helpers/boundary-cli';
|
||||
import * as vaultCli from '../../helpers/vault-cli';
|
||||
import { AuthMethodsPage } from '../pages/auth-methods.js';
|
||||
import { LoginPage } from '../pages/login.js';
|
||||
import { OrgsPage } from '../pages/orgs.js';
|
||||
import { RolesPage } from '../pages/roles.js';
|
||||
|
||||
// Reset storage state for this file to avoid being authenticated
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await boundaryCli.checkBoundaryCli();
|
||||
await vaultCli.checkVaultCli();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
execSync(`vault auth disable userpass`);
|
||||
});
|
||||
|
||||
test('Set up OIDC auth method @ce @ent @docker @aws', async ({
|
||||
page,
|
||||
context,
|
||||
baseURL,
|
||||
adminAuthMethodId,
|
||||
adminLoginName,
|
||||
adminPassword,
|
||||
vaultAddr,
|
||||
}) => {
|
||||
await page.goto('/');
|
||||
let orgName;
|
||||
let policyName;
|
||||
try {
|
||||
const userName = 'end-user';
|
||||
const password = 'password123';
|
||||
const email = 'vault@hashicorp.com';
|
||||
const { issuer, clientId, clientSecret, authPolicyName } =
|
||||
await vaultCli.setupVaultOidc(
|
||||
vaultAddr,
|
||||
userName,
|
||||
password,
|
||||
email,
|
||||
baseURL,
|
||||
);
|
||||
policyName = authPolicyName;
|
||||
|
||||
// Log in
|
||||
const loginPage = new LoginPage(page);
|
||||
await loginPage.login(adminLoginName, adminPassword);
|
||||
await expect(
|
||||
page.getByRole('navigation', { name: 'breadcrumbs' }).getByText('Orgs'),
|
||||
).toBeVisible();
|
||||
|
||||
// Create OIDC Auth Method
|
||||
const orgsPage = new OrgsPage(page);
|
||||
orgName = await orgsPage.createOrg();
|
||||
const authMethodsPage = new AuthMethodsPage(page);
|
||||
const oidcAuthMethodName = await authMethodsPage.createOidcAuthMethod(
|
||||
issuer,
|
||||
clientId,
|
||||
clientSecret,
|
||||
baseURL,
|
||||
);
|
||||
|
||||
// Change OIDC Auth Method state to active-public
|
||||
await page.getByTitle('Inactive').click();
|
||||
await page.getByText('Public').click();
|
||||
await expect(
|
||||
page.getByRole('alert').getByText('Success', { exact: true }),
|
||||
).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Dismiss' }).click();
|
||||
|
||||
// Set auth method as primary
|
||||
await page.getByText('Manage', { exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Make Primary' }).click();
|
||||
await page.getByRole('button', { name: 'OK' }).click();
|
||||
await expect(
|
||||
page.getByRole('alert').getByText('Success', { exact: true }),
|
||||
).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Dismiss' }).click();
|
||||
|
||||
// Create an OIDC managed group
|
||||
await page
|
||||
.getByRole('navigation', { name: 'breadcrumbs' })
|
||||
.getByText(oidcAuthMethodName)
|
||||
.click();
|
||||
await page.getByText('Manage', { exact: true }).click();
|
||||
await page.getByRole('link', { name: 'Create Managed Group' }).click();
|
||||
const oidcManagedGroupName = 'OIDC Managed Group';
|
||||
await page.getByLabel('Name (Optional)').fill(oidcManagedGroupName);
|
||||
await page.getByLabel('Description').fill('This is an automated test');
|
||||
await page.getByLabel('Filter').fill(`"engineering" in "/userinfo/groups"`);
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
await expect(
|
||||
page.getByRole('alert').getByText('Success', { exact: true }),
|
||||
).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Dismiss' }).click();
|
||||
|
||||
// Create a role and add LDAP managed group to role
|
||||
const rolesPage = new RolesPage(page);
|
||||
await rolesPage.createRole();
|
||||
await rolesPage.addPrincipalToRole(oidcManagedGroupName);
|
||||
|
||||
// Log in using oidc account
|
||||
await loginPage.logout(adminLoginName);
|
||||
await page.getByText('Choose a different scope').click();
|
||||
await page.getByRole('link', { name: orgName }).click();
|
||||
await page.getByRole('link', { name: oidcAuthMethodName }).click();
|
||||
const pagePromise = context.waitForEvent('page');
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
const vaultPage = await pagePromise;
|
||||
await vaultPage.getByLabel('Method').selectOption('Username');
|
||||
await vaultPage.getByLabel('Username').fill(userName);
|
||||
await vaultPage.getByLabel('Password').fill(password);
|
||||
await vaultPage.getByRole('button', { name: 'Sign In' }).click();
|
||||
await expect(
|
||||
page
|
||||
.getByRole('navigation', { name: 'breadcrumbs' })
|
||||
.getByText('Projects'),
|
||||
).toBeVisible();
|
||||
|
||||
// Log back in as an admin
|
||||
// WORKAROUND: Currently, users logging using OIDC don't have a username
|
||||
// displayed in the UI, so there's no simple locator to access this menu.
|
||||
await page.locator('details').filter({ hasText: 'Sign Out' }).click();
|
||||
await page.getByRole('button', { name: 'Sign Out' }).click();
|
||||
await expect(page.getByRole('button', { name: 'Sign In' })).toBeVisible();
|
||||
await loginPage.login(adminLoginName, adminPassword);
|
||||
await expect(
|
||||
page.getByRole('navigation', { name: 'breadcrumbs' }).getByText('Orgs'),
|
||||
).toBeVisible();
|
||||
|
||||
// View the OIDC account and verify account attributes
|
||||
await page.getByRole('link', { name: orgName }).click();
|
||||
await page
|
||||
.getByRole('navigation', { name: 'IAM' })
|
||||
.getByRole('link', { name: 'Auth Methods' })
|
||||
.click();
|
||||
await page.getByRole('link', { name: oidcAuthMethodName }).click();
|
||||
await page.getByRole('link', { name: 'Accounts' }).click();
|
||||
await expect(
|
||||
page
|
||||
.getByRole('navigation', { name: 'breadcrumbs' })
|
||||
.getByText('Accounts'),
|
||||
).toBeVisible();
|
||||
|
||||
let headersCount = await page
|
||||
.getByRole('table')
|
||||
.getByRole('columnheader')
|
||||
.count();
|
||||
let fullNameIndex;
|
||||
let emailIndex;
|
||||
for (let i = 0; i < headersCount; i++) {
|
||||
const header = await page
|
||||
.getByRole('table')
|
||||
.getByRole('columnheader')
|
||||
.nth(i)
|
||||
.innerText();
|
||||
if (header == 'Full Name') {
|
||||
fullNameIndex = i;
|
||||
} else if (header == 'Email') {
|
||||
emailIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
page
|
||||
.getByRole('cell', { name: userName })
|
||||
.locator('..')
|
||||
.getByRole('cell')
|
||||
.nth(fullNameIndex),
|
||||
).toHaveText(userName);
|
||||
expect(
|
||||
page
|
||||
.getByRole('cell', { name: userName })
|
||||
.locator('..')
|
||||
.getByRole('cell')
|
||||
.nth(emailIndex),
|
||||
).toHaveText(email);
|
||||
|
||||
// View the OIDC Managed Group and verify member in managed group
|
||||
await page.getByRole('link', { name: 'Managed Groups' }).click();
|
||||
await page.getByRole('link', { name: oidcManagedGroupName }).click();
|
||||
await page.getByRole('link', { name: 'Members' }).click();
|
||||
await expect(
|
||||
page
|
||||
.getByRole('navigation', { name: 'breadcrumbs' })
|
||||
.getByText('Members'),
|
||||
).toBeVisible();
|
||||
|
||||
headersCount = await page
|
||||
.getByRole('table')
|
||||
.getByRole('columnheader')
|
||||
.count();
|
||||
for (let i = 0; i < headersCount; i++) {
|
||||
const header = await page
|
||||
.getByRole('table')
|
||||
.getByRole('columnheader')
|
||||
.nth(i)
|
||||
.innerText();
|
||||
if (header == 'Full Name') {
|
||||
fullNameIndex = i;
|
||||
} else if (header == 'Email') {
|
||||
emailIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
page
|
||||
.getByRole('cell', { name: userName })
|
||||
.locator('..')
|
||||
.getByRole('cell')
|
||||
.nth(fullNameIndex),
|
||||
).toHaveText(userName);
|
||||
expect(
|
||||
page
|
||||
.getByRole('cell', { name: userName })
|
||||
.locator('..')
|
||||
.getByRole('cell')
|
||||
.nth(emailIndex),
|
||||
).toHaveText(email);
|
||||
|
||||
// View the User account and verify attributes
|
||||
await page
|
||||
.getByRole('navigation', { name: 'IAM' })
|
||||
.getByRole('link', { name: 'Users' })
|
||||
.click();
|
||||
await page.getByRole('cell', { hasText: email }).getByRole('link').click();
|
||||
await page.getByRole('link', { name: 'Accounts' }).click();
|
||||
expect(
|
||||
await page
|
||||
.getByRole('table')
|
||||
.getByRole('row')
|
||||
.nth(1) // Account row
|
||||
.getByRole('cell')
|
||||
.nth(0) // Name field
|
||||
.innerText(),
|
||||
).toContain(email);
|
||||
} finally {
|
||||
execSync(`vault auth disable userpass`);
|
||||
execSync(`vault policy delete ${policyName}`);
|
||||
|
||||
if (orgName) {
|
||||
await boundaryCli.authenticateBoundary(
|
||||
baseURL,
|
||||
adminAuthMethodId,
|
||||
adminLoginName,
|
||||
adminPassword,
|
||||
);
|
||||
const orgId = await boundaryCli.getOrgIdFromName(orgName);
|
||||
if (orgId) {
|
||||
await boundaryCli.deleteScope(orgId);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
# Copyright (c) HashiCorp, Inc.
|
||||
# SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
path "identity/oidc/provider/my-provider/authorize" {
|
||||
capabilities = [ "read" ]
|
||||
}
|
||||
@@ -50,3 +50,116 @@ export async function getVaultToken(boundaryPolicyName, secretPolicyName) {
|
||||
|
||||
return clientToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up vault oidc provider
|
||||
* @param {string} vaultAddr address of vault server
|
||||
* @param {string} userName name of user to create in OIDC server
|
||||
* @param {string} password password of user
|
||||
* @param {string} email email of user
|
||||
* @param {string} boundaryAddr address of boundary server for the redirect uri
|
||||
* @returns {Promise<object>} OIDC info
|
||||
*/
|
||||
export async function setupVaultOidc(
|
||||
vaultAddr,
|
||||
userName,
|
||||
password,
|
||||
email,
|
||||
boundaryAddr,
|
||||
) {
|
||||
const authPolicyName = 'auth-policy';
|
||||
|
||||
execSync(`vault auth enable userpass`);
|
||||
execSync(
|
||||
`vault policy write ${authPolicyName} ./admin/tests/fixtures/auth-policy.hcl`,
|
||||
);
|
||||
execSync(
|
||||
`vault write auth/userpass/users/${userName} password=${password} token_policies=${authPolicyName} token_ttl=1h`,
|
||||
);
|
||||
execSync(
|
||||
`vault write identity/entity name=${userName} metadata=email=${email} metadata=phone_number=123-456-7890 disabled=false`,
|
||||
);
|
||||
const entityId = execSync(
|
||||
`vault read -field=id identity/entity/name/${userName}`,
|
||||
)
|
||||
.toString()
|
||||
.trim();
|
||||
const groupName = 'engineering';
|
||||
execSync(
|
||||
`vault write identity/group name=${groupName} member_entity_ids=${entityId}`,
|
||||
);
|
||||
const groupId = execSync(
|
||||
`vault read -field=id identity/group/name/${groupName}`,
|
||||
)
|
||||
.toString()
|
||||
.trim();
|
||||
const authList = JSON.parse(
|
||||
execSync(`vault auth list -detailed -format json`),
|
||||
);
|
||||
const userpassAccessor = authList['userpass/'].accessor;
|
||||
execSync(
|
||||
`vault write identity/entity-alias name=${userName} canonical_id=${entityId} mount_accessor=${userpassAccessor}`,
|
||||
);
|
||||
const assignmentName = 'my-assignment';
|
||||
execSync(
|
||||
`vault write identity/oidc/assignment/${assignmentName} entity_ids=${entityId} group_ids=${groupId}`,
|
||||
);
|
||||
const keyName = 'my-key';
|
||||
execSync(
|
||||
`vault write identity/oidc/key/${keyName} allowed_client_ids=* verification_ttl=2h rotation_period=1h algorithm=RS256`,
|
||||
);
|
||||
const oidcClientName = 'boundary';
|
||||
execSync(
|
||||
`vault write identity/oidc/client/${oidcClientName}` +
|
||||
` redirect_uris=${boundaryAddr}/v1/auth-methods/oidc:authenticate:callback` +
|
||||
` assignments=${assignmentName}` +
|
||||
` key=${keyName}` +
|
||||
` id_token_ttl=30m` +
|
||||
` access_token_ttl=1h`,
|
||||
);
|
||||
const clientId = execSync(
|
||||
`vault read -field=client_id identity/oidc/client/${oidcClientName}`,
|
||||
)
|
||||
.toString()
|
||||
.trim();
|
||||
const userScopeTemplate = `
|
||||
{
|
||||
"username": {{identity.entity.name}},
|
||||
"email": {{identity.entity.metadata.email}},
|
||||
"phone_number": {{identity.entity.metadata.phone_number}}
|
||||
}`;
|
||||
const userScopeEncoded = Buffer.from(userScopeTemplate).toString('base64');
|
||||
execSync(`vault write identity/oidc/scope/user template=${userScopeEncoded}`);
|
||||
const groupScopeTemplate = `
|
||||
{
|
||||
"groups": {{identity.entity.groups.names}}
|
||||
}`;
|
||||
const groupScopeEncoded = Buffer.from(groupScopeTemplate).toString('base64');
|
||||
execSync(
|
||||
`vault write identity/oidc/scope/groups template=${groupScopeEncoded}`,
|
||||
);
|
||||
|
||||
const providerName = 'my-provider';
|
||||
execSync(
|
||||
`vault write identity/oidc/provider/${providerName} allowed_client_ids=${clientId} scopes_supported=groups,user issuer=${vaultAddr} &> /dev/null`,
|
||||
);
|
||||
|
||||
const oidcConfig = JSON.parse(
|
||||
execSync(
|
||||
`curl -s ${vaultAddr}/v1/identity/oidc/provider/${providerName}/.well-known/openid-configuration`,
|
||||
),
|
||||
);
|
||||
const issuer = oidcConfig.issuer;
|
||||
const clientSecret = execSync(
|
||||
`vault read -field=client_secret identity/oidc/client/${oidcClientName}`,
|
||||
)
|
||||
.toString()
|
||||
.trim();
|
||||
|
||||
return {
|
||||
issuer: issuer,
|
||||
clientId: clientId,
|
||||
clientSecret: clientSecret,
|
||||
authPolicyName: authPolicyName,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user