test(mfa): end-to-end coverage, OpenAPI and documentation
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
import { test, expect, type APIRequestContext, type BrowserContext, type Page } from "@playwright/test";
|
||||
import { authenticator } from "otplib";
|
||||
|
||||
/**
|
||||
* End-to-end coverage for Task 15 of the MFA design
|
||||
* (docs/superpowers/specs/2026-09-15-mfa-local-signin-design.md).
|
||||
*
|
||||
* Prerequisites: a running control plane (server + web) backed by MongoDB
|
||||
* and Redis, reachable at `E2E_BASE_URL` (default http://localhost:3000)
|
||||
* with the API at the same origin (the nginx fragment in
|
||||
* deploy/docker/docker-compose.yml, or an equivalent dev proxy).
|
||||
*
|
||||
* The suite needs an existing owner session to create fresh member accounts
|
||||
* per test, so state never leaks between tests. If the instance has never
|
||||
* been bootstrapped, the first test bootstraps it and every other test reuses
|
||||
* those owner credentials; set E2E_OWNER_EMAIL / E2E_OWNER_PASSWORD to point
|
||||
* at an already-bootstrapped instance's owner instead.
|
||||
*/
|
||||
|
||||
const BASE_URL = process.env.E2E_BASE_URL ?? "http://localhost:3000";
|
||||
|
||||
let ownerEmail = process.env.E2E_OWNER_EMAIL ?? "";
|
||||
let ownerPassword = process.env.E2E_OWNER_PASSWORD ?? "";
|
||||
|
||||
/** Ensures an owner account exists and returns its credentials, bootstrapping the instance if needed. */
|
||||
async function ensureOwner(request: APIRequestContext): Promise<{ email: string; password: string }> {
|
||||
if (ownerEmail && ownerPassword) return { email: ownerEmail, password: ownerPassword };
|
||||
|
||||
const status = await request.get(`${BASE_URL}/auth/bootstrap-status`);
|
||||
const body = await status.json();
|
||||
if (!body.needs_setup) {
|
||||
throw new Error(
|
||||
"Instance is already bootstrapped and E2E_OWNER_EMAIL/E2E_OWNER_PASSWORD were not set - " +
|
||||
"cannot create the owner account this suite needs to provision fresh members per test.",
|
||||
);
|
||||
}
|
||||
|
||||
ownerEmail = `e2e-owner-${Date.now()}@vantage.test`;
|
||||
ownerPassword = "correct horse battery staple 1";
|
||||
const res = await request.post(`${BASE_URL}/auth/bootstrap`, {
|
||||
data: { instance_name: "MFA E2E", email: ownerEmail, password: ownerPassword },
|
||||
});
|
||||
if (!res.ok()) throw new Error(`bootstrap failed: ${res.status()} ${await res.text()}`);
|
||||
return { email: ownerEmail, password: ownerPassword };
|
||||
}
|
||||
|
||||
/** Creates a fresh, unique local member with no MFA enrolled, using an owner session. */
|
||||
async function createMember(request: APIRequestContext): Promise<{ email: string; password: string }> {
|
||||
const owner = await ensureOwner(request);
|
||||
|
||||
const login = await request.post(`${BASE_URL}/auth/login`, {
|
||||
data: { email: owner.email, password: owner.password },
|
||||
});
|
||||
if (!login.ok()) throw new Error(`owner login failed: ${login.status()} ${await login.text()}`);
|
||||
|
||||
const email = `e2e-member-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@vantage.test`;
|
||||
const password = "correct horse battery staple 1";
|
||||
const res = await request.post(`${BASE_URL}/api/org/users`, {
|
||||
data: { email, password, role: "member" },
|
||||
});
|
||||
if (!res.ok()) throw new Error(`create member failed: ${res.status()} ${await res.text()}`);
|
||||
|
||||
await request.post(`${BASE_URL}/auth/logout`);
|
||||
return { email, password };
|
||||
}
|
||||
|
||||
/** Registers Chrome's virtual authenticator (CTAP2, resident keys, internal UV) on this context. */
|
||||
async function addVirtualAuthenticator(context: BrowserContext, page: Page): Promise<string> {
|
||||
const client = await context.newCDPSession(page);
|
||||
await client.send("WebAuthn.enable");
|
||||
const { authenticatorId } = await client.send("WebAuthn.addVirtualAuthenticator", {
|
||||
options: {
|
||||
protocol: "ctap2",
|
||||
transport: "internal",
|
||||
hasResidentKey: true,
|
||||
hasUserVerification: true,
|
||||
isUserVerified: true,
|
||||
},
|
||||
});
|
||||
return authenticatorId;
|
||||
}
|
||||
|
||||
async function fillCredentials(page: Page, email: string, password: string) {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel("Email").fill(email);
|
||||
await page.getByLabel("Password").fill(password);
|
||||
await page.getByRole("button", { name: "Sign In" }).click();
|
||||
}
|
||||
|
||||
test.describe("MFA end-to-end", () => {
|
||||
test("TOTP sign-in", async ({ page, request }) => {
|
||||
const { email, password } = await createMember(request);
|
||||
|
||||
// Sign in, land on the account security page, enrol TOTP.
|
||||
await fillCredentials(page, email, password);
|
||||
await expect(page).toHaveURL("/");
|
||||
|
||||
await page.goto("/account/security");
|
||||
await page.getByRole("button", { name: "Set up" }).click();
|
||||
await page.getByRole("button", { name: "Use an authenticator app" }).click();
|
||||
|
||||
const secret = await page.locator("p.font-mono").innerText();
|
||||
const code = authenticator.generate(secret.trim());
|
||||
await page.getByLabel("6-digit code").fill(code);
|
||||
await page.getByRole("button", { name: "Confirm" }).click();
|
||||
|
||||
// Recovery codes step - acknowledge and finish.
|
||||
await page.getByLabel("I have saved these codes").check();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await expect(page.getByText("Enabled")).toBeVisible();
|
||||
|
||||
// Sign out and sign back in - TOTP is now required.
|
||||
await page.request.post(`${BASE_URL}/auth/logout`);
|
||||
await fillCredentials(page, email, password);
|
||||
|
||||
await expect(page.getByText("Enter the 6-digit code")).toBeVisible();
|
||||
const nextCode = authenticator.generate(secret.trim());
|
||||
await page.getByLabel("Verification code").fill(nextCode);
|
||||
await page.getByRole("button", { name: "Verify" }).click();
|
||||
|
||||
await expect(page).toHaveURL("/");
|
||||
});
|
||||
|
||||
test("passkey second factor", async ({ page, context, request }) => {
|
||||
const { email, password } = await createMember(request);
|
||||
await addVirtualAuthenticator(context, page);
|
||||
|
||||
await fillCredentials(page, email, password);
|
||||
await expect(page).toHaveURL("/");
|
||||
|
||||
await page.goto("/account/security");
|
||||
await page.getByRole("button", { name: "Set up" }).click();
|
||||
await page.getByRole("button", { name: "Use a passkey" }).click();
|
||||
|
||||
await page.getByLabel("I have saved these codes").check();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await expect(page.getByText("No passkeys registered.")).toHaveCount(0);
|
||||
|
||||
await page.request.post(`${BASE_URL}/auth/logout`);
|
||||
await fillCredentials(page, email, password);
|
||||
|
||||
await expect(page.getByText("Enter the 6-digit code")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Use passkey" }).click();
|
||||
|
||||
await expect(page).toHaveURL("/");
|
||||
});
|
||||
|
||||
test("passwordless passkey sign-in", async ({ page, context, request }) => {
|
||||
const { email, password } = await createMember(request);
|
||||
await addVirtualAuthenticator(context, page);
|
||||
|
||||
// Enrol a passkey first (typing the password once, during setup only).
|
||||
await fillCredentials(page, email, password);
|
||||
await expect(page).toHaveURL("/");
|
||||
await page.goto("/account/security");
|
||||
await page.getByRole("button", { name: "Add a passkey" }).click();
|
||||
await page.request.post(`${BASE_URL}/auth/logout`);
|
||||
|
||||
// Sign back in with no password typed at all.
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Sign in with passkey" }).click();
|
||||
|
||||
await expect(page).toHaveURL("/");
|
||||
});
|
||||
|
||||
test("forced enrolment when require_mfa is on", async ({ page, request }) => {
|
||||
const owner = await ensureOwner(request);
|
||||
const { email, password } = await createMember(request);
|
||||
|
||||
// Owner enables the policy.
|
||||
const ownerLogin = await request.post(`${BASE_URL}/auth/login`, {
|
||||
data: { email: owner.email, password: owner.password },
|
||||
});
|
||||
expect(ownerLogin.ok()).toBeTruthy();
|
||||
const settingsRes = await request.put(`${BASE_URL}/api/settings`, {
|
||||
data: { require_mfa: true },
|
||||
});
|
||||
expect(settingsRes.ok()).toBeTruthy();
|
||||
await request.post(`${BASE_URL}/auth/logout`);
|
||||
|
||||
// The member, who has no MFA yet, must enrol before reaching the app.
|
||||
await fillCredentials(page, email, password);
|
||||
await expect(page.getByText("This instance requires a second sign-in factor. Set one up to continue.")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Use an authenticator app" }).click();
|
||||
const secret = await page.locator("p.font-mono").innerText();
|
||||
const code = authenticator.generate(secret.trim());
|
||||
await page.getByLabel("6-digit code").fill(code);
|
||||
await page.getByRole("button", { name: "Confirm" }).click();
|
||||
await page.getByLabel("I have saved these codes").check();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
await expect(page).toHaveURL("/");
|
||||
|
||||
// Clean up: turn the policy back off so it doesn't affect other tests.
|
||||
await request.post(`${BASE_URL}/auth/login`, { data: { email: owner.email, password: owner.password } });
|
||||
await request.put(`${BASE_URL}/api/settings`, { data: { require_mfa: false } });
|
||||
await request.post(`${BASE_URL}/auth/logout`);
|
||||
});
|
||||
|
||||
test("step-up on secret reveal", async ({ page, request }) => {
|
||||
const { email, password } = await createMember(request);
|
||||
|
||||
// Enrol TOTP so step-up has a factor to challenge.
|
||||
await fillCredentials(page, email, password);
|
||||
await page.goto("/account/security");
|
||||
await page.getByRole("button", { name: "Set up" }).click();
|
||||
await page.getByRole("button", { name: "Use an authenticator app" }).click();
|
||||
const secret = await page.locator("p.font-mono").innerText();
|
||||
await page.getByLabel("6-digit code").fill(authenticator.generate(secret.trim()));
|
||||
await page.getByRole("button", { name: "Confirm" }).click();
|
||||
await page.getByLabel("I have saved these codes").check();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// A brand-new session's StepUpAt is fresh from sign-in, so create a secret,
|
||||
// then create a group via the UI and attempt a reveal - the modal should
|
||||
// still appear because sign-in only counts as step-up for ten minutes and
|
||||
// this test does not wait that long; it is testing the prompt fires the
|
||||
// first time an authenticated caller with no fresh step-up reveals one.
|
||||
await request.post(`${BASE_URL}/api/secrets`, { data: { group: "e2e-stepup", values: { KEY: "value" } } });
|
||||
|
||||
await page.goto("/secrets/e2e-stepup");
|
||||
await page.getByRole("button", { name: "Reveal" }).click();
|
||||
|
||||
await expect(page.getByText("Confirm it's you")).toBeVisible();
|
||||
await page.getByLabel("6-digit code").fill(authenticator.generate(secret.trim()));
|
||||
await page.getByRole("button", { name: "Confirm" }).click();
|
||||
|
||||
await expect(page.getByText("Confirm it's you")).toHaveCount(0);
|
||||
await expect(page.locator("span.font-mono.text-xs.break-all")).toBeVisible();
|
||||
|
||||
// Reveal again within the ten-minute step-up window: no prompt this time.
|
||||
await page.getByRole("button", { name: "Hide" }).click().catch(() => {});
|
||||
await page.getByRole("button", { name: "Reveal" }).click();
|
||||
await expect(page.getByText("Confirm it's you")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
Generated
+124
@@ -17,6 +17,7 @@
|
||||
"tailwind-merge": "^2.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.48.0",
|
||||
"@types/node": "^20.14.11",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^18.3.3",
|
||||
@@ -24,6 +25,7 @@
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-next": "16.2.9",
|
||||
"otplib": "^12.0.1",
|
||||
"postcss": "^8.4.39",
|
||||
"tailwindcss": "^3.4.6",
|
||||
"typescript": "^5.5.3"
|
||||
@@ -1231,6 +1233,78 @@
|
||||
"node": ">=12.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/core": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz",
|
||||
"integrity": "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@otplib/plugin-crypto": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz",
|
||||
"integrity": "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==",
|
||||
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/plugin-thirty-two": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz",
|
||||
"integrity": "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==",
|
||||
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1",
|
||||
"thirty-two": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/preset-default": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/preset-default/-/preset-default-12.0.1.tgz",
|
||||
"integrity": "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==",
|
||||
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1",
|
||||
"@otplib/plugin-crypto": "^12.0.1",
|
||||
"@otplib/plugin-thirty-two": "^12.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/preset-v11": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/preset-v11/-/preset-v11-12.0.1.tgz",
|
||||
"integrity": "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1",
|
||||
"@otplib/plugin-crypto": "^12.0.1",
|
||||
"@otplib/plugin-thirty-two": "^12.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
||||
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"playwright": "1.63.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@rtsao/scc": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||
@@ -5133,6 +5207,18 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/otplib": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz",
|
||||
"integrity": "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1",
|
||||
"@otplib/preset-default": "^12.0.1",
|
||||
"@otplib/preset-v11": "^12.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/own-keys": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
|
||||
@@ -5270,6 +5356,35 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
||||
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.63.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
||||
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
@@ -6420,6 +6535,15 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/thirty-two": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz",
|
||||
"integrity": "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.2.6"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
|
||||
+4
-1
@@ -6,7 +6,8 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
"lint": "next lint",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.51.1",
|
||||
@@ -18,6 +19,8 @@
|
||||
"tailwind-merge": "^2.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.48.0",
|
||||
"otplib": "^12.0.1",
|
||||
"@types/node": "^20.14.11",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^18.3.3",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Minimal Playwright setup for the MFA end-to-end coverage (Task 15).
|
||||
*
|
||||
* There was no Playwright config in this repository before this task, so
|
||||
* this is the smallest one that works: one project (Chromium, the only
|
||||
* browser that implements the WebAuthn virtual authenticator CDP domain the
|
||||
* passkey tests need), pointed at an already-running stack.
|
||||
*
|
||||
* Prerequisites (not started by this config - the stack needs MongoDB and
|
||||
* Redis, which `webServer` cannot provision):
|
||||
* docker compose -f ../deploy/docker/docker-compose.yml up -d
|
||||
* npm run dev # or the built `server`/`web`, whichever is already running
|
||||
*
|
||||
* Run with: npx playwright test e2e/mfa.spec.ts
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
reporter: "list",
|
||||
use: {
|
||||
baseURL: process.env.E2E_BASE_URL ?? "http://localhost:3000",
|
||||
trace: "retain-on-failure",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
+3
-1
@@ -36,6 +36,8 @@
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
"node_modules",
|
||||
"e2e",
|
||||
"playwright.config.ts"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user