Changeset 0.37.10 (#222)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
@@ -1,8 +1,15 @@
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { createBrowserContext, loadSource } = require('./helpers');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { createBrowserContext, loadSource, SRC } = require('./helpers');
|
||||
const vm = require('vm');
|
||||
|
||||
// v0.37.10: extensions.js deleted — extension system moves to Preact in v0.37.12.
|
||||
// Skip all tests if the source file doesn't exist.
|
||||
const EXTENSIONS_EXISTS = fs.existsSync(path.join(SRC, 'extensions.js'));
|
||||
const maybeDescribe = EXTENSIONS_EXISTS ? describe : describe.skip;
|
||||
|
||||
/**
|
||||
* Load Events + Extensions into a browser context.
|
||||
*/
|
||||
@@ -23,7 +30,7 @@ function loadExtensions(overrides = {}) {
|
||||
// KaTeX Renderer Extension
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
describe('KaTeX renderer extension', () => {
|
||||
maybeDescribe('KaTeX renderer extension', () => {
|
||||
function loadWithKaTeX() {
|
||||
const ctx = loadExtensions();
|
||||
ctx.Extensions.register({
|
||||
@@ -133,7 +140,7 @@ describe('KaTeX renderer extension', () => {
|
||||
// CSV Table Extension
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
describe('CSV Table extension', () => {
|
||||
maybeDescribe('CSV Table extension', () => {
|
||||
function loadWithCSV() {
|
||||
const ctx = loadExtensions();
|
||||
ctx.Extensions.register({
|
||||
@@ -296,7 +303,7 @@ describe('CSV Table extension', () => {
|
||||
// Diff Viewer Extension
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
describe('Diff Viewer extension', () => {
|
||||
maybeDescribe('Diff Viewer extension', () => {
|
||||
function loadWithDiff() {
|
||||
const ctx = loadExtensions();
|
||||
ctx.Extensions.register({
|
||||
@@ -427,7 +434,7 @@ describe('Diff Viewer extension', () => {
|
||||
// JS Sandbox Extension (Tool)
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
describe('JS Sandbox extension', () => {
|
||||
maybeDescribe('JS Sandbox extension', () => {
|
||||
it('registers js_eval tool handler', async () => {
|
||||
const ctx = loadExtensions();
|
||||
ctx.Extensions.register({
|
||||
@@ -484,7 +491,7 @@ describe('JS Sandbox extension', () => {
|
||||
// Regex Tester Extension (Tool)
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
describe('Regex Tester extension', () => {
|
||||
maybeDescribe('Regex Tester extension', () => {
|
||||
function loadWithRegex() {
|
||||
const ctx = loadExtensions();
|
||||
ctx.Extensions.register({
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { createBrowserContext, loadSource } = require('./helpers');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { createBrowserContext, loadSource, SRC } = require('./helpers');
|
||||
const vm = require('vm');
|
||||
|
||||
// v0.37.10: extensions.js deleted — extension system moves to Preact in v0.37.12.
|
||||
// Skip all tests if the source file doesn't exist.
|
||||
const EXTENSIONS_EXISTS = fs.existsSync(path.join(SRC, 'extensions.js'));
|
||||
const maybeDescribe = EXTENSIONS_EXISTS ? describe : describe.skip;
|
||||
|
||||
/**
|
||||
* Load Events + Extensions into a browser context.
|
||||
* Returns an object with Events, Extensions, and the sandbox.
|
||||
@@ -25,7 +32,7 @@ function loadExtensions(overrides = {}) {
|
||||
|
||||
// ── Registration ─────────────────────────────
|
||||
|
||||
describe('Extensions.register', () => {
|
||||
maybeDescribe('Extensions.register', () => {
|
||||
it('registers an extension by id', () => {
|
||||
const ctx = loadExtensions();
|
||||
ctx.Extensions.register({ id: 'test-ext', init() {} });
|
||||
@@ -48,7 +55,7 @@ describe('Extensions.register', () => {
|
||||
|
||||
// ── Lifecycle ────────────────────────────────
|
||||
|
||||
describe('Extensions.initAll', () => {
|
||||
maybeDescribe('Extensions.initAll', () => {
|
||||
it('calls init on registered extensions', async () => {
|
||||
const ctx = loadExtensions();
|
||||
let initialized = false;
|
||||
@@ -84,7 +91,7 @@ describe('Extensions.initAll', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Extensions.destroyAll', () => {
|
||||
maybeDescribe('Extensions.destroyAll', () => {
|
||||
it('calls destroy and deactivates extensions', async () => {
|
||||
const ctx = loadExtensions();
|
||||
let destroyed = false;
|
||||
@@ -102,7 +109,7 @@ describe('Extensions.destroyAll', () => {
|
||||
|
||||
// ── Context Object ──────────────────────────
|
||||
|
||||
describe('Extension context', () => {
|
||||
maybeDescribe('Extension context', () => {
|
||||
it('provides scoped events', async () => {
|
||||
const ctx = loadExtensions();
|
||||
let receivedPayload = null;
|
||||
@@ -159,7 +166,7 @@ describe('Extension context', () => {
|
||||
|
||||
// ── Tool Registration ───────────────────────
|
||||
|
||||
describe('ctx.tools.handle', () => {
|
||||
maybeDescribe('ctx.tools.handle', () => {
|
||||
it('registers a tool handler', async () => {
|
||||
const ctx = loadExtensions();
|
||||
ctx.Extensions.register({
|
||||
@@ -177,7 +184,7 @@ describe('ctx.tools.handle', () => {
|
||||
|
||||
// ── Tool Bridge ─────────────────────────────
|
||||
|
||||
describe('Tool bridge', () => {
|
||||
maybeDescribe('Tool bridge', () => {
|
||||
it('routes tool.call.* to registered handler and emits result', async () => {
|
||||
const ctx = loadExtensions();
|
||||
let resultEmitted = null;
|
||||
@@ -269,7 +276,7 @@ describe('Tool bridge', () => {
|
||||
|
||||
// ── Renderer Pipeline ───────────────────────
|
||||
|
||||
describe('Renderer pipeline', () => {
|
||||
maybeDescribe('Renderer pipeline', () => {
|
||||
it('registers and runs block renderers', async () => {
|
||||
const ctx = loadExtensions();
|
||||
let rendered = false;
|
||||
@@ -356,7 +363,7 @@ describe('Renderer pipeline', () => {
|
||||
|
||||
// ── Debug ────────────────────────────────────
|
||||
|
||||
describe('Extensions.debug', () => {
|
||||
maybeDescribe('Extensions.debug', () => {
|
||||
it('returns extension info and renderer count', async () => {
|
||||
const ctx = loadExtensions();
|
||||
ctx.Extensions.register({
|
||||
@@ -376,7 +383,7 @@ describe('Extensions.debug', () => {
|
||||
|
||||
// ── Mermaid Extension ─────────────────────────────
|
||||
|
||||
describe('Mermaid renderer extension', () => {
|
||||
maybeDescribe('Mermaid renderer extension', () => {
|
||||
function loadWithMermaid() {
|
||||
const ctx = loadExtensions();
|
||||
// Load the mermaid extension script into the same VM context
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
// Loads the vanilla JS source files into a
|
||||
// simulated browser environment so tests run
|
||||
// against the ACTUAL frontend code.
|
||||
//
|
||||
// v0.37.10: Removed loadAppModules (api.js + app.js deleted).
|
||||
// ==========================================
|
||||
|
||||
const fs = require('fs');
|
||||
@@ -104,23 +106,24 @@ function createBrowserContext(overrides = {}) {
|
||||
* Returns the sandbox for inspection.
|
||||
*/
|
||||
function loadSource(sandbox, filename) {
|
||||
const code = fs.readFileSync(path.join(SRC, filename), 'utf-8');
|
||||
const filepath = path.join(SRC, filename);
|
||||
if (!fs.existsSync(filepath)) {
|
||||
throw new Error(`Source file not found: ${filename} (deleted in v0.37.10?)`);
|
||||
}
|
||||
const code = fs.readFileSync(filepath, 'utf-8');
|
||||
const ctx = vm.createContext(sandbox);
|
||||
vm.runInContext(code, ctx, { filename });
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads API + App modules into a fresh context.
|
||||
* Returns { API, App, sandbox }.
|
||||
* Safely read a source file, returning empty string if deleted.
|
||||
* For tests that audit source code content.
|
||||
*/
|
||||
function loadAppModules(overrides = {}) {
|
||||
const sandbox = createBrowserContext(overrides);
|
||||
loadSource(sandbox, 'sb.js');
|
||||
loadSource(sandbox, 'app-state.js');
|
||||
loadSource(sandbox, 'api.js');
|
||||
loadSource(sandbox, 'app.js');
|
||||
return { API: sandbox.API, App: sandbox.App, sandbox };
|
||||
function readSourceSafe(filename) {
|
||||
const filepath = path.join(SRC, filename);
|
||||
if (!fs.existsSync(filepath)) return '';
|
||||
return fs.readFileSync(filepath, 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,7 +161,7 @@ function processModelsResponse(data, hiddenModels = new Set()) {
|
||||
module.exports = {
|
||||
createBrowserContext,
|
||||
loadSource,
|
||||
loadAppModules,
|
||||
readSourceSafe,
|
||||
processModelsResponse,
|
||||
SRC,
|
||||
};
|
||||
|
||||
@@ -7,9 +7,12 @@
|
||||
// policy exists but the frontend doesn't
|
||||
// check it.
|
||||
//
|
||||
// v0.22.5: Updated for server-rendered Go
|
||||
// templates. HTML is now in server/pages/
|
||||
// templates/ — not in src/index.html.
|
||||
// v0.22.5: Updated for server-rendered Go templates.
|
||||
// v0.37.5: Settings surface moved to Preact — legacy SPA tests
|
||||
// replaced with component source audits.
|
||||
// v0.37.10: Legacy SPA source audit removed (ui-core.js, app.js,
|
||||
// pages.js, settings-handlers.js, ui-admin.js all deleted).
|
||||
// Policy gating now verified via Preact surfaces + admin templates.
|
||||
//
|
||||
// Run: node --test src/js/__tests__/policy-gating.test.js
|
||||
// ==========================================
|
||||
@@ -36,94 +39,6 @@ function readAllTemplates() {
|
||||
return files.join('\n');
|
||||
}
|
||||
|
||||
// ── Source code audits ───────────────────────
|
||||
// These tests read the actual source files and verify that required
|
||||
// wiring exists. If someone removes a policy check, CI breaks.
|
||||
|
||||
describe('Policy wiring audit — source code', () => {
|
||||
// Read all UI files (ui-core.js + ui-settings.js + ui-admin.js replace old ui.js)
|
||||
const uiSrc = ['ui-core.js', 'ui-settings.js', 'ui-admin.js', 'ui-format.js']
|
||||
.map(f => fs.readFileSync(path.join(SRC, f), 'utf-8')).join('\n');
|
||||
// Read all app-side files (app.js + extracted handler files replace old monolith app.js)
|
||||
const appSrc = ['app.js', 'settings-handlers.js', 'admin-handlers.js', 'chat.js', 'tokens.js', 'notes.js']
|
||||
.map(f => fs.readFileSync(path.join(SRC, f), 'utf-8')).join('\n');
|
||||
// Pages.js — server-rendered page handlers (v0.22.5+)
|
||||
const pagesSrc = fs.readFileSync(path.join(SRC, 'pages.js'), 'utf-8');
|
||||
const templateSrc = readAllTemplates();
|
||||
|
||||
// ── allow_user_byok ──
|
||||
|
||||
it('UI has checkUserProvidersAllowed function', () => {
|
||||
assert.ok(uiSrc.includes('checkUserProvidersAllowed'),
|
||||
'MISSING: checkUserProvidersAllowed — provider tab will show when policy is off');
|
||||
});
|
||||
|
||||
it('checkUserProvidersAllowed reads App.policies.allow_user_byok', () => {
|
||||
assert.ok(uiSrc.includes('allow_user_byok'),
|
||||
'MISSING: allow_user_byok check in UI');
|
||||
});
|
||||
|
||||
// ── allow_user_personas ──
|
||||
|
||||
it('UI has checkUserPersonasAllowed function', () => {
|
||||
assert.ok(uiSrc.includes('checkUserPersonasAllowed'),
|
||||
'MISSING: checkUserPersonasAllowed — persona button will show when policy is off');
|
||||
});
|
||||
|
||||
it('checkUserPersonasAllowed reads App.policies.allow_user_personas', () => {
|
||||
assert.ok(uiSrc.includes('allow_user_personas'),
|
||||
'MISSING: allow_user_personas check in UI');
|
||||
});
|
||||
|
||||
it('admin settings template has user-personas toggle', () => {
|
||||
assert.ok(templateSrc.includes('settUserPersonas'),
|
||||
'MISSING: settUserPersonas toggle in admin settings template');
|
||||
});
|
||||
|
||||
it('Pages.saveSettings writes allow_user_personas', () => {
|
||||
assert.ok(pagesSrc.includes('allow_user_personas'),
|
||||
'MISSING: allow_user_personas in Pages.saveSettings');
|
||||
});
|
||||
|
||||
it('admin settings save writes allow_user_personas (SPA bridge)', () => {
|
||||
assert.ok(appSrc.includes('allow_user_personas'),
|
||||
'MISSING: handleSaveAdminSettings must write allow_user_personas');
|
||||
});
|
||||
|
||||
// ── Settings live-apply ──
|
||||
|
||||
it('admin save calls initBanners for policy refresh', () => {
|
||||
assert.ok(appSrc.includes('await initBanners()'),
|
||||
'MISSING: initBanners call after admin save — policies won\'t refresh');
|
||||
});
|
||||
|
||||
it('admin save calls checkUserProvidersAllowed', () => {
|
||||
// Find in handleSaveAdminSettings
|
||||
const saveFunc = appSrc.slice(appSrc.indexOf('handleSaveAdminSettings'));
|
||||
assert.ok(saveFunc.includes('checkUserProvidersAllowed'),
|
||||
'MISSING: checkUserProvidersAllowed call after admin save');
|
||||
});
|
||||
|
||||
it('admin save calls checkUserPersonasAllowed', () => {
|
||||
const saveFunc = appSrc.slice(appSrc.indexOf('handleSaveAdminSettings'));
|
||||
assert.ok(saveFunc.includes('checkUserPersonasAllowed'),
|
||||
'MISSING: checkUserPersonasAllowed call after admin save');
|
||||
});
|
||||
|
||||
it('admin save calls fetchModels to refresh model list', () => {
|
||||
const saveFunc = appSrc.slice(appSrc.indexOf('handleSaveAdminSettings'));
|
||||
assert.ok(saveFunc.includes('fetchModels'),
|
||||
'MISSING: fetchModels call after admin save — model list stale after settings change');
|
||||
});
|
||||
|
||||
// ── Models tab calls preset check ──
|
||||
|
||||
it('models tab switch calls checkUserPersonasAllowed', () => {
|
||||
assert.ok(uiSrc.includes('checkUserPersonasAllowed'),
|
||||
'MISSING: checkUserPersonasAllowed call on models tab switch');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Policy evaluation logic ──────────────────
|
||||
|
||||
describe('Policy evaluation logic', () => {
|
||||
@@ -158,7 +73,6 @@ describe('Policy evaluation logic', () => {
|
||||
});
|
||||
|
||||
// ── Team member dropdown logic ───────────────
|
||||
// Bug: Empty dropdown because resp.data used instead of resp.users
|
||||
|
||||
describe('Team member dropdown population', () => {
|
||||
function getAvailableUsers(usersResp, membersResp) {
|
||||
@@ -221,72 +135,37 @@ describe('Team member dropdown population', () => {
|
||||
|
||||
// ── Admin settings field mapping ─────────────
|
||||
// v0.22.5: Server-rendered admin settings template uses new element IDs.
|
||||
// Pages.saveSettings() in pages.js is the primary handler.
|
||||
// v0.37.10: pages.js deleted — admin settings save now handled by
|
||||
// Preact admin surface. Template elements still required.
|
||||
|
||||
describe('Admin settings field mapping (server templates)', () => {
|
||||
// New element IDs used by server-rendered admin/settings.html + pages.js
|
||||
const settingsFieldMap = {
|
||||
'settRegEnabled': 'allow_registration',
|
||||
'settRegDefaultState': 'default_user_active',
|
||||
'settUserBYOK': 'allow_user_byok',
|
||||
'settUserPersonas': 'allow_user_personas',
|
||||
'settBannerEnabled': 'banner',
|
||||
};
|
||||
|
||||
const pagesSrc = fs.readFileSync(path.join(SRC, 'pages.js'), 'utf-8');
|
||||
const templateSrc = readAllTemplates();
|
||||
|
||||
for (const [elementId, settingKey] of Object.entries(settingsFieldMap)) {
|
||||
it(`template has element #${elementId}`, () => {
|
||||
assert.ok(templateSrc.includes(`id="${elementId}"`),
|
||||
`MISSING: #${elementId} in server templates — admin settings incomplete`);
|
||||
});
|
||||
|
||||
it(`Pages.saveSettings writes setting "${settingKey}"`, () => {
|
||||
assert.ok(pagesSrc.includes(settingKey),
|
||||
`MISSING: "${settingKey}" in Pages.saveSettings`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── SPA bridge field mapping (backward compat) ──
|
||||
// The SPA chat surface still loads settings-handlers.js + ui-admin.js.
|
||||
// These use legacy element IDs for handleSaveAdminSettings().
|
||||
// Verified at the JS level (elements are SPA-modal DOM, not templates).
|
||||
|
||||
describe('SPA bridge — admin settings handler references policy keys', () => {
|
||||
const appSrc = ['settings-handlers.js', 'admin-handlers.js']
|
||||
.map(f => fs.readFileSync(path.join(SRC, f), 'utf-8')).join('\n');
|
||||
|
||||
const requiredPolicies = [
|
||||
'allow_registration',
|
||||
'default_user_active',
|
||||
'allow_user_byok',
|
||||
'allow_user_personas',
|
||||
const requiredElements = [
|
||||
'settRegEnabled',
|
||||
'settRegDefaultState',
|
||||
'settUserBYOK',
|
||||
'settUserPersonas',
|
||||
'settBannerEnabled',
|
||||
];
|
||||
|
||||
for (const key of requiredPolicies) {
|
||||
it(`SPA bridge writes policy "${key}"`, () => {
|
||||
assert.ok(appSrc.includes(key),
|
||||
`MISSING: "${key}" in SPA bridge handler — policy not saved`);
|
||||
for (const id of requiredElements) {
|
||||
it(`template has element #${id}`, () => {
|
||||
assert.ok(templateSrc.includes(`id="${id}"`),
|
||||
`MISSING: #${id} in server templates — admin settings incomplete`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── HTML element existence checks ────────────
|
||||
// v0.22.5: Elements now live in server templates, not index.html.
|
||||
// v0.37.5: Settings surface elements moved to Preact components
|
||||
// (src/js/sw/surfaces/settings/). Only admin template elements
|
||||
// are checked here. Settings surface policy gating is handled by
|
||||
// the Preact SettingsSurface component (nav gating + section routing).
|
||||
// ── Critical HTML elements in templates ───────
|
||||
|
||||
describe('Critical HTML elements exist in server templates', () => {
|
||||
const templateSrc = readAllTemplates();
|
||||
|
||||
const requiredElements = [
|
||||
// Admin settings — policy toggles (still in Go templates)
|
||||
'settUserBYOK', // Admin toggle for BYOK (was adminUserProvidersToggle)
|
||||
'settUserPersonas', // Admin toggle for presets (was adminUserPresetsToggle)
|
||||
'settUserBYOK', // Admin toggle for BYOK
|
||||
'settUserPersonas', // Admin toggle for personas
|
||||
];
|
||||
|
||||
for (const id of requiredElements) {
|
||||
@@ -297,6 +176,34 @@ describe('Critical HTML elements exist in server templates', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Chat surface template ────────────────────
|
||||
// v0.37.10: Chat surface is now Preact-rendered. Verify the mount
|
||||
// point exists and the old SPA scaffold is gone.
|
||||
|
||||
describe('Chat surface template (v0.37.10)', () => {
|
||||
const templateSrc = readAllTemplates();
|
||||
|
||||
it('chat-mount div exists', () => {
|
||||
assert.ok(templateSrc.includes('id="chat-mount"'),
|
||||
'MISSING: #chat-mount — Preact chat surface cannot render');
|
||||
});
|
||||
|
||||
it('old appContainer div is gone', () => {
|
||||
assert.ok(!templateSrc.includes('id="appContainer"'),
|
||||
'STALE: #appContainer still in templates — old SPA scaffold not removed');
|
||||
});
|
||||
|
||||
it('chat template loads Preact surface module', () => {
|
||||
assert.ok(templateSrc.includes('sw/surfaces/chat/index.js'),
|
||||
'MISSING: chat surface module import in template');
|
||||
});
|
||||
|
||||
it('chat template loads SDK boot', () => {
|
||||
assert.ok(templateSrc.includes('sw/sdk/index.js'),
|
||||
'MISSING: SDK boot import in chat template');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Preact surface policy gating ─────────────
|
||||
// v0.37.5: Settings surface elements are now rendered by Preact
|
||||
// components. Verify the components handle policy gating.
|
||||
@@ -326,20 +233,26 @@ describe('Settings Preact surface handles policy gating', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── SPA bridge — dynamic DOM elements ────────
|
||||
// adminMemberUser is created by ui-admin.js loadMemberUserDropdown()
|
||||
// which runs inside the SPA chat surface. Verify the JS function exists.
|
||||
// ── Admin Preact surface ─────────────────────
|
||||
// v0.37.6: Admin surface handles settings save via Preact.
|
||||
// Verify the admin surface has policy key handling.
|
||||
|
||||
describe('SPA bridge — dynamic element creators', () => {
|
||||
const uiAdminSrc = fs.readFileSync(path.join(SRC, 'ui-admin.js'), 'utf-8');
|
||||
describe('Admin Preact surface handles settings', () => {
|
||||
const SURFACES = path.join(SRC, 'sw', 'surfaces', 'admin');
|
||||
const settingsPath = path.join(SURFACES, 'settings.js');
|
||||
|
||||
it('ui-admin.js has loadMemberUserDropdown', () => {
|
||||
assert.ok(uiAdminSrc.includes('loadMemberUserDropdown'),
|
||||
'MISSING: loadMemberUserDropdown — team member add will break');
|
||||
});
|
||||
// Only test if the admin settings component exists
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
const settingsSrc = fs.readFileSync(settingsPath, 'utf-8');
|
||||
|
||||
it('loadMemberUserDropdown references adminMemberUser', () => {
|
||||
assert.ok(uiAdminSrc.includes('adminMemberUser'),
|
||||
'MISSING: adminMemberUser reference in loadMemberUserDropdown');
|
||||
});
|
||||
it('admin settings component references allow_user_byok', () => {
|
||||
assert.ok(settingsSrc.includes('allow_user_byok'),
|
||||
'MISSING: allow_user_byok in admin settings component');
|
||||
});
|
||||
|
||||
it('admin settings component references allow_user_personas', () => {
|
||||
assert.ok(settingsSrc.includes('allow_user_personas'),
|
||||
'MISSING: allow_user_personas in admin settings component');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user