diff --git a/src/composables/useModuleConfig.js b/src/composables/useModuleConfig.js new file mode 100644 index 00000000..d471892b --- /dev/null +++ b/src/composables/useModuleConfig.js @@ -0,0 +1,98 @@ +import { onMounted, ref } from "vue"; +import { SessionUser } from "@/components/session/token/SessionUser.vue"; + +const TRUTHY_BOOL_LITERALS = new Set(["true", "1", "yes", "on"]); + +const coerceConfigBoolean = (raw) => { + if (raw === true || raw === 1) return true; + if (typeof raw === "string") { + return TRUTHY_BOOL_LITERALS.has(raw.trim().toLowerCase()); + } + return false; +}; + +const normalizeEntries = (payload) => { + if (Array.isArray(payload)) { + return payload.map((entry) => ({ + variable: entry?.variable, + value: entry?.value, + isSecret: entry?.isSecret === true, + isSet: entry?.isSet === true, + })); + } + return Object.values(payload ?? {}).map((entry) => ({ + variable: entry?.variable, + value: entry?.value, + isSecret: entry?.isSecret === true, + isSet: entry?.isSet === true, + })); +}; + +/** + * Replaces the copy-pasted `getModuleConfig` / `getModuleConfigValue` / + * `module_config = ref([])` triple that 20+ `Configuration*.vue` pages + * each defined themselves. + * + * @param {string} moduleName - e.g. "stripe", "xlvask", "bird" + * @param {object} [options] + * @param {boolean} [options.coerceBooleans=true] - return `true`/`false` for `'true'`/`'false'` strings + * @param {boolean} [options.autoLoad=true] - load on mount + * @returns module_config, loading, error, refresh, getModuleConfigValue, getEntry, isVariableSet + */ +export const useModuleConfig = (moduleName, options = {}) => { + const { coerceBooleans = true, autoLoad = true } = options; + + const module_config = ref([]); + const loading = ref(false); + const error = ref(null); + + const getEntry = (variable) => module_config.value.find((entry) => entry.variable === variable); + + const getModuleConfigValue = (variable) => { + const entry = getEntry(variable); + if (!entry) return ""; + const raw = entry.value; + if (coerceBooleans) { + if (raw === "true" || raw === true) return true; + if (raw === "false" || raw === false) return false; + } + return raw ?? ""; + }; + + const isVariableSet = (variable) => { + const entry = getEntry(variable); + if (!entry) return false; + if (entry.isSecret) return entry.isSet; + return entry.value !== null && entry.value !== undefined && entry.value !== ""; + }; + + const load = async () => { + loading.value = true; + error.value = null; + try { + const response = await SessionUser.superUser.modules[moduleName].config.get_all(); + module_config.value = normalizeEntries(response?.data?.data); + } catch (err) { + console.error(`useModuleConfig(${moduleName}): load failed`, err); + module_config.value = []; + error.value = err; + } finally { + loading.value = false; + } + }; + + if (autoLoad) { + onMounted(load); + } + + return { + module_config, + loading, + error, + load, + getEntry, + getModuleConfigValue, + isVariableSet, + coerceConfigBoolean, + }; +}; diff --git a/src/views/dashboards/superUserDashboard/configuration/ConfigurationEmail.vue b/src/views/dashboards/superUserDashboard/configuration/ConfigurationEmail.vue index 92b847fd..14774014 100644 --- a/src/views/dashboards/superUserDashboard/configuration/ConfigurationEmail.vue +++ b/src/views/dashboards/superUserDashboard/configuration/ConfigurationEmail.vue @@ -3,107 +3,64 @@ import PageTitle from "@/components/global/PageTitle.vue"; import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue"; import { SessionUser } from "@/components/session/token/SessionUser.vue"; -import { ref } from 'vue'; - -import ConfigurationSubPageWrapper - from "@/views/dashboards/superUserDashboard/configuration/ConfigurationSubPageWrapper.vue"; +import ConfigurationSubPageWrapper from "@/views/dashboards/superUserDashboard/configuration/ConfigurationSubPageWrapper.vue"; import ConfigurationCategory from "@/components/displays/superuser/configuration/ConfigurationCategory.vue"; import ConfigurationSwitch from "@/components/displays/superuser/configuration/ConfigurationSwitch.vue"; import ConfigurationSelect from "@/components/displays/superuser/configuration/ConfigurationSelect.vue"; import ConfigurationSecretKey from "@/components/displays/superuser/configuration/ConfigurationSecretKey.vue"; import ConfigurationInput from "@/components/displays/superuser/configuration/ConfigurationInput.vue"; import Swal from "sweetalert2"; -import { useI18n } from 'vue-i18n'; +import { useI18n } from "vue-i18n"; +import { useModuleConfig } from "@/composables/useModuleConfig.js"; const { t } = useI18n(); - -// Get the department from the route - - - -const module_config = ref([]); - - - -const getModuleConfig = async () => { - await SessionUser.superUser.modules.email.config.get_all().then((response) => { - let tmp_module_config = response.data.data; - let tmp_module_config_array = []; - for (const value of Object.values(tmp_module_config)) { - console.log(`${value.variable}: ${value.value}`); - tmp_module_config_array.push({ - variable: value.variable, - value: value.value, - }); - } - module_config.value = tmp_module_config_array; - console.log(module_config.value); - }).catch((error) => { - console.log(error); - }); -}; - -const getModuleConfigValue = (variable) => { - const config = module_config.value.find((config) => config.variable === variable); - // Log to the console, what the value was found - if (config) { - console.log(`Found value for ${variable}: ${config.value}`); - } else { - console.log(`No value found for ${variable}`); - } - return config ? config.value : ''; -}; - -const load = async () => { - await getModuleConfig(); -}; +const { module_config, getModuleConfigValue } = useModuleConfig("email"); const encryptionOptions = [ - { value: 'ssl', label: 'SSL' }, - { value: 'tls', label: 'TLS' }, - { value: 'starttls', label: 'STARTTLS' }, - { value: 'none', label: 'None' }, + { value: "ssl", label: "SSL" }, + { value: "tls", label: "TLS" }, + { value: "starttls", label: "STARTTLS" }, + { value: "none", label: "None" }, ]; const showSendTestEmail = async () => { await Swal.fire({ - title: t('configuration.email.send_test_email'), - text: t('configuration.email.send_test_email_prompt'), - input: 'email', + title: t("configuration.email.send_test_email"), + text: t("configuration.email.send_test_email_prompt"), + input: "email", inputAttributes: { - autocapitalize: 'off' + autocapitalize: "off", }, showCancelButton: true, - confirmButtonText: t('configuration.email.send'), + confirmButtonText: t("configuration.email.send"), showLoaderOnConfirm: true, preConfirm: (email) => { - return SessionUser.superUser.modules.email.sendTestEmail(email) - .then(() => { - Swal.fire({ - title: t('configuration.email.test_email_sent'), - text: t('configuration.email.test_email_sent_success'), - icon: 'success', - }); - }) - .catch(() => { - Swal.fire({ - title: t('common.error'), - text: t('configuration.email.test_email_error'), - icon: 'error', - }); + return SessionUser.superUser.modules.email + .sendTestEmail(email) + .then(() => { + Swal.fire({ + title: t("configuration.email.test_email_sent"), + text: t("configuration.email.test_email_sent_success"), + icon: "success", }); + }) + .catch(() => { + Swal.fire({ + title: t("common.error"), + text: t("configuration.email.test_email_error"), + icon: "error", + }); + }); }, - allowOutsideClick: () => !Swal.isLoading() + allowOutsideClick: () => !Swal.isLoading(), }); }; - -load(); - \ No newline at end of file + diff --git a/tests/unit/use-module-config.spec.js b/tests/unit/use-module-config.spec.js new file mode 100644 index 00000000..332a659c --- /dev/null +++ b/tests/unit/use-module-config.spec.js @@ -0,0 +1,149 @@ +// @vitest-environment jsdom +import { mount } from "@vue/test-utils"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { defineComponent, h } from "vue"; + +const mockGetAll = vi.fn(); + +vi.mock("@/components/session/token/SessionUser.vue", () => ({ + SessionUser: { + superUser: { + modules: { + testmod: { + config: { + get_all: mockGetAll, + }, + }, + }, + }, + }, +})); + +vi.mock("vue", async () => { + const actual = await vi.importActual("vue"); + return { + ...actual, + onMounted: (fn) => fn(), + }; +}); + +const payload = { + enabled: { variable: "enabled", value: "true" }, + disabled: { variable: "disabled", value: "false" }, + on_int: { variable: "on_int", value: 1 }, + flag: { variable: "flag", value: true }, + raw: { variable: "raw", value: "raw-value" }, + missing: { variable: "missing", value: null }, + secret_set: { variable: "secret_set", value: "[redacted]", isSecret: true, isSet: true }, + secret_unset: { variable: "secret_unset", value: "", isSecret: true, isSet: false }, +}; + +const mountComposable = async (overrides = {}) => { + const { useModuleConfig } = await import("@/composables/useModuleConfig.js"); + let captured; + const Harness = defineComponent({ + setup() { + captured = useModuleConfig("testmod", overrides); + return () => h("div"); + }, + }); + mount(Harness); + await Promise.resolve(); + await Promise.resolve(); + return captured; +}; + +describe("useModuleConfig", () => { + beforeEach(() => { + mockGetAll.mockReset(); + mockGetAll.mockResolvedValue({ data: { data: payload } }); + }); + + it("loads entries on mount and exposes module_config as a list", async () => { + const api = await mountComposable(); + expect(mockGetAll).toHaveBeenCalledTimes(1); + expect(api.module_config.value).toHaveLength(8); + expect(api.module_config.value.map((entry) => entry.variable)).toEqual([ + "enabled", + "disabled", + "on_int", + "flag", + "raw", + "missing", + "secret_set", + "secret_unset", + ]); + }); + + it("coerces the canonical 'true' / 'false' string pairs to JS booleans", async () => { + const api = await mountComposable(); + expect(api.getModuleConfigValue("enabled")).toBe(true); + expect(api.getModuleConfigValue("disabled")).toBe(false); + }); + + it("returns numbers unmodified when coerceBooleans is on", async () => { + const api = await mountComposable(); + expect(api.getModuleConfigValue("on_int")).toBe(1); + expect(api.getModuleConfigValue("flag")).toBe(true); + }); + + it("returns the raw string for non-boolean-like values", async () => { + const api = await mountComposable(); + expect(api.getModuleConfigValue("raw")).toBe("raw-value"); + }); + + it("returns empty string for unknown variables", async () => { + const api = await mountComposable(); + expect(api.getModuleConfigValue("not_in_payload")).toBe(""); + }); + + it("returns empty string for a variable that exists but is null", async () => { + const api = await mountComposable(); + // Matches the legacy Configuration*.vue contract: missing/null collapses to ''. + expect(api.getModuleConfigValue("missing")).toBe(""); + }); + + it("supports opt-out from boolean coercion for parity with the old inline helper", async () => { + const api = await mountComposable({ coerceBooleans: false }); + expect(api.getModuleConfigValue("enabled")).toBe("true"); + expect(api.getModuleConfigValue("disabled")).toBe("false"); + }); + + it("uses isSet for secret variables and a non-empty check for plain values", async () => { + const api = await mountComposable(); + expect(api.isVariableSet("secret_set")).toBe(true); + expect(api.isVariableSet("secret_unset")).toBe(false); + expect(api.isVariableSet("raw")).toBe(true); + expect(api.isVariableSet("missing")).toBe(false); + expect(api.isVariableSet("not_in_payload")).toBe(false); + }); + + it("records the error and clears the cache when the request fails", async () => { + mockGetAll.mockRejectedValueOnce(new Error("boom")); + const api = await mountComposable(); + expect(api.error.value).toBeInstanceOf(Error); + expect(api.module_config.value).toEqual([]); + expect(api.loading.value).toBe(false); + }); + + it("skips the auto-load when autoLoad is false and exposes a load()", async () => { + const api = await mountComposable({ autoLoad: false }); + expect(mockGetAll).not.toHaveBeenCalled(); + await api.load(); + expect(mockGetAll).toHaveBeenCalledTimes(1); + }); + + it("accepts a payload that is already an array of entries", async () => { + mockGetAll.mockResolvedValueOnce({ + data: { + data: [ + { variable: "k", value: "v" }, + { variable: "flag", value: "true" }, + ], + }, + }); + const api = await mountComposable(); + expect(api.module_config.value).toHaveLength(2); + expect(api.getModuleConfigValue("flag")).toBe(true); + }); +});