mirror of
https://github.com/hashicorp/vault.git
synced 2026-05-05 12:26:34 +02:00
[UI] API Service (#29965)
* adds api service * adds missing copyright headers * Update ui/app/services/api.ts Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com> * removes response cache and comments from api service * removes hide warnings condition from showWarnings middleware in api service * splits out setHeaders test --------- Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com>
This commit is contained in:
parent
32f74c1016
commit
bbcd0e0465
11
ui/app/config/environment.d.ts
vendored
11
ui/app/config/environment.d.ts
vendored
@ -13,7 +13,16 @@ declare const config: {
|
||||
podModulePrefix: string;
|
||||
locationType: 'history' | 'hash' | 'none';
|
||||
rootURL: string;
|
||||
APP: Record<string, unknown>;
|
||||
APP: {
|
||||
POLLING_URLS: string[];
|
||||
NAMESPACE_ROOT_URLS: string[];
|
||||
DEFAULT_PAGE_SIZE: number;
|
||||
LOG_TRANSITIONS?: boolean;
|
||||
LOG_ACTIVE_GENERATION?: boolean;
|
||||
LOG_VIEW_LOOKUPS?: boolean;
|
||||
rootElement?: string;
|
||||
autoboot?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@ -3,31 +3,34 @@
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import type { ApiError } from 'vault/api';
|
||||
|
||||
export default {
|
||||
isLocalStorageSupported() {
|
||||
try {
|
||||
const key = `__storage__test`;
|
||||
window.localStorage.setItem(key, null);
|
||||
window.localStorage.setItem(key, '');
|
||||
window.localStorage.removeItem(key);
|
||||
return true;
|
||||
} catch (e) {
|
||||
const error = e as ApiError;
|
||||
// modify the e object so we can customize the error message.
|
||||
// e.message is readOnly.
|
||||
e.errors = [`This is likely due to your browser's cookie settings.`];
|
||||
error.errors = [`This is likely due to your browser's cookie settings.`];
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
getItem(key) {
|
||||
getItem(key: string) {
|
||||
const item = window.localStorage.getItem(key);
|
||||
return item && JSON.parse(item);
|
||||
},
|
||||
|
||||
setItem(key, val) {
|
||||
setItem(key: string, val: unknown) {
|
||||
window.localStorage.setItem(key, JSON.stringify(val));
|
||||
},
|
||||
|
||||
removeItem(key) {
|
||||
removeItem(key: string) {
|
||||
return window.localStorage.removeItem(key);
|
||||
},
|
||||
|
||||
@ -35,7 +38,7 @@ export default {
|
||||
return Object.keys(window.localStorage);
|
||||
},
|
||||
|
||||
cleanupStorage(string, keyToKeep) {
|
||||
cleanupStorage(string: string, keyToKeep: string) {
|
||||
if (!string) return;
|
||||
const relevantKeys = this.keys().filter((str) => str.startsWith(string));
|
||||
relevantKeys?.forEach((key) => {
|
||||
@ -3,19 +3,19 @@
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
const cache = {};
|
||||
const cache: { [key: string]: string } = {};
|
||||
|
||||
export default {
|
||||
getItem(key) {
|
||||
var item = cache[key];
|
||||
getItem(key: string) {
|
||||
const item = cache[key];
|
||||
return item && JSON.parse(item);
|
||||
},
|
||||
|
||||
setItem(key, val) {
|
||||
setItem(key: string, val: unknown) {
|
||||
cache[key] = JSON.stringify(val);
|
||||
},
|
||||
|
||||
removeItem(key) {
|
||||
removeItem(key: string) {
|
||||
delete cache[key];
|
||||
},
|
||||
|
||||
175
ui/app/services/api.ts
Normal file
175
ui/app/services/api.ts
Normal file
@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Service, { service } from '@ember/service';
|
||||
import {
|
||||
Configuration,
|
||||
RequestContext,
|
||||
ResponseContext,
|
||||
AuthApi,
|
||||
IdentityApi,
|
||||
SecretsApi,
|
||||
SystemApi,
|
||||
} from '@hashicorp/vault-client-typescript';
|
||||
import config from '../config/environment';
|
||||
|
||||
import type AuthService from 'vault/services/auth';
|
||||
import type NamespaceService from 'vault/services/namespace';
|
||||
import type ControlGroupService from 'vault/services/control-group';
|
||||
import type FlashMessageService from 'vault/services/flash-messages';
|
||||
import type { ApiError, HeaderMap, XVaultHeaders } from 'vault/api';
|
||||
|
||||
export default class ApiService extends Service {
|
||||
@service('auth') declare readonly authService: AuthService;
|
||||
@service('namespace') declare readonly namespaceService: NamespaceService;
|
||||
@service declare readonly controlGroup: ControlGroupService;
|
||||
@service declare readonly flashMessages: FlashMessageService;
|
||||
|
||||
// -- Pre Request Middleware --
|
||||
setLastFetch = async (context: RequestContext) => {
|
||||
const { url } = context;
|
||||
const isPolling = config.APP.POLLING_URLS.some((str) => url.includes(str));
|
||||
|
||||
if (!isPolling) {
|
||||
this.authService.setLastFetch(Date.now());
|
||||
}
|
||||
};
|
||||
|
||||
getControlGroupToken = async (context: RequestContext) => {
|
||||
const { url, init } = context;
|
||||
const controlGroupToken = this.controlGroup.tokenForUrl(url);
|
||||
let newUrl = url;
|
||||
// if we have a Control Group token that matches the url,
|
||||
// unwrap it and return the unwrapped response as if it were the initial request
|
||||
// to do this, we rewrite the request
|
||||
if (controlGroupToken) {
|
||||
const { token } = controlGroupToken;
|
||||
const { headers } = this.buildHeaders({ token });
|
||||
newUrl = '/v1/sys/wrapping/unwrap';
|
||||
init.method = 'POST';
|
||||
init.headers = headers;
|
||||
init.body = JSON.stringify({ token });
|
||||
}
|
||||
return { url: newUrl, init };
|
||||
};
|
||||
|
||||
setHeaders = async (context: RequestContext) => {
|
||||
const { url, init } = context;
|
||||
const headers = new Headers(init.headers);
|
||||
// unauthenticated or clientToken requests should set the header in initOverrides
|
||||
// unauthenticated value should be empty string, not undefined or null
|
||||
if (!headers.has('X-Vault-Token')) {
|
||||
headers.set('X-Vault-Token', this.authService.currentToken);
|
||||
}
|
||||
if (init.method === 'PATCH') {
|
||||
headers.set('Content-Type', 'application/merge-patch+json');
|
||||
}
|
||||
// use initOverrides to set the namespace header to something other than path set in the namespace service
|
||||
// for requests that must be made to root namespace pass empty string as value
|
||||
const namespace = this.namespaceService.path;
|
||||
if (!headers.has('X-Vault-Namespace') && namespace) {
|
||||
headers.set('X-Vault-Namespace', namespace);
|
||||
}
|
||||
|
||||
init.headers = headers;
|
||||
return { url, init };
|
||||
};
|
||||
|
||||
// -- Post Request Middleware --
|
||||
showWarnings = async (context: ResponseContext) => {
|
||||
const response = context.response.clone();
|
||||
const json = await response?.json();
|
||||
|
||||
if (json?.warnings) {
|
||||
json.warnings.forEach((message: string) => {
|
||||
this.flashMessages.info(message);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
deleteControlGroupToken = async (context: ResponseContext) => {
|
||||
const { url } = context;
|
||||
const controlGroupToken = this.controlGroup.tokenForUrl(url);
|
||||
if (controlGroupToken) {
|
||||
this.controlGroup.deleteControlGroupToken(controlGroupToken.accessor);
|
||||
}
|
||||
};
|
||||
|
||||
formatErrorResponse = async (context: ResponseContext) => {
|
||||
const response = context.response.clone();
|
||||
const { headers, status, statusText } = response;
|
||||
|
||||
// backwards compatibility with Ember Data
|
||||
if (status >= 400) {
|
||||
const error: ApiError = (await response?.json()) || {};
|
||||
error.httpStatus = response?.status;
|
||||
error.path = context.url;
|
||||
// typically the Vault API error response looks like { errors: ['some error message'] }
|
||||
// but sometimes (eg RespondWithStatusCode) it's { data: { error: 'some error message' } }
|
||||
if (error?.data?.error && !error.errors) {
|
||||
// normalize the errors from RespondWithStatusCode
|
||||
error.errors = [error.data.error];
|
||||
}
|
||||
return new Response(JSON.stringify(error), { headers, status, statusText });
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
// the responses in the OpenAPI spec don't account for the return values to be under the 'data' key
|
||||
// return the data rather than the entire response
|
||||
// if there is no data then return the full response
|
||||
extractData = async (context: ResponseContext) => {
|
||||
const response = context.response.clone();
|
||||
const { headers, ok, status, statusText } = response;
|
||||
|
||||
if (ok) {
|
||||
const json = await response?.json();
|
||||
if (json.data) {
|
||||
return new Response(JSON.stringify(json.data), { headers, status, statusText });
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
// --- End Middleware ---
|
||||
|
||||
configuration = new Configuration({
|
||||
basePath: '/v1',
|
||||
middleware: [
|
||||
{ pre: this.setLastFetch },
|
||||
{ pre: this.getControlGroupToken },
|
||||
{ pre: this.setHeaders },
|
||||
{ post: this.showWarnings },
|
||||
{ post: this.deleteControlGroupToken },
|
||||
{ post: this.formatErrorResponse },
|
||||
{ post: this.extractData },
|
||||
],
|
||||
});
|
||||
|
||||
auth = new AuthApi(this.configuration);
|
||||
identity = new IdentityApi(this.configuration);
|
||||
secrets = new SecretsApi(this.configuration);
|
||||
sys = new SystemApi(this.configuration);
|
||||
|
||||
// convenience method for overriding headers for given requests to ensure consistency
|
||||
// eg. this.api.sys.wrap(data, { headers: { 'X-Vault-Wrap-TTL': wrap } });
|
||||
// -> this.api.sys.wrap(data, this.api.buildHeaders({ wrap }));
|
||||
buildHeaders(headerMap: HeaderMap) {
|
||||
const headers = {} as XVaultHeaders;
|
||||
|
||||
for (const key in headerMap) {
|
||||
const headerKey = {
|
||||
namespace: 'X-Vault-Namespace',
|
||||
token: 'X-Vault-Token',
|
||||
wrap: 'X-Vault-Wrap-TTL',
|
||||
}[key] as keyof XVaultHeaders;
|
||||
|
||||
headers[headerKey] = headerMap[key as keyof HeaderMap];
|
||||
}
|
||||
|
||||
return { headers };
|
||||
}
|
||||
}
|
||||
190
ui/tests/unit/services/api-test.js
Normal file
190
ui/tests/unit/services/api-test.js
Normal file
@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
import sinon from 'sinon';
|
||||
|
||||
module('Unit | Service | api', function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.apiService = this.owner.lookup('service:api');
|
||||
|
||||
const authService = this.owner.lookup('service:auth');
|
||||
this.setLastFetch = sinon.spy(authService, 'setLastFetch');
|
||||
this.currentToken = sinon.stub(authService, 'currentToken').value('foobar');
|
||||
|
||||
const namespaceService = this.owner.lookup('service:namespace');
|
||||
this.namespace = sinon.stub(namespaceService, 'path').value('another-ns');
|
||||
|
||||
const controlGroupService = this.owner.lookup('service:control-group');
|
||||
this.wrapInfo = { token: 'ctrl-group', accessor: '84tfdfd5pQ5vOOEMxC2o3Ymt' };
|
||||
this.tokenForUrl = sinon.stub(controlGroupService, 'tokenForUrl').returns(this.wrapInfo);
|
||||
this.deleteControlGroupToken = sinon.spy(controlGroupService, 'deleteControlGroupToken');
|
||||
|
||||
const flashMessageService = this.owner.lookup('service:flash-messages');
|
||||
this.info = sinon.spy(flashMessageService, 'info');
|
||||
|
||||
this.url = '/v1/sys/capabilities-self';
|
||||
});
|
||||
|
||||
test('it should set last fetch time', async function (assert) {
|
||||
await this.apiService.setLastFetch({ url: '/v1/sys/health' });
|
||||
assert.true(this.setLastFetch.notCalled, 'Last fetch is not set for polling url');
|
||||
|
||||
await this.apiService.setLastFetch({ url: '/v1/auth/token/lookup-self' });
|
||||
assert.true(this.setLastFetch.calledOnce, 'Last fetch is set for non polling url');
|
||||
});
|
||||
|
||||
test('it should get control group token', async function (assert) {
|
||||
const context = {
|
||||
url: this.url,
|
||||
init: {
|
||||
method: 'GET',
|
||||
headers: { 'X-Vault-Token': 'root' },
|
||||
},
|
||||
};
|
||||
|
||||
this.tokenForUrl.returns(undefined);
|
||||
const noTokenContext = await this.apiService.getControlGroupToken(context);
|
||||
|
||||
assert.true(this.tokenForUrl.calledWith(context.url), 'Url is passed to tokenForUrl method');
|
||||
assert.deepEqual(context, noTokenContext, 'Original context is returned when no token is present');
|
||||
|
||||
this.tokenForUrl.returns(this.wrapInfo);
|
||||
|
||||
const { token } = this.wrapInfo;
|
||||
const tokenContext = await this.apiService.getControlGroupToken(context);
|
||||
const newContext = {
|
||||
url: '/v1/sys/wrapping/unwrap',
|
||||
init: {
|
||||
method: 'POST',
|
||||
headers: { 'X-Vault-Token': token },
|
||||
body: JSON.stringify({ token }),
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepEqual(tokenContext, newContext, 'New context is returned when token is present');
|
||||
});
|
||||
|
||||
test('it should set default headers', async function (assert) {
|
||||
const {
|
||||
init: { headers },
|
||||
} = await this.apiService.setHeaders({ init: { method: 'PATCH' } });
|
||||
|
||||
assert.strictEqual(
|
||||
headers.get('X-Vault-Token'),
|
||||
'foobar',
|
||||
'Token header is set with value from auth service'
|
||||
);
|
||||
assert.strictEqual(
|
||||
headers.get('X-Vault-Namespace'),
|
||||
'another-ns',
|
||||
'Namespace header is set with value from namespace service'
|
||||
);
|
||||
assert.strictEqual(
|
||||
headers.get('Content-Type'),
|
||||
'application/merge-patch+json',
|
||||
'Content type header is set for PATCH method'
|
||||
);
|
||||
});
|
||||
|
||||
test('it should override default headers when set on request init', async function (assert) {
|
||||
const initHeaders = {
|
||||
'X-Vault-Token': 'root',
|
||||
'X-Vault-Namespace': 'ns1',
|
||||
};
|
||||
|
||||
const {
|
||||
init: { headers },
|
||||
} = await this.apiService.setHeaders({ init: { headers: initHeaders } });
|
||||
|
||||
assert.strictEqual(headers.get('X-Vault-Token'), 'root', 'Token header set on request init is preserved');
|
||||
assert.strictEqual(
|
||||
headers.get('X-Vault-Namespace'),
|
||||
'ns1',
|
||||
'Namespace header set on request init is preserved'
|
||||
);
|
||||
});
|
||||
|
||||
test('it should show warnings', async function (assert) {
|
||||
const warnings = ['warning1', 'warning2'];
|
||||
const response = new Response(JSON.stringify({ warnings }));
|
||||
|
||||
await this.apiService.showWarnings({ response });
|
||||
|
||||
assert.true(this.info.firstCall.calledWith(warnings[0]), 'First warning message is shown');
|
||||
assert.true(this.info.secondCall.calledWith(warnings[1]), 'Second warning message is shown');
|
||||
});
|
||||
|
||||
test('it should delete control group token', async function (assert) {
|
||||
await this.apiService.deleteControlGroupToken({ url: this.url });
|
||||
|
||||
assert.true(this.tokenForUrl.calledWith(this.url), 'Url is passed to tokenForUrl method');
|
||||
assert.true(
|
||||
this.deleteControlGroupToken.calledWith(this.wrapInfo.accessor),
|
||||
'Control group token is deleted'
|
||||
);
|
||||
});
|
||||
|
||||
test('it should format error response', async function (assert) {
|
||||
const e = { data: { error: 'Something went wrong' } };
|
||||
const response = new Response(JSON.stringify(e), { status: 400 });
|
||||
|
||||
const errorResponse = await this.apiService.formatErrorResponse({ response, url: this.url });
|
||||
const error = await errorResponse.json();
|
||||
const expectedError = {
|
||||
...e,
|
||||
httpStatus: 400,
|
||||
path: this.url,
|
||||
errors: ['Something went wrong'],
|
||||
};
|
||||
|
||||
assert.deepEqual(error, expectedError, 'Error is reformated and returned');
|
||||
});
|
||||
|
||||
test('it should extract data from response', async function (assert) {
|
||||
const data = { data: { foo: 'bar' } };
|
||||
const response = new Response(JSON.stringify(data), { status: 200 });
|
||||
|
||||
const extractedDataResponse = await this.apiService.extractData({ response, url: this.url });
|
||||
const json = await extractedDataResponse.json();
|
||||
|
||||
assert.deepEqual(json, { foo: 'bar' }, 'Data is extracted and returned');
|
||||
});
|
||||
|
||||
test('it should build headers', async function (assert) {
|
||||
const headerMap = {
|
||||
token: 'foobar',
|
||||
namespace: 'ns1',
|
||||
wrap: '10s',
|
||||
};
|
||||
|
||||
const token = await this.apiService.buildHeaders({ token: headerMap.token });
|
||||
assert.deepEqual(token.headers, { 'X-Vault-Token': headerMap.token }, 'Token header is set');
|
||||
|
||||
const namespace = await this.apiService.buildHeaders({ namespace: headerMap.namespace });
|
||||
assert.deepEqual(
|
||||
namespace.headers,
|
||||
{ 'X-Vault-Namespace': headerMap.namespace },
|
||||
'Namespace header is set'
|
||||
);
|
||||
|
||||
const wrapTTL = await this.apiService.buildHeaders({ wrap: headerMap.wrap });
|
||||
assert.deepEqual(wrapTTL.headers, { 'X-Vault-Wrap-TTL': '10s' }, 'Wrap TTL header is set');
|
||||
|
||||
const multi = await this.apiService.buildHeaders(headerMap);
|
||||
assert.deepEqual(
|
||||
multi.headers,
|
||||
{
|
||||
'X-Vault-Token': 'foobar',
|
||||
'X-Vault-Namespace': 'ns1',
|
||||
'X-Vault-Wrap-TTL': '10s',
|
||||
},
|
||||
'All supported headers are set'
|
||||
);
|
||||
});
|
||||
});
|
||||
58
ui/types/vault/api.d.ts
vendored
Normal file
58
ui/types/vault/api.d.ts
vendored
Normal file
@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
export interface ApiError {
|
||||
httpStatus: number;
|
||||
path: string;
|
||||
message: string;
|
||||
errors: Array<string | { [key: string]: unknown; title?: string; message?: string }>;
|
||||
data?: {
|
||||
[key: string]: unknown;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WrapInfo {
|
||||
accessor: string;
|
||||
creation_path: string;
|
||||
creation_time: string;
|
||||
wrapped_accessor: string;
|
||||
token: string;
|
||||
ttl: number;
|
||||
}
|
||||
|
||||
export interface ApiResponse {
|
||||
auth: unknown;
|
||||
data: unknown;
|
||||
lease_duration: number;
|
||||
lease_id: string;
|
||||
mount_type: string;
|
||||
renewable: boolean;
|
||||
request_id: string;
|
||||
warnings: Array<string> | null;
|
||||
wrap_info: WrapInfo | null;
|
||||
}
|
||||
|
||||
export type HeaderMap =
|
||||
| {
|
||||
namespace: string;
|
||||
}
|
||||
| {
|
||||
token: string;
|
||||
}
|
||||
| {
|
||||
wrap: string;
|
||||
};
|
||||
|
||||
export type XVaultHeaders =
|
||||
| {
|
||||
'X-Vault-Namespace': string;
|
||||
}
|
||||
| {
|
||||
'X-Vault-Token': string;
|
||||
}
|
||||
| {
|
||||
'X-Vault-Wrap-TTL': string;
|
||||
};
|
||||
2
ui/types/vault/services/auth.d.ts
vendored
2
ui/types/vault/services/auth.d.ts
vendored
@ -19,4 +19,6 @@ export interface AuthData {
|
||||
|
||||
export default class AuthService extends Service {
|
||||
authData: AuthData;
|
||||
currentToken: string;
|
||||
setLastFetch: (time: number) => void;
|
||||
}
|
||||
|
||||
40
ui/types/vault/services/control-group.d.ts
vendored
Normal file
40
ui/types/vault/services/control-group.d.ts
vendored
Normal file
@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Service from '@ember/service';
|
||||
|
||||
import type localStorage from 'vault/lib/local-storage';
|
||||
import type memoryStorage from 'vault/lib/memory-storage';
|
||||
import type { ApiResponse, WrapInfo } from 'vault/api';
|
||||
import type Transition from '@ember/routing/transition';
|
||||
|
||||
export interface ControlGroupErrorLog {
|
||||
type: string;
|
||||
content: string;
|
||||
href: string;
|
||||
token: string;
|
||||
accessor: string;
|
||||
creation_path: string;
|
||||
}
|
||||
|
||||
export default class ControlGroupService extends Service {
|
||||
tokenToUnwrap: WrapInfo | null;
|
||||
storage(): localStorage | memoryStorage;
|
||||
keyFromAccessor(accessor: string): string | null;
|
||||
storeControlGroupToken(info: WrapInfo): void;
|
||||
deleteControlGroupToken(accessor: string): void;
|
||||
deleteTokens(): void;
|
||||
wrapInfoForAccessor(accessor: string): string | null;
|
||||
markTokenForUnwrap(accessor: string): void;
|
||||
unmarkTokenForUnwrap(): void;
|
||||
tokenForUrl(url: string): { token: string; accessor: string; creationTime: string } | null;
|
||||
checkForControlGroup(
|
||||
callbackArgs: unknown,
|
||||
response: ApiResponse,
|
||||
wasWrapTTLRequested: boolean
|
||||
): Promise<unknown>;
|
||||
saveTokenFromError(error: WrapInfo): void;
|
||||
logFromError(error: WrapInfo): ControlGroupErrorLog;
|
||||
}
|
||||
1
ui/types/vault/services/namespace.d.ts
vendored
1
ui/types/vault/services/namespace.d.ts
vendored
@ -15,6 +15,7 @@ export default class NamespaceService extends Service {
|
||||
inRootNamespace: boolean;
|
||||
currentNamespace: string;
|
||||
relativeNamespace: string;
|
||||
path: string;
|
||||
setNamespace: () => void;
|
||||
findNamespacesForUser: () => void;
|
||||
reset: () => void;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user