diff --git a/CHANGELOG.md b/CHANGELOG.md index 0014aa18..1cb7eadc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ 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 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 ### 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/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.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/img/token-auto.png b/docs/reference/fields/img/token-auto.png new file mode 100644 index 00000000..75f85105 Binary files /dev/null and b/docs/reference/fields/img/token-auto.png differ diff --git a/docs/reference/fields/img/token-empty.png b/docs/reference/fields/img/token-empty.png new file mode 100644 index 00000000..9e698572 Binary files /dev/null and b/docs/reference/fields/img/token-empty.png differ diff --git a/docs/reference/fields/img/token-generated.png b/docs/reference/fields/img/token-generated.png new file mode 100644 index 00000000..0b0fa74d Binary files /dev/null and b/docs/reference/fields/img/token-generated.png differ diff --git a/docs/reference/fields/token.md b/docs/reference/fields/token.md new file mode 100644 index 00000000..b3000eb9 --- /dev/null +++ b/docs/reference/fields/token.md @@ -0,0 +1,62 @@ +← [Field types](../FIELDS.md) · [Documentation](../../README.md) + +# token + +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`. + +## 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 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, changes nothing: no **Generate** button, and no token for a new record. | +| `options.disabled` | boolean | `false` | Greyed out and not focusable. | + +## Variations + +### Default + +`resources/text_formats.js`, field `apiKey`. Empty, the field has only the **Generate** button: + +![Empty token](img/token-empty.png) + +After **Generate**, or once a token is typed or pasted, the **Copy** button appears next to it: + +![Generated token](img/token-generated.png) + +### Filled on creation + +`resources/text_formats.js`, field `hexToken` (`autoGenerate`, `length: 8`, `format: 'hex'`). A new record opens with its token: + +![Token made for a new record](img/token-auto.png) + +## Stored value + +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 + +- **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 + +- 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..01217d71 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_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 58c511c8..0832b0dc 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_GENERATE": "生成新令牌", "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/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 new file mode 100644 index 00000000..a33e9004 --- /dev/null +++ b/lib/util/token.js @@ -0,0 +1,57 @@ +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, `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', 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' + // 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 } +} + +/** + * @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 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) || !tokenOptions(field).autoGenerate) { + 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..c86ea880 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: '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 new file mode 100644 index 00000000..8d938d49 --- /dev/null +++ b/src/components/fields/TokenField.vue @@ -0,0 +1,80 @@ + + + + + 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..c64e8073 --- /dev/null +++ b/src/utils/token.js @@ -0,0 +1,48 @@ +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', autoGenerate: boolean}} 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' + // 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 } +} + +/** + * @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/docs/screenshots/fields.js b/test/docs/screenshots/fields.js index 43fd58fc..26ba1aa5 100644 --- a/test/docs/screenshots/fields.js +++ b/test/docs/screenshots/fields.js @@ -114,6 +114,15 @@ add('password-required-error', 'text_formats', async (c) => { return refuse(c, 'requiredPassword') }) + +// ---------------------------------------------------------------- token +add('token-empty', 'text_formats', (c) => c.field('apiKey')) +add('token-generated', 'text_formats', async (c) => { + await c.field('apiKey').locator('button.token-generate').click() + await c.blur() + return c.field('apiKey') +}) +add('token-auto', 'text_formats', (c) => c.field('hexToken')) // ---------------------------------------------------------------- number, integer, double add('number-default', 'numbers', (c) => [c.field('number'), c.field('requiredNumber')]) add('number-required-error', 'numbers', async (c) => { 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..94dbc12e --- /dev/null +++ b/test/frontend/tokenField.component.test.js @@ -0,0 +1,101 @@ +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 key typed, pasted or made with the Generate button, and copied with the Copy button. + +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', autoGenerate: false }) + expect(tokenOptions({ options: { length: 4, prefix: 'p_', format: 'hex', autoGenerate: true } })).toEqual({ length: 8, prefix: 'p_', format: 'hex', autoGenerate: true }) + expect(tokenOptions({ options: { length: 4096, autoGenerate: 'yes' } })).toMatchObject({ length: 128, autoGenerate: false }) + }) + + 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('starts empty, with the label, and a Generate button that fills it in the format the field says', async () => { + mount({ options: { prefix: 'key_', format: 'hex', length: 8 } }) + await flushPromises() + expect(input().element.value).toBe('') + expect(wrapper.text()).toContain('Key') + // nothing to copy yet: the Copy button comes with a token + expect(wrapper.find('.token-copy').exists()).toBe(false) + await wrapper.get('.token-generate').trigger('click') + expect(input().element.value).toMatch(/^key_[0-9a-f]{16}$/) + expect(wrapper.find('.token-copy').exists()).toBe(true) + }) + + it('takes a token typed or pasted in', async () => { + mount() + await flushPromises() + expect(input().attributes('readonly')).toBeUndefined() + await input().setValue('pasted-from-the-other-side') + expect(wrapper.emitted('input')?.at(-1)?.[0]).toBe('pasted-from-the-other-side') + }) + + it('keeps the token the record has, and replaces it with Generate', async () => { + mount({}, { _id: 'a-record', key: 'old-token' }) + await flushPromises() + expect(input().element.value).toBe('old-token') + await wrapper.get('.token-generate').trigger('click') + expect(input().element.value).not.toBe('old-token') + expect(input().element.value).toMatch(/^[A-Za-z0-9_-]{43}$/) + }) + + it('fills a new record at once when the field says autoGenerate, and leaves an existing one as it is', async () => { + mount({ options: { autoGenerate: true, prefix: 'auto_' } }) + await flushPromises() + expect(input().element.value).toMatch(/^auto_/) + wrapper.unmount() + mount({ options: { autoGenerate: true } }, { _id: 'saved-record' }) + await flushPromises() + expect(input().element.value).toBe('') + }) + + 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('is read-only, with no Generate button, when the field says so', async () => { + mount({ readonly: true, options: { autoGenerate: true } }, { key: 'fixed' }) + await flushPromises() + expect(input().element.value).toBe('fixed') + expect(input().attributes('readonly')).toBeDefined() + expect(wrapper.find('.token-generate').exists()).toBe(false) + }) +}) diff --git a/test/unit/token.test.js b/test/unit/token.test.js new file mode 100644 index 00000000..9a7ee06e --- /dev/null +++ b/test/unit/token.test.js @@ -0,0 +1,77 @@ +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', autoGenerate: false }) + expect(tokenOptions({ options: { autoGenerate: true } }).autoGenerate).to.equal(true) + 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_', autoGenerate: true } }, + { field: 'access.secret', input: 'token', options: { format: 'hex', length: 8, autoGenerate: true } }, + { field: 'pasted', input: 'token' } + ] + + it('gets a token in every autoGenerate field it leaves empty, nested ones too, and none in the others', () => { + const record = fillTokens(schema, { title: 'A', key: '' }) + expect(record.pasted).to.equal(undefined) + 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_', autoGenerate: true } }, { field: 'typed', input: 'token', localised: false }] }) + }) + after(() => app.close()) + + it('gives the record a token in an autoGenerate field 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}$/) + // without autoGenerate the field waits for a token typed, pasted or made in the admin + expect(made.typed).to.equal(undefined) + 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'