mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
Merge branch 'external-db-sync' of https://github.com/stack-auth/stack-auth into external-db-sync
This commit is contained in:
commit
61f2b79f46
16
.github/workflows/e2e-api-tests.yaml
vendored
16
.github/workflows/e2e-api-tests.yaml
vendored
@ -159,22 +159,6 @@ jobs:
|
||||
- name: Wait 10 seconds
|
||||
run: sleep 10
|
||||
|
||||
- name: Prime external DB sync
|
||||
run: |
|
||||
set -euo pipefail
|
||||
set -a
|
||||
source apps/backend/.env.test.local
|
||||
set +a
|
||||
baseUrl="http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}02"
|
||||
maxDurationMs="${STACK_EXTERNAL_DB_SYNC_MAX_DURATION_MS:-20000}"
|
||||
for _ in 1 2 3; do
|
||||
curl -fsS -H "Authorization: Bearer ${CRON_SECRET}" \
|
||||
"${baseUrl}/api/latest/internal/external-db-sync/sequencer?maxDurationMs=${maxDurationMs}&stopWhenIdle=true" >/dev/null
|
||||
curl -fsS -H "Authorization: Bearer ${CRON_SECRET}" \
|
||||
"${baseUrl}/api/latest/internal/external-db-sync/poller?maxDurationMs=${maxDurationMs}&stopWhenIdle=true" >/dev/null
|
||||
sleep 2
|
||||
done
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm test run ${{ matrix.freestyle-mode == 'prod' && '--min-workers=1 --max-workers=1' || '' }}
|
||||
|
||||
|
||||
@ -153,21 +153,6 @@ jobs:
|
||||
- name: Wait 10 seconds
|
||||
run: sleep 10
|
||||
|
||||
- name: Prime external DB sync
|
||||
run: |
|
||||
set -euo pipefail
|
||||
set -a
|
||||
source apps/backend/.env.test.local
|
||||
set +a
|
||||
baseUrl="http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}02"
|
||||
for _ in 1 2 3; do
|
||||
curl -fsS -H "Authorization: Bearer ${CRON_SECRET}" \
|
||||
"${baseUrl}/api/latest/internal/external-db-sync/sequencer" >/dev/null
|
||||
curl -fsS -H "Authorization: Bearer ${CRON_SECRET}" \
|
||||
"${baseUrl}/api/latest/internal/external-db-sync/poller" >/dev/null
|
||||
sleep 2
|
||||
done
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm test run
|
||||
|
||||
|
||||
@ -160,21 +160,6 @@ jobs:
|
||||
- name: Wait 10 seconds
|
||||
run: sleep 10
|
||||
|
||||
- name: Prime external DB sync
|
||||
run: |
|
||||
set -euo pipefail
|
||||
set -a
|
||||
source apps/backend/.env.test.local
|
||||
set +a
|
||||
baseUrl="http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}02"
|
||||
for _ in 1 2 3; do
|
||||
curl -fsS -H "Authorization: Bearer ${CRON_SECRET}" \
|
||||
"${baseUrl}/api/latest/internal/external-db-sync/sequencer" >/dev/null
|
||||
curl -fsS -H "Authorization: Bearer ${CRON_SECRET}" \
|
||||
"${baseUrl}/api/latest/internal/external-db-sync/poller" >/dev/null
|
||||
sleep 2
|
||||
done
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm test run
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@stackframe/stack-backend",
|
||||
"version": "2.8.63",
|
||||
"version": "2.8.64",
|
||||
"repository": "https://github.com/stack-auth/stack-auth",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@ -79,6 +79,10 @@ async function main() {
|
||||
const shouldSkipNeon = flags.includes("--skip-neon");
|
||||
const recentFirst = flags.includes("--recent-first");
|
||||
const noBail = flags.includes("--no-bail");
|
||||
const maxUsersPerProjectFlag = flags.find(f => f.startsWith("--max-users-per-project="));
|
||||
const maxUsersPerProject = maxUsersPerProjectFlag
|
||||
? parseInt(maxUsersPerProjectFlag.split("=")[1], 10)
|
||||
: Infinity;
|
||||
|
||||
const { recurse, collectedErrors } = createRecurse({ noBail });
|
||||
|
||||
@ -147,7 +151,9 @@ async function main() {
|
||||
console.warn("Using mock Stripe server (STACK_STRIPE_SECRET_KEY=sk_test_mockstripekey); skipping Stripe payout integrity checks.");
|
||||
}
|
||||
|
||||
const maxUsersPerProject = 100;
|
||||
if (maxUsersPerProject !== Infinity) {
|
||||
console.log(`Will check at most ${maxUsersPerProject} users per project.`);
|
||||
}
|
||||
|
||||
const endAt = Math.min(startAt + count, projects.length);
|
||||
for (let i = startAt; i < endAt; i++) {
|
||||
@ -157,7 +163,7 @@ async function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
const [currentProject, users, projectPermissionDefinitions, teamPermissionDefinitions] = await Promise.all([
|
||||
const [currentProject, projectPermissionDefinitions, teamPermissionDefinitions] = await Promise.all([
|
||||
expectStatusCode(200, `/api/v1/internal/projects/current`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
@ -166,14 +172,6 @@ async function main() {
|
||||
"x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"),
|
||||
},
|
||||
}),
|
||||
expectStatusCode(200, `/api/v1/users?limit=${maxUsersPerProject}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"x-stack-project-id": projectId,
|
||||
"x-stack-access-type": "admin",
|
||||
"x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"),
|
||||
},
|
||||
}),
|
||||
expectStatusCode(200, `/api/v1/project-permission-definitions`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
@ -193,6 +191,30 @@ async function main() {
|
||||
]);
|
||||
void currentProject;
|
||||
|
||||
// Fetch users with pagination
|
||||
const PAGE_LIMIT = 1000;
|
||||
const allUsers: any[] = [];
|
||||
let cursor: string | undefined = undefined;
|
||||
while (allUsers.length < maxUsersPerProject) {
|
||||
const remainingToFetch = maxUsersPerProject - allUsers.length;
|
||||
const limit = Math.min(PAGE_LIMIT, remainingToFetch);
|
||||
const cursorParam: string = cursor ? `&cursor=${encodeURIComponent(cursor)}` : "";
|
||||
const usersPage = await expectStatusCode(200, `/api/v1/users?limit=${limit}${cursorParam}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"x-stack-project-id": projectId,
|
||||
"x-stack-access-type": "admin",
|
||||
"x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"),
|
||||
},
|
||||
});
|
||||
allUsers.push(...usersPage.items);
|
||||
if (!usersPage.pagination?.next_cursor) {
|
||||
break;
|
||||
}
|
||||
cursor = usersPage.pagination.next_cursor;
|
||||
}
|
||||
const users = { items: allUsers.slice(0, maxUsersPerProject) };
|
||||
|
||||
const tenancy = await getSoleTenancyFromProjectBranch(projectId, DEFAULT_BRANCH_ID, true);
|
||||
const paymentsConfig = tenancy ? (tenancy.config as OrganizationRenderedConfig).payments : undefined;
|
||||
const paymentsVerifier = tenancy && paymentsConfig
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
|
||||
import { traceSpan } from "@/utils/telemetry";
|
||||
import { yupNumber, yupObject, yupString, yupTuple } from "@stackframe/stack-shared/dist/schema-fields";
|
||||
import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto";
|
||||
import { getEnvVariable, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env";
|
||||
@ -62,29 +63,31 @@ const fetchFromResend = async (): Promise<{ data: ResendEmail[] }> => {
|
||||
};
|
||||
|
||||
const performSignUp = async (email: string, password: string) => {
|
||||
const apiBaseUrl = getEnvVariable("NEXT_PUBLIC_STACK_API_URL");
|
||||
const response = await fetch(`${apiBaseUrl}/api/v1/auth/password/sign-up`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Stack-Access-Type": "client",
|
||||
"X-Stack-Publishable-Client-Key": getEnvVariable("STACK_EMAIL_MONITOR_PUBLISHABLE_CLIENT_KEY"),
|
||||
"X-Stack-Project-Id": getEnvVariable("STACK_EMAIL_MONITOR_PROJECT_ID"),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
password,
|
||||
verification_callback_url: getEnvVariable("STACK_EMAIL_MONITOR_VERIFICATION_CALLBACK_URL"),
|
||||
}),
|
||||
});
|
||||
|
||||
const responseBody = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new StackAssertionError(`Sign-up failed: ${response.status} - ${responseBody}`, {
|
||||
responseBody,
|
||||
await traceSpan("performing sign-up", async () => {
|
||||
const apiBaseUrl = getEnvVariable("NEXT_PUBLIC_STACK_API_URL");
|
||||
const response = await fetch(`${apiBaseUrl}/api/v1/auth/password/sign-up`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Stack-Access-Type": "client",
|
||||
"X-Stack-Publishable-Client-Key": getEnvVariable("STACK_EMAIL_MONITOR_PUBLISHABLE_CLIENT_KEY"),
|
||||
"X-Stack-Project-Id": getEnvVariable("STACK_EMAIL_MONITOR_PROJECT_ID"),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
password,
|
||||
verification_callback_url: getEnvVariable("STACK_EMAIL_MONITOR_VERIFICATION_CALLBACK_URL"),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const responseBody = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new StackAssertionError(`Sign-up failed: ${response.status} - ${responseBody}`, {
|
||||
responseBody,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const isExpectedVerificationEmail = (email: ResendEmail, testEmail: string): boolean => {
|
||||
@ -99,25 +102,34 @@ const isExpectedVerificationEmail = (email: ResendEmail, testEmail: string): boo
|
||||
};
|
||||
|
||||
const waitForVerificationEmail = async (testEmail: string, useInbucket: boolean) => {
|
||||
const MAX_POLL_ATTEMPTS = 24;
|
||||
const POLL_INTERVAL_MS = 5000;
|
||||
await traceSpan("waiting for verification email", async () => {
|
||||
const MAX_POLL_ATTEMPTS = 24;
|
||||
const POLL_INTERVAL_MS = 5000;
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_POLL_ATTEMPTS; attempt++) {
|
||||
await wait(POLL_INTERVAL_MS);
|
||||
for (let attempt = 1; attempt <= MAX_POLL_ATTEMPTS; attempt++) {
|
||||
const done = await traceSpan(`waiting for verification email - attempt ${attempt}`, async () => {
|
||||
await wait(POLL_INTERVAL_MS);
|
||||
|
||||
const listData = useInbucket
|
||||
? await fetchFromInbucket(testEmail)
|
||||
: await fetchFromResend();
|
||||
const listData = useInbucket
|
||||
? await fetchFromInbucket(testEmail)
|
||||
: await fetchFromResend();
|
||||
|
||||
const emails = listData.data;
|
||||
const verificationEmail = emails.find((email) => isExpectedVerificationEmail(email, testEmail));
|
||||
const emails = listData.data;
|
||||
const verificationEmail = emails.find((email) => isExpectedVerificationEmail(email, testEmail));
|
||||
|
||||
if (verificationEmail) {
|
||||
return;
|
||||
if (verificationEmail) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
if (done) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new StackAssertionError(`Couldn't find verification email in time limit`, { recipient_email: testEmail, max_poll_attempts: MAX_POLL_ATTEMPTS, poll_interval_ms: POLL_INTERVAL_MS });
|
||||
throw new StackAssertionError(`Couldn't find verification email in time limit`, { recipient_email: testEmail, max_poll_attempts: MAX_POLL_ATTEMPTS, poll_interval_ms: POLL_INTERVAL_MS });
|
||||
});
|
||||
};
|
||||
|
||||
export const POST = createSmartRouteHandler({
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@stackframe/stack-dashboard",
|
||||
"version": "2.8.63",
|
||||
"version": "2.8.64",
|
||||
"repository": "https://github.com/stack-auth/stack-auth",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@stackframe/dev-launchpad",
|
||||
"version": "2.8.63",
|
||||
"version": "2.8.64",
|
||||
"repository": "https://github.com/stack-auth/stack-auth",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@stackframe/e2e-tests",
|
||||
"version": "2.8.63",
|
||||
"version": "2.8.64",
|
||||
"repository": "https://github.com/stack-auth/stack-auth",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@ -914,7 +914,7 @@ $$;`);
|
||||
expect(res.rows.length).toBe(1);
|
||||
expect(res.rows[0].display_name).toBe('Final Name');
|
||||
expect(res.rows[0].id).toBe(newId);
|
||||
}, TEST_TIMEOUT);
|
||||
}, COMPLEX_SEQUENCE_TIMEOUT);
|
||||
|
||||
/**
|
||||
* What it does:
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@stackframe/mock-oauth-server",
|
||||
"version": "2.8.63",
|
||||
"version": "2.8.64",
|
||||
"repository": "https://github.com/stack-auth/stack-auth",
|
||||
"private": true,
|
||||
"main": "index.js",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@stackframe/stack-docs",
|
||||
"version": "2.8.63",
|
||||
"version": "2.8.64",
|
||||
"repository": "https://github.com/stack-auth/stack-auth",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@stackframe/example-cjs-test",
|
||||
"version": "2.8.63",
|
||||
"version": "2.8.64",
|
||||
"repository": "https://github.com/stack-auth/stack-auth",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@stackframe/convex-example",
|
||||
"version": "2.8.63",
|
||||
"version": "2.8.64",
|
||||
"repository": "https://github.com/stack-auth/stack-auth",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@stackframe/example-demo-app",
|
||||
"version": "2.8.63",
|
||||
"version": "2.8.64",
|
||||
"repository": "https://github.com/stack-auth/stack-auth",
|
||||
"description": "",
|
||||
"private": true,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@stackframe/docs-examples",
|
||||
"version": "2.8.63",
|
||||
"version": "2.8.64",
|
||||
"repository": "https://github.com/stack-auth/stack-auth",
|
||||
"description": "",
|
||||
"private": true,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@stackframe/e-commerce-demo",
|
||||
"version": "2.8.63",
|
||||
"version": "2.8.64",
|
||||
"repository": "https://github.com/stack-auth/stack-auth",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@stackframe/js-example",
|
||||
"version": "2.8.63",
|
||||
"version": "2.8.64",
|
||||
"repository": "https://github.com/stack-auth/stack-auth",
|
||||
"private": true,
|
||||
"description": "",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@stackframe/lovable-react-18-example",
|
||||
"private": true,
|
||||
"version": "2.8.63",
|
||||
"version": "2.8.64",
|
||||
"repository": "https://github.com/stack-auth/stack-auth",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@stackframe/example-middleware-demo",
|
||||
"version": "2.8.63",
|
||||
"version": "2.8.64",
|
||||
"repository": "https://github.com/stack-auth/stack-auth",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "react-example",
|
||||
"private": true,
|
||||
"version": "2.8.63",
|
||||
"version": "2.8.64",
|
||||
"repository": "https://github.com/stack-auth/stack-auth",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@stackframe/example-supabase",
|
||||
"version": "2.8.63",
|
||||
"version": "2.8.64",
|
||||
"repository": "https://github.com/stack-auth/stack-auth",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@stackframe/init-stack",
|
||||
"version": "2.8.63",
|
||||
"version": "2.8.64",
|
||||
"repository": "https://github.com/stack-auth/stack-auth",
|
||||
"description": "The setup wizard for Stack. https://stack-auth.com",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY, INSTEAD EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)",
|
||||
"name": "@stackframe/js",
|
||||
"version": "2.8.63",
|
||||
"version": "2.8.64",
|
||||
"repository": "https://github.com/stack-auth/stack-auth",
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY, INSTEAD EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)",
|
||||
"name": "@stackframe/react",
|
||||
"version": "2.8.63",
|
||||
"version": "2.8.64",
|
||||
"repository": "https://github.com/stack-auth/stack-auth",
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@stackframe/stack-sc",
|
||||
"version": "2.8.63",
|
||||
"version": "2.8.64",
|
||||
"repository": "https://github.com/stack-auth/stack-auth",
|
||||
"exports": {
|
||||
"./force-react-server": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@stackframe/stack-shared",
|
||||
"version": "2.8.63",
|
||||
"version": "2.8.64",
|
||||
"repository": "https://github.com/stack-auth/stack-auth",
|
||||
"scripts": {
|
||||
"build": "rimraf dist && tsup-node",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@stackframe/stack-ui",
|
||||
"version": "2.8.63",
|
||||
"version": "2.8.64",
|
||||
"repository": "https://github.com/stack-auth/stack-auth",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY, INSTEAD EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)",
|
||||
"name": "@stackframe/stack",
|
||||
"version": "2.8.63",
|
||||
"version": "2.8.64",
|
||||
"repository": "https://github.com/stack-auth/stack-auth",
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
|
||||
"//": "NEXT_LINE_PLATFORM template",
|
||||
"private": true,
|
||||
"version": "2.8.63",
|
||||
"version": "2.8.64",
|
||||
"repository": "https://github.com/stack-auth/stack-auth",
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY, INSTEAD EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)",
|
||||
"name": "@stackframe/template",
|
||||
"private": true,
|
||||
"version": "2.8.63",
|
||||
"version": "2.8.64",
|
||||
"repository": "https://github.com/stack-auth/stack-auth",
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@stackframe/swift-sdk",
|
||||
"version": "2.8.63",
|
||||
"version": "2.8.64",
|
||||
"private": true,
|
||||
"description": "Stack Auth Swift SDK",
|
||||
"scripts": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@stackframe/sdk-spec",
|
||||
"version": "2.8.63",
|
||||
"version": "2.8.64",
|
||||
"private": true,
|
||||
"description": "Stack Auth SDK specification files",
|
||||
"scripts": {}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user