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:
Jeppe B
2026-08-10 09:04:35 +02:00
committed by GitHub
parent d61d91b6ae
commit 8d9f1e6fde
15 changed files with 223 additions and 12 deletions
@@ -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>
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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'}",
+1
View File
@@ -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",
+1
View File
@@ -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");
});
});