Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions frontend/modules/settings_write.html
Original file line number Diff line number Diff line change
Expand Up @@ -3305,8 +3305,32 @@ <h6 class="mb-1"><lang class="lang_ai_title">Let the software write and read for

<div class="form-group" id="ai_key_row" style="max-width:460px; display:none;">
<label for="ai_api_key"><lang class="lang_ai_key">API key</lang></label>
<!--
KEY ON FILE.

The key is never sent back to a browser, so this row is the
only way the page can say one exists. Before it, a saved key
and no key looked identical: the same empty box every visit,
with a placeholder as the only tell. Owner: "if AI already
have value then show some indication... just showing same
form everytime how user will know?"

While a key is saved the box is hidden. Replace brings it
back empty; Remove sends the API's clear sentinel, because an
empty value means "leave the saved credential alone" and
removal has to be said on purpose.
-->
<div id="ai_key_status" class="ai-key-status" style="display:none;">
<span class="ai-key-badge"><i class="feather icon-check-circle"></i><lang class="lang_ai_key_on_file">A key is saved</lang></span>
<span class="ai-key-hint"><lang class="lang_ai_key_on_file_hint">It is never shown again, not even to you.</lang></span>
<span class="ai-key-actions">
<button type="button" class="btn btn-outline-secondary btn-sm" id="ai_key_replace"><i class="feather icon-edit mr-1"></i><lang class="lang_ai_key_replace">Replace</lang></button>
<button type="button" class="btn btn-outline-danger btn-sm" id="ai_key_remove"><i class="feather icon-x mr-1"></i><lang class="lang_ai_key_remove">Remove</lang></button>
</span>
</div>
<input type="password" id="ai_api_key" class="form-control border-control"
autocomplete="new-password" placeholder="Paste the key from your provider" data-t-placeholder="lang_paste_the_key_from_your_provider">
<a href="javascript:void(0)" id="ai_key_cancel" class="small" style="display:none;"><lang class="lang_ai_key_keep">Keep the saved key</lang></a>
</div>

<!-- How to get a key.
Expand Down
68 changes: 68 additions & 0 deletions frontend/static/script/js/modules/js/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -9168,6 +9168,12 @@ $(document).on(
*/
PosnicPro.settings = PosnicPro.settings || {};
PosnicPro.settings.ai = {
/* Must match CLEAR_SECRET in api/src/services/settings-groups.js. An
empty value means "leave the saved credential alone", so removing one
has to be said on purpose, with a word no empty box can send. */
CLEAR_SECRET: '__posnic_clear__',
/* True only between pressing Replace and saving or backing out. */
_replacing: false,
/*
* Where each provider actually hands out a key.
*
Expand Down Expand Up @@ -9218,13 +9224,22 @@ PosnicPro.settings.ai = {
: PosnicPro.i18n.t('lang_ai_howto_3_again', 'Create a key and paste it above. You can open that page again later if you need to see it.'));
}
$('#ai_key_row,#ai_cap_row').toggle(on);
/* A saved key and no key must not look the same. The key never comes
back to the browser, so "saved" is a badge and two buttons, and the
empty box only appears when somebody asks to replace it. */
var saved = PosnicPro.settings.ai._keySaved === true;
var replacing = PosnicPro.settings.ai._replacing === true;
$('#ai_key_status').toggle(on && saved && !replacing);
$('#ai_api_key').toggle(on && (!saved || replacing));
$('#ai_key_cancel').toggle(on && saved && replacing);
$('#ai_spend_row').toggle(on && $('#ai_spend_table').children().length > 0);
},

load: function () {
/* Collapsed again on every visit. Opening it was a request for this
look at the page, not a preference to remember. */
PosnicPro.settings.ai._howtoOpen = false;
PosnicPro.settings.ai._replacing = false;
PosnicPro.get({ url: 'settings/group/preferences' }, function (response) {
if (response.type !== 'success' || !response.data) { return; }
var v = response.data.values || response.data;
Expand Down Expand Up @@ -9264,6 +9279,45 @@ PosnicPro.settings.ai = {
}, function () { /* no meter is not a broken page */ });
},

/*
* Remove the saved key.
*
* Asked first, because the only thing this page knows about the key is
* that it exists, and the person removing it may not have the original to
* paste back. The provider account is untouched; only what this shop
* holds is cleared, and the AI buttons go with it until a new key is saved.
*/
removeKey: function () {
swal({
title: PosnicPro.i18n.t('lang_ai_key_remove_q', 'Remove the saved key?'),
text: PosnicPro.i18n.t('lang_ai_key_remove_text', 'The AI buttons disappear until a new key is saved. Your provider account is not touched.'),
showCancelButton: true,
confirmButtonClass: 'btn btn-danger',
cancelButtonClass: 'btn btn-secondary m-l-10',
confirmButtonText: PosnicPro.i18n.t('lang_ai_key_remove', 'Remove'),
cancelButtonText: PosnicPro.i18n.t('lang_cancel', 'Cancel')
}).then(function () {
PosnicPro.put({
url: 'settings/group/secrets',
data: JSON.stringify({ ai_api_key: PosnicPro.settings.ai.CLEAR_SECRET })
}, function (response) {
if (response.type !== 'success') {
PosnicPro.alert(response.type, response.message);
return;
}
PosnicPro.settings.ai._keySaved = false;
PosnicPro.settings.ai._replacing = false;
/* The item screen's cached "is AI available" answer is stale
the moment the key is gone. */
if (PosnicPro.items) { PosnicPro.items._aiAvailable = null; }
PosnicPro.alert('success', PosnicPro.i18n.t('lang_ai_key_removed',
'Key removed. The AI buttons are hidden until a new one is saved.'));
PosnicPro.settings.ai.load();
}, function () {
PosnicPro.alert('error', PosnicPro.i18n.t('lang_could_not_remove_the_ai_key', 'Could not remove the AI key'));
});
}, function () { /* kept */ });
},
save: function () {
var provider = $('#ai_provider').val() || '';
var key = String($('#ai_api_key').val() || '');
Expand Down Expand Up @@ -9337,3 +9391,17 @@ $(document).on('click', '#ai_howto_toggle', function () {
$(document).on('click', '#ai_save', function () {
PosnicPro.settings.ai.save();
});
$(document).on('click', '#ai_key_replace', function () {
PosnicPro.settings.ai._replacing = true;
PosnicPro.settings.ai.syncRows();
$('#ai_api_key').val('').trigger('focus');
});
$(document).on('click', '#ai_key_cancel', function () {
/* Nothing was sent; the saved key was never in danger. */
PosnicPro.settings.ai._replacing = false;
$('#ai_api_key').val('');
PosnicPro.settings.ai.syncRows();
});
$(document).on('click', '#ai_key_remove', function () {
PosnicPro.settings.ai.removeKey();
});
39 changes: 39 additions & 0 deletions frontend/static/style/css/custom.css
Original file line number Diff line number Diff line change
Expand Up @@ -6857,3 +6857,42 @@ tr.s-doc-purchase-row:hover td { background: rgba(9, 105, 218, 0.05); }
#v-pills-modules .module-ico { transition: none; }
#v-pills-modules .module-card:hover .module-ico { transform: none; }
}

/*
* AI KEY ON FILE.
*
* The key never comes back to a browser, so "one is saved" has to be said
* with a badge and two buttons rather than by showing it. Painted from theme
* variables only: the guards further up scan to the end of this file and ask
* that no colour be spelled out by hand where a theme could want a say.
*/
#v-pills-ai .ai-key-status {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
padding: 10px 12px;
margin-bottom: 10px;
border: 1px solid rgba(127, 127, 127, 0.22);
border-radius: 10px;
background: color-mix(in srgb, var(--theme-success-color, #1b7a3d) 8%, var(--theme-card-bg, #fff));
}
@supports not (background: color-mix(in srgb, red 10%, blue)) {
#v-pills-ai .ai-key-status { background: var(--theme-card-bg, #fff); }
}
#v-pills-ai .ai-key-badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-weight: 700;
font-size: 13px;
color: var(--theme-success-color, #1b7a3d);
}
#v-pills-ai .ai-key-badge i { font-size: 16px; }
#v-pills-ai .ai-key-hint {
flex: 1 1 auto;
font-size: 12.5px;
color: var(--theme-text-muted, #5b6577);
}
#v-pills-ai .ai-key-actions { display: inline-flex; gap: 6px; }
#v-pills-ai #ai_key_cancel { display: inline-block; margin-top: 6px; }
10 changes: 10 additions & 0 deletions languages/_english.json
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,14 @@
"lang_ai_howto_cost": "One item description costs about a tenth of a US cent, which is the currency providers bill in. A dollar or two of credit covers a month of ordinary use.",
"lang_ai_howto_show": "How do I get a key?",
"lang_ai_key": "API key",
"lang_ai_key_keep": "Keep the saved key",
"lang_ai_key_on_file": "A key is saved",
"lang_ai_key_on_file_hint": "It is never shown again, not even to you.",
"lang_ai_key_remove": "Remove",
"lang_ai_key_remove_q": "Remove the saved key?",
"lang_ai_key_remove_text": "The AI buttons disappear until a new key is saved. Your provider account is not touched.",
"lang_ai_key_removed": "Key removed. The AI buttons are hidden until a new one is saved.",
"lang_ai_key_replace": "Replace",
"lang_ai_key_saved": "A key is saved. Type a new one to replace it.",
"lang_ai_module_desc": "Writes item descriptions for you. You bring your own AI account and pay that provider directly; we charge nothing. Set it up on the AI assistance page.",
"lang_ai_not_subscription": "A Claude Pro or ChatGPT Plus subscription does not work here. Those pay for the chat website; an API key is billed separately and, for this kind of use, usually costs far less.",
Expand Down Expand Up @@ -324,6 +332,7 @@
"lang_campaigns_settings": "Campaigns",
"lang_campaigns_title": "Campaigns",
"lang_can_t_edit_this_field": "Can't edit this field",
"lang_cancel": "Cancel",
"lang_cancel_ignore": "Cancel/Ignore",
"lang_cancel_payment": "Cancel Payment",
"lang_cancel_record": "Cancel Record",
Expand Down Expand Up @@ -580,6 +589,7 @@
"lang_could_not_reach_the_till_39_s_connector_ru": "Could not reach the till&#39;s connector runtime.",
"lang_could_not_read_the_current_setting": "Could not read the current setting.",
"lang_could_not_remove_it": "Could not remove it.",
"lang_could_not_remove_the_ai_key": "Could not remove the AI key",
"lang_could_not_remove_the_webhook": "Could not remove the webhook.",
"lang_could_not_revoke_the_token": "Could not revoke the token.",
"lang_could_not_run_the_scan_try_again": "Could not run the scan - try again.",
Expand Down
80 changes: 80 additions & 0 deletions tests/ai-key-state.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
'use strict';

/*
* A saved AI key and no AI key must not look the same.
*
* The key is never sent back to a browser, so the page cannot show it. Before
* this, it showed nothing at all: the same empty box on every visit, with a
* placeholder as the only tell. Owner: "if AI already have value then show
* some indication... just showing same form everytime how user will know?"
*
* Now a saved key is a badge, a Replace and a Remove, and the empty box only
* appears when somebody asks to replace it. Remove has to say a word, because
* an empty value means "leave the saved credential alone" (see
* settings-groups.js CLEAR_SECRET) - which is right for every other save on
* this page and would make removal impossible without it.
*/

const test = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');

const ROOT = path.join(__dirname, '..');
const HTML = fs.readFileSync(path.join(ROOT, 'frontend', 'modules', 'settings_write.html'), 'utf8');
const JS = fs.readFileSync(path.join(ROOT, 'frontend', 'static', 'script', 'js', 'modules', 'js', 'settings.js'), 'utf8');
const CSS = fs.readFileSync(path.join(ROOT, 'frontend', 'static', 'style', 'css', 'custom.css'), 'utf8');
const groups = require(path.join(ROOT, 'api', 'src', 'services', 'settings-groups'));

const pane = HTML.slice(HTML.indexOf('id="v-pills-ai"'), HTML.indexOf('class="tab-pane fade"', HTML.indexOf('id="v-pills-ai"')));
const ai = JS.slice(JS.indexOf('PosnicPro.settings.ai = {'));

test('a saved key is shown as a state, not as an empty box', () => {
for (const id of ['ai_key_status', 'ai_key_replace', 'ai_key_remove', 'ai_key_cancel']) {
assert.ok(pane.includes(`id="${id}"`), `#${id} is missing from the AI page`);
}
/* the status sits with the key, inside its row, above the box */
const row = pane.slice(pane.indexOf('id="ai_key_row"'), pane.indexOf('id="ai_howto_toggle_row"'));
assert.ok(row.indexOf('id="ai_key_status"') < row.indexOf('id="ai_api_key"'),
'the saved-key state is not above the key box');
});

test('the box hides while a key is saved, unless somebody asked to replace it', () => {
assert.match(ai, /\$\('#ai_key_status'\)\.toggle\(on && saved && !replacing\)/);
assert.match(ai, /\$\('#ai_api_key'\)\.toggle\(on && \(!saved \|\| replacing\)\)/);
assert.match(ai, /\$\('#ai_key_cancel'\)\.toggle\(on && saved && replacing\)/);
/* a visit starts from the saved state, never from a half-finished replace */
assert.match(ai, /_howtoOpen = false;\n\s*PosnicPro\.settings\.ai\._replacing = false;/);
});

test('remove says the word the API listens for, and only that word', () => {
/* The frontend constant and the service constant are the same string, so a
change on one side fails here rather than silently doing nothing. */
const m = ai.match(/CLEAR_SECRET: '([^']+)'/);
assert.ok(m, 'the frontend has no clear sentinel');
assert.strictEqual(m[1], groups.CLEAR_SECRET, 'frontend and API disagree on the clear sentinel');
assert.match(ai, /url: 'settings\/group\/secrets',\s*data: JSON\.stringify\(\{ ai_api_key: PosnicPro\.settings\.ai\.CLEAR_SECRET \}\)/);
/* and it is never sent by the ordinary save path */
const save = ai.slice(ai.indexOf(' save: function () {'));
assert.ok(!save.includes('CLEAR_SECRET'), 'the ordinary save can clear the key');
});

test('remove asks first, and clears what the item screen remembered', () => {
const remove = ai.slice(ai.indexOf('removeKey: function () {'), ai.indexOf(' save: function () {'));
assert.match(remove, /swal\(\{/, 'the key can be removed without a question');
assert.match(remove, /showCancelButton: true/);
assert.match(remove, /PosnicPro\.items\._aiAvailable = null/, 'the item screen would keep showing the AI button after the key is gone');
assert.match(remove, /_keySaved = false/);
});

test('the three actions are wired', () => {
assert.match(JS, /on\('click', '#ai_key_replace'/);
assert.match(JS, /on\('click', '#ai_key_cancel'/);
assert.match(JS, /on\('click', '#ai_key_remove'/);
assert.match(JS, /PosnicPro\.settings\.ai\.removeKey\(\)/);
});

test('the state is painted from theme tokens', () => {
assert.match(CSS, /#v-pills-ai \.ai-key-status \{/);
assert.match(CSS, /var\(--theme-success-color/);
});
Loading