From f3ac28c62fcc1197e6bab671134a9a0d8e8a0137 Mon Sep 17 00:00:00 2001 From: Edouard Durand Date: Sun, 11 Oct 2026 18:24:39 +0000 Subject: [PATCH 1/4] feat(fields): add the token field, a random value made once input: 'token' holds a key or an id another system uses. A new record gets one as its form opens, and the server makes one for a record created over the API without it. The field is read-only, with Copy and Regenerate, and options.length, options.prefix and options.format shape the token. The docs count of input types was out of date and now says 36. --- CHANGELOG.md | 4 ++ docs/README.md | 2 +- docs/reference/FIELDS.md | 1 + docs/reference/fields/token.md | 43 +++++++++++ docs/start/CONCEPTS.md | 2 +- i18n/enUS.json | 3 + i18n/zhCN.json | 3 + lib/Resource.js | 8 +++ lib/util/token.js | 55 ++++++++++++++ resources/text_formats.js | 4 +- src/components/fields/TokenField.vue | 77 ++++++++++++++++++++ src/main.js | 2 + src/services/FormService.js | 4 ++ src/utils/tableModel.js | 1 + src/utils/token.js | 46 ++++++++++++ test/frontend/fieldComponents.test.js | 2 +- test/frontend/formService.test.js | 2 +- test/frontend/tokenField.component.test.js | 83 ++++++++++++++++++++++ test/unit/token.test.js | 72 +++++++++++++++++++ types/global.d.ts | 2 +- 20 files changed, 410 insertions(+), 6 deletions(-) create mode 100644 docs/reference/fields/token.md create mode 100644 lib/util/token.js create mode 100644 src/components/fields/TokenField.vue create mode 100644 src/utils/token.js create mode 100644 test/frontend/tokenField.component.test.js create mode 100644 test/unit/token.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 0014aa18..2a5c0f09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ What changed in each version of Embed CMS, from 3.0.0 on, newest first. The vers ## [Unreleased] +### Added + +- **A token field.** `input: 'token'` holds a random value made once, for a key or an id another system uses. A new record gets one as its form opens, and the server makes one for a record created over the API without it. The field is read-only, with a button to copy the token and one to make a new one. `options.length` (random bytes), `options.prefix` and `options.format` (`base64url` or `hex`) shape it. See [token](docs/reference/fields/token.md). + ## [3.1.1] - 2026-10-11 ### Changed diff --git a/docs/README.md b/docs/README.md index 0f22d64b..6a621581 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,7 +17,7 @@ For what changed in each version, and what is on `main` but not released yet, se ## Reference (`reference/`) - [Resource files](reference/RESOURCES.md): every key of a file in `resources/`, nested menu groups, and how records are named. -- [Field types](reference/FIELDS.md): the 24 input types, the options they share, localisation, and what is checked where. Each type has its own page in [`fields/`](reference/fields/), with every variation and a screenshot. +- [Field types](reference/FIELDS.md): the 36 input types, the options they share, localisation, and what is checked where. Each type has its own page in [`fields/`](reference/fields/), with every variation and a screenshot. - [Field catalogue](../resources/README.md): the example resources in `resources/`, one per family of fields, ready to copy from. - [Form layout](reference/FORM_LAYOUT.md): fields side by side on a line of the record form. - [Dynamic layout](reference/DYNAMIC_LAYOUT.md): paragraph blocks side by side in the editor. diff --git a/docs/reference/FIELDS.md b/docs/reference/FIELDS.md index 2dbe3eeb..feac1626 100644 --- a/docs/reference/FIELDS.md +++ b/docs/reference/FIELDS.md @@ -11,6 +11,7 @@ Every field in a resource's schema has an `input` type, and it's the input that | `text` | `CustomTextarea` | string | [text](fields/text.md) | | `phone` | `PhoneField` | international number, `+442071838750` | [phone](fields/phone.md) | | `password` | `CustomInput` | string, not hashed | [password](fields/password.md) | +| `token` | `TokenField` | string, a random value made once | [token](fields/token.md) | | `email` | `CustomInput` | string | [email](fields/email.md) | | `url` | `CustomInput` | string | [url](fields/url.md) | | `number` | `CustomInput` | number | [number](fields/number.md) | diff --git a/docs/reference/fields/token.md b/docs/reference/fields/token.md new file mode 100644 index 00000000..7522e65c --- /dev/null +++ b/docs/reference/fields/token.md @@ -0,0 +1,43 @@ +← [Field types](../FIELDS.md) · [Documentation](../../README.md) + +# token + +A random value made once: a key, a secret or an id that another system uses to reach this one. A new record gets a token as soon as its form opens, and the server makes one when a record is created over the API without it. The field is read-only. A button copies the token, and another replaces it with a new one. Component: `TokenField` (`src/components/fields/TokenField.vue`), with the tokens made by `src/utils/token.js` in the admin and `lib/util/token.js` on the server. + +Catalogue: `resources/text_formats.js` (group **Text**, resource **Formatted strings**), fields `apiKey` and `hexToken`. + +## Declaration + +```js +{ field: 'apiKey', input: 'token', label: 'API key', localised: false, options: { prefix: 'key_' } } +``` + +## Options + +| Option | Type | Default | Description | +|---|---|---|---| +| `field`, `input`, `label` | | | As for [string](string.md). | +| `localised` | boolean | | Write `localised: false`. A token is one value per record; the server leaves a value per language alone and makes none for it. | +| `required` | boolean | `false` | Adds `*` to the label. A new record always has a token, so the check only refuses one whose token was removed. | +| `options.length` | integer | `32` | How many random bytes the token holds, from 8 to 128. 32 bytes are 43 characters in `base64url` and 64 in `hex`. | +| `options.prefix` | string | none | Written before the random part, up to 32 characters (`key_`, `rpl_`), so a token says what it is for. | +| `options.format` | `'base64url'` or `'hex'` | `'base64url'` | How the bytes are written: letters, digits, `-` and `_` with no padding, or lower-case hexadecimal. | +| `options.hint` | string | none | Help text under the field. | +| `options.readonly` | boolean | `false` | Shows the token with no Regenerate button, and makes none for a new record. | +| `options.disabled` | boolean | `false` | Greyed out and not focusable. | + +## Stored value + +A string: the prefix, then the random bytes, `key_mH2v0Z8yJ3sQe5b1rXo9cWkT4uPn6aLdFgVhYi7jRzE`. + +## What happens when + +- **A new record:** the form fills the field as it opens. Cancel the record and the token goes with it. +- **Regenerate:** puts a new token in the field. The record keeps its old one until you save, so Discard brings it back. +- **Created over the API:** a record sent without the field, or with an empty string, gets a token from the server. A record sent with a value keeps that value. +- **An update** never makes a new token. + +## Notes + +- The token is stored as it is, not hashed, and anyone who can read the record can read it. Give the resource rights to the groups that should see the token, and no others. +- Two records can't have the same token in practice, but nothing checks it. Add `unique: true` if a duplicate must be refused. diff --git a/docs/start/CONCEPTS.md b/docs/start/CONCEPTS.md index 5fe6ede7..06e7df76 100644 --- a/docs/start/CONCEPTS.md +++ b/docs/start/CONCEPTS.md @@ -62,7 +62,7 @@ A field is one entry of the `schema`: a value of the record, plus the control th { field: 'price', input: 'number', label: 'Price', required: true, options: { min: 0, hint: 'In euros' } } ``` -`field` is the key in the record and `input` is the type of control. There are 24 of them: text, numbers, dates, choices, images, files, rich text, blocks and more. The type decides what gets stored and how the admin checks it. A dotted key such as `address.city` nests the value and groups the fields in the form. +`field` is the key in the record and `input` is the type of control. There are 36 of them: text, numbers, dates, choices, images, files, rich text, blocks and more. The type decides what gets stored and how the admin checks it. A dotted key such as `address.city` nests the value and groups the fields in the form. The admin checks `required`, formats, lengths and patterns before it saves. The server only checks `unique`, so anything that writes over the API is trusted to send valid data. [FIELDS.md](../reference/FIELDS.md) has every type, with screenshots. diff --git a/i18n/enUS.json b/i18n/enUS.json index 16317231..8bfb9853 100644 --- a/i18n/enUS.json +++ b/i18n/enUS.json @@ -533,6 +533,9 @@ "TL_TODAY": "Today", "TL_COMPACT_ROWS": "Compact rows", "TL_ID_COPIED": "ID copied", + "TL_TOKEN_COPY": "Copy the token", + "TL_TOKEN_COPIED": "Token copied", + "TL_TOKEN_REGENERATE": "Make a new token", "TL_RECORDS": "Records", "TL_EXPAND_SIDEBAR": "Expand sidebar", "TL_COLLAPSE_SIDEBAR": "Collapse sidebar", diff --git a/i18n/zhCN.json b/i18n/zhCN.json index 58c511c8..d5b34f57 100644 --- a/i18n/zhCN.json +++ b/i18n/zhCN.json @@ -522,6 +522,9 @@ "TL_TODAY": "今天", "TL_COMPACT_ROWS": "紧凑行", "TL_ID_COPIED": "已复制 ID", + "TL_TOKEN_COPY": "复制令牌", + "TL_TOKEN_COPIED": "已复制令牌", + "TL_TOKEN_REGENERATE": "生成新令牌", "TL_RECORDS": "记录", "TL_EXPAND_SIDEBAR": "展开侧边栏", "TL_COLLAPSE_SIDEBAR": "收起侧边栏", diff --git a/lib/Resource.js b/lib/Resource.js index 73891533..16a138ca 100644 --- a/lib/Resource.js +++ b/lib/Resource.js @@ -5,6 +5,7 @@ const path = require('path') const crypto = require('crypto') const { default: pAll } = require('p-all') const sift = require('sift') +const { fillTokens } = require('./util/token') const sanitizeQuery = require('./util/sanitizeQuery') const { buffer: streamToBuffer } = require('stream/consumers') @@ -550,6 +551,13 @@ class Resource { }) return context.next() }) + // a token field the record does not fill gets a new token (lib/util/token.js) + driver.before('create', (context) => { + if (_.isPlainObject(context.params.object)) { + fillTokens(_.get(context, 'resource.options.schema', []), context.params.object) + } + return context.next() + }) driver.before('create', h.getDependencyItems) // serialize creates: the limit and unique checks below must be atomic with the insert driver.before('create', h.lockResource) diff --git a/lib/util/token.js b/lib/util/token.js new file mode 100644 index 00000000..872370e4 --- /dev/null +++ b/lib/util/token.js @@ -0,0 +1,55 @@ +const crypto = require('crypto') +const _ = require('lodash') + +// The random value of a `token` field: what the server writes when a record is created without one. The admin makes +// the same tokens in src/utils/token.js, so a token reads alike wherever it was made. + +const MIN_BYTES = 8 +const MAX_BYTES = 128 +const DEFAULT_BYTES = 32 +const MAX_PREFIX = 32 + +/** + * The options of a token field, made safe: a length between 8 and 128 random bytes (32 by default), a prefix of at + * most 32 characters, and `base64url` (the default) or `hex`. + * @param {object} field a field of the schema, with its `options` + * @returns {{length: number, prefix: string, format: 'base64url'|'hex'}} + */ +function tokenOptions (field) { + const options = _.get(field, 'options', {}) || {} + const length = Number.isInteger(options.length) ? _.clamp(options.length, MIN_BYTES, MAX_BYTES) : DEFAULT_BYTES + const prefix = _.isString(options.prefix) ? options.prefix.slice(0, MAX_PREFIX) : '' + const format = options.format === 'hex' ? 'hex' : 'base64url' + return { length, prefix, format } +} + +/** + * @param {object} field a field of the schema + * @returns {string} a new token: the prefix, then the random bytes in the format of the field + */ +function generateToken (field) { + const { length, prefix, format } = tokenOptions(field) + return prefix + crypto.randomBytes(length).toString(format) +} + +/** + * Gives a token to every token field of a record that has none, before the record is created. A value that is there is + * kept, so a token can be set on purpose. A localised value (an object per locale) is left alone. + * @param {Array} schema the fields of the resource + * @param {object} record the record about to be created + * @returns {object} the record + */ +function fillTokens (schema, record) { + _.each(schema, (field) => { + if (_.get(field, 'input') !== 'token' || !_.isString(field.field)) { + return + } + const value = _.get(record, field.field) + if (_.isNil(value) || value === '') { + _.set(record, field.field, generateToken(field)) + } + }) + return record +} + +module.exports = { tokenOptions, generateToken, fillTokens } diff --git a/resources/text_formats.js b/resources/text_formats.js index e0c45196..3c3700ff 100644 --- a/resources/text_formats.js +++ b/resources/text_formats.js @@ -1,4 +1,4 @@ -// Text with a built-in format: email, url, phone, password +// Text with a built-in format: email, url, phone, password, token module.exports = { displayname: { enUS: 'Formatted strings', zhCN: '格式化字符串' }, group: { enUS: 'Text', zhCN: '文本' }, @@ -21,6 +21,8 @@ module.exports = { { field: 'readOnlyEmail', input: 'email', label: 'Read-only email', localised: false, options: { readonly: true, hint: 'Read-only: visible and copyable, not editable' } }, { field: 'readOnlyPhone', input: 'phone', label: 'Read-only phone', localised: false, options: { readonly: true, hint: 'Read-only: visible and copyable, not editable' } }, { field: 'disabledPhone', input: 'phone', label: 'Disabled phone', localised: false, options: { disabled: true, hint: 'Disabled: greyed out and not focusable' } }, + { field: 'apiKey', input: 'token', label: 'API key', localised: false, options: { prefix: 'key_', hint: 'Made when the record is created; copy it, or make a new one before saving' } }, + { field: 'hexToken', input: 'token', label: 'Short hex token', localised: false, options: { length: 8, format: 'hex', hint: '8 random bytes, written in hexadecimal' } }, { field: 'disabledUrl', input: 'url', label: 'Disabled website', localised: false, options: { disabled: true, hint: 'Disabled: greyed out and not focusable' } } ] } diff --git a/src/components/fields/TokenField.vue b/src/components/fields/TokenField.vue new file mode 100644 index 00000000..13cef0de --- /dev/null +++ b/src/components/fields/TokenField.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/src/main.js b/src/main.js index 1bfb50d3..375b15e6 100644 --- a/src/main.js +++ b/src/main.js @@ -46,6 +46,7 @@ import DurationField from '@c/fields/DurationField.vue' import MarkdownField from '@c/fields/MarkdownField.vue' import MaskedField from '@c/fields/MaskedField.vue' import MoneyField from '@c/fields/MoneyField.vue' +import TokenField from '@c/fields/TokenField.vue' import PhoneField from '@c/fields/PhoneField.vue' import JsonEditor from '@c/fields/JsonEditor.vue' import WysiwygField from '@c/fields/Wysiwyg.vue' @@ -118,6 +119,7 @@ app.use(router) .component('MarkdownField', MarkdownField) .component('MaskedField', MaskedField) .component('MoneyField', MoneyField) + .component('TokenField', TokenField) .component('PhoneField', PhoneField) .component('JsonEditor', JsonEditor) .component('WysiwygField', WysiwygField) diff --git a/src/services/FormService.js b/src/services/FormService.js index 8730c48c..fbc8ca1b 100644 --- a/src/services/FormService.js +++ b/src/services/FormService.js @@ -495,6 +495,10 @@ const typeMapper = { type: 'PhoneField', validator: customValidators.phone }, + // a random value made once, read-only, with Copy and Regenerate (see utils/token.js) + token: { + type: 'TokenField' + }, // a text written in Markdown, with a toolbar and a preview (see utils/markdown.js) markdown: { type: 'MarkdownField', diff --git a/src/utils/tableModel.js b/src/utils/tableModel.js index 5a5e66a7..79aba853 100644 --- a/src/utils/tableModel.js +++ b/src/utils/tableModel.js @@ -38,6 +38,7 @@ const KIND_BY_INPUT = { rating: 'rating', duration: 'duration', money: 'money', + token: 'text', phone: 'phone', markdown: 'markdown' } diff --git a/src/utils/token.js b/src/utils/token.js new file mode 100644 index 00000000..6a438106 --- /dev/null +++ b/src/utils/token.js @@ -0,0 +1,46 @@ +import _ from 'lodash' + +/** + * The value of a `token` field, made in the browser: the same options and the same shape as the tokens the server makes + * (lib/util/token.js), so a token reads alike wherever it was made. + */ + +const MIN_BYTES = 8 +const MAX_BYTES = 128 +const DEFAULT_BYTES = 32 +const MAX_PREFIX = 32 + +/** + * @param {object} schema the field: its `options` hold `length` (random bytes), `prefix` and `format` + * @returns {{length: number, prefix: string, format: 'base64url'|'hex'}} the options made safe + */ +export function tokenOptions (schema) { + const options = _.get(schema, 'options', {}) || {} + const length = Number.isInteger(options.length) ? _.clamp(options.length, MIN_BYTES, MAX_BYTES) : DEFAULT_BYTES + const prefix = _.isString(options.prefix) ? options.prefix.slice(0, MAX_PREFIX) : '' + const format = options.format === 'hex' ? 'hex' : 'base64url' + return { length, prefix, format } +} + +/** + * @param {Uint8Array} bytes + * @param {'base64url'|'hex'} format + * @returns {string} + */ +export function encodeBytes (bytes, format) { + if (format === 'hex') { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') + } + const base64 = btoa(String.fromCharCode(...bytes)) + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +/** + * @param {object} schema the field + * @param {function(Uint8Array): Uint8Array} [fill] fills the bytes with random values; the browser's crypto, unless a test gives its own + * @returns {string} a new token: the prefix, then the random bytes in the format of the field + */ +export function generateToken (schema, fill = (bytes) => crypto.getRandomValues(bytes)) { + const { length, prefix, format } = tokenOptions(schema) + return prefix + encodeBytes(fill(new Uint8Array(length)), format) +} diff --git a/test/frontend/fieldComponents.test.js b/test/frontend/fieldComponents.test.js index 1a3ab191..80863923 100644 --- a/test/frontend/fieldComponents.test.js +++ b/test/frontend/fieldComponents.test.js @@ -10,7 +10,7 @@ const source = (name) => fs.readFileSync(path.join(DIR, `${name}.vue`), 'utf8') const FIELD_COMPONENTS = [ 'AttachmentView', 'ColorPicker', 'CustomCheckbox', 'CustomCode', 'CustomDatetimePicker', 'CustomInput', 'CustomInputTag', - 'ChoiceField', 'CustomMultiSelect', 'CustomTextarea', 'CustomTreeView', 'DateRangeField', 'DurationField', 'GeopointField', 'ImageView', 'JsonEditor', 'MarkdownField', 'MaskedField', 'MoneyField', 'ParagraphView', 'PhoneField', 'RatingField', 'Transliterate', 'Wysiwyg' + 'ChoiceField', 'CustomMultiSelect', 'CustomTextarea', 'CustomTreeView', 'DateRangeField', 'DurationField', 'GeopointField', 'ImageView', 'JsonEditor', 'MarkdownField', 'MaskedField', 'MoneyField', 'ParagraphView', 'PhoneField', 'RatingField', 'TokenField', 'Transliterate', 'Wysiwyg' ] // Building blocks that are not inputs themselves const HELPERS = ['FieldLabel', 'GeoPickerDialog', 'Group', 'TiptapMenuBar', 'TiptapMenuItem'] diff --git a/test/frontend/formService.test.js b/test/frontend/formService.test.js index 9b820f88..26951e13 100644 --- a/test/frontend/formService.test.js +++ b/test/frontend/formService.test.js @@ -33,7 +33,7 @@ describe('FormService (how each input type is built and checked)', () => { it('knows every input type the documentation lists', () => { expect(Object.keys(mapper).sort()).toEqual([ 'checkbox', 'code', 'color', 'cropimage', 'date', 'daterange', 'datetime', 'double', 'duration', 'email', 'file', 'geopoint', 'group', 'image', 'imagemap', 'integer', 'json', 'markdown', 'money', 'multiselect', - 'number', 'object', 'paragraph', 'password', 'phone', 'pillbox', 'radio', 'rating', 'segmented', 'select', 'string', 'text', 'time', 'transliterate', 'url', 'wysiwyg' + 'number', 'object', 'paragraph', 'password', 'phone', 'pillbox', 'radio', 'rating', 'segmented', 'select', 'string', 'text', 'time', 'token', 'transliterate', 'url', 'wysiwyg' ]) }) diff --git a/test/frontend/tokenField.component.test.js b/test/frontend/tokenField.component.test.js new file mode 100644 index 00000000..0e470ce7 --- /dev/null +++ b/test/frontend/tokenField.component.test.js @@ -0,0 +1,83 @@ +import { describe, it, expect, afterEach, vi } from 'vitest' +import { flushPromises } from '@vue/test-utils' +import TokenField from '@c/fields/TokenField.vue' +import NotificationsService from '@s/NotificationsService' +import { tokenOptions, encodeBytes, generateToken } from '@u/token' +import { mountField } from './helpers/mountField.js' + +// The token field: a random value made once, read-only, copied with a button and replaced with another. + +let wrapper +const mount = (schema = {}, model = {}) => { + wrapper = mountField(TokenField, { model, schema: { model: 'key', label: 'Key', ...schema }, attachTo: document.body }) + return wrapper +} +const input = () => wrapper.get('input') + +afterEach(() => { + wrapper?.unmount() + vi.restoreAllMocks() +}) + +describe('token helpers', () => { + it('keep the options safe, as the server does', () => { + expect(tokenOptions({})).toEqual({ length: 32, prefix: '', format: 'base64url' }) + expect(tokenOptions({ options: { length: 4, prefix: 'p_', format: 'hex' } })).toEqual({ length: 8, prefix: 'p_', format: 'hex' }) + expect(tokenOptions({ options: { length: 4096 } }).length).toBe(128) + }) + + it('write the bytes in hex or in base64url without padding', () => { + const bytes = new Uint8Array([0, 15, 255, 251, 239]) + expect(encodeBytes(bytes, 'hex')).toBe('000ffffbef') + expect(encodeBytes(bytes, 'base64url')).toBe('AA__--8') + }) + + it('make a token of the prefix and the random bytes', () => { + const fill = (bytes) => bytes.fill(255) + expect(generateToken({ options: { length: 8, prefix: 'rpl_', format: 'hex' } }, fill)).toBe('rpl_ffffffffffffffff') + expect(generateToken({})).toMatch(/^[A-Za-z0-9_-]{43}$/) + }) +}) + +describe('TokenField', () => { + it('gives a new record a token at once, read-only, in the format the field says', async () => { + mount({ options: { prefix: 'key_', format: 'hex', length: 8 } }) + await flushPromises() + expect(input().element.value).toMatch(/^key_[0-9a-f]{16}$/) + expect(input().attributes('readonly')).toBeDefined() + expect(wrapper.text()).toContain('Key') + }) + + it('keeps the token the record has', async () => { + mount({}, { key: 'saved-token' }) + await flushPromises() + expect(input().element.value).toBe('saved-token') + }) + + it('makes another token with Regenerate', async () => { + mount({}, { key: 'old-token' }) + await flushPromises() + await wrapper.get('.token-regenerate').trigger('click') + expect(input().element.value).not.toBe('old-token') + expect(input().element.value).toMatch(/^[A-Za-z0-9_-]{43}$/) + }) + + it('copies the token and says so', async () => { + const writeText = vi.fn(() => Promise.resolve()) + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }) + const send = vi.spyOn(NotificationsService, 'send').mockImplementation(() => {}) + mount({}, { key: 'copy-me' }) + await flushPromises() + await wrapper.get('.token-copy').trigger('click') + await flushPromises() + expect(writeText).toHaveBeenCalledWith('copy-me') + expect(send).toHaveBeenCalledWith('Token copied', 'success') + }) + + it('makes no token and offers no Regenerate when the field is read-only', async () => { + mount({ readonly: true }) + await flushPromises() + expect(input().element.value).toBe('') + expect(wrapper.find('.token-regenerate').exists()).toBe(false) + }) +}) diff --git a/test/unit/token.test.js b/test/unit/token.test.js new file mode 100644 index 00000000..61a4a2ba --- /dev/null +++ b/test/unit/token.test.js @@ -0,0 +1,72 @@ +const { expect } = require('chai') +const { tokenOptions, generateToken, fillTokens } = require('../../lib/util/token') +const { startApp } = require('../helpers/app') + +// the token field: a random value the server makes when a record is created without one +describe('token field (unit)', () => { + describe('the options', () => { + it('keeps the length between 8 and 128 random bytes, 32 by default, a short prefix, and base64url unless it says hex', () => { + expect(tokenOptions({})).to.deep.equal({ length: 32, prefix: '', format: 'base64url' }) + expect(tokenOptions({ options: { length: 2 } }).length).to.equal(8) + expect(tokenOptions({ options: { length: 1000 } }).length).to.equal(128) + expect(tokenOptions({ options: { length: 'long' } }).length).to.equal(32) + expect(tokenOptions({ options: { prefix: 'x'.repeat(40) } }).prefix).to.have.length(32) + expect(tokenOptions({ options: { format: 'hex' } }).format).to.equal('hex') + expect(tokenOptions({ options: { format: 'base32' } }).format).to.equal('base64url') + }) + }) + + describe('a token', () => { + it('is the prefix, then the random bytes in the format of the field', () => { + expect(generateToken({ options: { length: 16, format: 'hex', prefix: 'rpl_' } })).to.match(/^rpl_[0-9a-f]{32}$/) + // 32 bytes are 43 characters of base64url, with no padding and no + or / + expect(generateToken({})).to.match(/^[A-Za-z0-9_-]{43}$/) + }) + + it('is new every time', () => { + const field = { options: { length: 16 } } + expect(generateToken(field)).to.not.equal(generateToken(field)) + }) + }) + + describe('a record about to be created', () => { + const schema = [ + { field: 'title', input: 'string' }, + { field: 'key', input: 'token', options: { prefix: 'k_' } }, + { field: 'access.secret', input: 'token', options: { format: 'hex', length: 8 } } + ] + + it('gets a token in every token field it leaves empty, nested ones too', () => { + const record = fillTokens(schema, { title: 'A', key: '' }) + expect(record.key).to.match(/^k_[A-Za-z0-9_-]{43}$/) + expect(record.access.secret).to.match(/^[0-9a-f]{16}$/) + expect(record.title).to.equal('A') + }) + + it('keeps a token it was given, and leaves a value per language alone', () => { + const record = fillTokens(schema, { key: 'chosen', access: { secret: { enUS: 'one' } } }) + expect(record.key).to.equal('chosen') + expect(record.access.secret).to.deep.equal({ enUS: 'one' }) + }) + }) + + describe('a create', () => { + let app + before(async () => { + app = await startApp({ disableJwtLogin: true }) + app.cms.resource('keys', { schema: [{ field: 'name', input: 'string' }, { field: 'token', input: 'token', localised: false, options: { prefix: 'key_' } }] }) + }) + after(() => app.close()) + + it('gives the record a token when it has none, and keeps the one it sends', async () => { + const api = app.cms.api()('keys') + const made = await api.create({ name: 'first' }) + expect(made.token).to.match(/^key_[A-Za-z0-9_-]{43}$/) + const given = await api.create({ name: 'second', token: 'key_mine' }) + expect(given.token).to.equal('key_mine') + // an update does not make a new one + const updated = await api.update(made._id, { name: 'renamed' }) + expect(updated.token).to.equal(made.token) + }) + }) +}) diff --git a/types/global.d.ts b/types/global.d.ts index 95fcbf6a..b7a49774 100644 --- a/types/global.d.ts +++ b/types/global.d.ts @@ -70,7 +70,7 @@ declare namespace EmbedCMS { type FieldInput = | 'string' | 'transliterate' | 'text' | 'password' | 'email' | 'url' | 'number' | 'integer' | 'double' - | 'checkbox' | 'radio' | 'segmented' | 'color' | 'rating' | 'duration' | 'money' | 'phone' | 'markdown' + | 'checkbox' | 'radio' | 'segmented' | 'color' | 'rating' | 'duration' | 'money' | 'phone' | 'markdown' | 'token' | 'date' | 'daterange' | 'time' | 'datetime' | 'geopoint' | 'pillbox' | 'select' | 'multiselect' | 'json' | 'object' | 'code' | 'wysiwyg' From e4e4e488f86766c06378b51068c91f07e9935db5 Mon Sep 17 00:00:00 2001 From: Edouard Durand Date: Sun, 11 Oct 2026 18:58:24 +0000 Subject: [PATCH 2/4] feat(fields): type, paste or generate a token, and use it for the Sync token The token field was read-only and filled itself. It is now a text field with Generate and Copy buttons: a token is typed, pasted, or made at random. options.autoGenerate gives a new record one by itself, in the admin and over the API; it is off by default. The Token under This CMS in the Sync settings is a token field, so its secret no longer has to be invented by hand; the token of the other CMS stays a text field, where it is pasted. --- CHANGELOG.md | 3 +- docs/operations/SYNC.md | 2 +- docs/reference/fields/token.md | 17 ++++---- i18n/enUS.json | 2 +- i18n/zhCN.json | 2 +- lib/plugins/sync/index.js | 11 +++-- lib/util/token.js | 14 ++++--- resources/text_formats.js | 4 +- src/components/fields/TokenField.vue | 25 ++++++----- src/utils/token.js | 6 ++- test/frontend/tokenField.component.test.js | 49 ++++++++++++++-------- test/unit/token.test.js | 17 +++++--- 12 files changed, 93 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a5c0f09..1cb7eadc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,8 @@ What changed in each version of Embed CMS, from 3.0.0 on, newest first. The vers ### Added -- **A token field.** `input: 'token'` holds a random value made once, for a key or an id another system uses. A new record gets one as its form opens, and the server makes one for a record created over the API without it. The field is read-only, with a button to copy the token and one to make a new one. `options.length` (random bytes), `options.prefix` and `options.format` (`base64url` or `hex`) shape it. See [token](docs/reference/fields/token.md). +- **A token field.** `input: 'token'` holds a key or a secret that two systems share. Type it, paste it, or make a random one with the **Generate** button, and copy it with **Copy**. `options.length` (random bytes), `options.prefix` and `options.format` (`base64url` or `hex`) shape what Generate makes, and `options.autoGenerate` gives a new record a token by itself, in the admin and over the API. See [token](docs/reference/fields/token.md). +- **The Token under This CMS in the Sync settings is a token field.** It has a Generate and a Copy button, so the secret no longer has to be invented by hand. ## [3.1.1] - 2026-10-11 diff --git a/docs/operations/SYNC.md b/docs/operations/SYNC.md index 19332d3e..3f3d0c08 100644 --- a/docs/operations/SYNC.md +++ b/docs/operations/SYNC.md @@ -33,7 +33,7 @@ The rest of the setup happens in the admin, in **Sync settings** (CMS group), on | Setting | What to put there | |---|---| | What the other CMS may do here | `read` to let the other server read the synced resources, `write` to let it change them. | -| This CMS > Token | A long random secret. The other server must send it to sync with this one; without it, sync is refused. | +| This CMS > Token | A long random secret: type one, or make one with **Generate**, then copy it into the other server. The other server must send it to sync with this one; without it, sync is refused. | | This CMS > Address | How the other server reaches this one, e.g. `https://staging.example.com`. | | The other CMS > Token | The other server's own token. | | The other CMS > Address | The other server's address, e.g. `https://production.example.com`. | diff --git a/docs/reference/fields/token.md b/docs/reference/fields/token.md index 7522e65c..6a8e2a4c 100644 --- a/docs/reference/fields/token.md +++ b/docs/reference/fields/token.md @@ -2,7 +2,7 @@ # token -A random value made once: a key, a secret or an id that another system uses to reach this one. A new record gets a token as soon as its form opens, and the server makes one when a record is created over the API without it. The field is read-only. A button copies the token, and another replaces it with a new one. Component: `TokenField` (`src/components/fields/TokenField.vue`), with the tokens made by `src/utils/token.js` in the admin and `lib/util/token.js` on the server. +A key, a secret or an id that two systems share. Type it, paste it, or make a random one with the **Generate** button; the **Copy** button copies it. With `options.autoGenerate`, a new record gets a token by itself. Component: `TokenField` (`src/components/fields/TokenField.vue`), with the tokens made by `src/utils/token.js` in the admin and `lib/util/token.js` on the server. Catalogue: `resources/text_formats.js` (group **Text**, resource **Formatted strings**), fields `apiKey` and `hexToken`. @@ -18,23 +18,24 @@ Catalogue: `resources/text_formats.js` (group **Text**, resource **Formatted str |---|---|---|---| | `field`, `input`, `label` | | | As for [string](string.md). | | `localised` | boolean | | Write `localised: false`. A token is one value per record; the server leaves a value per language alone and makes none for it. | -| `required` | boolean | `false` | Adds `*` to the label. A new record always has a token, so the check only refuses one whose token was removed. | -| `options.length` | integer | `32` | How many random bytes the token holds, from 8 to 128. 32 bytes are 43 characters in `base64url` and 64 in `hex`. | +| `required` | boolean | `false` | Adds `*` to the label; a save with no token is refused (`This field is required!`). | +| `options.autoGenerate` | boolean | `false` | A new record gets a token by itself: as its form opens, and from the server when it is created over the API without one. | +| `options.length` | integer | `32` | How many random bytes **Generate** makes, from 8 to 128. 32 bytes are 43 characters in `base64url` and 64 in `hex`. | | `options.prefix` | string | none | Written before the random part, up to 32 characters (`key_`, `rpl_`), so a token says what it is for. | | `options.format` | `'base64url'` or `'hex'` | `'base64url'` | How the bytes are written: letters, digits, `-` and `_` with no padding, or lower-case hexadecimal. | | `options.hint` | string | none | Help text under the field. | -| `options.readonly` | boolean | `false` | Shows the token with no Regenerate button, and makes none for a new record. | +| `options.readonly` | boolean | `false` | Shows the token, changes nothing: no **Generate** button, and no token for a new record. | | `options.disabled` | boolean | `false` | Greyed out and not focusable. | ## Stored value -A string: the prefix, then the random bytes, `key_mH2v0Z8yJ3sQe5b1rXo9cWkT4uPn6aLdFgVhYi7jRzE`. +A string. A generated token is the prefix, then the random bytes: `key_mH2v0Z8yJ3sQe5b1rXo9cWkT4uPn6aLdFgVhYi7jRzE`. A typed or pasted one is kept as it is. ## What happens when -- **A new record:** the form fills the field as it opens. Cancel the record and the token goes with it. -- **Regenerate:** puts a new token in the field. The record keeps its old one until you save, so Discard brings it back. -- **Created over the API:** a record sent without the field, or with an empty string, gets a token from the server. A record sent with a value keeps that value. +- **You type or paste:** the field takes the value as it is, like a text field. +- **Generate:** puts a new random token in the field. The record keeps its old one until you save, so Discard brings it back. +- **A new record, with `autoGenerate`:** the form fills the field as it opens, and the server fills it for a record created over the API without one. A record sent with a value keeps that value. - **An update** never makes a new token. ## Notes diff --git a/i18n/enUS.json b/i18n/enUS.json index 8bfb9853..01217d71 100644 --- a/i18n/enUS.json +++ b/i18n/enUS.json @@ -535,7 +535,7 @@ "TL_ID_COPIED": "ID copied", "TL_TOKEN_COPY": "Copy the token", "TL_TOKEN_COPIED": "Token copied", - "TL_TOKEN_REGENERATE": "Make a new token", + "TL_TOKEN_GENERATE": "Generate a new token", "TL_RECORDS": "Records", "TL_EXPAND_SIDEBAR": "Expand sidebar", "TL_COLLAPSE_SIDEBAR": "Collapse sidebar", diff --git a/i18n/zhCN.json b/i18n/zhCN.json index d5b34f57..0832b0dc 100644 --- a/i18n/zhCN.json +++ b/i18n/zhCN.json @@ -524,7 +524,7 @@ "TL_ID_COPIED": "已复制 ID", "TL_TOKEN_COPY": "复制令牌", "TL_TOKEN_COPIED": "已复制令牌", - "TL_TOKEN_REGENERATE": "生成新令牌", + "TL_TOKEN_GENERATE": "生成新令牌", "TL_RECORDS": "记录", "TL_EXPAND_SIDEBAR": "展开侧边栏", "TL_COLLAPSE_SIDEBAR": "收起侧边栏", diff --git a/lib/plugins/sync/index.js b/lib/plugins/sync/index.js index 1962a113..2315229e 100644 --- a/lib/plugins/sync/index.js +++ b/lib/plugins/sync/index.js @@ -246,9 +246,14 @@ class SyncClass { }, { field: 'local.token', - input: 'string', + // typed, pasted, or made with the Generate button; copied into the other CMS + input: 'token', + localised: false, label: { enUS: 'Token', zhCN: '令牌' }, - options: { hint: { enUS: 'The secret the other CMS must send to sync with this one. Required, or syncing with this CMS is refused.', zhCN: '对方 CMS 与本 CMS 同步时必须提供的密钥。必填,否则拒绝同步。' } } + options: { + prefix: 'sync_', + hint: { enUS: 'The secret the other CMS must send to sync with this one. Required, or syncing with this CMS is refused. Type one, or make one with the Generate button, then copy it into the other CMS, under "The other CMS".', zhCN: '对方 CMS 与本 CMS 同步时必须提供的密钥。必填,否则拒绝同步。输入一个,或用“生成”按钮生成,然后复制到对方 CMS 的“对方 CMS”下。' } + } }, { field: 'local.url', @@ -260,7 +265,7 @@ class SyncClass { field: 'remote.token', input: 'string', label: { enUS: 'Token', zhCN: '令牌' }, - options: { hint: { enUS: 'The secret of the other CMS, sent with every request to it. It is the Token under "This CMS" in its own Sync settings.', zhCN: '对方 CMS 的密钥,随每个请求发送。即对方自己的同步设置中“本 CMS”下的令牌。' } } + options: { hint: { enUS: 'The secret of the other CMS, sent with every request to it. Paste the Token under "This CMS" in its own Sync settings.', zhCN: '对方 CMS 的密钥,随每个请求发送。粘贴对方自己的同步设置中“本 CMS”下的令牌。' } } }, { field: 'remote.url', diff --git a/lib/util/token.js b/lib/util/token.js index 872370e4..a33e9004 100644 --- a/lib/util/token.js +++ b/lib/util/token.js @@ -11,16 +11,18 @@ const MAX_PREFIX = 32 /** * The options of a token field, made safe: a length between 8 and 128 random bytes (32 by default), a prefix of at - * most 32 characters, and `base64url` (the default) or `hex`. + * most 32 characters, `base64url` (the default) or `hex`, and whether a new record gets a token by itself (`autoGenerate`, off by default). * @param {object} field a field of the schema, with its `options` - * @returns {{length: number, prefix: string, format: 'base64url'|'hex'}} + * @returns {{length: number, prefix: string, format: 'base64url'|'hex', autoGenerate: boolean}} */ function tokenOptions (field) { const options = _.get(field, 'options', {}) || {} const length = Number.isInteger(options.length) ? _.clamp(options.length, MIN_BYTES, MAX_BYTES) : DEFAULT_BYTES const prefix = _.isString(options.prefix) ? options.prefix.slice(0, MAX_PREFIX) : '' const format = options.format === 'hex' ? 'hex' : 'base64url' - return { length, prefix, format } + // true: a new record gets a token by itself; otherwise the field waits for one typed, pasted or made with its button + const autoGenerate = options.autoGenerate === true + return { length, prefix, format, autoGenerate } } /** @@ -33,15 +35,15 @@ function generateToken (field) { } /** - * Gives a token to every token field of a record that has none, before the record is created. A value that is there is - * kept, so a token can be set on purpose. A localised value (an object per locale) is left alone. + * Gives a token to every token field with `autoGenerate` that a record leaves empty, before the record is created. A + * value that is there is kept, so a token can be set on purpose, and a localised value (an object per locale) is left alone. * @param {Array} schema the fields of the resource * @param {object} record the record about to be created * @returns {object} the record */ function fillTokens (schema, record) { _.each(schema, (field) => { - if (_.get(field, 'input') !== 'token' || !_.isString(field.field)) { + if (_.get(field, 'input') !== 'token' || !_.isString(field.field) || !tokenOptions(field).autoGenerate) { return } const value = _.get(record, field.field) diff --git a/resources/text_formats.js b/resources/text_formats.js index 3c3700ff..c86ea880 100644 --- a/resources/text_formats.js +++ b/resources/text_formats.js @@ -21,8 +21,8 @@ module.exports = { { field: 'readOnlyEmail', input: 'email', label: 'Read-only email', localised: false, options: { readonly: true, hint: 'Read-only: visible and copyable, not editable' } }, { field: 'readOnlyPhone', input: 'phone', label: 'Read-only phone', localised: false, options: { readonly: true, hint: 'Read-only: visible and copyable, not editable' } }, { field: 'disabledPhone', input: 'phone', label: 'Disabled phone', localised: false, options: { disabled: true, hint: 'Disabled: greyed out and not focusable' } }, - { field: 'apiKey', input: 'token', label: 'API key', localised: false, options: { prefix: 'key_', hint: 'Made when the record is created; copy it, or make a new one before saving' } }, - { field: 'hexToken', input: 'token', label: 'Short hex token', localised: false, options: { length: 8, format: 'hex', hint: '8 random bytes, written in hexadecimal' } }, + { field: 'apiKey', input: 'token', label: 'API key', localised: false, options: { prefix: 'key_', hint: 'Type or paste a key, or make one with the Generate button; Copy copies it' } }, + { field: 'hexToken', input: 'token', label: 'Short hex token', localised: false, options: { length: 8, format: 'hex', autoGenerate: true, hint: 'Filled when the record is created: 8 random bytes, written in hexadecimal' } }, { field: 'disabledUrl', input: 'url', label: 'Disabled website', localised: false, options: { disabled: true, hint: 'Disabled: greyed out and not focusable' } } ] } diff --git a/src/components/fields/TokenField.vue b/src/components/fields/TokenField.vue index 13cef0de..360aeb33 100644 --- a/src/components/fields/TokenField.vue +++ b/src/components/fields/TokenField.vue @@ -2,10 +2,11 @@