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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/operations/SYNC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand Down
1 change: 1 addition & 0 deletions docs/reference/FIELDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
Binary file added docs/reference/fields/img/token-auto.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/reference/fields/img/token-empty.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/reference/fields/img/token-generated.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
62 changes: 62 additions & 0 deletions docs/reference/fields/token.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion docs/start/CONCEPTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 3 additions & 0 deletions i18n/enUS.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions i18n/zhCN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "收起侧边栏",
Expand Down
8 changes: 8 additions & 0 deletions lib/Resource.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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)
Expand Down
11 changes: 8 additions & 3 deletions lib/plugins/sync/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down
57 changes: 57 additions & 0 deletions lib/util/token.js
Original file line number Diff line number Diff line change
@@ -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<object>} 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 }
4 changes: 3 additions & 1 deletion resources/text_formats.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

80 changes: 80 additions & 0 deletions src/components/fields/TokenField.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<template>
<div class="token-field" :class="{ 'is-locked': isLocked() }">
<v-text-field
:id="inputId" ref="input"
:theme="theme" :class="[schema.labelClasses]" :model-value="_value" :readonly="isLocked()"
:variant="getVariant()" :flat="get('flat')" :rounded="get('rounded')" :density="get('density')" :disabled="disabled"
persistent-placeholder hide-details="auto" autocomplete="off" spellcheck="false" validate-on="blur" :rules="[validateField]"
:aria-label="schema.label" :aria-required="schema.required ? 'true' : undefined" :aria-readonly="isLocked() ? 'true' : undefined"
@update:model-value="onChangeData" @update:focused="onFieldFocus"
>
<template #prepend><field-label :schema="schema" :disabled="disabled" :input-id="inputId" /></template>
<template #append-inner>
<v-btn
v-if="_value" class="token-copy" icon variant="text" size="small" :disabled="disabled"
:aria-label="$filters.translate('TL_TOKEN_COPY')" :title="$filters.translate('TL_TOKEN_COPY')" @click="copy"
>
<v-icon icon="$contentCopy" />
</v-btn>
<v-btn
v-if="!isLocked()" class="token-generate" icon variant="text" size="small"
:aria-label="$filters.translate('TL_TOKEN_GENERATE')" :title="$filters.translate('TL_TOKEN_GENERATE')" @click="generate"
>
<v-icon icon="$refresh" />
</v-btn>
</template>
</v-text-field>
<div v-if="showHint()" class="help-block">
<v-icon size="small" icon="$information" />
<span>{{ schema.options.hint }}</span>
</div>
</div>
</template>

<script>
import _ from 'lodash'
import AbstractField from '@m/AbstractField'
import NotificationsService from '@s/NotificationsService'
import TranslateService from '@s/TranslateService'
import { generateToken, tokenOptions } from '@u/token'

/**
* A token: a key or an id that two systems share. It is typed, pasted, or made here with the Generate button, and copied
* with the Copy button. With `autoGenerate`, a new record gets one at once, and the server makes one too when a record is
* created without it (lib/util/token.js).
*/
export default {
mixins: [AbstractField],
emits: ['input'],
mounted () {
// a new record only: an existing one keeps what it has, even nothing, so that opening it changes nothing
if (!this._value && !this.isLocked() && !_.get(this.model, '_id') && tokenOptions(this.schema).autoGenerate) {
this._value = generateToken(this.schema)
}
},
methods: {
/** Copies the token, and says so. */
async copy () {
try {
await navigator.clipboard.writeText(this._value)
NotificationsService.send(TranslateService.get('TL_TOKEN_COPIED'), 'success')
} catch (error) {
console.error('The token could not be copied:', error)
}
},
/** Puts a new token in the field; the record keeps the old one until it is saved. */
generate () {
this._value = generateToken(this.schema)
}
}
}
</script>

<style lang="scss" scoped>
.token-field {
:deep(input) {
font-family: var(--cms-font-mono);
font-size: var(--cms-fs-sm);
}
}
</style>
2 changes: 2 additions & 0 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions src/services/FormService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/utils/tableModel.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const KIND_BY_INPUT = {
rating: 'rating',
duration: 'duration',
money: 'money',
token: 'text',
phone: 'phone',
markdown: 'markdown'
}
Expand Down
Loading