From 232642bef28126d48fc3cd71bff3d068b05f3e86 Mon Sep 17 00:00:00 2001 From: ktx-mihir Date: Thu, 9 Jul 2026 12:29:45 +0530 Subject: [PATCH] fix: form migration with zod for management module (#12877) - form migration with zod schema for management module --------- Co-authored-by: github-actions[bot] Co-authored-by: Claude Fable 5 Co-authored-by: Abhay Padamani <141122205+ktx-abhay@users.noreply.github.com> --- .../pages/generalPages/modelPricingPage.js | 18 + .../GeneralTests/model-pricing.spec.js | 31 +- .../settings-form-validation.spec.js | 23 +- .../DomainManagement.integration.spec.ts | 121 ++- .../settings/DomainManagement.schema.ts | 147 +++ .../settings/DomainManagement.spec.ts | 256 ++++-- .../components/settings/DomainManagement.vue | 235 +++-- web/src/components/settings/General.schema.ts | 61 ++ web/src/components/settings/General.spec.ts | 190 ++-- web/src/components/settings/General.vue | 143 ++- web/src/components/settings/License.schema.ts | 23 + web/src/components/settings/License.spec.ts | 56 +- web/src/components/settings/License.vue | 224 +++-- .../settings/ModelPricingEditor.schema.ts | 150 ++++ .../settings/ModelPricingEditor.spec.ts | 754 ++++++++++------ .../settings/ModelPricingEditor.vue | 598 ++++++------ .../settings/OrgStorageEditor.schema.ts | 73 ++ .../settings/OrgStorageEditor.spec.ts | 364 ++++++++ .../components/settings/OrgStorageEditor.vue | 299 +++--- .../settings/OrganizationManagement.schema.ts | 48 + .../settings/OrganizationManagement.spec.ts | 120 +-- .../settings/OrganizationManagement.vue | 163 ++-- .../settings/OrganizationSettings.schema.ts | 46 + .../settings/OrganizationSettings.spec.ts | 850 ++++-------------- .../settings/OrganizationSettings.vue | 158 ++-- web/src/locales/languages/de.json | 3 + web/src/locales/languages/en.json | 3 + web/src/locales/languages/es.json | 3 + web/src/locales/languages/fr.json | 3 + web/src/locales/languages/hi.json | 3 + web/src/locales/languages/it.json | 3 + web/src/locales/languages/ja.json | 11 +- web/src/locales/languages/ko.json | 15 +- web/src/locales/languages/nl.json | 3 + web/src/locales/languages/pt.json | 3 + web/src/locales/languages/tr.json | 3 + web/src/locales/languages/zh.json | 15 +- web/src/locales/languages/zh_TW.json | 3 + 38 files changed, 3130 insertions(+), 2092 deletions(-) create mode 100644 web/src/components/settings/DomainManagement.schema.ts create mode 100644 web/src/components/settings/General.schema.ts create mode 100644 web/src/components/settings/License.schema.ts create mode 100644 web/src/components/settings/ModelPricingEditor.schema.ts create mode 100644 web/src/components/settings/OrgStorageEditor.schema.ts create mode 100644 web/src/components/settings/OrgStorageEditor.spec.ts create mode 100644 web/src/components/settings/OrganizationManagement.schema.ts create mode 100644 web/src/components/settings/OrganizationSettings.schema.ts diff --git a/tests/ui-testing/pages/generalPages/modelPricingPage.js b/tests/ui-testing/pages/generalPages/modelPricingPage.js index 64a4f5f990..ef0670beb0 100644 --- a/tests/ui-testing/pages/generalPages/modelPricingPage.js +++ b/tests/ui-testing/pages/generalPages/modelPricingPage.js @@ -305,6 +305,24 @@ export class ModelPricingPage { }, { timeout: 5000 }).toBe(true); } + /** Fill a tier's "add price" draft row WITHOUT committing it (no Add click). + * Used to exercise the save-time draft-key guard: an uncommitted key with + * spaces or a pure-integer key produces a toast at submit. (Committing the + * row instead surfaces an inline per-row error via the schema.) + * @param {string} key + * @param {number|string} valuePricePerMillion + * @param {number} [tierIndex=0] + */ + async fillDraftPriceRow(key, valuePricePerMillion, tierIndex = 0) { + const keyInput = this.page.locator(`[data-test="model-pricing-add-price-key-input-${tierIndex}-field"]`); + await keyInput.waitFor({ state: 'visible', timeout: 5000 }); + await keyInput.fill(key); + + const valueInput = this.page.locator(`[data-test="model-pricing-add-price-value-input-${tierIndex}-field"]`); + await valueInput.waitFor({ state: 'visible', timeout: 5000 }); + await valueInput.fill(String(valuePricePerMillion)); + } + async clickSave() { await this.saveBtn.click(); await Promise.race([ diff --git a/tests/ui-testing/playwright-tests/GeneralTests/model-pricing.spec.js b/tests/ui-testing/playwright-tests/GeneralTests/model-pricing.spec.js index 3ea4da08d3..b6cbfac67d 100644 --- a/tests/ui-testing/playwright-tests/GeneralTests/model-pricing.spec.js +++ b/tests/ui-testing/playwright-tests/GeneralTests/model-pricing.spec.js @@ -176,20 +176,27 @@ test.describe("Model Pricing — Create & Form Validation", () => { const pm = new PageManager(page); await pm.modelPricingPage.gotoEditorCreate(); - // Both fields empty → save disabled - await expect(pm.modelPricingPage.saveBtn).toBeDisabled({ timeout: 5000 }); + // Save is a plain submit button — the OForm has no disabled-gate and + // validates submit-then-change, so field errors surface only after the + // first submit attempt (not on load / first keystroke). + await expect(pm.modelPricingPage.saveBtn).toBeEnabled(); - // Name too long → inline error; fix → error clears + // Both fields empty → the first submit arms validation: required errors + // appear and the editor stays open (submit is blocked). + await pm.modelPricingPage.saveBtn.click(); + await expect(pm.modelPricingPage.nameInputError).toBeVisible({ timeout: 5000 }); + await expect(pm.modelPricingPage.patternInputError).toBeVisible({ timeout: 5000 }); + await pm.modelPricingPage.editorTitle.waitFor({ state: 'visible', timeout: 5000 }); + + // Name too long → inline error; fix → error clears (revalidates on change) await pm.modelPricingPage.fillName('a'.repeat(257)); await expect(pm.modelPricingPage.nameInputError).toBeVisible({ timeout: 5000 }); - await expect(pm.modelPricingPage.saveBtn).toBeDisabled(); await pm.modelPricingPage.fillName(validName); await expect(pm.modelPricingPage.nameInputError).not.toBeVisible({ timeout: 5000 }); // Invalid regex → inline error; fix → error clears await pm.modelPricingPage.fillPattern('[invalid('); await expect(pm.modelPricingPage.patternInputError).toBeVisible({ timeout: 5000 }); - await expect(pm.modelPricingPage.saveBtn).toBeDisabled(); await pm.modelPricingPage.fillPattern(`^${validName}.*`); await expect(pm.modelPricingPage.patternInputError).not.toBeVisible({ timeout: 5000 }); @@ -197,13 +204,19 @@ test.describe("Model Pricing — Create & Form Validation", () => { await pm.modelPricingPage.saveBtn.click(); await pm.modelPricingPage.editorTitle.waitFor({ state: 'visible', timeout: 5000 }); - // Key with spaces → toast error (filter by text — earlier toasts may still be visible) - await pm.modelPricingPage.addPriceRow('input tokens', '0.000002'); + // Draft key with spaces → save-time toast. The "Add price" button now + // commits any non-empty key (bad keys get an inline row error instead), + // so key validation as a TOAST fires via the save() draft-row guard on an + // uncommitted draft. Leaving it uncommitted also keeps the form clean for + // the successful save below. (Filter by text — earlier toasts may linger.) + await pm.modelPricingPage.fillDraftPriceRow('input tokens', '0.000002'); + await pm.modelPricingPage.saveBtn.click(); await expect(pm.modelPricingPage.toastMessage.filter({ hasText: /must not contain spaces/i })) .toBeVisible({ timeout: 5000 }); - // Pure integer key → toast error - await pm.modelPricingPage.addPriceRow('123', '0.000002'); + // Draft key that is a pure integer → save-time toast + await pm.modelPricingPage.fillDraftPriceRow('123', '0.000002'); + await pm.modelPricingPage.saveBtn.click(); await expect(pm.modelPricingPage.toastMessage.filter({ hasText: /cannot be a pure integer/i })) .toBeVisible({ timeout: 5000 }); diff --git a/tests/ui-testing/playwright-tests/GeneralTests/settings-form-validation.spec.js b/tests/ui-testing/playwright-tests/GeneralTests/settings-form-validation.spec.js index c7bfe69093..bf1f2a5afa 100644 --- a/tests/ui-testing/playwright-tests/GeneralTests/settings-form-validation.spec.js +++ b/tests/ui-testing/playwright-tests/GeneralTests/settings-form-validation.spec.js @@ -295,24 +295,25 @@ test.describe("Settings ModelPricingEditor form validation", () => { testLogger.info('Navigated to Settings > Model Pricing'); }); - test("should disable save or show error when model pricing name is empty", { + test("should show error when model pricing name is empty", { tag: ['@domainFormValidation', '@P0', '@smoke'] }, async ({ page }) => { - testLogger.info('Verifying save is disabled or error shown when model pricing name is empty'); + testLogger.info('Verifying name error shown when model pricing name is empty'); - // Clear the name field to trigger validation + // Clear the name field await pm.settingsFormValidation.clearModelPricingName(); - const saveBtn = pm.settingsFormValidation.getModelPricingSaveBtnLocator(); + // Attempt save to trigger validation. The OForm validates submit-then-change, + // so the required-field error surfaces only after the first submit attempt + // (the Save button itself is never disabled by validity). + await pm.settingsFormValidation.clickModelPricingSave(); + const nameError = pm.settingsFormValidation.getModelPricingNameErrorLocator(); + await nameError.waitFor({ state: 'visible', timeout: 5000 }); + await expect(nameError).toBeVisible(); + await expect(nameError).toContainText('Model name is required'); - // Either the save button is disabled OR an error message is shown - const saveDisabled = await saveBtn.isDisabled().catch(() => false); - const errorVisible = await nameError.isVisible().catch(() => false); - - expect(saveDisabled || errorVisible).toBe(true); - - testLogger.info('Save button disabled or name error shown for empty model pricing name'); + testLogger.info('Name error correctly shown when empty model pricing name'); }); test("should show pattern error when pattern is empty", { diff --git a/web/src/components/settings/DomainManagement.integration.spec.ts b/web/src/components/settings/DomainManagement.integration.spec.ts index 3a7dd16017..8afa6e209a 100644 --- a/web/src/components/settings/DomainManagement.integration.spec.ts +++ b/web/src/components/settings/DomainManagement.integration.spec.ts @@ -16,6 +16,7 @@ import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from "vitest"; import { flushPromises, mount } from "@vue/test-utils"; import DomainManagement from "./DomainManagement.vue"; +import { isValidDomain, isValidEmail } from "./DomainManagement.schema"; import i18n from "@/locales"; import { nextTick } from "vue"; import { @@ -207,8 +208,7 @@ describe("DomainManagement Integration Tests", () => { expect(vm.domains.length).toBe(3); // From mockDomainData.complex // 2. Add a new domain - vm.newDomain = "newcompany.com"; - await vm.addDomain(); + await vm.addDomain({ newDomain: "newcompany.com" }); expect(vm.domains.length).toBe(4); expect(vm.domains[3].name).toBe("newcompany.com"); expect(vm.domains[3].allowAllUsers).toBe(true); @@ -217,12 +217,10 @@ describe("DomainManagement Integration Tests", () => { vm.domains[3].allowAllUsers = false; // 4. Add emails to restricted domain - vm.domains[3].newEmail = "admin@newcompany.com"; - await vm.addEmail(vm.domains[3]); + await vm.addEmail(vm.domains[3], "admin@newcompany.com"); expect(vm.domains[3].allowedEmails).toContain("admin@newcompany.com"); - vm.domains[3].newEmail = "user@newcompany.com"; - await vm.addEmail(vm.domains[3]); + await vm.addEmail(vm.domains[3], "user@newcompany.com"); expect(vm.domains[3].allowedEmails).toContain("user@newcompany.com"); expect(vm.domains[3].allowedEmails.length).toBe(2); @@ -288,7 +286,7 @@ describe("DomainManagement Integration Tests", () => { domainValidationTestCases.forEach(({ input, expected, description }) => { it(`should ${expected ? 'accept' : 'reject'} ${description}: "${input}"`, () => { const vm = wrapper.vm; - expect(vm.isValidDomain(input)).toBe(expected); + expect(isValidDomain(input)).toBe(expected); }); }); }); @@ -297,7 +295,7 @@ describe("DomainManagement Integration Tests", () => { emailValidationTestCases.forEach(({ email, domain, expected, description }) => { it(`should ${expected ? 'accept' : 'reject'} ${description}`, () => { const vm = wrapper.vm; - expect(vm.isValidEmail(email, domain)).toBe(expected); + expect(isValidEmail(email, domain)).toBe(expected); }); }); }); @@ -339,8 +337,7 @@ describe("DomainManagement Integration Tests", () => { const initialState = JSON.parse(JSON.stringify(vm.domains)); // Add domain - vm.newDomain = "test.com"; - await vm.addDomain(); + await vm.addDomain({ newDomain: "test.com" }); // Verify state change expect(vm.domains.length).toBe(initialState.length + 1); @@ -380,44 +377,39 @@ describe("DomainManagement Integration Tests", () => { expect(wrapper.text()).toContain(vm.domains.length.toString()); // Add a domain and check count update - vm.newDomain = "test.com"; - await vm.addDomain(); + await vm.addDomain({ newDomain: "test.com" }); await nextTick(); expect(wrapper.text()).toContain(vm.domains.length.toString()); }); - it("should enable/disable buttons based on validation", async () => { + it("keeps the add-domain button always enabled (R3 — validation is schema-driven)", async () => { wrapper = createWrapper(); await flushPromises(); const vm = wrapper.vm; - // Test add domain button state - vm.newDomain = ""; - await nextTick(); - - // Try to find the add domain button with a simpler selector - const addDomainButton = wrapper.find('button'); - const buttons = wrapper.findAll('button'); - - // Look for button with "Add Domain" text - let addButton = null; - for (let i = 0; i < buttons.length; i++) { - if (buttons[i].text().includes('Add Domain')) { - addButton = buttons[i]; - break; + const findAddButton = () => { + const buttons = wrapper.findAll("button"); + for (let i = 0; i < buttons.length; i++) { + if (buttons[i].text().includes("Add Domain")) return buttons[i]; } - } - + return null; + }; + + // Empty input → button stays enabled (no :disabled gate); the schema + // blocks the actual submit instead. + vm.addDomainForm.form.setFieldValue("newDomain", ""); + await nextTick(); + let addButton = findAddButton(); if (addButton) { - expect(addButton.element.disabled).toBe(true); + expect(addButton.element.disabled).toBe(false); } - // Set valid domain - vm.newDomain = "valid.com"; + // Valid domain → still enabled. + vm.addDomainForm.form.setFieldValue("newDomain", "valid.com"); await nextTick(); - + addButton = findAddButton(); if (addButton) { expect(addButton.element.disabled).toBe(false); } @@ -473,7 +465,7 @@ describe("DomainManagement Integration Tests", () => { ]; for (const maliciousDomain of maliciousDomains) { - expect(vm.isValidDomain(maliciousDomain)).toBe(false); + expect(isValidDomain(maliciousDomain)).toBe(false); } }); @@ -488,7 +480,7 @@ describe("DomainManagement Integration Tests", () => { ]; for (const maliciousEmail of maliciousEmails) { - expect(vm.isValidEmail(maliciousEmail, "example.com")).toBe(false); + expect(isValidEmail(maliciousEmail, "example.com")).toBe(false); } }); @@ -497,15 +489,15 @@ describe("DomainManagement Integration Tests", () => { const longDomain = "a".repeat(250) + ".com"; // Domain names should be max 253 chars const veryLongDomain = "a".repeat(300) + ".com"; - expect(vm.isValidDomain(longDomain)).toBe(false); - expect(vm.isValidDomain(veryLongDomain)).toBe(false); + expect(isValidDomain(longDomain)).toBe(false); + expect(isValidDomain(veryLongDomain)).toBe(false); }); it("should reject extremely long email addresses", async () => { const vm = wrapper.vm; const longEmail = "a".repeat(300) + "@example.com"; // Email addresses have practical limits - expect(vm.isValidEmail(longEmail, "example.com")).toBe(false); + expect(isValidEmail(longEmail, "example.com")).toBe(false); }); it("should reject Unicode and special character exploits", async () => { @@ -519,7 +511,7 @@ describe("DomainManagement Integration Tests", () => { ]; for (const unicodeDomain of unicodeExploits) { - expect(vm.isValidDomain(unicodeDomain)).toBe(false); + expect(isValidDomain(unicodeDomain)).toBe(false); } }); }); @@ -553,15 +545,13 @@ describe("DomainManagement Integration Tests", () => { name: "test.com", allowAllUsers: false, allowedEmails: [], - newEmail: "" }; vm.domains.push(testDomain); // Add many emails for (let i = 0; i < 50; i++) { - testDomain.newEmail = `user${i}@test.com`; - await vm.addEmail(testDomain); + await vm.addEmail(testDomain, `user${i}@test.com`); } expect(testDomain.allowedEmails.length).toBe(50); @@ -571,16 +561,14 @@ describe("DomainManagement Integration Tests", () => { const vm = wrapper.vm; // Test empty domain - vm.newDomain = ""; - expect(vm.isValidDomain("")).toBe(true); // Empty is considered valid (optional) + expect(isValidDomain("")).toBe(true); // Empty is considered valid (optional) // Test whitespace-only domain - vm.newDomain = " "; - expect(vm.isValidDomain(" ")).toBe(false); + expect(isValidDomain(" ")).toBe(false); // Test tabs and newlines - expect(vm.isValidDomain("\t\n")).toBe(false); - expect(vm.isValidEmail(" ", "example.com")).toBe(false); + expect(isValidDomain("\t\n")).toBe(false); + expect(isValidEmail(" ", "example.com")).toBe(false); }); }); @@ -591,8 +579,7 @@ describe("DomainManagement Integration Tests", () => { // Rapid domain additions const addPromises = []; for (let i = 0; i < 5; i++) { - vm.newDomain = `rapid${i}.com`; - addPromises.push(vm.addDomain()); + addPromises.push(vm.addDomain({ newDomain: `rapid${i}.com` })); } await Promise.all(addPromises); @@ -608,8 +595,7 @@ describe("DomainManagement Integration Tests", () => { const savePromise = vm.saveChanges(); // Modify data while saving - vm.newDomain = "concurrent.com"; - vm.addDomain(); + vm.addDomain({ newDomain: "concurrent.com" }); // Should complete without errors await expect(savePromise).resolves.not.toThrow(); @@ -687,8 +673,7 @@ describe("DomainManagement Integration Tests", () => { const vm = wrapper.vm; // Start operations - vm.newDomain = "test.com"; - const addPromise = vm.addDomain(); + const addPromise = vm.addDomain({ newDomain: "test.com" }); const savePromise = vm.saveChanges(); // Unmount component immediately @@ -705,8 +690,7 @@ describe("DomainManagement Integration Tests", () => { const vm = wrapper.vm; // Trigger various operations to create potential listeners - vm.newDomain = "test.com"; - await vm.addDomain(); + await vm.addDomain({ newDomain: "test.com" }); await vm.saveChanges(); // Store a reference to test that the component exists @@ -738,16 +722,21 @@ describe("DomainManagement Integration Tests", () => { it("should maintain form state during validation errors", async () => { const vm = wrapper.vm; - - // Set up form data - vm.newDomain = "invalid domain with spaces"; - - // Try to add invalid domain - await vm.addDomain(); - - // Form should maintain state and not be corrupted - expect(vm.newDomain).toBe("invalid domain with spaces"); - expect(vm.domains.length).toBe(3); // Should not have been added + const initialCount = vm.domains.length; + + // Set up form data — an invalid domain. + vm.addDomainForm.form.setFieldValue("newDomain", "invalid domain with spaces"); + await nextTick(); + + // Submit through the real form: the schema blocks the invalid domain, + // so @submit (addDomain) never runs — nothing is added and the field + // keeps its value. + await vm.addDomainForm.form.handleSubmit(); + await flushPromises(); + + expect(vm.addDomainForm.form.state.isValid).toBe(false); + expect(vm.addDomainForm.form.state.values.newDomain).toBe("invalid domain with spaces"); + expect(vm.domains.length).toBe(initialCount); // Should not have been added }); }); }); diff --git a/web/src/components/settings/DomainManagement.schema.ts b/web/src/components/settings/DomainManagement.schema.ts new file mode 100644 index 0000000000..1fce34e400 --- /dev/null +++ b/web/src/components/settings/DomainManagement.schema.ts @@ -0,0 +1,147 @@ +// Copyright 2026 OpenObserve Inc. +// +// Validation schemas for the two add-rows in DomainManagement.vue (Settings > +// Domain Management). This is an add-to-list / dynamic-array shape (partial +// fit): only the two add-rows are OForm-validated; the rendered domain/email +// lists + radios are display, and the save-time "domain needs emails" check +// stays a submit-level guard in saveChanges(). +// +// Restores the Quasar BEFORE rules (truthy→Zod inversion): +// • newDomain: `isValidDomain(v) || invalidDomain` → required + domain regex +// (the audit adds ≤253 + reject-malicious, already enforced by isValidDomain). +// • domain.newEmail: REQUIRED for the Add-Email action — the email must be +// present, valid, and belong to the domain (clicking "Add Email" with an +// empty box shows "Email is required" on first submit). The add-email form is +// its OWN OForm, so requiring it does NOT affect the domain-level +// "Save Changes" button. (Deliberate UX choice over the old empty-passes rule.) +// +// The pure validators live here so they are shared by the schema AND re-exposed +// from the component (the spec exercises them directly). Validation TIMING is +// owned by OForm (submit-then-change); these schemas only describe what is valid. + +import { z } from "zod"; + +export const isValidDomain = (domain: any): boolean => { + // Handle null, undefined, and non-string inputs + if (domain === null || domain === undefined) return true; // Empty is valid + if (typeof domain !== "string") return false; // Non-strings are invalid + + // Handle empty strings - empty is valid, but whitespace-only is not + const trimmed = domain.trim(); + if (!trimmed) return domain.length === 0; // Empty string is valid, whitespace-only is not + + // Security: Check for potentially malicious content (more targeted patterns) + const maliciousPatterns = [ + " + upperDomain.includes(pattern.toUpperCase()), + ) + ) { + return false; + } + + // Length validation (DNS limit is 253 characters) + if (trimmed.length > 253) return false; + + // Remove trailing dot if present (valid in DNS) + const cleanDomain = trimmed.endsWith(".") ? trimmed.slice(0, -1) : trimmed; + + // Each label can be 1-63 characters, the domain must have at least one dot. + const domainRegex = + /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/; + + try { + return domainRegex.test(cleanDomain); + } catch (error) { + return false; // Any regex error means invalid + } +}; + +export const isValidEmail = (email: any, domain: any): boolean => { + // Handle null, undefined, and non-string inputs + if (email === null || email === undefined || typeof email !== "string") + return false; + if (domain === null || domain === undefined || typeof domain !== "string") + return false; + + // Handle empty strings + if (!email.trim() || !domain.trim()) return false; + + // Security: Check for potentially malicious content + const maliciousPatterns = [ + "<", ">", "script", "javascript:", "DROP", "SELECT", "INSERT", "UPDATE", + "DELETE", "UNION", "CREATE", "ALTER", "TABLE", "FROM", "--", "/*", "*/", + "'", '"', "\0", "\n", "\r", "\t", + ]; + + const upperEmail = email.toUpperCase(); + if ( + maliciousPatterns.some((pattern) => + upperEmail.includes(pattern.toUpperCase()), + ) + ) { + return false; + } + + // Length validation (practical email limit) + if (email.length > 254 || domain.length > 253) return false; + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + + try { + if (!emailRegex.test(email)) return false; + // Check if email belongs to the domain + return email.toLowerCase().endsWith(`@${domain.toLowerCase()}`); + } catch (error) { + return false; // Any error means invalid + } +}; + +// ── Add-domain row: required + valid domain ────────────────────────────────── +export const makeAddDomainSchema = (t: (_key: string) => string) => + z.object({ + newDomain: z + .string() + .min(1, t("settings.domainRequired") || "Domain is required") + .refine((v) => isValidDomain(v), { + message: + t("settings.invalidDomain") || + "Please enter a valid domain (e.g. example.com)", + }), + }); + +export type AddDomainForm = z.infer>; + +// Typed default-values factory (matches the pattern used by every other +// settings form: licenseDefaults / extendTrialDefaults / generalSettingsDefaults +// …) instead of an inline `{ newDomain: '' }` literal in the template. +export const addDomainDefaults = (): AddDomainForm => ({ newDomain: "" }); + +// ── Add-email row (per-domain): REQUIRED + valid + belongs to the domain. +// Empty → "Email is required" (first submit); non-empty invalid → email error. +// Its own OForm, so requiring it does not gate the domain-level save. ──────── +export const makeAddEmailSchema = ( + domainName: string, + t: (_key: string) => string, +) => + z.object({ + newEmail: z + .string() + .min(1, t("settings.emailRequired") || "Email is required") + .refine((v) => isValidEmail(v, domainName), { + message: t("settings.invalidEmail") || "Please enter a valid email", + }), + }); + +export type AddEmailForm = z.infer>; + +// Typed default-values factory for the per-domain add-email row. The default is +// the same blank value for every domain, so a plain factory (not keyed by +// domain) is enough. +export const addEmailDefaults = (): AddEmailForm => ({ newEmail: "" }); diff --git a/web/src/components/settings/DomainManagement.spec.ts b/web/src/components/settings/DomainManagement.spec.ts index a61b6ec0aa..63f0744d3f 100644 --- a/web/src/components/settings/DomainManagement.spec.ts +++ b/web/src/components/settings/DomainManagement.spec.ts @@ -16,6 +16,11 @@ import { DOMWrapper, flushPromises, mount } from "@vue/test-utils"; import { describe, expect, it, beforeEach, vi, afterEach, afterAll } from "vitest"; import DomainManagement from "./DomainManagement.vue"; +import { + isValidDomain, + isValidEmail, + makeAddEmailSchema, +} from "./DomainManagement.schema"; import i18n from "@/locales"; import { nextTick } from "vue"; @@ -245,29 +250,29 @@ describe("DomainManagement Component", () => { const vm = wrapper.vm; // Valid domains - expect(vm.isValidDomain("example.com")).toBe(true); - expect(vm.isValidDomain("sub.example.com")).toBe(true); - expect(vm.isValidDomain("my-company.org")).toBe(true); + expect(isValidDomain("example.com")).toBe(true); + expect(isValidDomain("sub.example.com")).toBe(true); + expect(isValidDomain("my-company.org")).toBe(true); // Invalid domains - expect(vm.isValidDomain("")).toBe(true); // Empty is considered valid for optional validation - expect(vm.isValidDomain("invalid")).toBe(false); - expect(vm.isValidDomain("spaces in domain.com")).toBe(false); - expect(vm.isValidDomain(".example.com")).toBe(false); + expect(isValidDomain("")).toBe(true); // Empty is considered valid for optional validation + expect(isValidDomain("invalid")).toBe(false); + expect(isValidDomain("spaces in domain.com")).toBe(false); + expect(isValidDomain(".example.com")).toBe(false); }); it("should validate email addresses for specific domains", async () => { const vm = wrapper.vm; // Valid emails for domain - expect(vm.isValidEmail("user@example.com", "example.com")).toBe(true); - expect(vm.isValidEmail("admin@company.org", "company.org")).toBe(true); + expect(isValidEmail("user@example.com", "example.com")).toBe(true); + expect(isValidEmail("admin@company.org", "company.org")).toBe(true); // Invalid emails - expect(vm.isValidEmail("", "example.com")).toBe(false); - expect(vm.isValidEmail("invalid-email", "example.com")).toBe(false); - expect(vm.isValidEmail("user@different.com", "example.com")).toBe(false); - expect(vm.isValidEmail("user@example.com", "different.org")).toBe(false); + expect(isValidEmail("", "example.com")).toBe(false); + expect(isValidEmail("invalid-email", "example.com")).toBe(false); + expect(isValidEmail("user@different.com", "example.com")).toBe(false); + expect(isValidEmail("user@example.com", "different.org")).toBe(false); }); }); @@ -280,10 +285,9 @@ describe("DomainManagement Component", () => { it("should add a new domain successfully", async () => { const vm = wrapper.vm; - vm.newDomain = "newdomain.com"; - await vm.addDomain(); + await vm.addDomain({ newDomain: "newdomain.com" }); await nextTick(); - + expect(vm.domains).toContainEqual( expect.objectContaining({ name: "newdomain.com", @@ -296,9 +300,7 @@ describe("DomainManagement Component", () => { it("should not add invalid domain", async () => { const vm = wrapper.vm; - vm.newDomain = "invalid-domain"; - - expect(vm.isValidDomain("invalid-domain")).toBe(false); + expect(isValidDomain("invalid-domain")).toBe(false); }); it("should not add duplicate domain", async () => { @@ -309,8 +311,7 @@ describe("DomainManagement Component", () => { const initialDomainsCount = vm.domains.length; // Try to add a domain that already exists - vm.newDomain = "example.com"; - await vm.addDomain(); + await vm.addDomain({ newDomain: "example.com" }); expect(vm.domains.length).toBe(initialDomainsCount); }); @@ -318,10 +319,56 @@ describe("DomainManagement Component", () => { it("should clear input after adding domain", async () => { const vm = wrapper.vm; - vm.newDomain = "newdomain.com"; - await vm.addDomain(); - - expect(vm.newDomain).toBe(""); + vm.addDomainForm.form.setFieldValue("newDomain", "newdomain.com"); + await vm.addDomain({ newDomain: "newdomain.com" }); + + expect(vm.addDomainForm.form.state.values.newDomain).toBe(""); + }); + }); + + describe("Add-domain schema validation (real OForm)", () => { + beforeEach(async () => { + wrapper = createWrapper(); + await flushPromises(); + }); + + it("blocks submit and does NOT add a domain when the input is empty", async () => { + const vm = wrapper.vm; + const initialCount = vm.domains.length; + + await vm.addDomainForm.form.handleSubmit(); + await flushPromises(); + + expect(vm.addDomainForm.form.state.isValid).toBe(false); + expect(vm.domains.length).toBe(initialCount); + }); + + it("blocks submit when the domain is invalid (restored domain regex rule)", async () => { + const vm = wrapper.vm; + const initialCount = vm.domains.length; + + vm.addDomainForm.form.setFieldValue("newDomain", "not-a-domain"); + await nextTick(); + await vm.addDomainForm.form.handleSubmit(); + await flushPromises(); + + expect(vm.addDomainForm.form.state.isValid).toBe(false); + expect(vm.domains.length).toBe(initialCount); + }); + + it("adds the domain when a valid value is submitted through the form", async () => { + const vm = wrapper.vm; + + vm.addDomainForm.form.setFieldValue("newDomain", "valid-domain.com"); + await nextTick(); + await vm.addDomainForm.form.handleSubmit(); + await flushPromises(); + + expect( + vm.domains.some((d: any) => d.name === "valid-domain.com"), + ).toBe(true); + // Inline add-row cleared after a successful add. + expect(vm.addDomainForm.form.state.values.newDomain).toBe(""); }); }); @@ -371,12 +418,10 @@ describe("DomainManagement Component", () => { if (restrictedDomain) { const initialEmailCount = restrictedDomain.allowedEmails.length; - restrictedDomain.newEmail = `newuser@${restrictedDomain.name}`; - await vm.addEmail(restrictedDomain); - + await vm.addEmail(restrictedDomain, `newuser@${restrictedDomain.name}`); + expect(restrictedDomain.allowedEmails.length).toBe(initialEmailCount + 1); expect(restrictedDomain.allowedEmails).toContain(`newuser@${restrictedDomain.name}`); - expect(restrictedDomain.newEmail).toBe(""); } }); @@ -387,8 +432,7 @@ describe("DomainManagement Component", () => { if (restrictedDomain) { const initialEmailCount = restrictedDomain.allowedEmails.length; - restrictedDomain.newEmail = "invalid-email"; - await vm.addEmail(restrictedDomain); + await vm.addEmail(restrictedDomain, "invalid-email"); expect(restrictedDomain.allowedEmails.length).toBe(initialEmailCount); } @@ -402,8 +446,7 @@ describe("DomainManagement Component", () => { const initialEmailCount = restrictedDomain.allowedEmails.length; const existingEmail = restrictedDomain.allowedEmails[0]; - restrictedDomain.newEmail = existingEmail; - await vm.addEmail(restrictedDomain); + await vm.addEmail(restrictedDomain, existingEmail); expect(restrictedDomain.allowedEmails.length).toBe(initialEmailCount); } @@ -450,7 +493,6 @@ describe("DomainManagement Component", () => { name: "test.com", allowAllUsers: false, allowedEmails: [], - newEmail: "", }); await vm.saveChanges(); @@ -489,14 +531,14 @@ describe("DomainManagement Component", () => { const vm = wrapper.vm; // Modify some data - vm.newDomain = "test-input"; - + vm.addDomainForm.form.setFieldValue("newDomain", "test-input"); + // Clear the mock call count from beforeEach/mount mockDomainManagement.getDomainRestrictions.mockClear(); - + await vm.resetForm(); - - expect(vm.newDomain).toBe(""); + + expect(vm.addDomainForm.form.state.values.newDomain).toBe(""); expect(mockDomainManagement.getDomainRestrictions).toHaveBeenCalledTimes(1); // Called once by resetForm }); }); @@ -601,8 +643,7 @@ describe("DomainManagement Component", () => { ]; for (const xss of xssAttempts) { - vm.newDomain = xss; - expect(vm.isValidDomain(xss)).toBe(false); + expect(isValidDomain(xss)).toBe(false); } }); @@ -618,8 +659,8 @@ describe("DomainManagement Component", () => { ]; for (const sql of sqlInjections) { - expect(vm.isValidDomain(sql)).toBe(false); - expect(vm.isValidEmail(sql + "@test.com", "test.com")).toBe(false); + expect(isValidDomain(sql)).toBe(false); + expect(isValidEmail(sql + "@test.com", "test.com")).toBe(false); } }); }); @@ -629,14 +670,14 @@ describe("DomainManagement Component", () => { const vm = wrapper.vm; // Test null/undefined domain validation - expect(vm.isValidDomain(null)).toBe(true); // null treated as empty - expect(vm.isValidDomain(undefined)).toBe(true); // undefined treated as empty + expect(isValidDomain(null)).toBe(true); // null treated as empty + expect(isValidDomain(undefined)).toBe(true); // undefined treated as empty // Test null/undefined email validation - expect(vm.isValidEmail(null, "test.com")).toBe(false); - expect(vm.isValidEmail(undefined, "test.com")).toBe(false); - expect(vm.isValidEmail("test@test.com", null)).toBe(false); - expect(vm.isValidEmail("test@test.com", undefined)).toBe(false); + expect(isValidEmail(null, "test.com")).toBe(false); + expect(isValidEmail(undefined, "test.com")).toBe(false); + expect(isValidEmail("test@test.com", null)).toBe(false); + expect(isValidEmail("test@test.com", undefined)).toBe(false); }); it("should reject non-string inputs", async () => { @@ -652,8 +693,8 @@ describe("DomainManagement Component", () => { ]; for (const input of nonStringInputs) { - expect(vm.isValidDomain(input)).toBe(false); - expect(vm.isValidEmail(input, "test.com")).toBe(false); + expect(isValidDomain(input)).toBe(false); + expect(isValidEmail(input, "test.com")).toBe(false); } }); @@ -663,8 +704,8 @@ describe("DomainManagement Component", () => { const hugeDomain = "a".repeat(10000) + ".com"; const hugeEmail = "a".repeat(10000) + "@test.com"; - expect(vm.isValidDomain(hugeDomain)).toBe(false); - expect(vm.isValidEmail(hugeEmail, "test.com")).toBe(false); + expect(isValidDomain(hugeDomain)).toBe(false); + expect(isValidEmail(hugeEmail, "test.com")).toBe(false); }); }); @@ -709,8 +750,7 @@ describe("DomainManagement Component", () => { // Rapid form submissions for (let i = 0; i < 10; i++) { - vm.newDomain = `rapid${i}.com`; - vm.addDomain(); // Don't await to simulate rapid clicking + vm.addDomain({ newDomain: `rapid${i}.com` }); // Don't await to simulate rapid clicking } // Wait for all operations to complete @@ -721,8 +761,10 @@ describe("DomainManagement Component", () => { it("should maintain performance with large datasets", async () => { const vm = wrapper.vm; - - // Add many domains (reduced count to prevent timeout) + + // Add many domains (reduced count to prevent timeout). Each restricted + // domain now renders its own add-email , so this stress scenario + // is a little heavier — give it more headroom under parallel CPU load. const largeDomainSet = []; for (let i = 0; i < 50; i++) { largeDomainSet.push({ @@ -735,11 +777,10 @@ describe("DomainManagement Component", () => { vm.domains.splice(0, vm.domains.length, ...largeDomainSet); // Should still function normally - vm.newDomain = "newdomain.com"; - await vm.addDomain(); + await vm.addDomain({ newDomain: "newdomain.com" }); expect(vm.domains.length).toBe(51); - }, 10000); + }, 20000); }); describe("State Consistency Testing", () => { @@ -751,7 +792,6 @@ describe("DomainManagement Component", () => { name: "test.com", allowAllUsers: false, allowedEmails: ["user1@test.com"], - newEmail: "" }); const domain = vm.domains[vm.domains.length - 1]; @@ -774,10 +814,10 @@ describe("DomainManagement Component", () => { // Rapid validation calls const validationPromises = [ - Promise.resolve(vm.isValidDomain("test1.com")), - Promise.resolve(vm.isValidDomain("test2.com")), - Promise.resolve(vm.isValidEmail("user@test.com", "test.com")), - Promise.resolve(vm.isValidEmail("admin@test.com", "test.com")) + Promise.resolve(isValidDomain("test1.com")), + Promise.resolve(isValidDomain("test2.com")), + Promise.resolve(isValidEmail("user@test.com", "test.com")), + Promise.resolve(isValidEmail("admin@test.com", "test.com")) ]; const results = await Promise.all(validationPromises); @@ -795,8 +835,7 @@ describe("DomainManagement Component", () => { const vm = wrapper.vm; // Start long-running operations - vm.newDomain = "test.com"; - const addPromise = vm.addDomain(); + const addPromise = vm.addDomain({ newDomain: "test.com" }); const savePromise = vm.saveChanges(); // Destroy component immediately @@ -819,13 +858,11 @@ describe("DomainManagement Component", () => { name: `stress-test-${i}.com`, allowAllUsers: false, allowedEmails: [`user@stress-test-${i}.com`], - newEmail: "" }); } // Component operations should still work under load - vm.newDomain = "memory-test.com"; - await vm.addDomain(); + await vm.addDomain({ newDomain: "memory-test.com" }); expect(vm.domains.some(d => d.name === "memory-test.com")).toBe(true); expect(vm.domains.length).toBe(initialCount + 30 + 1); // Original + stress domains + new domain @@ -836,3 +873,82 @@ describe("DomainManagement Component", () => { }); }); }); + +describe("makeAddEmailSchema (required: empty fails, must be valid + belong to domain)", () => { + const schema = makeAddEmailSchema("example.com", (k: string) => k); + + it("FAILS when the email is empty/omitted (required for the Add-Email action)", () => { + // Option A: the Add-Email field is required, so empty → "Email is required". + // (No .trim() — matches pre-migration; whitespace-only is caught as invalid.) + expect(schema.safeParse({ newEmail: "" }).success).toBe(false); + expect(schema.safeParse({ newEmail: undefined }).success).toBe(false); + expect(schema.safeParse({}).success).toBe(false); + }); + + it("passes a valid email that belongs to the domain", () => { + expect(schema.safeParse({ newEmail: "user@example.com" }).success).toBe( + true, + ); + }); + + it("rejects a malformed email or one from another domain", () => { + expect(schema.safeParse({ newEmail: "invalid-email" }).success).toBe(false); + expect(schema.safeParse({ newEmail: "user@other.com" }).success).toBe( + false, + ); + }); + + describe("Add-email form — first-submit validation (repro)", () => { + const createWrapper = () => + mount(DomainManagement, { global: { plugins: [i18n] } }); + const emailFormFor = async (wrapper: any, name: string) => { + const vm = wrapper.vm as any; + vm.domains.push({ name, allowAllUsers: false, allowedEmails: [] }); + await nextTick(); + await flushPromises(); + await nextTick(); + return vm.emailFormRefs[name]?.form; + }; + + it("INVALID email on the FIRST submit reveals the error and does not add", async () => { + const wrapper = createWrapper(); + await flushPromises(); + const form = await emailFormFor(wrapper, "openobaseve.ai"); + expect(form).toBeTruthy(); + + form.setFieldValue("newEmail", "abc"); // invalid + await nextTick(); + await form.handleSubmit(); // FIRST submit + await flushPromises(); + await nextTick(); + + const domain = (wrapper.vm as any).domains.find( + (d: any) => d.name === "openobaseve.ai", + ); + expect(domain.allowedEmails).not.toContain("abc"); + // The bug would be: isValid true / no error on first submit. + expect(form.state.isValid).toBe(false); + expect((form.getFieldMeta("newEmail")?.errors ?? []).length).toBeGreaterThan(0); + wrapper.unmount(); + }); + + it("EMPTY email on the FIRST submit shows 'required' and does not add (Option A)", async () => { + const wrapper = createWrapper(); + await flushPromises(); + const form = await emailFormFor(wrapper, "openobaseve.ai"); + expect(form).toBeTruthy(); + + await form.handleSubmit(); // FIRST submit, empty + await flushPromises(); + await nextTick(); + + const domain = (wrapper.vm as any).domains.find( + (d: any) => d.name === "openobaseve.ai", + ); + expect(domain.allowedEmails).toEqual([]); + expect(form.state.isValid).toBe(false); // required now → empty blocked + expect((form.getFieldMeta("newEmail")?.errors ?? []).length).toBeGreaterThan(0); + wrapper.unmount(); + }); + }); +}); diff --git a/web/src/components/settings/DomainManagement.vue b/web/src/components/settings/DomainManagement.vue index 3ccab6f134..74fd161a41 100644 --- a/web/src/components/settings/DomainManagement.vue +++ b/web/src/components/settings/DomainManagement.vue @@ -168,31 +168,32 @@ along with this program. If not, see .
{{ t("settings.domainAndAllowedUsers") }}
- -
- + +
-
{{ t('settings.addDomain') }} -
+
{{ t('settings.domainHint', { at_sign: '@' }) }}
@@ -211,7 +212,10 @@ along with this program. If not, see . :class="store.state.theme === 'dark' ? 'border-[#444] bg-[#1e1e1e]' : 'bg-white'" >
-
{{ domain.name }}
+
{{ domain.name }}
.
-
-
+ + +
+ {{ t('settings.emailPlaceholder', { domain: '@' + domain.name }) }} +
+
+
+
@@ -346,7 +361,8 @@ import { ref, reactive, onMounted, onActivated, watch, computed } from "vue"; import { useI18n } from "vue-i18n"; import { useStore } from "vuex"; import OButton from "@/lib/core/Button/OButton.vue"; -import OInput from "@/lib/forms/Input/OInput.vue"; +import OForm from "@/lib/forms/Form/OForm.vue"; +import OFormInput from "@/lib/forms/Input/OFormInput.vue"; import OSelect from "@/lib/forms/Select/OSelect.vue"; import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue"; import OIcon from "@/lib/core/Icon/OIcon.vue"; @@ -364,12 +380,19 @@ import ORadio from "@/lib/forms/Radio/ORadio.vue"; import ORadioGroup from "@/lib/forms/Radio/ORadioGroup.vue"; import { toast } from "@/lib/feedback/Toast/useToast"; import OSeparator from '@/lib/core/Separator/OSeparator.vue'; +import { + isValidEmail, + makeAddDomainSchema, + makeAddEmailSchema, + addDomainDefaults, + addEmailDefaults, + type AddDomainForm, +} from "./DomainManagement.schema"; interface Domain { name: string; allowAllUsers: boolean; allowedEmails: string[]; - newEmail: string; } const { t } = useI18n(); @@ -382,11 +405,33 @@ const pendingRemoveEmail = ref<{ domain: any; emailIndex: number; email: string const store = useStore(); const router = useRouter(); -const newDomain = ref(""); -const domainError = ref(""); const domains = reactive([]); const saving = ref(false); +// Schema-driven validation replaces the manual domainError ref + the +// :disabled="!newDomain || !isValidDomain" gate (R3). The pure validators live +// in the schema file (used by the Zod refines); the component only needs +// `isValidEmail` for the addEmail guard, imported directly. + +const addDomainForm = ref(null); +const addDomainSchema = makeAddDomainSchema(t); + +// Per-domain email add-row forms: a schema cache (keyed by domain name, since +// the email schema embeds the domain) + a ref map so each row can be reset +// after a successful add. +const emailSchemaCache = new Map>(); +const getEmailSchema = (domainName: string) => { + if (!emailSchemaCache.has(domainName)) { + emailSchemaCache.set(domainName, makeAddEmailSchema(domainName, t)); + } + return emailSchemaCache.get(domainName); +}; +const emailFormRefs = ref>({}); +const setEmailFormRef = (domainName: string, el: any) => { + if (el) emailFormRefs.value[domainName] = el; + else delete emailFormRefs.value[domainName]; +}; + // Claim parser function state const claimParserFunction = ref(""); const originalClaimParserFunction = ref(""); // Track original value to detect changes @@ -445,7 +490,7 @@ const loadDomainSettings = async () => { .map((domain: any) => ({ name: domain.domain, allowAllUsers: domain.allow_all_users, - allowedEmails: domain.allowed_emails || [] + allowedEmails: domain.allowed_emails || [], })); domains.splice(0, domains.length, ...loadedDomains); } @@ -466,91 +511,15 @@ const loadDomainSettings = async () => { } }; -const isValidDomain = (domain: any): boolean => { - // Handle null, undefined, and non-string inputs - if (domain === null || domain === undefined) return true; // Empty is valid - if (typeof domain !== 'string') return false; // Non-strings are invalid - - // Handle empty strings - empty is valid, but whitespace-only is not - const trimmed = domain.trim(); - if (!trimmed) return domain.length === 0; // Empty string is valid, whitespace-only is not - - // Security: Check for potentially malicious content (more targeted patterns) - const maliciousPatterns = [ - ' upperDomain.includes(pattern.toUpperCase()))) { - return false; - } - - // Length validation (DNS limit is 253 characters) - if (trimmed.length > 253) return false; - - // Remove trailing dot if present (valid in DNS) - const cleanDomain = trimmed.endsWith('.') ? trimmed.slice(0, -1) : trimmed; - - // Improved domain validation that properly handles hyphens and edge cases - // Domain parts can contain letters, numbers, and hyphens (but not start/end with hyphens) - // Each label can be 1-63 characters, and the domain must have at least one dot - const domainRegex = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/; - - try { - return domainRegex.test(cleanDomain); - } catch (error) { - return false; // Any regex error means invalid - } -}; - -const isValidEmail = (email: any, domain: any): boolean => { - // Handle null, undefined, and non-string inputs - if (email === null || email === undefined || typeof email !== 'string') return false; - if (domain === null || domain === undefined || typeof domain !== 'string') return false; - - // Handle empty strings - if (!email.trim() || !domain.trim()) return false; - - // Security: Check for potentially malicious content - const maliciousPatterns = [ - '<', '>', 'script', 'javascript:', 'DROP', 'SELECT', 'INSERT', 'UPDATE', 'DELETE', - 'UNION', 'CREATE', 'ALTER', 'TABLE', 'FROM', '--', '/*', '*/', "'", '"', - '\0', '\n', '\r', '\t' - ]; - - const upperEmail = email.toUpperCase(); - if (maliciousPatterns.some(pattern => upperEmail.includes(pattern.toUpperCase()))) { - return false; - } - - // Length validation (practical email limit) - if (email.length > 254 || domain.length > 253) return false; - - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - - try { - if (!emailRegex.test(email)) return false; - - // Check if email belongs to the domain - return email.toLowerCase().endsWith(`@${domain.toLowerCase()}`); - } catch (error) { - return false; // Any error means invalid - } -}; - -const addDomain = () => { - if (!newDomain.value) { - domainError.value = t("settings.domainRequired") || "Domain is required"; - return; - } - if (!isValidDomain(newDomain.value)) { - domainError.value = t("settings.invalidDomain") || "Please enter a valid domain (e.g. example.com)"; - return; - } +// @submit handler — fires only once the schema passes (required + valid +// domain), so the old empty/invalid guards are gone. The submitted form value +// is the single source of truth; the cross-state duplicate check stays here. +const addDomain = (value?: AddDomainForm) => { + const candidate = (value?.newDomain ?? "").trim(); + if (!candidate) return; // Check if domain already exists - if (domains.some(d => d.name.toLowerCase() === newDomain.value.toLowerCase())) { + if (domains.some(d => d.name.toLowerCase() === candidate.toLowerCase())) { toast({ variant: "error", message: t("settings.domainAlreadyExists"), @@ -559,19 +528,14 @@ const addDomain = () => { } domains.push({ - name: newDomain.value, + name: candidate, allowAllUsers: true, allowedEmails: [], - newEmail: "" }); - newDomain.value = ""; - - // toast({ - // variant: "success", - // message: t("settings.domainAdded"), - // timeout: 3000, - // }); + // Inline add-row cleared after save → reset() clears the field AND submit + // state (submissionAttempts → 0), so no post-save "required" flash. + addDomainForm.value?.form?.reset(); }; const removeDomain = (index: number) => { @@ -591,11 +555,15 @@ const doRemoveDomain = () => { }); }; -const addEmail = (domain: Domain) => { - if (!domain.newEmail || !isValidEmail(domain.newEmail, domain.name)) return; +// @submit handler for a domain's email add-row. The schema is conditional +// (empty passes), so an empty submit is a no-op; otherwise the email must be +// valid + belong to the domain. Still callable directly with the row model. +const addEmail = (domain: Domain, emailValue?: string) => { + const email = (emailValue ?? "").trim(); + if (!email || !isValidEmail(email, domain.name)) return; // Check if email already exists - if (domain.allowedEmails.includes(domain.newEmail.toLowerCase())) { + if (domain.allowedEmails.includes(email.toLowerCase())) { toast({ variant: "error", message: t("settings.emailAlreadyExists"), @@ -603,8 +571,8 @@ const addEmail = (domain: Domain) => { return; } - domain.allowedEmails.push(domain.newEmail.toLowerCase()); - domain.newEmail = ""; + domain.allowedEmails.push(email.toLowerCase()); + emailFormRefs.value[domain.name]?.form?.reset(); toast({ variant: "success", @@ -801,7 +769,7 @@ const viewAllErrors = () => { const saveChanges = async () => { saving.value = true; - + try { // Validate all domains have proper configuration for (const domain of domains) { @@ -844,8 +812,9 @@ const saveChanges = async () => { }; const resetForm = () => { - newDomain.value = ""; + // Reset the add-domain row through the form (clears the field + submit state), + // keeping TanStack the single owner of that field — no parallel ref. + addDomainForm.value?.form?.reset(); loadDomainSettings(); }; - diff --git a/web/src/components/settings/General.schema.ts b/web/src/components/settings/General.schema.ts new file mode 100644 index 0000000000..a4fa232d25 --- /dev/null +++ b/web/src/components/settings/General.schema.ts @@ -0,0 +1,61 @@ +// Copyright 2026 OpenObserve Inc. +// +// Validation schema for the General Settings "platform settings" form (the two +// number fields inside the ). The logo text / logo uploads / theme color +// chips are pure-UI controls with their own inline actions and are decoupled +// OUTSIDE the form, so they are NOT in this schema. +// +// Restores the Quasar BEFORE rules (truthy→Zod inversion): +// • scrapeInterval: `!!v || scrapeIntervalRequired` (+ HTML min=0) +// → required AND ≥ 0 (0 is a valid value). +// • maxSeriesPerQuery: empty→pass; else `v>=1000 && v<=1000000` +// → OPTIONAL, only range-checked when present (NOT required). +// +// The emits a string, so the rules read the raw value via +// Number(); the submit handler coerces at use. +// +// Validation TIMING is owned by OForm (submit-then-change via revalidateLogic); +// this file only describes WHAT is valid. + +import { z } from "zod"; + +const isEmpty = (v: unknown) => v === "" || v === null || v === undefined; + +// Factory (takes `t`) so the messages are localized like makeLicenseSchema / +// makeAddEmailSchema, rather than hardcoded English. The component owns the +// `t` instance and builds the schema once in setup(). +export const makeGeneralSettingsSchema = (t: (_key: string) => string) => + z.object({ + // Required (non-empty) and ≥ 0. 0 passes; empty fails as "required". + scrape_interval: z + .any() + .refine((v) => !isEmpty(v), { + message: + t("settings.scrapeIntervalRequired") || "Scrape interval is required", + }) + .refine( + (v) => isEmpty(v) || (!Number.isNaN(Number(v)) && Number(v) >= 0), + { + message: + t("settings.scrapeIntervalPositive") || + "Scrape interval must be a positive number", + }, + ), + // Optional — only range-checked (1000..1000000) when a value is present. + max_series_per_query: z.any().refine( + (v) => + isEmpty(v) || + (!Number.isNaN(Number(v)) && + Number(v) >= 1000 && + Number(v) <= 1000000), + { + message: + t("settings.maxSeriesPerQueryValidation") || + "Max series per query must be between 1000 and 1000000", + }, + ), + }); + +export type GeneralSettingsForm = z.infer< + ReturnType +>; diff --git a/web/src/components/settings/General.spec.ts b/web/src/components/settings/General.spec.ts index 16cdacab06..80cfec31bf 100644 --- a/web/src/components/settings/General.spec.ts +++ b/web/src/components/settings/General.spec.ts @@ -144,31 +144,9 @@ const createWrapper = (props = {}, options = {}) => { store: mockStore, }, stubs: { - OForm: { - template: - '
', - emits: ["submit"], - }, - OInput: { - template: ``, - props: [ - "modelValue", - "type", - "min", - "placeholder", - "error", - "errorMessage", - ], - emits: ["update:modelValue"], - }, + // OForm / OFormInput / OInput are intentionally NOT stubbed so the real + // schema validation runs (per the migration playbook: at least one path + // must mount the real ). OButton: { template: ``, + props: ["type", "variant", "disabled"], + emits: ["click"], + }, + }, + }, + }); + +const getForm = (wrapper: any) => wrapper.vm.storageForm.form; + +const selectProvider = async (wrapper: any, provider: string) => { + await wrapper + .find(`[data-test="storage-settings-provider-card-${provider}"]`) + .trigger("click"); + await nextTick(); +}; + +describe("OrgStorageEditor", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCreate.mockResolvedValue({ data: {} }); + mockUpdate.mockResolvedValue({ data: {} }); + mockGet.mockResolvedValue({ data: { provider: null, data: {} } }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("mounts and shows the provider cards in add mode", () => { + const wrapper = createWrapper("add"); + expect(wrapper.exists()).toBe(true); + expect( + wrapper + .find('[data-test="storage-settings-provider-card-AwsCredentials"]') + .exists(), + ).toBe(true); + expect( + wrapper + .find('[data-test="storage-settings-provider-card-AwsRoleArn"]') + .exists(), + ).toBe(true); + }); + + it("renders the AWS credential fields after selecting the provider", async () => { + const wrapper = createWrapper("add"); + await selectProvider(wrapper, "AwsCredentials"); + + expect( + wrapper.find('[data-test="storage-settings-bucket-name-input"]').exists(), + ).toBe(true); + expect( + wrapper.find('[data-test="storage-settings-access-key-input"]').exists(), + ).toBe(true); + expect( + wrapper.find('[data-test="storage-settings-secret-key-input"]').exists(), + ).toBe(true); + }); + + it("bridges the selected provider into the form", async () => { + const wrapper = createWrapper("add"); + await selectProvider(wrapper, "AwsCredentials"); + expect(getForm(wrapper).state.values.selectedProvider).toBe( + "AwsCredentials", + ); + }); + + describe("Provider-discriminated validation (real OForm)", () => { + it("blocks submit and does NOT call create when AWS required fields are empty", async () => { + const wrapper = createWrapper("add"); + await selectProvider(wrapper, "AwsCredentials"); + + await getForm(wrapper).handleSubmit(); + await flushPromises(); + + expect(getForm(wrapper).state.isValid).toBe(false); + expect(mockCreate).not.toHaveBeenCalled(); + }); + + it("creates the AWS config with the correct payload when valid", async () => { + const wrapper = createWrapper("add"); + await selectProvider(wrapper, "AwsCredentials"); + + const form = getForm(wrapper); + form.setFieldValue("bucket_name", "my-bucket"); + form.setFieldValue("region", "us-east-1"); + form.setFieldValue("access_key", "AKIA123"); + form.setFieldValue("secret_key", "secret123"); + await nextTick(); + + await form.handleSubmit(); + await flushPromises(); + + expect(mockCreate).toHaveBeenCalledWith("test-org-123", { + provider: "AwsCredentials", + data: { + bucket_name: "my-bucket", + server_url: "", + region: "us-east-1", + access_key: "AKIA123", + secret_key: "secret123", + }, + }); + }); + + it("blocks submit when AwsRoleArn is missing role_arn / external_id (restored per-provider rules)", async () => { + const wrapper = createWrapper("add"); + await selectProvider(wrapper, "AwsRoleArn"); + + const form = getForm(wrapper); + form.setFieldValue("bucket_name", "role-bucket"); + form.setFieldValue("region", "us-east-1"); + // role_arn + external_id intentionally left empty + await nextTick(); + + await form.handleSubmit(); + await flushPromises(); + + expect(form.state.isValid).toBe(false); + expect(mockCreate).not.toHaveBeenCalled(); + }); + + it("creates the AwsRoleArn config when all required fields are present", async () => { + const wrapper = createWrapper("add"); + await selectProvider(wrapper, "AwsRoleArn"); + + const form = getForm(wrapper); + form.setFieldValue("bucket_name", "role-bucket"); + form.setFieldValue("region", "us-east-1"); + form.setFieldValue("role_arn", "arn:aws:iam::123:role/x"); + form.setFieldValue("external_id", "ext-123"); + await nextTick(); + + await form.handleSubmit(); + await flushPromises(); + + expect(mockCreate).toHaveBeenCalledWith("test-org-123", { + provider: "AwsRoleArn", + data: { + bucket_name: "role-bucket", + region: "us-east-1", + role_arn: "arn:aws:iam::123:role/x", + external_id: "ext-123", + }, + }); + }); + }); + + describe("Azure provider validation (real OForm)", () => { + it("blocks submit and does NOT call create when Azure required fields are empty", async () => { + const wrapper = createWrapper("add"); + await selectProvider(wrapper, "AzureCredentials"); + + await getForm(wrapper).handleSubmit(); + await flushPromises(); + + expect(getForm(wrapper).state.isValid).toBe(false); + expect(mockCreate).not.toHaveBeenCalled(); + }); + + it("creates the Azure config with the correct payload when valid", async () => { + const wrapper = createWrapper("add"); + await selectProvider(wrapper, "AzureCredentials"); + + const form = getForm(wrapper); + form.setFieldValue("storage_account", "myaccount"); + form.setFieldValue("bucket_name", "az-bucket"); + form.setFieldValue("secret_key", "az-secret"); + await nextTick(); + + await form.handleSubmit(); + await flushPromises(); + + expect(mockCreate).toHaveBeenCalledWith("test-org-123", { + provider: "AzureCredentials", + data: { + bucket_name: "az-bucket", + server_url: "", + storage_account: "myaccount", + secret_key: "az-secret", + }, + }); + }); + }); + + describe("Provider switch reset (④c dependent-field side-effect)", () => { + it("clears the previous provider's credentials when the provider changes", async () => { + const wrapper = createWrapper("add"); + await selectProvider(wrapper, "AwsCredentials"); + + const form = getForm(wrapper); + form.setFieldValue("bucket_name", "aws-bucket"); + form.setFieldValue("access_key", "AKIA-should-clear"); + form.setFieldValue("secret_key", "secret-should-clear"); + await nextTick(); + // sanity: the credentials are set before the provider switch + expect(form.state.values.access_key).toBe("AKIA-should-clear"); + + // Switching the provider fires the create-mode watch → form.reset(blank), + // wiping the previous provider's credentials (the rulebook's ④c side-effect). + await selectProvider(wrapper, "AwsRoleArn"); + await flushPromises(); + + const values = getForm(wrapper).state.values; + expect(values.selectedProvider).toBe("AwsRoleArn"); + expect(values.access_key).toBe(""); + expect(values.secret_key).toBe(""); + expect(values.bucket_name).toBe(""); + }); + }); + + describe("GCP provider validation (schema-level — card not in the grid)", () => { + // GCP is intentionally not offered as a provider card (providerDefinitions + // comments it out), so it can't be exercised through the UI — but the + // superRefine branch still exists, so it is covered directly here. + const schema = makeOrgStorageEditorSchema((k: string) => k); + + it("requires bucket_name and access_key for GcpCredentials", () => { + const res = schema.safeParse({ selectedProvider: "GcpCredentials" }); + expect(res.success).toBe(false); + const paths = res.success + ? [] + : res.error.issues.map((iss: any) => iss.path.join(".")); + expect(paths).toContain("bucket_name"); + expect(paths).toContain("access_key"); + }); + + it("passes for GcpCredentials when bucket_name and access_key are present", () => { + const res = schema.safeParse({ + selectedProvider: "GcpCredentials", + bucket_name: "gcp-bucket", + access_key: "gcp-key", + }); + expect(res.success).toBe(true); + }); + }); + + describe("Edit mode", () => { + it("prefills non-credential fields and calls update on submit", async () => { + mockGet.mockResolvedValue({ + data: { + provider: "AwsCredentials", + data: { + bucket_name: "existing-bucket", + region: "eu-west-1", + server_url: "", + }, + }, + }); + + const wrapper = createWrapper("edit"); + await flushPromises(); + await nextTick(); + + const form = getForm(wrapper); + // Non-credential fields prefilled from the loaded config + expect(form.state.values.bucket_name).toBe("existing-bucket"); + expect(form.state.values.region).toBe("eu-west-1"); + + // Credentials must be entered fresh + form.setFieldValue("access_key", "new-key"); + form.setFieldValue("secret_key", "new-secret"); + await nextTick(); + + await form.handleSubmit(); + await flushPromises(); + + expect(mockUpdate).toHaveBeenCalledWith("test-org-123", { + provider: "AwsCredentials", + data: { + bucket_name: "existing-bucket", + server_url: "", + region: "eu-west-1", + access_key: "new-key", + secret_key: "new-secret", + }, + }); + }); + }); +}); diff --git a/web/src/components/settings/OrgStorageEditor.vue b/web/src/components/settings/OrgStorageEditor.vue index 024b055dd8..feda697126 100644 --- a/web/src/components/settings/OrgStorageEditor.vue +++ b/web/src/components/settings/OrgStorageEditor.vue @@ -34,7 +34,12 @@ along with this program. If not, see .
- + . @@ -187,40 +189,37 @@ along with this program. If not, see . diff --git a/web/src/components/settings/OrganizationManagement.schema.ts b/web/src/components/settings/OrganizationManagement.schema.ts new file mode 100644 index 0000000000..e3dba893d8 --- /dev/null +++ b/web/src/components/settings/OrganizationManagement.schema.ts @@ -0,0 +1,48 @@ +// Copyright 2026 OpenObserve Inc. +// +// Validation schemas for the two self-owned dialogs in OrganizationManagement.vue +// (meta-org admin): the External Contract dialog and the Extend-Trial dialog. +// +// Both dialogs previously gated their POST on imperative JS guards inside the +// @click:primary handlers (a toast + early-return for a missing contract end +// date). Those guards are replaced here by Zod rules wired to , so the +// footer Save + Enter submit through `form-id` and validation is schema-driven. +// +// Validation TIMING is owned by OForm (submit-then-change via revalidateLogic); +// these schemas only describe WHAT is valid. + +import { z } from "zod"; + +// ── External Contract dialog ───────────────────────────────────────────────── +// The single date field is required. The message is mode-aware so it reads +// "End date is required." when creating a new contract and "New end date is +// required." when extending an existing one (mirrors the old toast guards). +export const makeContractSchema = (mode: "create" | "extend") => + z.object({ + contractEndDate: z + .string() + .min( + 1, + mode === "create" + ? "End date is required." + : "New end date is required.", + ), + }); + +export type ContractForm = z.infer>; + +export const contractDefaults = (): ContractForm => ({ contractEndDate: "" }); + +// ── Extend-Trial dialog ────────────────────────────────────────────────────── +// The week count comes from a custom pill grid (1–4), bridged into the form via +// setFieldValue. It must be at least one week. `z.coerce.number()` because the +// bridged value can arrive as a string. +export const extendTrialSchema = z.object({ + extendedTrial: z.coerce + .number() + .min(1, "Trial extension must be at least 1 week."), +}); + +export type ExtendTrialForm = z.infer; + +export const extendTrialDefaults = (): ExtendTrialForm => ({ extendedTrial: 1 }); diff --git a/web/src/components/settings/OrganizationManagement.spec.ts b/web/src/components/settings/OrganizationManagement.spec.ts index 34325876c4..b7c2a2e4c6 100644 --- a/web/src/components/settings/OrganizationManagement.spec.ts +++ b/web/src/components/settings/OrganizationManagement.spec.ts @@ -26,6 +26,7 @@ import { import { mount, flushPromises } from "@vue/test-utils"; import { createRouter, createWebHistory } from "vue-router"; import OrganizationManagement from "./OrganizationManagement.vue"; +import { extendTrialSchema } from "./OrganizationManagement.schema"; import store from "../../test/unit/helpers/store"; import { createI18n } from "vue-i18n"; import { nextTick } from "vue"; @@ -342,7 +343,8 @@ describe("OrganizationManagement.vue", () => { wrapper = createWrapper(); expect(wrapper.vm.contractPrompt).toBe(false); expect(wrapper.vm.contractMode).toBe("create"); - expect(wrapper.vm.contractEndDate).toBe(""); + // contractEndDate is now form-owned (OFormInput) — the schema default is "". + expect(wrapper.vm.contractDefaults()).toEqual({ contractEndDate: "" }); }); }); @@ -1180,22 +1182,16 @@ describe("OrganizationManagement.vue", () => { expect(wrapper.vm.extendTrialPrompt).toBe(false); }); - it("should call updateTrialPeriod when primary is clicked on the extend-trial dialog", async () => { + it("should call updateTrialPeriod when the extend-trial form is submitted (real OForm)", async () => { mockExtendTrialPeriod.mockResolvedValue({ data: true }); wrapper = createWrapper(); - wrapper.vm.extendTrialPrompt = true; - wrapper.vm.extendTrialDataRow = { name: "Acme", identifier: "acme-id" }; + wrapper.vm.toggleExtendTrialDialog({ name: "Acme", identifier: "acme-id" }); wrapper.vm.extendedTrial = 3; await nextTick(); - const dialog = wrapper - .findAll('[data-test="o-dialog-stub"]') - .find((d: any) => - (d.attributes("data-title") || "").startsWith("Extend Trial"), - ); - expect(dialog?.exists()).toBe(true); - - await dialog!.find('[data-test="o-dialog-stub-primary"]').trigger("click"); + const form = wrapper.findComponent({ name: "OForm" }); + expect(form.exists()).toBe(true); + await (form.vm as any).form.handleSubmit(); await flushPromises(); expect(mockExtendTrialPeriod).toHaveBeenCalledWith( @@ -1266,24 +1262,23 @@ describe("OrganizationManagement.vue", () => { expect(wrapper.vm.contractPrompt).toBe(false); }); - it("should invoke submitContract when primary is clicked", async () => { + it("should call create_external_contract when the contract form is submitted with a valid date (real OForm)", async () => { mockCreateExternalContract?.mockResolvedValue?.({ data: true }); wrapper = createWrapper(); wrapper.vm.toggleContractDialog( { name: "Acme", identifier: "acme" }, "create", ); - wrapper.vm.contractEndDate = "2030-12-31"; await nextTick(); - const dialog = wrapper - .findAll('[data-test="o-dialog-stub"]') - .find((d: any) => - (d.attributes("data-title") || "").includes("External Contract"), - ); - expect(dialog?.exists()).toBe(true); + // Fill the form-owned date field, then submit through the real OForm. + await wrapper + .find('[data-test="contract-end-date-input"]') + .setValue("2030-12-31"); - await dialog!.find('[data-test="o-dialog-stub-primary"]').trigger("click"); + const form = wrapper.findComponent({ name: "OForm" }); + expect(form.exists()).toBe(true); + await (form.vm as any).form.handleSubmit(); await flushPromises(); expect(mockCreateExternalContract).toHaveBeenCalledWith( @@ -1294,9 +1289,8 @@ describe("OrganizationManagement.vue", () => { }); describe("toggleContractDialog Function", () => { - it("should open the contract dialog in create mode and reset the end date", () => { + it("should open the contract dialog in create mode", () => { wrapper = createWrapper(); - wrapper.vm.contractEndDate = "2025-01-01"; wrapper.vm.toggleContractDialog( { name: "Acme", identifier: "acme" }, "create", @@ -1308,7 +1302,8 @@ describe("OrganizationManagement.vue", () => { name: "Acme", identifier: "acme", }); - expect(wrapper.vm.contractEndDate).toBe(""); + // The end date is no longer a component ref — the form re-seeds it to "" + // on each open (dialog body remounts), proven by the real-OForm gate tests. }); it("should open the contract dialog in extend mode", () => { @@ -1333,43 +1328,40 @@ describe("OrganizationManagement.vue", () => { store.state.zoConfig.meta_org = "different_org"; }); - it("should warn and return when create mode has no end date", async () => { + // Validation is now schema-driven: an empty end date blocks the submit + // (the old toast required-guards are gone). Mount the REAL and prove + // the schema gates the submit and the service is NOT called. + it("blocks the create submit and does not call the service when the end date is empty (real OForm)", async () => { wrapper = createWrapper(); wrapper.vm.toggleContractDialog( { name: "Acme", identifier: "acme" }, "create", ); - wrapper.vm.contractEndDate = ""; + await nextTick(); - await wrapper.vm.submitContract(); + const form = wrapper.findComponent({ name: "OForm" }); + expect(form.exists()).toBe(true); + await (form.vm as any).form.handleSubmit(); await flushPromises(); - expect(mockToastFn).toHaveBeenCalledWith( - expect.objectContaining({ - variant: "error", - message: "End date is required.", - }), - ); + expect((form.vm as any).form.state.isValid).toBe(false); expect(mockCreateExternalContract).not.toHaveBeenCalled(); }); - it("should warn and return when extend mode has no end date", async () => { + it("blocks the extend submit and does not call the service when the end date is empty (real OForm)", async () => { wrapper = createWrapper(); wrapper.vm.toggleContractDialog( { name: "Acme", identifier: "acme" }, "extend", ); - wrapper.vm.contractEndDate = ""; + await nextTick(); - await wrapper.vm.submitContract(); + const form = wrapper.findComponent({ name: "OForm" }); + expect(form.exists()).toBe(true); + await (form.vm as any).form.handleSubmit(); await flushPromises(); - expect(mockToastFn).toHaveBeenCalledWith( - expect.objectContaining({ - variant: "error", - message: "New end date is required.", - }), - ); + expect((form.vm as any).form.state.isValid).toBe(false); expect(mockExtendExternalContract).not.toHaveBeenCalled(); }); @@ -1380,9 +1372,8 @@ describe("OrganizationManagement.vue", () => { { name: "Acme", identifier: "acme" }, "create", ); - wrapper.vm.contractEndDate = "2030-12-31"; - await wrapper.vm.submitContract(); + await wrapper.vm.submitContract({ contractEndDate: "2030-12-31" }); await flushPromises(); expect(mockCreateExternalContract).toHaveBeenCalledTimes(1); @@ -1404,8 +1395,7 @@ describe("OrganizationManagement.vue", () => { { name: "Acme", identifier: "acme" }, "create", ); - wrapper.vm.contractEndDate = "2030-12-31"; - await wrapper.vm.submitContract(); + await wrapper.vm.submitContract({ contractEndDate: "2030-12-31" }); await flushPromises(); expect(wrapper.vm.contractPrompt).toBe(false); @@ -1421,9 +1411,8 @@ describe("OrganizationManagement.vue", () => { { name: "Acme", identifier: "acme" }, "create", ); - wrapper.vm.contractEndDate = "2030-12-31"; - await wrapper.vm.submitContract(); + await wrapper.vm.submitContract({ contractEndDate: "2030-12-31" }); await flushPromises(); expect(mockToastFn).toHaveBeenCalledWith( @@ -1443,9 +1432,8 @@ describe("OrganizationManagement.vue", () => { { name: "Acme", identifier: "acme" }, "create", ); - wrapper.vm.contractEndDate = "2030-12-31"; - await wrapper.vm.submitContract(); + await wrapper.vm.submitContract({ contractEndDate: "2030-12-31" }); await flushPromises(); expect(mockToastFn).toHaveBeenCalledWith( @@ -1463,9 +1451,8 @@ describe("OrganizationManagement.vue", () => { { name: "Beta", identifier: "beta" }, "extend", ); - wrapper.vm.contractEndDate = "2030-12-31"; - await wrapper.vm.submitContract(); + await wrapper.vm.submitContract({ contractEndDate: "2030-12-31" }); await flushPromises(); expect(mockExtendExternalContract).toHaveBeenCalledTimes(1); @@ -1484,9 +1471,8 @@ describe("OrganizationManagement.vue", () => { { name: "Beta", identifier: "beta" }, "extend", ); - wrapper.vm.contractEndDate = "2030-12-31"; - await wrapper.vm.submitContract(); + await wrapper.vm.submitContract({ contractEndDate: "2030-12-31" }); await flushPromises(); expect(mockToastFn).toHaveBeenCalledWith( @@ -1917,3 +1903,29 @@ describe("OrganizationManagement.vue", () => { }); }); }); + +describe("extendTrialSchema", () => { + it("rejects a week count below 1 (the .min(1) rule)", () => { + expect(extendTrialSchema.safeParse({ extendedTrial: 0 }).success).toBe( + false, + ); + }); + + it("coerces a numeric string and still enforces the minimum", () => { + // The pill value is bridged in and can arrive as a string. + expect(extendTrialSchema.safeParse({ extendedTrial: "0" }).success).toBe( + false, + ); + const res = extendTrialSchema.safeParse({ extendedTrial: "2" }); + expect(res.success).toBe(true); + if (res.success) expect(res.data.extendedTrial).toBe(2); + }); + + it("accepts the in-range pill values 1..4", () => { + for (const v of [1, 2, 3, 4]) { + expect(extendTrialSchema.safeParse({ extendedTrial: v }).success).toBe( + true, + ); + } + }); +}); diff --git a/web/src/components/settings/OrganizationManagement.vue b/web/src/components/settings/OrganizationManagement.vue index 2f8aa5245e..2d17912c44 100644 --- a/web/src/components/settings/OrganizationManagement.vue +++ b/web/src/components/settings/OrganizationManagement.vue @@ -149,27 +149,35 @@ along with this program. If not, see . sub-title="Set the new trial extension period." :secondary-button-label="t('common.cancel')" :primary-button-label="`Extend trial by ${extendedTrial} week(s)`" + form-id="org-extend-trial-form" @click:secondary="extendTrialPrompt = false" - @click:primary="updateTrialPeriod(extendTrialDataRow.identifier, extendedTrial)" > -
-
Week(s)
-
- - {{ page }} - + +
+
Week(s)
+
+ + {{ page }} + +
-
+ @@ -180,25 +188,31 @@ along with this program. If not, see . :title="`${contractMode === 'create' ? 'Create' : 'Extend'} External Contract for ${contractDataRow?.name}`" :secondary-button-label="t('common.cancel')" :primary-button-label="contractMode === 'create' ? 'Create Contract' : 'Extend Contract'" + form-id="org-contract-form" @click:secondary="contractPrompt = false" - @click:primary="submitContract" > -
-
- {{ contractMode === 'create' ? 'End Date' : 'New End Date' }} -
- -
-
- Current end date: {{ formatMicrosToDate(contractDataRow.contract_end_date) }} -
+
+ +
+
+ Current end date: {{ formatMicrosToDate(contractDataRow.contract_end_date) }} +
+
@@ -217,7 +231,8 @@ import { useStore } from "vuex"; import { useRouter } from "vue-router"; import OrganizationServices from "@/services/organizations"; import OButton from "@/lib/core/Button/OButton.vue"; -import OInput from "@/lib/forms/Input/OInput.vue"; +import OForm from "@/lib/forms/Form/OForm.vue"; +import OFormInput from "@/lib/forms/Input/OFormInput.vue"; import OSearchInput from "@/lib/forms/SearchInput/OSearchInput.vue"; import ODialog from "@/lib/overlay/Dialog/ODialog.vue"; import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue"; @@ -230,6 +245,13 @@ import { useConfirmDialog } from "@/composables/useConfirmDialog"; import AppPageHeader from "@/components/common/AppPageHeader.vue"; import { useShortcuts } from "@/lib/vue-shortcut-manager"; import { isInputFocused } from "@/utils/keyboardShortcuts"; +import { + makeContractSchema, + contractDefaults, + extendTrialSchema, + type ContractForm, + type ExtendTrialForm, +} from "./OrganizationManagement.schema"; export default defineComponent({ name: "PageAlerts", @@ -239,7 +261,8 @@ export default defineComponent({ OButton, ODialog, OTooltip, - OInput, + OForm, + OFormInput, OSearchInput, OTable, }, @@ -261,7 +284,26 @@ export default defineComponent({ const contractPrompt = ref(false); const contractDataRow = ref({}); const contractMode = ref<"create" | "extend">("create"); - const contractEndDate = ref(""); + + // ── Form schemas (Options-API: MUST be returned from setup() or :schema + // resolves to undefined and validation silently no-ops). ─────────────── + // The contract message is mode-aware; the dialog body remounts on open + // (reka-ui), so a freshly-mounted always reads the current schema. + const contractSchema = computed(() => makeContractSchema(contractMode.value)); + + // Extend-trial week count is bridged from the pill grid into the form below. + // Dynamic defaults (project the current pill value) → a typed computed. + const extendTrialFormRef = ref(null); + const extendTrialDefaults = computed( + (): ExtendTrialForm => ({ extendedTrial: extendedTrial.value }), + ); + + // Keep the form's copy of the bridged pill value in sync (the pill grid is a + // custom control, not an , so it is bridged via setFieldValue — the + // documented sanctioned exception, as CreateDestinationForm does). + watch(extendedTrial, (v) => { + extendTrialFormRef.value?.form?.setFieldValue("extendedTrial", Number(v)); + }); onMounted(() => { if ( @@ -457,21 +499,21 @@ export default defineComponent({ const toggleContractDialog = (row: any, mode: "create" | "extend") => { contractDataRow.value = row; contractMode.value = mode; - contractEndDate.value = ""; + // No contractEndDate reset needed: the dialog body remounts on open and + // re-seeds the field to blank. contractPrompt.value = true; }; - const submitContract = () => { + // @submit handler — fires only once the schema passes (contractEndDate + // required), so the old toast required-guards are gone. Awaited by OForm, so + // the footer Save spinner spans the POST automatically. + const submitContract = async (value: ContractForm) => { const metaOrg = store.state.selectedOrganization.identifier; if (contractMode.value === "create") { - if (!contractEndDate.value) { - toast({ variant: "error", message: "End date is required." }); - return; - } const payload = { org_id: contractDataRow.value.identifier, - end_date: dateToMicros(contractEndDate.value), + end_date: dateToMicros(value.contractEndDate), }; loading.value = true; @@ -480,7 +522,7 @@ export default defineComponent({ message: "Creating external contract...", timeout: 0, }); - OrganizationServices.create_external_contract(metaOrg, payload) + return OrganizationServices.create_external_contract(metaOrg, payload) .then(() => { toast({ variant: "success", @@ -503,13 +545,9 @@ export default defineComponent({ }); }); } else { - if (!contractEndDate.value) { - toast({ variant: "error", message: "New end date is required." }); - return; - } const payload = { org_id: contractDataRow.value.identifier, - new_end_date: dateToMicros(contractEndDate.value), + new_end_date: dateToMicros(value.contractEndDate), }; loading.value = true; @@ -518,7 +556,7 @@ export default defineComponent({ message: "Extending external contract...", timeout: 0, }); - OrganizationServices.extend_external_contract(metaOrg, payload) + return OrganizationServices.extend_external_contract(metaOrg, payload) .then(() => { toast({ variant: "success", @@ -630,7 +668,7 @@ export default defineComponent({ "Please wait while processing trial period extension request...", timeout: 0, }); - OrganizationServices.extend_trial_period( + return OrganizationServices.extend_trial_period( store.state.selectedOrganization.identifier, payload, ) @@ -663,6 +701,16 @@ export default defineComponent({ }); }; + // @submit handler for the extend-trial dialog — awaited by OForm so the + // footer Save spinner spans the POST. The week count comes from the + // schema-validated form value (bridged from the pill grid). + const onExtendTrialSubmit = async (value: ExtendTrialForm) => { + return updateTrialPeriod( + extendTrialDataRow.value?.identifier, + Number(value.extendedTrial), + ); + }; + const filterData = (rows: string | any[], terms: string) => { var filtered = []; terms = terms.toLowerCase(); @@ -704,7 +752,6 @@ export default defineComponent({ contractPrompt, contractDataRow, contractMode, - contractEndDate, toggleContractDialog, submitContract, confirmRevokeContract, @@ -714,7 +761,15 @@ export default defineComponent({ filterData, visibleRows, store, + // Form wiring (Options-API: schemas/defaults MUST be returned so :schema + // resolves and validation runs). + contractSchema, + contractDefaults, + extendTrialSchema, + extendTrialDefaults, + extendTrialFormRef, + onExtendTrialSubmit, }; }, }); - \ No newline at end of file + diff --git a/web/src/components/settings/OrganizationSettings.schema.ts b/web/src/components/settings/OrganizationSettings.schema.ts new file mode 100644 index 0000000000..ef0a1e606c --- /dev/null +++ b/web/src/components/settings/OrganizationSettings.schema.ts @@ -0,0 +1,46 @@ +// Copyright 2026 OpenObserve Inc. +// +// Validation schema for OrganizationSettings.vue (Settings > Organization > Log +// Details). The trace-id / span-id field names are required and must match the +// allowed character set; the two switches are non-validated form state; the +// crossLinks composite (CrossLinkManager) has no OForm* equivalent so it stays +// bare and is merged at submit (declared optional here for documentation). +// +// Restores the Quasar BEFORE rule (truthy→Zod inversion): +// `!!v ? isValidTraceField || 'Use alphanumeric…' : nameRequired` +// → required (min 1) + regex(/^[a-zA-Z0-9+=,.@_-]+$/). +// +// Validation TIMING is owned by OForm (submit-then-change via revalidateLogic); +// this file only describes WHAT is valid. + +import { z } from "zod"; + +export const orgSettingsFieldNameRegex = /^[a-zA-Z0-9+=,.@_-]+$/; + +const FIELD_FORMAT_MESSAGE = + "Use alphanumeric and '+=,.@-_' characters only, without spaces."; + +export const makeOrganizationSettingsSchema = (t: (_key: string) => string) => + z.object({ + // NO .trim(): OForm/TanStack validates with the schema but SAVES the raw value, + // so .trim() would let "trace_id " pass yet persist the space (breaks field lookup). + // Validate the raw value; the regex already rejects spaces. + traceIdFieldName: z + .string() + .min(1, t("common.nameRequired")) + .regex(orgSettingsFieldNameRegex, FIELD_FORMAT_MESSAGE), + spanIdFieldName: z + .string() + .min(1, t("common.nameRequired")) + .regex(orgSettingsFieldNameRegex, FIELD_FORMAT_MESSAGE), + // Non-validated form state (R1-strict: still form-owned via OFormSwitch). + toggleIngestionLogs: z.boolean().optional().default(false), + usageStreamEnabled: z.boolean().optional().default(false), + // CrossLinkManager is a composite custom control (documented no-OForm* + // exception) — kept bare and merged at submit; optional so it never blocks. + crossLinks: z.array(z.any()).optional(), + }); + +export type OrganizationSettingsForm = z.infer< + ReturnType +>; diff --git a/web/src/components/settings/OrganizationSettings.spec.ts b/web/src/components/settings/OrganizationSettings.spec.ts index 6962ede4c5..0f5f382905 100644 --- a/web/src/components/settings/OrganizationSettings.spec.ts +++ b/web/src/components/settings/OrganizationSettings.spec.ts @@ -13,16 +13,14 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { mount } from "@vue/test-utils"; +import { mount, flushPromises } from "@vue/test-utils"; import { describe, expect, it, beforeEach, vi, afterEach } from "vitest"; import OrganizationSettings from "./OrganizationSettings.vue"; import i18n from "@/locales"; import { nextTick, reactive } from "vue"; import store from "@/test/unit/helpers/store"; - // Mock toast from @/lib/feedback/Toast/useToast (replaces Quasar $q.notify) -// Must use vi.hoisted so the mock factory can access mockToast before it's initialized const { mockToast } = vi.hoisted(() => ({ mockToast: vi.fn(), })); @@ -42,14 +40,14 @@ vi.mock("@/services/organizations", () => ({ })); import organizations from "@/services/organizations"; -const mockPostOrganizationSettings = organizations.post_organization_settings as any; +const mockPostOrganizationSettings = + organizations.post_organization_settings as any; // Mock useStore to return our test store vi.mock("vuex", () => ({ useStore: () => mockStore, })); -// Create enhanced mock store with proper dispatch mock // Use reactive() so watchers in the component can observe state changes const mockStore = reactive({ ...store, @@ -74,50 +72,24 @@ const mockStore = reactive({ const createWrapper = (props = {}, options = {}) => { return mount(OrganizationSettings, { - props: { - ...props, - }, + props: { ...props }, global: { plugins: [i18n], - provide: { - store: mockStore, - }, + provide: { store: mockStore }, + // NOTE: OForm / OFormInput / OFormSwitch are intentionally NOT stubbed so + // the real schema validation runs (per the migration playbook: at least + // one path must mount the real ). stubs: { - OInput: { - template: ``, - props: ["modelValue", "label", "error", "errorMessage", "helpText"], - emits: ["update:modelValue"], - }, - OSwitch: { - template: ``, - props: ["modelValue", "label"], - emits: ["update:modelValue"], - }, OButton: { template: ``, + >`, props: ["variant", "size", "type", "loading"], emits: ["click"], }, - OSeparator: { - template: '
', - }, + OSeparator: { template: '
' }, CrossLinkManager: { template: '
CrossLinkManager
', props: ["modelValue", "title", "subtitle"], @@ -130,18 +102,19 @@ const createWrapper = (props = {}, options = {}) => { }); }; +const getForm = (wrapper: any) => wrapper.findComponent({ name: "OForm" }); + describe("OrganizationSettings", () => { beforeEach(() => { vi.clearAllMocks(); - // Reset store state mockStore.state.selectedOrganization = { identifier: "test-org-123", - label: "Test Organization", + label: "Test Organization", id: 123, }; mockStore.state.organizationData.organizationSettings = { trace_id_field_name: "trace_id", - span_id_field_name: "span_id", + span_id_field_name: "span_id", toggle_ingestion_logs: false, }; mockPostOrganizationSettings.mockResolvedValue({ status: 200 }); @@ -152,7 +125,7 @@ describe("OrganizationSettings", () => { vi.clearAllMocks(); }); - describe("Component mounting and initialization", () => { + describe("Component mounting and rendering", () => { it("should mount successfully", () => { const wrapper = createWrapper(); expect(wrapper.exists()).toBe(true); @@ -161,299 +134,175 @@ describe("OrganizationSettings", () => { it("should display the settings title", () => { const wrapper = createWrapper(); const title = wrapper.get("div.text-base.font-bold"); - expect(title.exists()).toBe(true); expect(title.text()).toContain("Log Details"); }); - it("should initialize traceIdFieldName from store", () => { + it("should render trace and span ID field inputs (data-tests preserved)", () => { const wrapper = createWrapper(); - expect(wrapper.vm.traceIdFieldName).toBe("trace_id"); + expect( + wrapper.find('[data-test="settings-org-trace-id-input"]').exists(), + ).toBe(true); + expect( + wrapper.find('[data-test="settings-org-span-id-input"]').exists(), + ).toBe(true); }); - it("should initialize spanIdFieldName from store", () => { + it("should render the ingestion + usage stream toggles (data-tests preserved)", () => { const wrapper = createWrapper(); - expect(wrapper.vm.spanIdFieldName).toBe("span_id"); + expect(wrapper.find('[data-test="add-toggle-ingestion"]').exists()).toBe( + true, + ); + expect( + wrapper.find('[data-test="add-toggle-ingestion-btn"]').exists(), + ).toBe(true); + expect( + wrapper.find('[data-test="add-toggle-usage-stream"]').exists(), + ).toBe(true); + expect( + wrapper.find('[data-test="add-toggle-usage-stream-btn"]').exists(), + ).toBe(true); }); - it("should initialize toggleIngestionLogs from store", () => { + it("should render the save button and keep it enabled (R3)", () => { const wrapper = createWrapper(); - expect(wrapper.vm.toggleIngestionLogs).toBe(false); + const saveBtn = wrapper.find('[data-test="add-alert-submit-btn"]'); + expect(saveBtn.exists()).toBe(true); + expect(saveBtn.attributes("disabled")).toBeFalsy(); }); - it("should initialize isValidSpanField as true", () => { + it("should seed the form defaults from the store", () => { const wrapper = createWrapper(); - expect(wrapper.vm.isValidSpanField).toBe(true); + const form = getForm(wrapper); + expect(form.vm.form.state.values).toMatchObject({ + traceIdFieldName: "trace_id", + spanIdFieldName: "span_id", + toggleIngestionLogs: false, + usageStreamEnabled: false, + }); }); + }); - it("should initialize isValidTraceField as true", () => { - const wrapper = createWrapper(); - expect(wrapper.vm.isValidTraceField).toBe(true); - }); - - it("should handle missing organizationSettings gracefully", () => { - mockStore.state.organizationData.organizationSettings = null; - const wrapper = createWrapper(); - expect(wrapper.vm.traceIdFieldName).toBeUndefined(); - expect(wrapper.vm.spanIdFieldName).toBeUndefined(); - expect(wrapper.vm.toggleIngestionLogs).toBe(false); - }); - - it("should use default value for toggleIngestionLogs when not in store", () => { + describe("Schema validation (real OForm)", () => { + it("blocks submit and does NOT call the service when field names are empty", async () => { mockStore.state.organizationData.organizationSettings = { - trace_id_field_name: "trace_id", + trace_id_field_name: "", + span_id_field_name: "", + }; + const wrapper = createWrapper(); + const form = getForm(wrapper); + + await form.vm.form.handleSubmit(); + await flushPromises(); + + expect(form.vm.form.state.isValid).toBe(false); + expect(mockPostOrganizationSettings).not.toHaveBeenCalled(); + }); + + it("blocks submit when a field name has invalid characters (restored regex rule)", async () => { + mockStore.state.organizationData.organizationSettings = { + trace_id_field_name: "invalid trace", + span_id_field_name: "valid_span", + }; + const wrapper = createWrapper(); + const form = getForm(wrapper); + + await form.vm.form.handleSubmit(); + await flushPromises(); + + expect(form.vm.form.state.isValid).toBe(false); + expect(mockPostOrganizationSettings).not.toHaveBeenCalled(); + }); + + it("blocks submit when a field name has trailing whitespace (regression: no schema .trim to mask it)", async () => { + // Before the fix, .trim() validated a trimmed copy so "trace_id " passed + // and was saved raw with the space. The regex must now reject it directly. + mockStore.state.organizationData.organizationSettings = { + trace_id_field_name: "trace_id ", span_id_field_name: "span_id", }; const wrapper = createWrapper(); - expect(wrapper.vm.toggleIngestionLogs).toBe(false); + const form = getForm(wrapper); + + await form.vm.form.handleSubmit(); + await flushPromises(); + + expect(form.vm.form.state.isValid).toBe(false); + expect(mockPostOrganizationSettings).not.toHaveBeenCalled(); }); - it("should initialize toggleIngestionLogs as true when set in store", () => { - mockStore.state.organizationData.organizationSettings.toggle_ingestion_logs = true; + it("submits when both field names are valid", async () => { const wrapper = createWrapper(); - expect(wrapper.vm.toggleIngestionLogs).toBe(true); - }); - }); + const form = getForm(wrapper); - describe("Form inputs", () => { - it("should render trace ID field name input", () => { - const wrapper = createWrapper(); - const traceInput = wrapper.find('.trace-id-field-name input[data-test-stub="o-input"]'); - expect(traceInput.exists()).toBe(true); - }); + await form.vm.form.handleSubmit(); + await flushPromises(); - it("should render span ID field name input", () => { - const wrapper = createWrapper(); - const spanInput = wrapper.find('.span-id-field-name input[data-test-stub="o-input"]'); - expect(spanInput.exists()).toBe(true); - }); - - it("should render toggle ingestion logs", () => { - const wrapper = createWrapper(); - const toggleInput = wrapper.find('input[data-test-stub="o-switch"]'); - expect(toggleInput.exists()).toBe(true); - }); - - it("should update traceIdFieldName when input changes", async () => { - const wrapper = createWrapper(); - const traceInput = wrapper.find('.trace-id-field-name input[data-test-stub="o-input"]'); - - await traceInput.setValue("new_trace_id"); - expect(wrapper.vm.traceIdFieldName).toBe("new_trace_id"); - }); - - it("should update spanIdFieldName when input changes", async () => { - const wrapper = createWrapper(); - const spanInput = wrapper.find('.span-id-field-name input[data-test-stub="o-input"]'); - - await spanInput.setValue("new_span_id"); - expect(wrapper.vm.spanIdFieldName).toBe("new_span_id"); - }); - - it("should update toggleIngestionLogs when toggle changes", async () => { - const wrapper = createWrapper(); - const toggle = wrapper.find('input[data-test-stub="o-switch"]'); - - await toggle.setChecked(true); - expect(wrapper.vm.toggleIngestionLogs).toBe(true); - }); - - it("should trim whitespace from trace ID input", async () => { - const wrapper = createWrapper(); - const traceInput = wrapper.find('.trace-id-field-name input[data-test-stub="o-input"]'); - - await traceInput.setValue(" trace_with_spaces "); - expect(wrapper.vm.traceIdFieldName).toBe("trace_with_spaces"); - }); - - it("should trim whitespace from span ID input", async () => { - const wrapper = createWrapper(); - const spanInput = wrapper.find('.span-id-field-name input[data-test-stub="o-input"]'); - - await spanInput.setValue(" span_with_spaces "); - expect(wrapper.vm.spanIdFieldName).toBe("span_with_spaces"); - }); - }); - - describe("Validation functions", () => { - it("should validate field names with valid characters", () => { - const wrapper = createWrapper(); - expect(wrapper.vm.validateFieldName("valid_field-name123")).toBe(true); - expect(wrapper.vm.validateFieldName("test+field@company.com")).toBe(true); - expect(wrapper.vm.validateFieldName("field.name")).toBe(true); - expect(wrapper.vm.validateFieldName("field,name")).toBe(true); - expect(wrapper.vm.validateFieldName("field=value")).toBe(true); - }); - - it("should reject field names with invalid characters", () => { - const wrapper = createWrapper(); - expect(wrapper.vm.validateFieldName("field name")).toBe(false); // space - expect(wrapper.vm.validateFieldName("field#name")).toBe(false); // hash - expect(wrapper.vm.validateFieldName("field$name")).toBe(false); // dollar - expect(wrapper.vm.validateFieldName("field%name")).toBe(false); // percent - expect(wrapper.vm.validateFieldName("field&name")).toBe(false); // ampersand - }); - - it("should validate empty field names as invalid", () => { - const wrapper = createWrapper(); - expect(wrapper.vm.validateFieldName("")).toBe(false); - }); - - it("should validate field names with special allowed characters", () => { - const wrapper = createWrapper(); - expect(wrapper.vm.validateFieldName("field+name")).toBe(true); - expect(wrapper.vm.validateFieldName("field=name")).toBe(true); - expect(wrapper.vm.validateFieldName("field,name")).toBe(true); - expect(wrapper.vm.validateFieldName("field.name")).toBe(true); - expect(wrapper.vm.validateFieldName("field@name")).toBe(true); - expect(wrapper.vm.validateFieldName("field_name")).toBe(true); - expect(wrapper.vm.validateFieldName("field-name")).toBe(true); - }); - - it("should handle null or undefined in validateFieldName", () => { - const wrapper = createWrapper(); - // The regex.test() method converts null/undefined to string, so they return true - // This tests the actual behavior of the original component - expect(wrapper.vm.validateFieldName(null)).toBe(true); - expect(wrapper.vm.validateFieldName(undefined)).toBe(true); - }); - }); - - describe("isValidRoleName computed property", () => { - it("should return true for valid trace ID field name", async () => { - const wrapper = createWrapper(); - wrapper.vm.traceIdFieldName = "valid_trace_id"; - await nextTick(); - expect(wrapper.vm.isValidRoleName).toBe(true); - }); - - it("should return false for invalid trace ID field name", async () => { - const wrapper = createWrapper(); - wrapper.vm.traceIdFieldName = "invalid trace id"; - await nextTick(); - expect(wrapper.vm.isValidRoleName).toBe(false); - }); - - it("should return false for empty trace ID field name", async () => { - const wrapper = createWrapper(); - wrapper.vm.traceIdFieldName = ""; - await nextTick(); - expect(wrapper.vm.isValidRoleName).toBe(false); - }); - - it("should react to changes in traceIdFieldName", async () => { - const wrapper = createWrapper(); - wrapper.vm.traceIdFieldName = "valid_trace"; - await nextTick(); - expect(wrapper.vm.isValidRoleName).toBe(true); - - wrapper.vm.traceIdFieldName = "invalid trace"; - await nextTick(); - expect(wrapper.vm.isValidRoleName).toBe(false); - }); - }); - - describe("updateFieldName function", () => { - it("should update isValidSpanField for span field", async () => { - const wrapper = createWrapper(); - wrapper.vm.spanIdFieldName = "valid_span_id"; - - wrapper.vm.updateFieldName("span"); - expect(wrapper.vm.isValidSpanField).toBe(true); - }); - - it("should update isValidSpanField to false for invalid span field", async () => { - const wrapper = createWrapper(); - wrapper.vm.spanIdFieldName = "invalid span id"; - - wrapper.vm.updateFieldName("span"); - expect(wrapper.vm.isValidSpanField).toBe(false); - }); - - it("should update isValidTraceField for trace field", async () => { - const wrapper = createWrapper(); - wrapper.vm.traceIdFieldName = "valid_trace_id"; - - wrapper.vm.updateFieldName("trace"); - expect(wrapper.vm.isValidTraceField).toBe(true); - }); - - it("should update isValidTraceField to false for invalid trace field", async () => { - const wrapper = createWrapper(); - wrapper.vm.traceIdFieldName = "invalid trace id"; - - wrapper.vm.updateFieldName("trace"); - expect(wrapper.vm.isValidTraceField).toBe(false); - }); - - it("should not update validation flags for unknown field type", () => { - const wrapper = createWrapper(); - const initialSpanValid = wrapper.vm.isValidSpanField; - const initialTraceValid = wrapper.vm.isValidTraceField; - - wrapper.vm.updateFieldName("unknown"); - expect(wrapper.vm.isValidSpanField).toBe(initialSpanValid); - expect(wrapper.vm.isValidTraceField).toBe(initialTraceValid); - }); - - it("should be called when span input changes", async () => { - const wrapper = createWrapper(); - const spanInput = wrapper.find('.span-id-field-name input[data-test-stub="o-input"]'); - - // Set spy on updateFieldName - const updateFieldNameSpy = vi.spyOn(wrapper.vm, 'updateFieldName'); - - await spanInput.setValue('new_span'); - - // The template calls updateFieldName('span') on @update:model-value - // Since we're testing the function exists and works, let's call it directly - wrapper.vm.updateFieldName('span'); - expect(updateFieldNameSpy).toHaveBeenCalledWith('span'); - }); - }); - - describe("saveOrgSettings function", () => { - it("should save organization settings successfully", async () => { - const wrapper = createWrapper(); - wrapper.vm.traceIdFieldName = "custom_trace_id"; - wrapper.vm.spanIdFieldName = "custom_span_id"; - wrapper.vm.toggleIngestionLogs = true; - - await wrapper.vm.saveOrgSettings(); - - expect(mockPostOrganizationSettings).toHaveBeenCalledWith( - "test-org-123", - { - trace_id_field_name: "custom_trace_id", - span_id_field_name: "custom_span_id", - toggle_ingestion_logs: true, - cross_links: [], - usage_stream_enabled: false, - } - ); - }); - - it("should dispatch setOrganizationSettings after successful save", async () => { - const wrapper = createWrapper(); - wrapper.vm.traceIdFieldName = "new_trace"; - wrapper.vm.spanIdFieldName = "new_span"; - wrapper.vm.toggleIngestionLogs = false; - - await wrapper.vm.saveOrgSettings(); - - expect(mockStore.dispatch).toHaveBeenCalledWith("setOrganizationSettings", { - ...mockStore.state.organizationData.organizationSettings, - trace_id_field_name: "new_trace", - span_id_field_name: "new_span", + expect(mockPostOrganizationSettings).toHaveBeenCalledWith("test-org-123", { + trace_id_field_name: "trace_id", + span_id_field_name: "span_id", toggle_ingestion_logs: false, cross_links: [], usage_stream_enabled: false, }); }); - it("should show success notification after successful save", async () => { + it("submits the toggled switch values", async () => { const wrapper = createWrapper(); + const form = getForm(wrapper); + form.vm.form.setFieldValue("toggleIngestionLogs", true); + form.vm.form.setFieldValue("usageStreamEnabled", true); - await wrapper.vm.saveOrgSettings(); + await form.vm.form.handleSubmit(); + await flushPromises(); + + expect(mockPostOrganizationSettings).toHaveBeenCalledWith( + "test-org-123", + expect.objectContaining({ + toggle_ingestion_logs: true, + usage_stream_enabled: true, + }), + ); + }); + }); + + describe("saveOrgSettings handler", () => { + const validValue = { + traceIdFieldName: "custom_trace_id", + spanIdFieldName: "custom_span_id", + toggleIngestionLogs: true, + usageStreamEnabled: false, + }; + + it("should call post_organization_settings with the correct payload", async () => { + const wrapper = createWrapper(); + await wrapper.vm.saveOrgSettings(validValue); + + expect(mockPostOrganizationSettings).toHaveBeenCalledWith("test-org-123", { + trace_id_field_name: "custom_trace_id", + span_id_field_name: "custom_span_id", + toggle_ingestion_logs: true, + cross_links: [], + usage_stream_enabled: false, + }); + }); + + it("should dispatch setOrganizationSettings after a successful save", async () => { + const wrapper = createWrapper(); + await wrapper.vm.saveOrgSettings(validValue); + + expect(mockStore.dispatch).toHaveBeenCalledWith("setOrganizationSettings", { + ...mockStore.state.organizationData.organizationSettings, + trace_id_field_name: "custom_trace_id", + span_id_field_name: "custom_span_id", + toggle_ingestion_logs: true, + cross_links: [], + usage_stream_enabled: false, + }); + }); + + it("should show a success notification", async () => { + const wrapper = createWrapper(); + await wrapper.vm.saveOrgSettings(validValue); expect(mockToast).toHaveBeenCalledWith({ message: "Organization settings updated successfully", @@ -461,38 +310,23 @@ describe("OrganizationSettings", () => { }); }); - it("should handle API error with message", async () => { + it("should handle an API error with a message", async () => { const wrapper = createWrapper(); - const errorMessage = "Invalid field names"; - mockPostOrganizationSettings.mockRejectedValue({ - message: errorMessage, - }); + mockPostOrganizationSettings.mockRejectedValue({ message: "Invalid field names" }); - await wrapper.vm.saveOrgSettings(); + await wrapper.vm.saveOrgSettings(validValue); expect(mockToast).toHaveBeenCalledWith({ - message: errorMessage, + message: "Invalid field names", variant: "error", }); }); - it("should handle API error without message", async () => { + it("should fall back to a default message when the API error has none", async () => { const wrapper = createWrapper(); mockPostOrganizationSettings.mockRejectedValue({}); - await wrapper.vm.saveOrgSettings(); - - expect(mockToast).toHaveBeenCalledWith({ - message: "Error saving organization settings", - variant: "error", - }); - }); - - it("should handle API error with non-object error", async () => { - const wrapper = createWrapper(); - mockPostOrganizationSettings.mockRejectedValue("Network error"); - - await wrapper.vm.saveOrgSettings(); + await wrapper.vm.saveOrgSettings(validValue); expect(mockToast).toHaveBeenCalledWith({ message: "Error saving organization settings", @@ -504,289 +338,18 @@ describe("OrganizationSettings", () => { const wrapper = createWrapper(); mockPostOrganizationSettings.mockRejectedValue({ message: "Error" }); - await wrapper.vm.saveOrgSettings(); + await wrapper.vm.saveOrgSettings(validValue); expect(mockStore.dispatch).not.toHaveBeenCalled(); }); }); - describe("Button interactions", () => { - it.skip("should render cancel button", () => { - // Cancel button is currently commented out in the component - const wrapper = createWrapper(); - const cancelBtn = wrapper.find('[data-test="add-alert-cancel-btn"]'); - expect(cancelBtn.exists()).toBe(true); - }); - - it("should render save button", () => { - const wrapper = createWrapper(); - const saveBtn = wrapper.find('[data-test="add-alert-submit-btn"]'); - expect(saveBtn.exists()).toBe(true); - }); - - it("should call saveOrgSettings when save button is clicked", async () => { - const wrapper = createWrapper(); - - // Mock the saveOrgSettings method - const saveOrgSettingsSpy = vi.spyOn(wrapper.vm, 'saveOrgSettings').mockImplementation(() => Promise.resolve()); - - // Since we're using stubs, we need to manually call the method that would be called by the template - // In the real component, clicking the button calls saveOrgSettings - await wrapper.vm.saveOrgSettings(); - - expect(saveOrgSettingsSpy).toHaveBeenCalled(); - }); - - it.skip("should emit cancel event when cancel button is clicked", async () => { - const wrapper = createWrapper(); - const cancelBtn = wrapper.find('[data-test="add-alert-cancel-btn"]'); - - await cancelBtn.trigger("click"); - - expect(wrapper.emitted('cancel:hideform')).toBeTruthy(); - }); - }); - - describe("Form validation rules", () => { - it("should have validation rules for trace ID field", () => { - const wrapper = createWrapper(); - const traceInput = wrapper.findComponent({ name: 'OInput' }); - - if (traceInput.exists()) { - expect(traceInput.props('error')).toBeDefined(); - expect(traceInput.props('errorMessage')).toBeDefined(); - } - }); - - it("should validate required trace ID field", () => { - const wrapper = createWrapper(); - const traceInput = wrapper.find('.trace-id-field-name'); - - // Simulate the validation rule logic - wrapper.vm.traceIdFieldName = ""; - wrapper.vm.isValidTraceField = false; - - // The rule should return the required message for empty value - expect(wrapper.vm.traceIdFieldName).toBe(""); - }); - - it("should validate trace ID field format", () => { - const wrapper = createWrapper(); - wrapper.vm.traceIdFieldName = "invalid trace"; - wrapper.vm.isValidTraceField = false; - - // Should indicate invalid format - expect(wrapper.vm.isValidTraceField).toBe(false); - }); - - it("should validate span ID field format", () => { - const wrapper = createWrapper(); - wrapper.vm.spanIdFieldName = "invalid span"; - wrapper.vm.isValidSpanField = false; - - // Should indicate invalid format - expect(wrapper.vm.isValidSpanField).toBe(false); - }); - - it("should pass validation for valid field names", () => { - const wrapper = createWrapper(); - wrapper.vm.traceIdFieldName = "valid_trace_id"; - wrapper.vm.spanIdFieldName = "valid_span_id"; - wrapper.vm.isValidTraceField = true; - wrapper.vm.isValidSpanField = true; - - expect(wrapper.vm.isValidTraceField).toBe(true); - expect(wrapper.vm.isValidSpanField).toBe(true); - }); - }); - - describe("Component layout and styling", () => { - it("should have correct CSS classes for trace ID input", () => { - const wrapper = createWrapper(); - const traceDiv = wrapper.find('.trace-id-field-name'); - expect(traceDiv.exists()).toBe(true); - expect(traceDiv.classes()).toContain('o2-input'); - }); - - it("should have correct CSS classes for span ID input", () => { - const wrapper = createWrapper(); - const spanDiv = wrapper.find('.span-id-field-name'); - expect(spanDiv.exists()).toBe(true); - expect(spanDiv.classes()).toContain('o2-input'); - }); - - it("should apply scoped styling for field widths", () => { - const wrapper = createWrapper(); - // The component should have scoped styles for 400px width - // This is tested by verifying the style section exists in the component - expect(wrapper.find('.trace-id-field-name').exists()).toBe(true); - expect(wrapper.find('.span-id-field-name').exists()).toBe(true); - }); - }); - - describe("Edge cases and error scenarios", () => { - it("should handle store with undefined selectedOrganization", async () => { - const wrapper = createWrapper(); - mockStore.state.selectedOrganization = undefined; - - // Should handle gracefully and use fallback or throw appropriate error - try { - await wrapper.vm.saveOrgSettings(); - - // If it doesn't throw, verify it handles undefined gracefully - expect(mockPostOrganizationSettings).toHaveBeenCalledWith( - undefined, - expect.any(Object) - ); - } catch (error) { - // If it throws an error, that's also valid behavior - expect(error).toBeDefined(); - } - }); - - it("should handle store with null organizationSettings", () => { - mockStore.state.organizationData.organizationSettings = null; - const wrapper = createWrapper(); - - expect(wrapper.vm.traceIdFieldName).toBeUndefined(); - expect(wrapper.vm.spanIdFieldName).toBeUndefined(); - expect(wrapper.vm.toggleIngestionLogs).toBe(false); - }); - - it("should handle very long field names", async () => { - const wrapper = createWrapper(); - const longFieldName = "a".repeat(1000); - - wrapper.vm.traceIdFieldName = longFieldName; - wrapper.vm.spanIdFieldName = longFieldName; - - await wrapper.vm.saveOrgSettings(); - - expect(mockPostOrganizationSettings).toHaveBeenCalledWith( - "test-org-123", - expect.objectContaining({ - trace_id_field_name: longFieldName, - span_id_field_name: longFieldName, - }) - ); - }); - - it("should handle special characters in organization identifier", async () => { - mockStore.state.selectedOrganization.identifier = "org-with-special@chars+123"; - const wrapper = createWrapper(); - - await wrapper.vm.saveOrgSettings(); - - expect(mockPostOrganizationSettings).toHaveBeenCalledWith( - "org-with-special@chars+123", - expect.any(Object) - ); - }); - - it("should handle network timeout error", async () => { - const wrapper = createWrapper(); - mockPostOrganizationSettings.mockRejectedValue({ - message: "Network timeout", - code: "TIMEOUT" - }); - - await wrapper.vm.saveOrgSettings(); - - expect(mockToast).toHaveBeenCalledWith({ - message: "Network timeout", - variant: "error", - }); - }); - }); - - describe("Integration tests", () => { - it("should perform complete form submission flow", async () => { - const wrapper = createWrapper(); - - // Update form fields - wrapper.vm.traceIdFieldName = "integration_trace_id"; - wrapper.vm.spanIdFieldName = "integration_span_id"; - wrapper.vm.toggleIngestionLogs = true; - - await nextTick(); - - // Validate fields - wrapper.vm.updateFieldName("span"); - wrapper.vm.updateFieldName("trace"); - - expect(wrapper.vm.isValidSpanField).toBe(true); - expect(wrapper.vm.isValidTraceField).toBe(true); - - // Submit form - await wrapper.vm.saveOrgSettings(); - - // Verify all expected calls - expect(mockPostOrganizationSettings).toHaveBeenCalledWith( - "test-org-123", - { - trace_id_field_name: "integration_trace_id", - span_id_field_name: "integration_span_id", - toggle_ingestion_logs: true, - cross_links: [], - usage_stream_enabled: false, - } - ); - - expect(mockStore.dispatch).toHaveBeenCalledWith("setOrganizationSettings", { - ...mockStore.state.organizationData.organizationSettings, - trace_id_field_name: "integration_trace_id", - span_id_field_name: "integration_span_id", - toggle_ingestion_logs: true, - cross_links: [], - usage_stream_enabled: false, - }); - - expect(mockToast).toHaveBeenCalledWith({ - message: "Organization settings updated successfully", - variant: "success", - }); - }); - - it("should handle form submission with validation errors", async () => { - const wrapper = createWrapper(); - - // Set invalid field names - wrapper.vm.traceIdFieldName = "invalid trace"; - wrapper.vm.spanIdFieldName = "invalid span"; - - wrapper.vm.updateFieldName("trace"); - wrapper.vm.updateFieldName("span"); - - expect(wrapper.vm.isValidTraceField).toBe(false); - expect(wrapper.vm.isValidSpanField).toBe(false); - - // The component now validates before saving and returns early on invalid data - await wrapper.vm.saveOrgSettings(); - - expect(mockPostOrganizationSettings).not.toHaveBeenCalled(); - }); - }); - describe("Usage stream settings", () => { - it("should render usage stream toggle", () => { - const wrapper = createWrapper(); - const usageToggle = wrapper.find('[data-test="add-toggle-usage-stream"] input[data-test-stub="o-switch"]'); - expect(usageToggle.exists()).toBe(true); - }); - - it("should update usageStreamEnabled when toggle changes", async () => { - const wrapper = createWrapper(); - - const usageToggle = wrapper.find('[data-test="add-toggle-usage-stream"] input[data-test-stub="o-switch"]'); - await usageToggle.setChecked(true); - - expect(wrapper.vm.usageStreamEnabled).toBe(true); - }); - - it("should initialize usageStreamEnabled from store", () => { + it("should seed usageStreamEnabled from the store", () => { mockStore.state.organizationData.organizationSettings.usage_stream_enabled = true; const wrapper = createWrapper(); - expect(wrapper.vm.usageStreamEnabled).toBe(true); + const form = getForm(wrapper); + expect(form.vm.form.state.values.usageStreamEnabled).toBe(true); }); }); @@ -801,79 +364,56 @@ describe("OrganizationSettings", () => { it("should render CrossLinkManager when cross-linking is enabled", () => { const wrapper = createWrapper(); - const crossLinkManager = wrapper.find('[data-test="cross-link-manager"]'); - expect(crossLinkManager.exists()).toBe(true); + expect(wrapper.find('[data-test="cross-link-manager"]').exists()).toBe( + true, + ); }); - it("should update crossLinks when the watch triggers", async () => { + it("should update crossLinks when the store watch triggers", async () => { const wrapper = createWrapper(); - // Mutate the store's cross_links array to a new reference to trigger the watcher mockStore.state.organizationData.organizationSettings = { ...mockStore.state.organizationData.organizationSettings, cross_links: [{ sourceField: "trace_id", targetOrg: "other-org" }], }; - await nextTick(); - // The watch should have updated crossLinks ref from the store expect(wrapper.vm.crossLinks).toEqual([ { sourceField: "trace_id", targetOrg: "other-org" }, ]); }); - }); - describe("Validation errors during save", () => { - it("should set traceIdFieldNameError when trace name is empty", async () => { + it("should include crossLinks in the submit payload", async () => { + mockStore.state.organizationData.organizationSettings = { + trace_id_field_name: "trace_id", + span_id_field_name: "span_id", + toggle_ingestion_logs: false, + cross_links: [{ sourceField: "trace_id", targetOrg: "other-org" }], + }; const wrapper = createWrapper(); - wrapper.vm.traceIdFieldName = ""; - wrapper.vm.spanIdFieldName = "valid_span"; + const form = getForm(wrapper); - await wrapper.vm.saveOrgSettings(); + await form.vm.form.handleSubmit(); + await flushPromises(); - expect(wrapper.vm.traceIdFieldNameError).not.toBe(""); - }); - - it("should set spanIdFieldNameError when span name is empty", async () => { - const wrapper = createWrapper(); - wrapper.vm.traceIdFieldName = "valid_trace"; - wrapper.vm.spanIdFieldName = ""; - - await wrapper.vm.saveOrgSettings(); - - expect(wrapper.vm.spanIdFieldNameError).not.toBe(""); - }); - - it("should not call API when both fields are empty", async () => { - const wrapper = createWrapper(); - wrapper.vm.traceIdFieldName = ""; - wrapper.vm.spanIdFieldName = ""; - - await wrapper.vm.saveOrgSettings(); - - expect(mockPostOrganizationSettings).not.toHaveBeenCalled(); - }); - - it("should set format error when trace name is invalid", async () => { - const wrapper = createWrapper(); - wrapper.vm.traceIdFieldName = "invalid trace"; - wrapper.vm.isValidTraceField = false; - wrapper.vm.spanIdFieldName = "valid_span"; - - await wrapper.vm.saveOrgSettings(); - - expect(wrapper.vm.traceIdFieldNameError).not.toBe(""); - }); - - it("should set format error when span name is invalid", async () => { - const wrapper = createWrapper(); - wrapper.vm.traceIdFieldName = "valid_trace"; - wrapper.vm.spanIdFieldName = "invalid span"; - wrapper.vm.isValidSpanField = false; - - await wrapper.vm.saveOrgSettings(); - - expect(wrapper.vm.spanIdFieldNameError).not.toBe(""); + expect(mockPostOrganizationSettings).toHaveBeenCalledWith( + "test-org-123", + expect.objectContaining({ + cross_links: [{ sourceField: "trace_id", targetOrg: "other-org" }], + }), + ); }); }); -}); \ No newline at end of file + + describe("Layout and styling", () => { + it("should keep the trace/span field width wrappers", () => { + const wrapper = createWrapper(); + const traceDiv = wrapper.find(".trace-id-field-name"); + const spanDiv = wrapper.find(".span-id-field-name"); + expect(traceDiv.exists()).toBe(true); + expect(traceDiv.classes()).toContain("o2-input"); + expect(spanDiv.exists()).toBe(true); + expect(spanDiv.classes()).toContain("o2-input"); + }); + }); +}); diff --git a/web/src/components/settings/OrganizationSettings.vue b/web/src/components/settings/OrganizationSettings.vue index e232c677df..26473e983e 100644 --- a/web/src/components/settings/OrganizationSettings.vue +++ b/web/src/components/settings/OrganizationSettings.vue @@ -8,20 +8,25 @@
-
+
-
@@ -29,31 +34,29 @@ data-test="add-role-rolename-input-btn" class="span-id-field-name o2-input w-100" > -
-
- @@ -82,10 +85,10 @@ variant="primary" size="sm-action" type="submit" - @click="saveOrgSettings" + :loading="isSubmitting" >{{ t('alerts.save') }}
-
+
@@ -96,40 +99,42 @@ import organizations from "@/services/organizations"; import { useStore } from "vuex"; import CrossLinkManager from "@/components/cross-linking/CrossLinkManager.vue"; import OButton from "@/lib/core/Button/OButton.vue"; -import OInput from "@/lib/forms/Input/OInput.vue"; -import OSwitch from "@/lib/forms/Switch/OSwitch.vue"; +import OForm from "@/lib/forms/Form/OForm.vue"; +import OFormInput from "@/lib/forms/Input/OFormInput.vue"; +import OFormSwitch from "@/lib/forms/Switch/OFormSwitch.vue"; import OSeparator from '@/lib/core/Separator/OSeparator.vue'; import config from "@/aws-exports"; import { toast } from "@/lib/feedback/Toast/useToast"; +import { + makeOrganizationSettingsSchema, + type OrganizationSettingsForm, +} from "./OrganizationSettings.schema"; const { t } = useI18n(); const store = useStore(); -const traceIdFieldName = ref( - store.state?.organizationData?.organizationSettings?.trace_id_field_name, -); +// Schema-driven validation replaces the hand-rolled validate()/error refs. +const organizationSettingsSchema = makeOrganizationSettingsSchema(t); -const spanIdFieldName = ref( - store.state?.organizationData?.organizationSettings?.span_id_field_name, -); - - -const isValidSpanField = ref(true); -const isValidTraceField = ref(true); -const traceIdFieldNameError = ref(''); -const spanIdFieldNameError = ref(''); -const toggleIngestionLogs = ref( - store.state?.organizationData?.organizationSettings?.toggle_ingestion_logs || - false, -); -const usageStreamEnabled = ref( - store.state?.organizationData?.organizationSettings?.usage_stream_enabled || - false, -); +// CrossLinkManager has no OForm* equivalent (composite) — kept as local state +// and merged at submit (the documented exception). const crossLinks = ref( store.state?.organizationData?.organizationSettings?.cross_links || [], ); +const formDirty = ref(false); + +// Dynamic defaults (edit-prefill from the store) → a typed computed. The trace/ +// span/toggle values are form-owned (OFormInput / OFormSwitch). +const organizationSettingsDefaults = computed((): OrganizationSettingsForm => { + const s = store.state?.organizationData?.organizationSettings; + return { + traceIdFieldName: s?.trace_id_field_name ?? "", + spanIdFieldName: s?.span_id_field_name ?? "", + toggleIngestionLogs: s?.toggle_ingestion_logs ?? false, + usageStreamEnabled: s?.usage_stream_enabled ?? false, + }; +}); watch( () => store.state?.organizationData?.organizationSettings?.cross_links, @@ -139,56 +144,18 @@ watch( } }, ); -const formDirty = ref(false); -const isValidRoleName = computed(() => { - const roleNameRegex = /^[a-zA-Z0-9+=,.@_-]+$/; - // Check if the role name is valid - return roleNameRegex.test(traceIdFieldName.value); -}); - -const validateFieldName = (value: string) => { - const roleNameRegex = /^[a-zA-Z0-9+=,.@_-]+$/; - // Check if the role name is valid - return roleNameRegex.test(value); -}; - -const updateFieldName = (fieldName: string) => { - if (fieldName === "span") - isValidSpanField.value = validateFieldName(spanIdFieldName.value); - - if (fieldName === "trace") - isValidTraceField.value = validateFieldName(traceIdFieldName.value); -}; - -const validateOrgSettings = () => { - let valid = true; - if (!traceIdFieldName.value) { - traceIdFieldNameError.value = t('common.nameRequired'); - valid = false; - } else if (!isValidTraceField.value) { - traceIdFieldNameError.value = `Use alphanumeric and '+=,.@-_' characters only, without spaces.`; - valid = false; - } - if (!spanIdFieldName.value) { - spanIdFieldNameError.value = t('common.nameRequired'); - valid = false; - } else if (!isValidSpanField.value) { - spanIdFieldNameError.value = `Use alphanumeric and '+=,.@-_' characters only, without spaces.`; - valid = false; - } - return valid; -}; - -const saveOrgSettings = async () => { - if (!validateOrgSettings()) return; +// @submit fires only once the schema passes (both field names required + regex), +// so the old validateOrgSettings()/error refs are gone. Awaited by OForm so the +// inline Save button's spinner spans the POST. +const saveOrgSettings = async (value: OrganizationSettingsForm) => { try { const payload: any = { - trace_id_field_name: traceIdFieldName.value, - span_id_field_name: spanIdFieldName.value, - toggle_ingestion_logs: toggleIngestionLogs.value, + trace_id_field_name: value.traceIdFieldName, + span_id_field_name: value.spanIdFieldName, + toggle_ingestion_logs: value.toggleIngestionLogs, cross_links: crossLinks.value, - usage_stream_enabled: usageStreamEnabled.value, + usage_stream_enabled: value.usageStreamEnabled, }; await organizations.post_organization_settings( @@ -198,11 +165,11 @@ const saveOrgSettings = async () => { const updatedSettings: any = { ...store.state?.organizationData?.organizationSettings, - trace_id_field_name: traceIdFieldName.value, - span_id_field_name: spanIdFieldName.value, - toggle_ingestion_logs: toggleIngestionLogs.value, + trace_id_field_name: value.traceIdFieldName, + span_id_field_name: value.spanIdFieldName, + toggle_ingestion_logs: value.toggleIngestionLogs, cross_links: crossLinks.value, - usage_stream_enabled: usageStreamEnabled.value, + usage_stream_enabled: value.usageStreamEnabled, }; store.dispatch("setOrganizationSettings", updatedSettings); @@ -220,4 +187,13 @@ const saveOrgSettings = async () => { }); } }; + +// Exposed for unit tests that exercise the submit handler directly. +defineExpose({ + crossLinks, + formDirty, + saveOrgSettings, + organizationSettingsSchema, + organizationSettingsDefaults, +}); diff --git a/web/src/locales/languages/de.json b/web/src/locales/languages/de.json index 2dadba086e..19cd097940 100644 --- a/web/src/locales/languages/de.json +++ b/web/src/locales/languages/de.json @@ -3951,6 +3951,8 @@ "saveChanges": "Speichern", "invalidDomain": "Bitte geben Sie einen gültigen Domainnamen ein", "invalidEmail": "Bitte geben Sie eine gültige E-Mail-Adresse für diese Domain ein", + "domainRequired": "Die Domain ist erforderlich", + "emailRequired": "Die E-Mail-Adresse ist erforderlich", "domainAlreadyExists": "Diese Domain wurde bereits hinzugefügt", "domainAdded": "Domain wurde erfolgreich hinzugefügt", "confirmRemoveDomain": "Möchten Sie die Domain '{domain}' wirklich entfernen?", @@ -3967,6 +3969,7 @@ "pageSubtitle": "Passen Sie die Einstellungen für Ihre Anwendung an", "platformSettings": "Plattform-Einstellungen", "scrapeIntervalRequired": "Das Scrape-Intervall ist erforderlich", + "scrapeIntervalPositive": "Das Scrape-Intervall muss eine positive Zahl sein", "scrapeIntervalDescription": "Das Scrape-Intervall ist die Häufigkeit in Sekunden, mit der das Überwachungssystem Metriken erfasst.", "maxSeriesPerQueryLabel": "Max. Serien pro Abfrage", "maxSeriesPerQueryDescription": "Maximale Anzahl von Serien, die von einer einzelnen PromQL-Abfrage zurückgegeben werden können. Hilft, das Einfrieren der Benutzeroberfläche bei großen Ergebnismengen zu verhindern. Lassen Sie das Feld leer, um den Systemstandard (40.000) zu verwenden.", diff --git a/web/src/locales/languages/en.json b/web/src/locales/languages/en.json index fe634dd43f..c164edcf71 100644 --- a/web/src/locales/languages/en.json +++ b/web/src/locales/languages/en.json @@ -3955,6 +3955,8 @@ "saveChanges": "Save", "invalidDomain": "Please enter a valid domain name", "invalidEmail": "Please enter a valid email address for this domain", + "domainRequired": "Domain is required", + "emailRequired": "Email is required", "domainAlreadyExists": "This domain has already been added", "domainAdded": "Domain added successfully", "confirmRemoveDomain": "Are you sure you want to remove the domain '{domain}'?", @@ -3971,6 +3973,7 @@ "pageSubtitle": "Adjust settings for your Application", "platformSettings": "Platform Settings", "scrapeIntervalRequired": "Scrape interval is required", + "scrapeIntervalPositive": "Scrape interval must be a positive number", "scrapeIntervalDescription": "The scrape interval is the frequency, in seconds, at which the monitoring system collects metrics.", "maxSeriesPerQueryLabel": "Max Series Per Query", "maxSeriesPerQueryDescription": "Maximum number of series that can be returned from a single PromQL query. Helps prevent UI freezing with large result sets. Leave empty to use system default (40,000).", diff --git a/web/src/locales/languages/es.json b/web/src/locales/languages/es.json index 1b902e1d96..8db1ecc1d1 100644 --- a/web/src/locales/languages/es.json +++ b/web/src/locales/languages/es.json @@ -3951,6 +3951,8 @@ "saveChanges": "Guardar", "invalidDomain": "Introduce un nombre de dominio válido", "invalidEmail": "Introduce una dirección de correo electrónico válida para este dominio", + "domainRequired": "Se requiere el dominio", + "emailRequired": "Se requiere el correo electrónico", "domainAlreadyExists": "Este dominio ya se ha agregado", "domainAdded": "El dominio se agregó correctamente", "confirmRemoveDomain": "¿Estás seguro de que quieres eliminar el dominio '{domain}'?", @@ -3967,6 +3969,7 @@ "pageSubtitle": "Ajusta la configuración de tu aplicación", "platformSettings": "Configuración de plataforma", "scrapeIntervalRequired": "Se requiere un intervalo de raspado", + "scrapeIntervalPositive": "El intervalo de raspado debe ser un número positivo", "scrapeIntervalDescription": "El intervalo de raspado es la frecuencia, en segundos, con la que el sistema de monitoreo recopila las métricas.", "maxSeriesPerQueryLabel": "Número máximo de series por consulta", "maxSeriesPerQueryDescription": "Número máximo de series que se pueden devolver desde una sola consulta de PromQL. Ayuda a evitar que la interfaz de usuario se bloquee con conjuntos de resultados grandes. Déjelo vacío para usar los valores predeterminados del sistema (40 000).", diff --git a/web/src/locales/languages/fr.json b/web/src/locales/languages/fr.json index 4200a05d82..71c555edf3 100644 --- a/web/src/locales/languages/fr.json +++ b/web/src/locales/languages/fr.json @@ -3951,6 +3951,8 @@ "saveChanges": "Enregistrer", "invalidDomain": "Veuillez saisir un nom de domaine valide", "invalidEmail": "Veuillez saisir une adresse e-mail valide pour ce domaine", + "domainRequired": "Le domaine est requis", + "emailRequired": "L'adresse e-mail est requise", "domainAlreadyExists": "Ce domaine a déjà été ajouté", "domainAdded": "Domaine ajouté avec succès", "confirmRemoveDomain": "Êtes-vous sûr de vouloir supprimer le domaine « {domain} » ?", @@ -3967,6 +3969,7 @@ "pageSubtitle": "Ajustez les paramètres de votre application", "platformSettings": "Paramètres de la plateforme", "scrapeIntervalRequired": "Un intervalle de raclage est requis", + "scrapeIntervalPositive": "L'intervalle de raclage doit être un nombre positif", "scrapeIntervalDescription": "L'intervalle de scrape est la fréquence, en secondes, à laquelle le système de surveillance collecte les métriques.", "maxSeriesPerQueryLabel": "Nombre maximum de séries par requête", "maxSeriesPerQueryDescription": "Nombre maximum de séries pouvant être renvoyées à partir d'une seule requête ProMQL. Aide à prévenir le gel de l'interface utilisateur en cas de grands ensembles de résultats. Laissez ce champ vide pour utiliser la valeur par défaut du système (40 000).", diff --git a/web/src/locales/languages/hi.json b/web/src/locales/languages/hi.json index d7c1e01dc3..112d19995d 100644 --- a/web/src/locales/languages/hi.json +++ b/web/src/locales/languages/hi.json @@ -2981,6 +2981,8 @@ "saveChanges": "Save", "invalidDomain": "Please enter a valid domain name", "invalidEmail": "Please enter a valid email address for this domain", + "domainRequired": "Domain is required", + "emailRequired": "Email is required", "domainAlreadyExists": "This domain has already been added", "domainAdded": "Domain added successfully", "confirmRemoveDomain": "Are you sure you want to remove the domain '{domain}'?", @@ -2997,6 +2999,7 @@ "pageSubtitle": "Adjust settings for your Application", "platformSettings": "Platform Settings", "scrapeIntervalRequired": "Scrape interval is required", + "scrapeIntervalPositive": "Scrape interval must be a positive number", "scrapeIntervalDescription": "The scrape interval is the frequency, in seconds, at which the monitoring system collects metrics.", "maxSeriesPerQueryLabel": "Max Series Per Query", "maxSeriesPerQueryDescription": "Maximum number of series that can be returned from a single PromQL query. Helps prevent UI freezing with large result sets. Leave empty to use system default (40,000).", diff --git a/web/src/locales/languages/it.json b/web/src/locales/languages/it.json index 0b651b6d22..720c9c0058 100644 --- a/web/src/locales/languages/it.json +++ b/web/src/locales/languages/it.json @@ -3951,6 +3951,8 @@ "saveChanges": "Salva", "invalidDomain": "Inserisci un nome di dominio valido", "invalidEmail": "Inserisci un indirizzo email valido per questo dominio", + "domainRequired": "È richiesto il dominio", + "emailRequired": "È richiesto l'indirizzo email", "domainAlreadyExists": "Questo dominio è già stato aggiunto", "domainAdded": "Dominio aggiunto con successo", "confirmRemoveDomain": "Sei sicuro di voler rimuovere il dominio '{domain}'?", @@ -3967,6 +3969,7 @@ "pageSubtitle": "Modifica le impostazioni per la tua applicazione", "platformSettings": "Impostazioni della piattaforma", "scrapeIntervalRequired": "È richiesto l'intervallo di raschiatura", + "scrapeIntervalPositive": "L'intervallo di raschiatura deve essere un numero positivo", "scrapeIntervalDescription": "L'intervallo di scrape è la frequenza, in secondi, con cui il sistema di monitoraggio raccoglie le metriche.", "maxSeriesPerQueryLabel": "Serie massima per query", "maxSeriesPerQueryDescription": "Numero massimo di serie che possono essere restituite da una singola query ProMQL. Aiuta a prevenire il blocco dell'interfaccia utente con set di risultati di grandi dimensioni. Lascia vuoto per utilizzare l'impostazione predefinita del sistema (40.000).", diff --git a/web/src/locales/languages/ja.json b/web/src/locales/languages/ja.json index 8035ab0741..f70d26e040 100644 --- a/web/src/locales/languages/ja.json +++ b/web/src/locales/languages/ja.json @@ -3951,6 +3951,8 @@ "saveChanges": "[保存]", "invalidDomain": "有効なドメイン名を入力してください", "invalidEmail": "このドメインの有効なメールアドレスを入力してください", + "domainRequired": "ドメインが必要です", + "emailRequired": "メールアドレスが必要です", "domainAlreadyExists": "このドメインは既に追加されています", "domainAdded": "ドメインは正常に追加されました", "confirmRemoveDomain": "ドメイン '{domain}' を削除してよろしいですか?", @@ -3967,6 +3969,7 @@ "pageSubtitle": "アプリケーションの設定を調整する", "platformSettings": "プラットフォーム設定", "scrapeIntervalRequired": "スクレイプ間隔が必要です", + "scrapeIntervalPositive": "スクレイプ間隔は正の数でなければなりません", "scrapeIntervalDescription": "スクレイプ間隔は、モニタリングシステムがメトリックを収集する頻度(秒単位)です。", "maxSeriesPerQueryLabel": "クエリあたりの最大シリーズ数", "maxSeriesPerQueryDescription": "1 つの PromQL クエリから返されるシリーズの最大数。結果セットが大きい場合に UI がフリーズするのを防ぐのに役立ちます。システムデフォルト (40,000) を使用するには空のままにします。", @@ -5328,13 +5331,13 @@ "loading": "オンライン評価設定をロード中...", "loadError": "オンライン評価を読み込めませんでした", "deletePrompt": "{name} を削除しますか?", - "deleteTitle": "{ラベル} を削除", + "deleteTitle": "{label} を削除", "deleteConfirmMessage": "「{name}」を削除してよろしいですか?このアクションは元に戻せません。", - "deleted": "{ラベル} は削除されました", + "deleted": "{label} は削除されました", "deleteError": "{label} を削除できませんでした", "saved": "{label} が保存されました", - "newButton": "新規 {ラベル}", - "search": "検索 {ラベル}", + "newButton": "新規 {label}", + "search": "検索 {label}", "refresh": "リフレッシュ", "allStatuses": "すべてのステータス", "noRowsFound": "{label} が見つかりません", diff --git a/web/src/locales/languages/ko.json b/web/src/locales/languages/ko.json index dfe5c9b93f..d91bf80f59 100644 --- a/web/src/locales/languages/ko.json +++ b/web/src/locales/languages/ko.json @@ -3951,6 +3951,8 @@ "saveChanges": "저장", "invalidDomain": "유효한 도메인 이름을 입력하세요", "invalidEmail": "이 도메인의 유효한 이메일 주소를 입력하십시오.", + "domainRequired": "도메인이 필요합니다.", + "emailRequired": "이메일 주소가 필요합니다.", "domainAlreadyExists": "이 도메인은 이미 추가되었습니다", "domainAdded": "도메인이 성공적으로 추가되었습니다.", "confirmRemoveDomain": "'{domain}' 도메인을 정말로 제거하시겠습니까?", @@ -3967,6 +3969,7 @@ "pageSubtitle": "애플리케이션 설정 조정", "platformSettings": "플랫폼 설정", "scrapeIntervalRequired": "스크래핑 간격이 필요합니다.", + "scrapeIntervalPositive": "스크래핑 간격은 양수여야 합니다.", "scrapeIntervalDescription": "스크랩 간격은 모니터링 시스템이 메트릭을 수집하는 빈도 (초) 입니다.", "maxSeriesPerQueryLabel": "쿼리당 최대 시리즈 수", "maxSeriesPerQueryDescription": "단일 PromQL 쿼리에서 반환할 수 있는 최대 시리즈 수입니다.결과 집합이 클 때 UI가 멈추는 것을 방지하는 데 도움이 됩니다.시스템 기본값 (40,000) 을 사용하려면 비워 두십시오.", @@ -5328,16 +5331,16 @@ "loading": "온라인 평가 구성을 로드하는 중...", "loadError": "온라인 평가를 로드하지 못했습니다.", "deletePrompt": "{name} 을 (를) 삭제하시겠습니까?", - "deleteTitle": "{라벨} 삭제", + "deleteTitle": "{label} 삭제", "deleteConfirmMessage": "정말로 “{name}”을 삭제하시겠습니까?이 작업은 취소할 수 없습니다.", - "deleted": "{라벨} 삭제됨", + "deleted": "{label} 삭제됨", "deleteError": "{label} 을 (를) 삭제하지 못했습니다.", - "saved": "{라벨} 저장됨", - "newButton": "새 {라벨}", - "search": "{라벨} 검색", + "saved": "{label} 저장됨", + "newButton": "새 {label}", + "search": "{label} 검색", "refresh": "새로 고침", "allStatuses": "모든 상태", - "noRowsFound": "{라벨} 을 (를) 찾을 수 없습니다.", + "noRowsFound": "{label} 을 (를) 찾을 수 없습니다.", "getStartedHint": "하나를 생성하여 온라인 배선 평가를 시작하십시오.", "columns": { "name": "이름", diff --git a/web/src/locales/languages/nl.json b/web/src/locales/languages/nl.json index f4a3ed6b39..4d0db8e455 100644 --- a/web/src/locales/languages/nl.json +++ b/web/src/locales/languages/nl.json @@ -3951,6 +3951,8 @@ "saveChanges": "Opslaan", "invalidDomain": "Voer een geldige domeinnaam in", "invalidEmail": "Voer een geldig e-mailadres in voor dit domein", + "domainRequired": "Domein is vereist", + "emailRequired": "E-mailadres is vereist", "domainAlreadyExists": "Dit domein is al toegevoegd", "domainAdded": "Domein succesvol toegevoegd", "confirmRemoveDomain": "Weet je zeker dat je het domein '{domain}' wilt verwijderen?", @@ -3967,6 +3969,7 @@ "pageSubtitle": "Instellingen voor uw toepassing aanpassen", "platformSettings": "Platform-instellingen", "scrapeIntervalRequired": "Schraapinterval is vereist", + "scrapeIntervalPositive": "Het schraapinterval moet een positief getal zijn", "scrapeIntervalDescription": "Het schraapinterval is de frequentie, in seconden, waarmee het bewakingssysteem statistieken verzamelt.", "maxSeriesPerQueryLabel": "Max. aantal reeksen per zoekopdracht", "maxSeriesPerQueryDescription": "Maximaal aantal series dat kan worden geretourneerd uit een enkele ProMQL-query. Helpt bij grote resultatensets te voorkomen dat de gebruikersinterface vastloopt. Laat dit veld leeg om de systeemstandaard te gebruiken (40.000).", diff --git a/web/src/locales/languages/pt.json b/web/src/locales/languages/pt.json index 63355d3bab..412b616633 100644 --- a/web/src/locales/languages/pt.json +++ b/web/src/locales/languages/pt.json @@ -3951,6 +3951,8 @@ "saveChanges": "Salvar", "invalidDomain": "Insira um nome de domínio válido", "invalidEmail": "Insira um endereço de e-mail válido para este domínio", + "domainRequired": "O domínio é necessário", + "emailRequired": "O e-mail é necessário", "domainAlreadyExists": "Este domínio já foi adicionado", "domainAdded": "Domínio adicionado com sucesso", "confirmRemoveDomain": "Tem certeza de que deseja remover o domínio '{domain}'?", @@ -3967,6 +3969,7 @@ "pageSubtitle": "Ajuste as configurações do seu aplicativo", "platformSettings": "Configurações da plataforma", "scrapeIntervalRequired": "O intervalo de raspagem é necessário", + "scrapeIntervalPositive": "O intervalo de raspagem deve ser um número positivo", "scrapeIntervalDescription": "O intervalo de raspagem é a frequência, em segundos, na qual o sistema de monitoramento coleta métricas.", "maxSeriesPerQueryLabel": "Série máxima por consulta", "maxSeriesPerQueryDescription": "Número máximo de séries que podem ser retornadas de uma única consulta ProMQL. Ajuda a evitar o congelamento da interface com grandes conjuntos de resultados. Deixe em branco para usar o padrão do sistema (40.000).", diff --git a/web/src/locales/languages/tr.json b/web/src/locales/languages/tr.json index 87ddd86451..85dd7fe13d 100644 --- a/web/src/locales/languages/tr.json +++ b/web/src/locales/languages/tr.json @@ -3951,6 +3951,8 @@ "saveChanges": "Kaydet", "invalidDomain": "Lütfen geçerli bir alan adı girin", "invalidEmail": "Lütfen bu alan adı için geçerli bir e-posta adresi girin", + "domainRequired": "Alan adı gereklidir", + "emailRequired": "E-posta adresi gereklidir", "domainAlreadyExists": "Bu alan adı zaten eklendi", "domainAdded": "Etki alanı başarıyla eklendi", "confirmRemoveDomain": "'{ domain} 'alan adını kaldırmak istediğinizden emin misiniz?", @@ -3967,6 +3969,7 @@ "pageSubtitle": "Uygulamanızın ayarlarını ayarlama", "platformSettings": "Platform Ayarları", "scrapeIntervalRequired": "Kazıma aralığı gereklidir", + "scrapeIntervalPositive": "Kazıma aralığı pozitif bir sayı olmalıdır", "scrapeIntervalDescription": "Kazıma aralığı, izleme sisteminin metrikleri topladığı saniye cinsinden sıklıktır.", "maxSeriesPerQueryLabel": "Sorgu Başına Maksimum Seri", "maxSeriesPerQueryDescription": "Tek bir PromQL sorgusundan döndürülebilecek maksimum seri sayısı. Büyük sonuç kümeleriyle kullanıcı arayüzünün donmasını önlemeye yardımcı olur. Sistem varsayılanını kullanmak için boş bırakın (40.000).", diff --git a/web/src/locales/languages/zh.json b/web/src/locales/languages/zh.json index 91aa8ab376..69be764ee5 100644 --- a/web/src/locales/languages/zh.json +++ b/web/src/locales/languages/zh.json @@ -3951,6 +3951,8 @@ "saveChanges": "保存", "invalidDomain": "请输入有效的域名", "invalidEmail": "请输入该域的有效电子邮件地址", + "domainRequired": "需要域名", + "emailRequired": "需要电子邮件地址", "domainAlreadyExists": "此域已被添加", "domainAdded": "域名已成功添加", "confirmRemoveDomain": "您确定要移除域名 '{domain}' 吗?", @@ -3967,6 +3969,7 @@ "pageSubtitle": "调整应用程序的设置", "platformSettings": "平台设置", "scrapeIntervalRequired": "需要抓取间隔", + "scrapeIntervalPositive": "抓取间隔必须为正数", "scrapeIntervalDescription": "抓取间隔是监控系统收集指标的频率,以秒为单位。", "maxSeriesPerQueryLabel": "每个查询的最大序列数", "maxSeriesPerQueryDescription": "单个 PromQL 查询可以返回的最大序列数。使用大型结果集有助于防止界面冻结。留空以使用系统默认值 (40,000)。", @@ -5241,7 +5244,7 @@ "nameTooLong": "型号名称必须少于 256 个字符", "patternRequired": "匹配模式为必填项", "patternTooLong": "匹配模式必须少于 512 个字符", - "invalidRegex": "无效的正则表达式:{错误}", + "invalidRegex": "无效的正则表达式:{error}", "usageKeyPureInteger": "用法键不能是纯整数", "usageKeyContainsSpaces": "使用密钥不得包含空格", "tierPriceMissingKey": "等级 “{name}” 的价格没有使用密钥。添加使用密钥(例如 “输入”)或使用快速设置模板。", @@ -5327,17 +5330,17 @@ "description": "配置 LLM 提供商、分数定义、评分器和实时评测任务。", "loading": "正在加载在线评估配置...", "loadError": "加载在线评估失败", - "deletePrompt": "删除 {姓名}?", - "deleteTitle": "删除 {标签}", + "deletePrompt": "删除 {name}?", + "deleteTitle": "删除 {label}", "deleteConfirmMessage": "您确定要删除 “{name}” 吗?此操作无法撤消。", "deleted": "{label} 已删除", "deleteError": "删除 {label} 失败", "saved": "{label} 已保存", - "newButton": "新的 {标签}", - "search": "搜索 {标签}", + "newButton": "新的 {label}", + "search": "搜索 {label}", "refresh": "刷新", "allStatuses": "所有状态", - "noRowsFound": "未找到 {标签}", + "noRowsFound": "未找到 {label}", "getStartedHint": "创建一个,开始接线在线评估。", "columns": { "name": "姓名", diff --git a/web/src/locales/languages/zh_TW.json b/web/src/locales/languages/zh_TW.json index c838186c22..baf289ecc5 100644 --- a/web/src/locales/languages/zh_TW.json +++ b/web/src/locales/languages/zh_TW.json @@ -3329,6 +3329,8 @@ "saveChanges": "儲存", "invalidDomain": "請輸入有效的網域名稱", "invalidEmail": "請輸入此網域的有效電子郵件地址", + "domainRequired": "網域為必填", + "emailRequired": "電子郵件地址為必填", "domainAlreadyExists": "此網域已被新增", "domainAdded": "網域新增成功", "confirmRemoveDomain": "確定要移除網域 '{domain}' 嗎?", @@ -3345,6 +3347,7 @@ "pageSubtitle": "調整應用程式的設定", "platformSettings": "平台設定", "scrapeIntervalRequired": "擷取間隔為必填", + "scrapeIntervalPositive": "擷取間隔必須為正數", "scrapeIntervalDescription": "擷取間隔是監控系統收集指標的頻率 (以秒為單位)。", "maxSeriesPerQueryLabel": "每次查詢最大系列數", "maxSeriesPerQueryDescription": "單一 PromQL 查詢可傳回的最大系列數。有助於防止大型結果集導致 UI 凍結。留空以使用系統預設值 (40,000)。",