fix(pleno-vue): persist MiniMax API key across refresh + render fix (#281)
Fixes the operator-reported MiniMax configuration bug: the API key appeared to be 'not saved' on every refresh.
The api backend is correct — direct repro against api-v2.truckwash.io (2026-08-10 07:58) showed 200 on POST and `isSet: true` on subsequent GET for both `{variable, value}` and raw-key payload shapes. The bugs were all on the frontend.
## What was actually broken
1. **Read response shape mismatch (root cause).** `ConfigurationXLVask.refreshMiniMaxStatus` parsed the GET response as an object, but the endpoint returns `[{module, variable, type, value, isSecret, isSet}]`. `typeof array === 'string'` is false → `minimaxApiKeyIsSet` was reset to `false` after every reload.
2. **Inline edit-save flow never notified the parent.** `ConfigurationSecretKey` had no event out, so the inline edit-and-save on the api_key field always left `isSet=false` (and the warning visible) until the user fully reloaded the page.
3. **MiniMax 'Enable' toggle was bound to a function reference.** `:value="SessionUser.superUser.modules.minimax.config.enabled.get ? true : false"` evaluates as `function ? true : false` = `true` (every function is truthy), so the switch always rendered as on.
4. **Hardcoded English warning text** in `ConfigurationSecretKey.vue` — i18n-v2 violation.
5. **Missing key registration in `xlvask/Config.vue.keys`.** PR #269 added a switch for `minimax_integration_enabled` on `xlvask.config.keys`, but never registered the key — accessing `.set` on `undefined.set` throws `TypeError` and aborts the Vue render mid-tree. Production build #c353bfa only renders 3 of 4 categories because of this.
## Changes
- `ConfigurationSecretKey.vue` — emits `saved` after a successful `onSave`; stays in edit mode + surfaces error on failure. Warning title/body come from `useI18n` (`configuration.secret_key_not_set` + `common.warning`) with optional prop overrides.
- `ConfigurationXLVask.vue` — `extractConfigEntry` helper unwraps the array response and trusts the explicit `isSet` flag. The MiniMax enable toggle reads `minimaxEnabled` (real boolean) and re-fetches via `onMiniMaxEnabledSwitch` (optimistic rollback on failure). After re-authenticate/remove/inline-save the parent re-fetches status so the UI matches persistence.
- `xlvask/Config.vue.keys` — registers the missing `minimax_integration_enabled` key.
- New i18n key `configuration.secret_key_not_set` + global shared alias; added to da/de/en/no/sv.
- New `tests/unit/configuration-secret-key.spec.js` (4 tests).
## Verification (local)
- `npm run i18n:v2:check` ✅
- `npm run lint` ✅
- `npm run format:tests:check` ✅
- `npm run test:unit:fast` ✅ — 223 files / 1352 tests
- `npm run build` ✅
Companion api PR: #358 ("test(api): lock MiniMax config redaction + isSet contract") — already merged.
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { parseError, getError } from "@/components/request/HandleGlobalError.vue";
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
onSave: Function, // Function to call when the secret key is changed
|
||||
@@ -11,10 +14,25 @@ const props = defineProps({
|
||||
icon: String, // The icon of the select
|
||||
color: String, // The color of the select
|
||||
isSet: Boolean, // If the secret key is set or not, this is used to show a warning if the secret key is not set.
|
||||
warningTitle: { // Optional override for the warning title (defaults to i18n key)
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
warningMessage: { // Optional override for the warning body (defaults to i18n key)
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
warningKey: { // Override the i18n key used for the default warning body
|
||||
type: String,
|
||||
default: 'configuration.secret_key_not_set',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['saved']);
|
||||
|
||||
const v_model = ref(props.value);
|
||||
const isEditting = ref(false);
|
||||
const saving = ref(false);
|
||||
|
||||
const errorId = ref(null);
|
||||
|
||||
@@ -24,6 +42,32 @@ const parseErrorOnSave = (error) => {
|
||||
parseError(error, unique_id);
|
||||
}
|
||||
|
||||
const onClickSave = async () => {
|
||||
if (!props.onSave) {
|
||||
isEditting.value = false;
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
errorId.value = null;
|
||||
try {
|
||||
const result = await props.onSave(v_model.value);
|
||||
// Treat any resolved value (including undefined) as success — the only signal
|
||||
// we have for failure is the catch path. The previous implementation did the
|
||||
// same thing but also clobbered `isEditting = false` synchronously, which
|
||||
// could mask in-flight failures when the parent awaited silently.
|
||||
emit('saved', { value: v_model.value, result });
|
||||
isEditting.value = false;
|
||||
} catch (error) {
|
||||
parseErrorOnSave(error);
|
||||
// Stay in edit mode so the user can correct the input.
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resolvedWarningTitle = () => props.warningTitle ?? t('common.warning', 'Warning');
|
||||
const resolvedWarningMessage = () => props.warningMessage ?? t(props.warningKey, 'The secret key is not set, please set it before enabling the integration.');
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
@@ -33,8 +77,8 @@ const parseErrorOnSave = (error) => {
|
||||
<!-- If the secret key is not set, show a warning -->
|
||||
<div v-if="!props.isSet" class="message is-warning">
|
||||
<div class="message-body">
|
||||
<strong>Warning:</strong>
|
||||
The secret key is not set, please set it before enabling the integration.
|
||||
<strong>{{ resolvedWarningTitle() }}:</strong>
|
||||
{{ resolvedWarningMessage() }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="control">
|
||||
@@ -52,11 +96,16 @@ const parseErrorOnSave = (error) => {
|
||||
class="input"
|
||||
type="text"
|
||||
v-model="v_model"
|
||||
:disabled="disabled || !isEditting"
|
||||
:disabled="disabled || !isEditting || saving"
|
||||
/>
|
||||
</div>
|
||||
<div class="control">
|
||||
<a class="button is-info" @click="onSave(v_model).catch(parseErrorOnSave); isEditting = false" v-if="isEditting">
|
||||
<a
|
||||
class="button is-info"
|
||||
@click="onClickSave"
|
||||
v-if="isEditting"
|
||||
:class="{ 'is-loading': saving }"
|
||||
>
|
||||
<span class="icon">
|
||||
<i class="fas fa-save"></i>
|
||||
</span>
|
||||
|
||||
@@ -2541,6 +2541,7 @@
|
||||
"zip": "Postnummer"
|
||||
},
|
||||
"configuration": {
|
||||
"secret_key_not_set": "The secret key is not set, please set it before enabling the integration.",
|
||||
"backups": {
|
||||
"active_jobs": "Active jobs",
|
||||
"actions": "Actions",
|
||||
|
||||
@@ -2651,6 +2651,7 @@
|
||||
"zip": "Postleitzahl"
|
||||
},
|
||||
"configuration": {
|
||||
"secret_key_not_set": "The secret key is not set, please set it before enabling the integration.",
|
||||
"backups": {
|
||||
"active_jobs": "Active jobs",
|
||||
"actions": "Actions",
|
||||
|
||||
@@ -2372,6 +2372,7 @@
|
||||
"zip": "@:{'words.generated.zip'} @:{'words.generated.code'}"
|
||||
},
|
||||
"configuration": {
|
||||
"secret_key_not_set": "The secret key is not set, please set it before enabling the integration.",
|
||||
"backups": {
|
||||
"active_jobs": "Active jobs",
|
||||
"actions": "Actions",
|
||||
|
||||
@@ -1255,6 +1255,7 @@
|
||||
"workspace_id": "Workspace ID",
|
||||
"workspace_id_desc": "The canonical Bird workspace identifier. Existing workplaceId values remain readable during migration."
|
||||
},
|
||||
"secret_key_not_set": "@:{'templates.generated.compat.configuration.secret_key_not_set'}",
|
||||
"backups": {
|
||||
"active_jobs": "@:{'templates.generated.compat.configuration.backups.active_jobs'}",
|
||||
"actions": "@:{'templates.generated.compat.configuration.backups.actions'}",
|
||||
|
||||
@@ -2654,6 +2654,7 @@
|
||||
"zip": "@:{'words.generated.postnummer'}"
|
||||
},
|
||||
"configuration": {
|
||||
"secret_key_not_set": "The secret key is not set, please set it before enabling the integration.",
|
||||
"backups": {
|
||||
"active_jobs": "Active jobs",
|
||||
"actions": "Actions",
|
||||
|
||||
@@ -2704,6 +2704,7 @@
|
||||
"zip": "Postnummer"
|
||||
},
|
||||
"configuration": {
|
||||
"secret_key_not_set": "The secret key is not set, please set it before enabling the integration.",
|
||||
"backups": {
|
||||
"active_jobs": "Active jobs",
|
||||
"actions": "Actions",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"compat": {
|
||||
"configuration": {
|
||||
"secret_key_not_set": "The secret key is not set, please set it before enabling the integration.",
|
||||
"backups": {
|
||||
"active_jobs": "Active jobs",
|
||||
"actions": "Actions",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"compat": {
|
||||
"configuration": {
|
||||
"secret_key_not_set": "The secret key is not set, please set it before enabling the integration.",
|
||||
"backups": {
|
||||
"active_jobs": "Active jobs",
|
||||
"actions": "Actions",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"compat": {
|
||||
"configuration": {
|
||||
"secret_key_not_set": "The secret key is not set, please set it before enabling the integration.",
|
||||
"backups": {
|
||||
"active_jobs": "Active jobs",
|
||||
"actions": "Actions",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"configuration": {
|
||||
"secret_key_not_set": "@:{'phrases.compat.configuration.secret_key_not_set'}",
|
||||
"backups": {
|
||||
"active_jobs": "@:{'phrases.compat.configuration.backups.active_jobs'}",
|
||||
"actions": "@:{'phrases.compat.configuration.backups.actions'}",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"compat": {
|
||||
"configuration": {
|
||||
"secret_key_not_set": "The secret key is not set, please set it before enabling the integration.",
|
||||
"backups": {
|
||||
"active_jobs": "Active jobs",
|
||||
"actions": "Actions",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"compat": {
|
||||
"configuration": {
|
||||
"secret_key_not_set": "The secret key is not set, please set it before enabling the integration.",
|
||||
"backups": {
|
||||
"active_jobs": "Active jobs",
|
||||
"actions": "Actions",
|
||||
|
||||
@@ -85,20 +85,46 @@ const onClickTestConnection = async () => {
|
||||
|
||||
// --- MiniMax re-authenticate / remove ---------------------------------------
|
||||
const minimaxApiKeyIsSet = ref(false);
|
||||
const minimaxEnabled = ref(false);
|
||||
const reauthenticatingMiniMax = ref(false);
|
||||
const removingMiniMax = ref(false);
|
||||
const togglingMiniMaxEnabled = ref(false);
|
||||
|
||||
const refreshMiniMaxStatus = async () => {
|
||||
/**
|
||||
* The backend returns the get-config response as an array (one entry per
|
||||
* module variable). Extract the single entry we asked for, then trust the
|
||||
* explicit `isSet` flag instead of the redacted `value`.
|
||||
*/
|
||||
const extractConfigEntry = (response) => {
|
||||
const payload = response?.data?.data;
|
||||
if (Array.isArray(payload)) {
|
||||
return payload[0] ?? null;
|
||||
}
|
||||
return payload ?? null;
|
||||
};
|
||||
|
||||
const refreshMiniMaxApiKeyStatus = async () => {
|
||||
try {
|
||||
const response = await SessionUser.superUser.modules.minimax.config.keys.api_key.get();
|
||||
const value = response?.data?.data?.value ?? response?.data?.data ?? '';
|
||||
minimaxApiKeyIsSet.value = typeof value === 'string' && value !== '';
|
||||
const entry = extractConfigEntry(response);
|
||||
minimaxApiKeyIsSet.value = entry?.isSet === true;
|
||||
} catch (error) {
|
||||
// If the endpoint is unreachable or the key isn't set yet, treat as not-set.
|
||||
minimaxApiKeyIsSet.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const refreshMiniMaxEnabled = async () => {
|
||||
try {
|
||||
const response = await SessionUser.superUser.modules.minimax.config.enabled.get();
|
||||
const entry = extractConfigEntry(response);
|
||||
const raw = entry?.value;
|
||||
minimaxEnabled.value = raw === true || raw === 'true' || raw === '1' || raw === 1;
|
||||
} catch (error) {
|
||||
minimaxEnabled.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onClickReauthenticateMiniMax = async () => {
|
||||
const { value: apiKey } = await Swal.fire({
|
||||
title: t('configuration.xlvask.minimax_reauth_title'),
|
||||
@@ -121,7 +147,10 @@ const onClickReauthenticateMiniMax = async () => {
|
||||
reauthenticatingMiniMax.value = true;
|
||||
try {
|
||||
await SessionUser.superUser.modules.minimax.config.keys.api_key.set(apiKey);
|
||||
minimaxApiKeyIsSet.value = true;
|
||||
// Re-fetch from the backend so the UI matches actual persistence (and so a
|
||||
// silent failure surfaces as "still not set" instead of a misleading green
|
||||
// checkmark).
|
||||
await refreshMiniMaxApiKeyStatus();
|
||||
Swal.fire({
|
||||
icon: 'success',
|
||||
title: t('configuration.xlvask.minimax_reauth_success'),
|
||||
@@ -152,7 +181,7 @@ const onClickRemoveMiniMax = async () => {
|
||||
removingMiniMax.value = true;
|
||||
try {
|
||||
await SessionUser.superUser.modules.minimax.config.keys.api_key.set('');
|
||||
minimaxApiKeyIsSet.value = false;
|
||||
await refreshMiniMaxApiKeyStatus();
|
||||
Swal.fire({
|
||||
icon: 'success',
|
||||
title: t('configuration.xlvask.minimax_remove_success'),
|
||||
@@ -169,9 +198,37 @@ const onClickRemoveMiniMax = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* The ConfigurationSecretKey inline-edit flow calls onSave and resolves on
|
||||
* success. Refresh the parent state from the API so the "Hidden" view replaces
|
||||
* the warning as soon as the request actually persists.
|
||||
*/
|
||||
const onMiniMaxApiKeySaved = async () => {
|
||||
await refreshMiniMaxApiKeyStatus();
|
||||
};
|
||||
|
||||
/**
|
||||
* The MiniMax "Enable" toggle calls on-switch with the next boolean value.
|
||||
* Re-fetch after the save so the UI reflects persisted state (the inline
|
||||
* `set()` call does not refresh the UI on its own).
|
||||
*/
|
||||
const onMiniMaxEnabledSwitch = async (nextValue) => {
|
||||
togglingMiniMaxEnabled.value = true;
|
||||
try {
|
||||
await SessionUser.superUser.modules.minimax.config.enabled.set(nextValue);
|
||||
await refreshMiniMaxEnabled();
|
||||
} catch (error) {
|
||||
// Roll back the optimistic UI flip on failure.
|
||||
minimaxEnabled.value = !nextValue;
|
||||
throw error;
|
||||
} finally {
|
||||
togglingMiniMaxEnabled.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
await getModuleConfig();
|
||||
await refreshMiniMaxStatus();
|
||||
await Promise.all([refreshMiniMaxApiKeyStatus(), refreshMiniMaxEnabled()]);
|
||||
};
|
||||
|
||||
load();
|
||||
@@ -294,8 +351,9 @@ load();
|
||||
:title="$t('configuration.xlvask.minimax_enable')"
|
||||
:description="$t('configuration.xlvask.minimax_enable_desc')"
|
||||
icon="fas fa-robot"
|
||||
:value="SessionUser.superUser.modules.minimax.config.enabled.get ? true : false"
|
||||
:on-switch="SessionUser.superUser.modules.minimax.config.enabled.set"
|
||||
:value="minimaxEnabled"
|
||||
:on-switch="onMiniMaxEnabledSwitch"
|
||||
:disabled="togglingMiniMaxEnabled"
|
||||
/>
|
||||
<ConfigurationSecretKey
|
||||
class="mt-2"
|
||||
@@ -305,6 +363,7 @@ load();
|
||||
icon="fas fa-key"
|
||||
:isSet="minimaxApiKeyIsSet"
|
||||
:on-save="SessionUser.superUser.modules.minimax.config.keys.api_key.set"
|
||||
@saved="onMiniMaxApiKeySaved"
|
||||
/>
|
||||
<div class="buttons mt-2">
|
||||
<button class="button is-warning" @click="onClickReauthenticateMiniMax" :disabled="reauthenticatingMiniMax">
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { flushPromises, mount } from "@vue/test-utils";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("vue-i18n", () => ({
|
||||
useI18n: () => ({
|
||||
t: (key, fallback) => (typeof fallback === "string" ? fallback : key),
|
||||
}),
|
||||
}));
|
||||
|
||||
const parseError = vi.fn();
|
||||
const getError = vi.fn();
|
||||
vi.mock("@/components/request/HandleGlobalError.vue", () => ({
|
||||
parseError: (...args) => parseError(...args),
|
||||
getError: (...args) => getError(...args),
|
||||
}));
|
||||
|
||||
import ConfigurationSecretKey from "@/components/displays/superuser/configuration/ConfigurationSecretKey.vue";
|
||||
|
||||
const findSaveButton = (wrapper) => wrapper.findAll("a.button").find((b) => b.find("i.fa-save").exists());
|
||||
const findEditButton = (wrapper) => wrapper.findAll("a.button").find((b) => b.find("i.fa-edit").exists());
|
||||
|
||||
const mountKey = (props = {}) =>
|
||||
mount(ConfigurationSecretKey, {
|
||||
props: {
|
||||
title: "API key",
|
||||
description: "desc",
|
||||
...props,
|
||||
},
|
||||
});
|
||||
|
||||
describe("ConfigurationSecretKey", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows the warning when not set, hidden view when set", () => {
|
||||
const unset = mountKey({ isSet: false });
|
||||
expect(unset.find(".message.is-warning").exists()).toBe(true);
|
||||
const setWrapper = mountKey({ isSet: true });
|
||||
expect(setWrapper.find(".message.is-warning").exists()).toBe(false);
|
||||
expect(setWrapper.find('input[value="Hidden"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("emits 'saved' after a successful onSave and exits edit mode", async () => {
|
||||
const onSave = vi.fn().mockResolvedValue({ ok: true });
|
||||
const wrapper = mountKey({ onSave, isSet: false });
|
||||
// The input is disabled until the user enters edit mode — click Edit first,
|
||||
// then type, then Save. This matches the production UX for ConfigurationXLVask.
|
||||
await findEditButton(wrapper).trigger("click");
|
||||
await wrapper.find('input[type="text"]').setValue("super-secret");
|
||||
await findSaveButton(wrapper).trigger("click");
|
||||
await flushPromises();
|
||||
expect(onSave).toHaveBeenCalledWith("super-secret");
|
||||
expect(wrapper.emitted("saved")).toBeTruthy();
|
||||
expect(wrapper.emitted("saved").length).toBe(1);
|
||||
expect(wrapper.emitted("saved")[0][0]).toEqual(expect.objectContaining({ value: "super-secret" }));
|
||||
// Edit mode exited — the Save button is gone, the Edit button is back.
|
||||
expect(findSaveButton(wrapper)).toBeUndefined();
|
||||
expect(findEditButton(wrapper).exists()).toBe(true);
|
||||
// Whether the "Hidden" view shows after a save depends on the parent updating
|
||||
// isSet; that's the caller's responsibility and is exercised in the
|
||||
// ConfigurationXLVask end-to-end coverage.
|
||||
});
|
||||
|
||||
it("stays in edit mode and surfaces the error when onSave rejects", async () => {
|
||||
const failure = new Error("boom");
|
||||
const onSave = vi.fn().mockRejectedValue(failure);
|
||||
getError.mockReturnValue("boom");
|
||||
const wrapper = mountKey({ onSave, isSet: false });
|
||||
await findEditButton(wrapper).trigger("click");
|
||||
await wrapper.find('input[type="text"]').setValue("super-secret");
|
||||
await findSaveButton(wrapper).trigger("click");
|
||||
await flushPromises();
|
||||
expect(onSave).toHaveBeenCalledWith("super-secret");
|
||||
expect(wrapper.emitted("saved")).toBeFalsy();
|
||||
expect(parseError).toHaveBeenCalledWith(failure, expect.any(String));
|
||||
// Edit mode preserved so the user can retry.
|
||||
expect(findSaveButton(wrapper).exists()).toBe(true);
|
||||
expect(wrapper.find(".help.is-danger").exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("renders localized warning message instead of hardcoded English", () => {
|
||||
// useI18n mock returns the fallback for any unknown key, so the warning body
|
||||
// should reflect whatever the caller passed via the i18n key fallback path.
|
||||
const wrapper = mountKey({ isSet: false });
|
||||
const body = wrapper.find(".message.is-warning .message-body").text();
|
||||
expect(body).toContain("The secret key is not set");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user