feat(companysetting): add writeonly column and mask values for GUI

This commit is contained in:
seb
2026-09-15 08:48:24 +02:00
parent 2e16a22c99
commit e56f725bd9
10 changed files with 155 additions and 107 deletions

View File

@@ -536,8 +536,8 @@ BEGIN
article_id INT NOT NULL,
entity_id INT NOT NULL,
price_id INT NULL,
SupplierSKU NVARCHAR(100) NULL,
SupplierStock FLOAT NULL,
supplierSKU NVARCHAR(100) NULL,
supplierStock FLOAT NULL,
pu FLOAT NOT NULL CONSTRAINT DF_articlesupplier_pu DEFAULT 1,
CONSTRAINT FK_articlesupplier_article FOREIGN KEY (article_id) REFERENCES article(id) ON DELETE CASCADE,
CONSTRAINT FK_articlesupplier_entity FOREIGN KEY (entity_id) REFERENCES entity(id) ON DELETE CASCADE,
@@ -762,6 +762,7 @@ BEGIN
[key] NVARCHAR(255) NOT NULL,
value NVARCHAR(MAX) NOT NULL,
frontend BIT NOT NULL CONSTRAINT DF_companysetting_frontend DEFAULT 0,
writeonly BIT NOT NULL CONSTRAINT DF_companysetting_writeonly DEFAULT 0,
CONSTRAINT UQ_companysetting_company_key UNIQUE (company_id, [key]),
CONSTRAINT FK_companysetting_company FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE CASCADE
);
@@ -799,47 +800,47 @@ END
/** DDL scripts in dependency order for schema init. */
export const SCHEMA_SCRIPTS = [
INIT_META,
INIT_LANGUAGE,
INIT_TAX,
INIT_CATEGORY,
INIT_PIC,
INIT_COMPANY,
INIT_GOOGLE_AUTH,
INIT_CUSTOMERGROUP,
INIT_COMPANY_POS_CUSTOMERGROUP_FK,
INIT_ENTITY,
INIT_ENTITY_COMPANY_NUMBER_UQ,
INIT_ENTITYADDRESS,
INIT_ENTITYCONTACT,
INIT_UNIT,
INIT_UNITTEXT,
INIT_OPTIONSLIST,
INIT_ARTICLELIST,
INIT_BRAND,
INIT_ARTICLE,
INIT_ARTICLEEAN,
INIT_ARTICLEPIC,
INIT_BRANDPIC,
INIT_ARTICLELISTARTICLE,
INIT_OPTIONSLISTARTICLE,
INIT_CATEGORYARTICLE,
INIT_CATEGORYPIC,
INIT_CATEGORYTEXT,
INIT_ARTICLENAME,
INIT_PRICE,
INIT_ARTICLE_SUPPLIER,
INIT_MANIFEST,
INIT_MANIFESTARTICLE,
INIT_LEDGER,
INIT_NUMBERS,
INIT_GET_NEXT_NUMBER_WITH_PREFIX_AND_PADDING,
INIT_PEEK_NEXT_NUMBER_WITH_PREFIX_AND_PADDING,
INIT_COMPANYUSER,
INIT_ARTICLEHISTORY,
INIT_ENTITYHISTORY,
INIT_SYNCPOS,
INIT_COMPANYSETTING,
INIT_POSTERMINAL,
INIT_POSTERMINALCATEGORY,
INIT_META,
INIT_LANGUAGE,
INIT_TAX,
INIT_CATEGORY,
INIT_PIC,
INIT_COMPANY,
INIT_GOOGLE_AUTH,
INIT_CUSTOMERGROUP,
INIT_COMPANY_POS_CUSTOMERGROUP_FK,
INIT_ENTITY,
INIT_ENTITY_COMPANY_NUMBER_UQ,
INIT_ENTITYADDRESS,
INIT_ENTITYCONTACT,
INIT_UNIT,
INIT_UNITTEXT,
INIT_OPTIONSLIST,
INIT_ARTICLELIST,
INIT_BRAND,
INIT_ARTICLE,
INIT_ARTICLEEAN,
INIT_ARTICLEPIC,
INIT_BRANDPIC,
INIT_ARTICLELISTARTICLE,
INIT_OPTIONSLISTARTICLE,
INIT_CATEGORYARTICLE,
INIT_CATEGORYPIC,
INIT_CATEGORYTEXT,
INIT_ARTICLENAME,
INIT_PRICE,
INIT_ARTICLE_SUPPLIER,
INIT_MANIFEST,
INIT_MANIFESTARTICLE,
INIT_LEDGER,
INIT_NUMBERS,
INIT_GET_NEXT_NUMBER_WITH_PREFIX_AND_PADDING,
INIT_PEEK_NEXT_NUMBER_WITH_PREFIX_AND_PADDING,
INIT_COMPANYUSER,
INIT_ARTICLEHISTORY,
INIT_ENTITYHISTORY,
INIT_SYNCPOS,
INIT_COMPANYSETTING,
INIT_POSTERMINAL,
INIT_POSTERMINALCATEGORY,
];

View File

@@ -2,7 +2,8 @@ export const LIST_BY_COMPANY = `
SELECT
[key],
value,
CAST(frontend AS INT) AS frontend
CAST(frontend AS INT) AS frontend,
CAST(writeonly AS INT) AS writeonly
FROM companysetting
WHERE company_id = @companyId
ORDER BY [key]
@@ -12,7 +13,8 @@ export const LIST_BY_COMPANY_FRONTEND = `
SELECT
[key],
value,
CAST(frontend AS INT) AS frontend
CAST(frontend AS INT) AS frontend,
CAST(writeonly AS INT) AS writeonly
FROM companysetting
WHERE company_id = @companyId
AND frontend = 1
@@ -23,7 +25,8 @@ export const GET_BY_COMPANY_AND_KEY = `
SELECT
[key],
value,
CAST(frontend AS INT) AS frontend
CAST(frontend AS INT) AS frontend,
CAST(writeonly AS INT) AS writeonly
FROM companysetting
WHERE company_id = @companyId
AND [key] = @key
@@ -34,8 +37,8 @@ MERGE companysetting AS target
USING (SELECT @companyId AS company_id, @key AS [key]) AS src
ON target.company_id = src.company_id AND target.[key] = src.[key]
WHEN MATCHED THEN
UPDATE SET value = @value, frontend = @frontend
UPDATE SET value = @value, frontend = @frontend, writeonly = @writeonly
WHEN NOT MATCHED THEN
INSERT (company_id, [key], value, frontend)
VALUES (@companyId, @key, @value, @frontend);
INSERT (company_id, [key], value, frontend, writeonly)
VALUES (@companyId, @key, @value, @frontend, @writeonly);
`;

View File

@@ -12,13 +12,18 @@ export default function get(pool) {
* @param {string} key - The setting key.
* @param {object} [options] - Optional options.
* @param {boolean} [options.frontendOnly] - Only return the setting if it is frontend-visible.
* @returns {Promise<{key: string, value: string, frontend: boolean}|undefined>} The setting, or `undefined` if not found.
* @param {boolean} [options.maskWriteonly] - Mask writeonly settings as '***'. Defaults to `frontendOnly` (usergui gets '***', backend gets full value).
* @returns {Promise<{key: string, value: string, frontend: boolean, writeonly: boolean}|undefined>} The setting, or `undefined` if not found.
*
* @example
* const setting = await store.companysetting.get(42, 'theme');
* // => { key, value, frontend }
* const setting = await store.companysetting.get(42, 'ui.theme');
* // => { key, value, frontend, writeonly }
*/
return async (googleAuthId, key, { frontendOnly = false } = {}) => {
return async (
googleAuthId,
key,
{ frontendOnly = false, maskWriteonly } = {},
) => {
if (!key?.trim()) {
return undefined;
}
@@ -39,6 +44,7 @@ export default function get(pool) {
return undefined;
}
return shapeSetting(row);
const shouldMask = maskWriteonly ?? frontendOnly;
return shapeSetting(row, { exposeValue: !shouldMask });
};
}

View File

@@ -11,16 +11,23 @@ export default function list(pool) {
/**
* Lists the settings of the user's selected company, optionally only frontend-visible ones.
*
* Write-only settings (such as `keys.tavily`, `keys.openrouter`) have their value masked
* with `'***'` by default for usergui presentation.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} [options] - Optional options.
* @param {boolean} [options.frontendOnly] - Only return frontend-visible settings.
* @returns {Promise<Array<{key: string, value: string, frontend: boolean}>>} The settings.
* @param {boolean} [options.maskWriteonly] - Whether to mask writeonly settings with '***'. Defaults to `true`.
* @returns {Promise<Array<{key: string, value: string, frontend: boolean, writeonly: boolean}>>} The settings.
*
* @example
* const settings = await store.companysetting.list(42, { frontendOnly: true });
* // => [{ key, value, frontend }]
* // => [{ key, value, frontend, writeonly }]
*/
return async (googleAuthId, { frontendOnly = false } = {}) => {
return async (
googleAuthId,
{ frontendOnly = false, maskWriteonly = true } = {},
) => {
const { companyId } = await access(googleAuthId);
const result = await pool
@@ -28,6 +35,8 @@ export default function list(pool) {
.input('companyId', companyId)
.query(frontendOnly ? LIST_BY_COMPANY_FRONTEND : LIST_BY_COMPANY);
return result.recordset.map(shapeSetting);
return result.recordset.map((row) =>
shapeSetting(row, { exposeValue: !maskWriteonly }),
);
};
}

View File

@@ -10,6 +10,13 @@ const ALLOWED_KEYS = new Set([
'labelprinter.type',
]);
// Keys that are write-only by default: the GUI receives a scrambled "***"
// placeholder when reading, but the backend always gets the real value.
const WRITEONLY_KEYS = new Set([
'keys.tavily',
'keys.openrouter',
]);
export default function set(pool) {
const access = resolveSelectedCompanyAccess(pool);
@@ -19,17 +26,22 @@ export default function set(pool) {
* Only a fixed allow-list of keys can be set: `ui.theme`, `keys.tavily`,
* `keys.openrouter`, `labelprinter.addr`, `labelprinter.type`.
*
* Keys in the write-only set (`keys.tavily`, `keys.openrouter`) are stored
* with `writeonly = 1`; when retrieved by the GUI the value is returned as
* `"***"`. The backend always receives the real value.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} input - The setting input.
* @param {string} input.key - The setting key (must be in the allow-list).
* @param {string} input.value - The setting value.
* @param {boolean} [input.frontend] - Whether the setting is frontend-visible.
* @returns {Promise<{key: string, value: string, frontend: boolean}>} The upserted setting.
* @param {boolean} [input.writeonly] - Whether the setting is write-only. Defaults to true for `keys.tavily` and `keys.openrouter`, false otherwise.
* @returns {Promise<{key: string, value: string, frontend: boolean, writeonly: boolean}>} The upserted setting.
* @throws {Error} If the input is invalid.
*
* @example
* const setting = await store.companysetting.set(42, { key: 'ui.theme', value: 'dark' });
* // => { key, value, frontend }
* // => { key, value, frontend, writeonly }
*/
return async (googleAuthId, input = {}) => {
const key = input.key?.trim();
@@ -41,6 +53,10 @@ export default function set(pool) {
}
const frontend = Boolean(input.frontend);
const writeonly =
input.writeonly !== undefined
? Boolean(input.writeonly)
: WRITEONLY_KEYS.has(key);
const { companyId } = await access(googleAuthId);
await pool
@@ -49,6 +65,7 @@ export default function set(pool) {
.input('key', key)
.input('value', input.value)
.input('frontend', frontend)
.input('writeonly', writeonly)
.query(UPSERT);
const result = await pool
@@ -57,6 +74,7 @@ export default function set(pool) {
.input('key', key)
.query(GET_BY_COMPANY_AND_KEY);
// Return the masked value to the GUI; the DB holds the real value.
return shapeSetting(result.recordset[0]);
};
}

View File

@@ -1,8 +1,10 @@
function shapeSetting(row) {
function shapeSetting(row, { exposeValue = false } = {}) {
const writeonly = Boolean(row.writeonly);
return {
key: row.key,
value: row.value,
value: writeonly && !exposeValue ? '***' : row.value,
frontend: Boolean(row.frontend),
writeonly,
};
}