fix: a credential store holds credentials of one kind, without a kind field in its entries, and the dialog edits several stores in tabs.
Release / release (push) Successful in 19s

This commit is contained in:
slawek committed 2026-10-28 20:57:45 +01:00
1 parent b0b7a4bb7f
commit 7959c90012
9 files changed
+319 -224

No files matched your search

+9 -5
View File
@@ -57,11 +57,15 @@ Context: sql-query-webui kept PostgreSQL connection profiles with encrypted pass
manage-ui needs a PostgreSQL connection and an Azure service principal without taking them from manage-ui needs a PostgreSQL connection and an Azure service principal without taking them from
its environment. Other systems will need other kinds of credentials. its environment. Other systems will need other kinds of credentials.
Decision: A credential store is a list in a key of the application's `config.json`; each entry Decision: A credential store is a list in a key of the application's `config.json`, all of one
has a name, a kind and the fields of its kind. The application gives the kinds as data: each field kind; each entry has a name and the fields of the store's kind, and no kind of its own, as the key
with its key, label, help, type (text, port, GUID or a choice), whether it is required and whether of `config.json`, whose schema the application defines, already says what the entries are. An
it is secret. The toolkit checks the entries, serves read, validate and write routes, and renders application with several kinds of credentials has a store, a key, for each. The application gives
the dialog from the kinds, so a new kind needs no new code. Secret fields are encrypted with the kind as data: each field with its key, label, help, type (text, port, GUID or a choice),
whether it is required and whether it is secret. The toolkit checks the entries, serves read,
validate and write routes, and renders the dialog from the kind, so a new kind needs no new code;
the dialog edits the stores of one `config.json` in tabs, and its Save writes them one after the
other, each with the version the previous write answers. Secret fields are encrypted with
AES-256-GCM, a random 12-byte IV each, with the key the SHA-256 of `ENCRYPTION_KEY`, else of the AES-256-GCM, a random 12-byte IV each, with the key the SHA-256 of `ENCRYPTION_KEY`, else of the
content of `ENCRYPTION_KEY_FILE`. A secret is never answered: read gives null and whether it is content of `ENCRYPTION_KEY_FILE`. A secret is never answered: read gives null and whether it is
kept, and a write sends null to keep it, `''` to drop it, or a new secret. The applications decide kept, and a write sends null to keep it, `''` to drop it, or a new secret. The applications decide
+13
View File
@@ -1,5 +1,18 @@
# Changelog # Changelog
## 0.2.1
fix: a credential store holds credentials of one kind, without a kind field in its entries, and the dialog edits several stores in tabs.
- A `CredentialStore` has one `kind`, and its entries hold the name and the fields of that kind
only; `kind` is no longer a field of an entry, of `CredentialProfile`, or of `CredentialKind`. The
read answer has the store's `kind` in place of `kinds`.
- `CredentialsButton` takes `stores`, the prefixes and labels of the stores of the same
`config.json`, with a tab for each when there are several; Save writes each store that changed.
The message of an empty store is centered in the dialog, and the kind's label keeps its case.
- The README's Using it shows the `dev-link` script of the applications, `npm link --no-save` in
both their packages, in place of the two commands.
## 0.2.0 ## 0.2.0
feature: the credential store keeps credentials of any kind in config.json with encrypted secrets, with its routes and its dialog. feature: the credential store keeps credentials of any kind in config.json with encrypted secrets, with its routes and its dialog.
+24 -19
View File
@@ -33,16 +33,17 @@ export default defineConfig(({ mode }) => ({
``` ```
To change the toolkit and an application together, `npm link --no-save` replaces the installed To change the toolkit and an application together, `npm link --no-save` replaces the installed
package with the checkout, the TypeScript source, in each package of the application: package with the checkout, the TypeScript source, in each package of the application, whatever
version it names. The applications run it for both their packages as `npm run dev-link`:
```sh ```json
npm link --no-save ../sk-react-toolkit { "scripts": { "dev-link": "npm link --no-save --no-fund ../sk-react-toolkit && cd web && npm link --no-save --no-fund ../../sk-react-toolkit" } }
npm --prefix web link --no-save ../../sk-react-toolkit
``` ```
`package.json` and the lock file keep the published version; `npm ci` or `npm install` replaces `package.json` and the lock files keep the published version; `npm ci` or `npm install` replaces
the link with it again. A change of the toolkit reaches the application through a release of the the link with it again, and `npm run dev-link` links the checkout again. A change of the toolkit
toolkit and an update of the dependency. reaches the released application through a release of the toolkit and an update of the
dependency.
The peer dependencies are the application's: `fastify` and `@fastify/static` for the server The peer dependencies are the application's: `fastify` and `@fastify/static` for the server
helpers; `react`, `react-dom`, `@mui/material`, `@mui/icons-material` and Emotion for the web helpers; `react`, `react-dom`, `@mui/material`, `@mui/icons-material` and Emotion for the web
@@ -88,16 +89,16 @@ code; `vite` for the Vite helpers.
## Credential store ## Credential store
A credential store is a list of credentials in a key of the application's `config.json`, of the A credential store is a list of credentials in a key of the application's `config.json`, all of
kinds the application gives, such as a database connection or a service principal. Each entry has the one kind the application gives the store, such as a database connection or a service
a `name`, unique and not one of the store's reserved names, a `kind`, and the fields of its kind: principal; an application with several kinds has a store, a key, for each. Each entry has a
`name`, unique and not one of the store's reserved names, and the fields of the store's kind:
```json ```json
{ {
"credentials": [ "pg_connections": [
{ {
"name": "state", "name": "state",
"kind": "postgresql",
"host": "db.example.com", "host": "db.example.com",
"port": 5432, "port": 5432,
"database": "platform", "database": "platform",
@@ -109,7 +110,8 @@ a `name`, unique and not one of the store's reserved names, a `kind`, and the fi
} }
``` ```
A kind (`CredentialKind`) names its fields (`CredentialField`), each by its key in `config.json`, A kind (`CredentialKind`) has a label for one credential and names its fields
(`CredentialField`), each by its key in `config.json`,
with a label, a help text, a type, `text`, `port`, `guid` or `choice` with its `choices`, whether with a label, a help text, a type, `text`, `port`, `guid` or `choice` with its `choices`, whether
it is required, and whether it is secret. `POSTGRESQL_KIND` is the kind of a PostgreSQL it is required, and whether it is secret. `POSTGRESQL_KIND` is the kind of a PostgreSQL
connection: `host`, `port`, `database`, `user`, `ssl_mode` (`disable`, `require` or connection: `host`, `port`, `database`, `user`, `ssl_mode` (`disable`, `require` or
@@ -121,7 +123,7 @@ file `ENCRYPTION_KEY_FILE` names, without its final newline. Without either, no
saved or used; a secret encrypted with another key cannot be decrypted and has to be entered saved or used; a secret encrypted with another key cannot be decrypted and has to be entered
again. again.
`CredentialStore` describes a store: the path of `config.json`, the key, the kinds, the reserved `CredentialStore` describes a store: the path of `config.json`, the key, the kind, the reserved
names and whether a missing `config.json` reads as `{}`. names and whether a missing `config.json` reads as `{}`.
- `credentialProblems(value, store)`: the problems of the key's value by `<key>.<index>.<field>`, - `credentialProblems(value, store)`: the problems of the key's value by `<key>.<index>.<field>`,
@@ -131,16 +133,19 @@ names and whether a missing `config.json` reads as `{}`.
- `registerCredentialRoutes(app, store, prefix, log)`: `GET <prefix>/read`, `POST - `registerCredentialRoutes(app, store, prefix, log)`: `GET <prefix>/read`, `POST
<prefix>/validate` and `POST <prefix>/write`. Read answers the credentials, `CredentialProfile`, <prefix>/validate` and `POST <prefix>/write`. Read answers the credentials, `CredentialProfile`,
with `values` (the fields that are not secret, as text), `secrets` (null for each secret, never with `values` (the fields that are not secret, as text), `secrets` (null for each secret, never
answered) and `stored` (which secrets are kept), the kinds, the reserved names, the path of answered) and `stored` (which secrets are kept), the kind, the reserved names, the path of
`config.json`, its version and whether the server has a key. A write takes the version read and `config.json`, its version and whether the server has a key. A write takes the version read and
the credentials; a secret is null to keep the secret of the credential named `original`, `''` the credentials; a secret is null to keep the secret of the credential named `original`, `''`
for none, or the new secret, which is encrypted. A file that changed since the version read is for none, or the new secret, which is encrypted. A file that changed since the version read is
409, credentials with problems 422; the other keys of `config.json` are kept. 409, credentials with problems 422; the other keys of `config.json` are kept.
`CredentialsButton` (`prefix`, `title`, `icon`, `note`, `onSaved`) opens the store's credentials in `CredentialsButton` (`stores`, `title`, `icon`, `note`, `onSaved`) opens the credentials of the
a dialog: a list at the left with Add for each kind and Remove, and the fields of the credential stores in a dialog. `stores` lists the prefix of each store's routes and the label of its tab; the
selected, as its kind names them. A secret shows only whether it is kept: Set or Change replaces tabs are shown when there are several stores, which share one `config.json`. Each tab has a list at
it and Remove drops it. the left with Add and Remove, and the fields of the credential selected, as the store's kind names
them; a tab with a problem has a red label. A secret shows only whether it is kept: Set or Change
replaces it and Remove drops it. Save writes each store that changed, each write with the version
the previous one answers.
## Development ## Development
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@koszewscy/sk-react-toolkit", "name": "@koszewscy/sk-react-toolkit",
"version": "0.2.0", "version": "0.2.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@koszewscy/sk-react-toolkit", "name": "@koszewscy/sk-react-toolkit",
"version": "0.2.0", "version": "0.2.1",
"devDependencies": { "devDependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@koszewscy/sk-react-toolkit", "name": "@koszewscy/sk-react-toolkit",
"version": "0.2.0", "version": "0.2.1",
"description": "Shared code of the React and Fastify web applications: web components, server helpers and the Vite font plugin.", "description": "Shared code of the React and Fastify web applications: web components, server helpers and the Vite font plugin.",
"private": true, "private": true,
"type": "module", "type": "module",
+27 -39
View File
@@ -7,27 +7,26 @@ import { readJsonObject } from './process.ts'
import { decrypt, encrypt, encryptionKey } from './secrets.ts' import { decrypt, encrypt, encryptionKey } from './secrets.ts'
/** /**
* A credential store: the list under key of config.json at configPath, of the kinds the application gives, with the * A credential store: the list under key of config.json at configPath, every entry of the kind the application gives,
* names reserved for its own credentials. With missingAsEmpty a missing config.json reads as {}, and the first write * with the names reserved for its own credentials. With missingAsEmpty a missing config.json reads as {}, and the
* creates it. * first write creates it.
*/ */
export type CredentialStore = { export type CredentialStore = {
configPath: string configPath: string
key: string key: string
kinds: CredentialKind[] kind: CredentialKind
reserved?: string[] reserved?: string[]
missingAsEmpty: boolean missingAsEmpty: boolean
} }
/** A credential with its secrets decrypted; a secret that is not kept is absent. */ /** A credential with its secrets decrypted; a secret that is not kept is absent. */
export type CredentialValues = { name: string; kind: string; values: Record<string, string | number> } export type CredentialValues = { name: string; values: Record<string, string | number> }
/** /**
* The kind of a PostgreSQL connection: host, port, database, user, SSL mode and an encrypted password. The SSL modes * The kind of a PostgreSQL connection: host, port, database, user, SSL mode and an encrypted password. The SSL modes
* are those libpq and node-postgres both take: disable, require (no check of the certificate) and verify-full. * are those libpq and node-postgres both take: disable, require (no check of the certificate) and verify-full.
*/ */
export const POSTGRESQL_KIND: CredentialKind = { export const POSTGRESQL_KIND: CredentialKind = {
kind: 'postgresql',
label: 'PostgreSQL connection', label: 'PostgreSQL connection',
fields: [ fields: [
{ key: 'host', label: 'Host', help: 'The host name or address of the server.', type: 'text', required: true, secret: false }, { key: 'host', label: 'Host', help: 'The host name or address of the server.', type: 'text', required: true, secret: false },
@@ -55,7 +54,7 @@ const text = (value: unknown) => typeof value === 'string' && value.trim() !== '
* The problems of the value of the store's key in config.json, by <key>, <key>.<index> or <key>.<index>.<field key>, * The problems of the value of the store's key in config.json, by <key>, <key>.<index> or <key>.<index>.<field key>,
* each with what it must be; empty when every value is allowed. * each with what it must be; empty when every value is allowed.
*/ */
export function credentialProblems(entries: unknown, store: Pick<CredentialStore, 'key' | 'kinds' | 'reserved'>): Record<string, string> { export function credentialProblems(entries: unknown, store: Pick<CredentialStore, 'key' | 'kind' | 'reserved'>): Record<string, string> {
if (entries === undefined) { if (entries === undefined) {
return {} return {}
} }
@@ -64,10 +63,11 @@ export function credentialProblems(entries: unknown, store: Pick<CredentialStore
} }
const problems: Record<string, string> = {} const problems: Record<string, string> = {}
const reserved = store.reserved ?? [] const reserved = store.reserved ?? []
const keys = ['name', ...store.kind.fields.map((field) => field.key)]
entries.forEach((entry: unknown, index) => { entries.forEach((entry: unknown, index) => {
const at = `${store.key}.${index}` const at = `${store.key}.${index}`
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) { if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
problems[at] = 'must be an object with name and kind' problems[at] = `must be an object with ${keys.join(', ')}`
return return
} }
const record = entry as Record<string, unknown> const record = entry as Record<string, unknown>
@@ -79,17 +79,11 @@ export function credentialProblems(entries: unknown, store: Pick<CredentialStore
} else if (entries.findIndex((other) => (other as Record<string, unknown> | null)?.name === name) !== index) { } else if (entries.findIndex((other) => (other as Record<string, unknown> | null)?.name === name) !== index) {
problems[`${at}.name`] = 'must differ from the names of the other credentials' problems[`${at}.name`] = 'must differ from the names of the other credentials'
} }
const kind = store.kinds.find((item) => item.kind === record.kind)
if (kind === undefined) {
problems[`${at}.kind`] = `must be one of ${store.kinds.map((item) => item.kind).join(', ')}`
return
}
const keys = ['name', 'kind', ...kind.fields.map((field) => field.key)]
const unknown = Object.keys(record).filter((key) => !keys.includes(key)) const unknown = Object.keys(record).filter((key) => !keys.includes(key))
if (unknown.length > 0) { if (unknown.length > 0) {
problems[at] = `must hold only ${keys.join(', ')}; ${unknown.join(', ')} is not a field of ${kind.kind}` problems[at] = `must hold only ${keys.join(', ')}; ${unknown.join(', ')} is not one of them`
} }
for (const field of kind.fields) { for (const field of store.kind.fields) {
const value = record[field.key] const value = record[field.key]
const path = `${at}.${field.key}` const path = `${at}.${field.key}`
if (value === undefined) { if (value === undefined) {
@@ -146,19 +140,15 @@ function read(store: CredentialStore) {
/** The credentials of the entries, without their secrets. */ /** The credentials of the entries, without their secrets. */
function toProfiles(data: Record<string, unknown>, store: CredentialStore): CredentialProfile[] { function toProfiles(data: Record<string, unknown>, store: CredentialStore): CredentialProfile[] {
return entriesOf(data, store).map((entry) => { const { fields } = store.kind
const kind = store.kinds.find((item) => item.kind === entry.kind) const shown = (value: unknown) => (typeof value === 'string' || typeof value === 'number' ? String(value) : '')
const fields = kind?.fields ?? [] return entriesOf(data, store).map((entry) => ({
const shown = (value: unknown) => (typeof value === 'string' || typeof value === 'number' ? String(value) : '') original: typeof entry.name === 'string' ? entry.name : null,
return { name: typeof entry.name === 'string' ? entry.name : '',
original: typeof entry.name === 'string' ? entry.name : null, values: Object.fromEntries(fields.filter((field) => !field.secret).map((field) => [field.key, shown(entry[field.key])])),
name: typeof entry.name === 'string' ? entry.name : '', secrets: Object.fromEntries(fields.filter((field) => field.secret).map((field) => [field.key, null])),
kind: typeof entry.kind === 'string' ? entry.kind : '', stored: Object.fromEntries(fields.filter((field) => field.secret).map((field) => [field.key, typeof entry[field.key] === 'string'])),
values: Object.fromEntries(fields.filter((field) => !field.secret).map((field) => [field.key, shown(entry[field.key])])), }))
secrets: Object.fromEntries(fields.filter((field) => field.secret).map((field) => [field.key, null])),
stored: Object.fromEntries(fields.filter((field) => field.secret).map((field) => [field.key, typeof entry[field.key] === 'string'])),
}
})
} }
/** /**
@@ -169,13 +159,12 @@ function toProfiles(data: Record<string, unknown>, store: CredentialStore): Cred
function toEntries(profiles: CredentialProfile[], data: Record<string, unknown>, store: CredentialStore, seal: (secret: string) => string) { function toEntries(profiles: CredentialProfile[], data: Record<string, unknown>, store: CredentialStore, seal: (secret: string) => string) {
const stored = entriesOf(data, store) const stored = entriesOf(data, store)
return profiles.map((profile) => { return profiles.map((profile) => {
const kind = store.kinds.find((item) => item.kind === profile.kind)
const previous = profile.original === null ? undefined : stored.find((entry) => entry.name === profile.original) const previous = profile.original === null ? undefined : stored.find((entry) => entry.name === profile.original)
const entry: Record<string, unknown> = { name: profile.name.trim(), kind: profile.kind } const entry: Record<string, unknown> = { name: profile.name.trim() }
for (const field of kind?.fields ?? []) { for (const field of store.kind.fields) {
if (field.secret) { if (field.secret) {
const secret = profile.secrets[field.key] ?? null const secret = profile.secrets[field.key] ?? null
const value = secret === null ? (previous?.kind === profile.kind ? previous?.[field.key] : undefined) : secret === '' ? undefined : seal(secret) const value = secret === null ? previous?.[field.key] : secret === '' ? undefined : seal(secret)
if (value !== undefined) { if (value !== undefined) {
entry[field.key] = value entry[field.key] = value
} }
@@ -202,11 +191,10 @@ export function credentialValues(store: CredentialStore, name: string): Credenti
} }
const entry = entriesOf(data, store).find((item) => item.name === name) const entry = entriesOf(data, store).find((item) => item.name === name)
if (entry === undefined) { if (entry === undefined) {
throw new HttpError(404, `The credential ${name} does not exist.`) throw new HttpError(404, `The ${store.kind.label} ${name} does not exist.`)
} }
const kind = store.kinds.find((item) => item.kind === entry.kind)!
const values: Record<string, string | number> = {} const values: Record<string, string | number> = {}
for (const field of kind.fields) { for (const field of store.kind.fields) {
const value = entry[field.key] const value = entry[field.key]
if (value === undefined) { if (value === undefined) {
continue continue
@@ -225,7 +213,7 @@ export function credentialValues(store: CredentialStore, name: string): Credenti
throw new HttpError(400, `The ${field.label.toLowerCase()} of ${name} cannot be decrypted with the server's encryption key; enter it again.`) throw new HttpError(400, `The ${field.label.toLowerCase()} of ${name} cannot be decrypted with the server's encryption key; enter it again.`)
} }
} }
return { name, kind: kind.kind, values } return { name, values }
} }
/** /**
@@ -239,7 +227,7 @@ export function registerCredentialRoutes(app: FastifyInstance, store: Credential
const flags = { type: 'object', additionalProperties: { type: 'boolean' } } const flags = { type: 'object', additionalProperties: { type: 'boolean' } }
const profiles = { const profiles = {
type: 'array', type: 'array',
items: strict({ original: { type: ['string', 'null'] }, name: { type: 'string' }, kind: { type: 'string' }, values: record, secrets, stored: flags }), items: strict({ original: { type: ['string', 'null'] }, name: { type: 'string' }, values: record, secrets, stored: flags }),
} }
/** The problems of the credentials by <index>.<field key>, a new secret included when the server has no key. */ /** The problems of the credentials by <index>.<field key>, a new secret included when the server has no key. */
@@ -267,7 +255,7 @@ export function registerCredentialRoutes(app: FastifyInstance, store: Credential
value: toProfiles(file.data, store), value: toProfiles(file.data, store),
path: store.configPath, path: store.configPath,
encryption: key() !== null, encryption: key() !== null,
kinds: store.kinds, kind: store.kind,
reserved: store.reserved ?? [], reserved: store.reserved ?? [],
} }
} }
+6 -5
View File
@@ -14,9 +14,11 @@ export type CredentialField = {
secret: boolean secret: boolean
} }
/** A kind of credential, such as a database connection or a service principal, with its fields. */ /**
* The kind of the credentials of a store, such as a database connection or a service principal: a label for one
* credential and the fields of each. Every credential of a store is of its kind.
*/
export type CredentialKind = { export type CredentialKind = {
kind: string
label: string label: string
fields: CredentialField[] fields: CredentialField[]
} }
@@ -29,13 +31,12 @@ export type CredentialKind = {
export type CredentialProfile = { export type CredentialProfile = {
original: string | null original: string | null
name: string name: string
kind: string
values: Record<string, string> values: Record<string, string>
secrets: Record<string, string | null> secrets: Record<string, string | null>
stored: Record<string, boolean> stored: Record<string, boolean>
} }
/** The read answer of a credential store and of a write: its credentials, kinds and the names it does not allow. */ /** The read answer of a credential store and of a write: its credentials, its kind and the names it does not allow. */
export type CredentialsDocument = { export type CredentialsDocument = {
/** Identifies the content of config.json; a write sends it back and is refused with 409 when the file changed since. */ /** Identifies the content of config.json; a write sends it back and is refused with 409 when the file changed since. */
version: string version: string
@@ -44,6 +45,6 @@ export type CredentialsDocument = {
path: string path: string
/** Whether ENCRYPTION_KEY or ENCRYPTION_KEY_FILE gives the server a key, which a secret needs. */ /** Whether ENCRYPTION_KEY or ENCRYPTION_KEY_FILE gives the server a key, which a secret needs. */
encryption: boolean encryption: boolean
kinds: CredentialKind[] kind: CredentialKind
reserved: string[] reserved: string[]
} }
+236 -152
View File
@@ -1,25 +1,33 @@
import Add from '@mui/icons-material/Add' import Add from '@mui/icons-material/Add'
import Delete from '@mui/icons-material/Delete' import Delete from '@mui/icons-material/Delete'
import Key from '@mui/icons-material/Key' import Key from '@mui/icons-material/Key'
import { Alert, Box, Button, IconButton, List, ListItemButton, ListItemText, MenuItem, Snackbar, Stack, TextField, Tooltip, Typography } from '@mui/material' import {
import { useState, type ReactNode } from 'react' Alert, Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, List, ListItemButton, ListItemText, MenuItem, Snackbar, Stack, Tab, Tabs,
import type { CredentialKind, CredentialProfile, CredentialsDocument } from '../shared/credentials.ts' TextField, Tooltip, Typography,
} from '@mui/material'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import type { CredentialProfile, CredentialsDocument } from '../shared/credentials.ts'
import { request } from './api.ts' import { request } from './api.ts'
import RowDialog from './RowDialog.tsx'
import TextInput from './TextInput.tsx' import TextInput from './TextInput.tsx'
import { useValidation } from './useValidation.ts'
/** A new credential of the kind, named apart from those of profiles, its choices at their first value. */ /** A credential store the dialog edits: the prefix of its routes and the label of its tab. */
function newProfile(kind: CredentialKind, profiles: CredentialProfile[]): CredentialProfile { export type CredentialsStoreTab = { prefix: string; label: string }
/** The state of the validation of a store's credentials: its problems, and whether the answer is pending. */
type Check = { problems: Record<string, string>; pending: boolean; error: string }
/** A new credential of the kind of document, named apart from those of profiles, its choices at their first value. */
function newProfile(document: CredentialsDocument, profiles: CredentialProfile[]): CredentialProfile {
let number = profiles.length + 1 let number = profiles.length + 1
while (profiles.some((profile) => profile.name === `${kind.kind}${number}`)) { while (profiles.some((profile) => profile.name === `credential${number}`)) {
number++ number++
} }
const plain = kind.fields.filter((field) => !field.secret) const plain = document.kind.fields.filter((field) => !field.secret)
const secret = kind.fields.filter((field) => field.secret) const secret = document.kind.fields.filter((field) => field.secret)
return { return {
original: null, original: null,
name: `${kind.kind}${number}`, name: `credential${number}`,
kind: kind.kind,
values: Object.fromEntries(plain.map((field) => [field.key, field.type === 'choice' ? field.choices?.[0] ?? '' : ''])), values: Object.fromEntries(plain.map((field) => [field.key, field.type === 'choice' ? field.choices?.[0] ?? '' : ''])),
secrets: Object.fromEntries(secret.map((field) => [field.key, ''])), secrets: Object.fromEntries(secret.map((field) => [field.key, ''])),
stored: Object.fromEntries(secret.map((field) => [field.key, false])), stored: Object.fromEntries(secret.map((field) => [field.key, false])),
@@ -27,29 +35,190 @@ function newProfile(kind: CredentialKind, profiles: CredentialProfile[]): Creden
} }
/** /**
* A button opening the credentials of a store, served under prefix, in a dialog: the credentials in a list at the * The editor of one store: the credentials in a list at the left, Add and Remove, and the fields of the one selected
* left, Add for each kind and Remove, and the fields of the one selected at the right, as its kind names them; a * at the right, as the store's kind names them; a credential with a problem is red in the list. A secret shows only
* credential with a problem is red in the list. A secret is kept as it is until it is typed anew or removed, and is * whether it is kept: Set or Change replaces it and Remove drops it. The server validates the credentials as they
* never shown. The server validates the credentials as they change and writes them on save, encrypting new secrets; * change, and onCheck follows the validation.
* onSaved follows a save. note is shown above the list.
*/ */
export default function CredentialsButton(props: { prefix: string; title: string; icon?: ReactNode; note?: ReactNode; onSaved?: () => void }) { function StoreEditor(props: { prefix: string; document: CredentialsDocument; profiles: CredentialProfile[]; onChange: (profiles: CredentialProfile[]) => void; onCheck: (check: Check) => void }) {
const { prefix, title } = props const { document, profiles, onChange, onCheck } = props
const [document, setDocument] = useState<CredentialsDocument | null>(null)
const [selected, setSelected] = useState(0) const [selected, setSelected] = useState(0)
const validation = useValidation(`${props.prefix}/validate`, profiles)
const problems = validation.result?.problems ?? {}
// The latest onCheck; the validation's answers, not a new onCheck, report the check.
const report = useRef(onCheck)
useEffect(() => {
report.current = onCheck
})
useEffect(() => {
report.current({ problems: validation.result?.problems ?? {}, pending: validation.pending, error: validation.error })
}, [validation.result, validation.pending, validation.error])
const index = Math.min(selected, profiles.length - 1)
const profile = profiles[index]
const problem = (field: string) => problems[`${index}.${field}`]
const change = (next: Partial<CredentialProfile>) => onChange(profiles.map((item, at) => (at === index ? { ...item, ...next } : item)))
const { kind } = document
return (
<Stack sx={{ gap: 1 }}>
{validation.error && <Alert severity="error">{validation.error}</Alert>}
<Stack direction="row" sx={{ gap: 2, minHeight: 420, position: 'relative' }}>
<Stack sx={{ width: 180, flexShrink: 0 }}>
<List dense sx={{ flexGrow: 1, overflow: 'auto' }}>
{profiles.map((item, at) => (
<ListItemButton key={at} selected={at === index} onClick={() => setSelected(at)}>
<ListItemText
primary={item.name || '(no name)'}
slotProps={{ primary: { noWrap: true, color: Object.keys(problems).some((key) => key === `${at}` || key.startsWith(`${at}.`)) ? 'error' : undefined } }}
/>
</ListItemButton>
))}
</List>
<Stack direction="row" sx={{ gap: 1 }}>
<Button size="small" startIcon={<Add />} onClick={() => {
onChange([...profiles, newProfile(document, profiles)])
setSelected(profiles.length)
}}>
Add
</Button>
<Button size="small" color="error" startIcon={<Delete />} disabled={profile === undefined} onClick={() => onChange(profiles.filter((_item, at) => at !== index))}>
Remove
</Button>
</Stack>
</Stack>
{profile === undefined ? (
// Centered over the list and the fields together.
<Box sx={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', pointerEvents: 'none' }}>
<Typography variant="body2" color="text.secondary">
None yet. Add creates a {kind.label}.
</Typography>
</Box>
) : (
<Stack sx={{ flexGrow: 1, gap: 1 }}>
<TextInput label="Name" value={profile.name} problem={problem('name')} help={`The name of the ${kind.label}.`} onChange={(name) => change({ name })} />
{kind.fields.map((field) => {
if (field.type === 'choice' && !field.secret) {
return (
<TextField
key={field.key}
select
label={field.label}
value={profile.values[field.key] ?? ''}
onChange={(event) => change({ values: { ...profile.values, [field.key]: event.target.value } })}
error={problem(field.key) !== undefined}
helperText={problem(field.key) ?? field.help}
>
{field.choices?.map((choice) => (
<MenuItem key={choice} value={choice}>{choice}</MenuItem>
))}
</TextField>
)
}
if (!field.secret) {
return (
<TextInput
key={field.key}
label={field.label}
value={profile.values[field.key] ?? ''}
problem={problem(field.key)}
help={field.help}
onChange={(value) => change({ values: { ...profile.values, [field.key]: value } })}
/>
)
}
const secret = profile.secrets[field.key] ?? null
const stored = profile.stored[field.key] ?? false
const setSecret = (value: string | null, keep = stored) => change({ secrets: { ...profile.secrets, [field.key]: value }, stored: { ...profile.stored, [field.key]: keep } })
return secret === null ? (
<Box key={field.key}>
<Typography variant="body2" sx={{ mb: 1 }}>
{field.label}: {stored ? 'saved, encrypted.' : 'none.'}
</Typography>
<Stack direction="row" sx={{ gap: 1 }}>
<Button size="small" variant="outlined" disabled={!document.encryption} onClick={() => setSecret('')}>
{stored ? 'Change' : 'Set'}
</Button>
{stored && (
<Button size="small" color="error" onClick={() => setSecret('', false)}>
Remove
</Button>
)}
</Stack>
</Box>
) : (
<TextInput
key={field.key}
label={field.label}
kind="secret"
value={secret}
problem={problem(field.key)}
help={stored ? `The new ${field.label.toLowerCase()}; empty: none.` : `${field.help} Empty: none.`}
disabled={!document.encryption}
onChange={(value) => setSecret(value)}
/>
)
})}
</Stack>
)}
</Stack>
</Stack>
)
}
/**
* A button opening the credentials of one or more stores of the same config.json, served under their prefixes, in a
* dialog, with a tab for each store when there are several; a tab whose store has a problem has a red label. Save
* writes each store that changed, encrypting new secrets, after the server found no problem in any; onSaved follows a
* save. note is shown above the tabs.
*/
export default function CredentialsButton(props: { stores: CredentialsStoreTab[]; title: string; icon?: ReactNode; note?: ReactNode; onSaved?: () => void }) {
const { stores, title } = props
const [documents, setDocuments] = useState<CredentialsDocument[] | null>(null)
const [values, setValues] = useState<CredentialProfile[][]>([])
const [checks, setChecks] = useState<Check[]>([])
const [tab, setTab] = useState(0)
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
function open() { function open() {
setSelected(0) setTab(0)
request<CredentialsDocument>('GET', `${prefix}/read`).then(setDocument, (caught: Error) => setError(caught.message)) setSaveError('')
Promise.all(stores.map((store) => request<CredentialsDocument>('GET', `${store.prefix}/read`))).then((answers) => {
setValues(answers.map((answer) => answer.value))
setChecks(answers.map(() => ({ problems: {}, pending: true, error: '' })))
setDocuments(answers)
}, (caught: Error) => setError(caught.message))
} }
async function save(value: CredentialProfile[]) { async function save() {
await request<CredentialsDocument>('POST', `${prefix}/write`, { version: document?.version, value }) if (documents === null) {
setDocument(null) return
props.onSaved?.() }
setSaving(true)
setSaveError('')
try {
// The stores share config.json: each write answers the version the next one sends.
let version = documents[0].version
for (const [index, store] of stores.entries()) {
if (JSON.stringify(values[index]) !== JSON.stringify(documents[index].value)) {
version = (await request<CredentialsDocument>('POST', `${store.prefix}/write`, { version, value: values[index] })).version
}
}
setDocuments(null)
props.onSaved?.()
} catch (caught) {
setSaveError((caught as Error).message)
} finally {
setSaving(false)
}
} }
const blocked = checks.some((check) => check.pending || check.error !== '' || Object.keys(check.problems).length > 0)
return ( return (
<> <>
<Tooltip title={title}> <Tooltip title={title}>
@@ -57,133 +226,48 @@ export default function CredentialsButton(props: { prefix: string; title: string
{props.icon ?? <Key />} {props.icon ?? <Key />}
</IconButton> </IconButton>
</Tooltip> </Tooltip>
{document && ( {documents && (
<RowDialog<CredentialProfile[]> <Dialog open onClose={(_event, reason) => reason !== 'backdropClick' && setDocuments(null)} maxWidth="sm" fullWidth>
title={title} <DialogTitle>{title}</DialogTitle>
initial={document.value} <DialogContent>
validatePath={`${prefix}/validate`} <Stack sx={{ gap: 2, pt: 1 }}>
saveLabel="Save" {saveError && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{saveError}</Alert>}
onSave={save} <Typography variant="body2" color="text.secondary">
onCancel={() => setDocument(null)} Saved to <b>{documents[0].path}</b>.
fields={(profiles, set, problems) => { </Typography>
const index = Math.min(selected, profiles.length - 1) {props.note}
const profile = profiles[index] {!documents[0].encryption && (
const kind = document.kinds.find((item) => item.kind === profile?.kind) <Alert severity="info">The server has no ENCRYPTION_KEY or ENCRYPTION_KEY_FILE, so the credentials cannot have secrets.</Alert>
const problem = (field: string) => problems[`${index}.${field}`] )}
const change = (next: Partial<CredentialProfile>) => set(profiles.map((item, at) => (at === index ? { ...item, ...next } : item))) {stores.length > 1 && (
const add = (item: CredentialKind) => { <Tabs value={tab} onChange={(_event, next: number) => setTab(next)} sx={{ borderBottom: 1, borderColor: 'divider' }}>
set([...profiles, newProfile(item, profiles)]) {stores.map((store, index) => (
setSelected(profiles.length) <Tab key={store.prefix} value={index} label={store.label} sx={Object.keys(checks[index]?.problems ?? {}).length > 0 ? { color: 'error.main' } : undefined} />
} ))}
return ( </Tabs>
<> )}
<Typography variant="body2" color="text.secondary"> <Box sx={{ display: 'grid' }}>
Saved to <b>{document.path}</b>. {stores.map((store, index) => (
</Typography> <Box key={store.prefix} sx={{ gridArea: '1 / 1', visibility: tab === index ? 'visible' : 'hidden' }}>
{props.note} <StoreEditor
{!document.encryption && ( prefix={store.prefix}
<Alert severity="info">The server has no ENCRYPTION_KEY or ENCRYPTION_KEY_FILE, so the credentials cannot have secrets.</Alert> document={documents[index]}
)} profiles={values[index]}
<Stack direction="row" sx={{ gap: 2, minHeight: 420 }}> onChange={(profiles) => setValues((current) => current.map((item, at) => (at === index ? profiles : item)))}
<Stack sx={{ width: 180, flexShrink: 0 }}> onCheck={(check) => setChecks((current) => current.map((item, at) => (at === index ? check : item)))}
<List dense sx={{ flexGrow: 1, overflow: 'auto' }}> />
{profiles.map((item, at) => ( </Box>
<ListItemButton key={at} selected={at === index} onClick={() => setSelected(at)}> ))}
<ListItemText </Box>
primary={item.name || '(no name)'} </Stack>
secondary={document.kinds.length > 1 ? document.kinds.find((entry) => entry.kind === item.kind)?.label ?? item.kind : undefined} </DialogContent>
slotProps={{ primary: { noWrap: true, color: Object.keys(problems).some((key) => key === `${at}` || key.startsWith(`${at}.`)) ? 'error' : undefined } }} <DialogActions>
/> <Button onClick={() => setDocuments(null)}>Cancel</Button>
</ListItemButton> <Button variant="contained" disabled={saving || blocked} onClick={() => void save()}>
))} Save
</List> </Button>
<Stack sx={{ gap: 0.5, alignItems: 'flex-start' }}> </DialogActions>
{document.kinds.map((item) => ( </Dialog>
<Button key={item.kind} size="small" startIcon={<Add />} onClick={() => add(item)}>
{document.kinds.length > 1 ? item.label : 'Add'}
</Button>
))}
<Button size="small" color="error" startIcon={<Delete />} disabled={profile === undefined} onClick={() => set(profiles.filter((_item, at) => at !== index))}>
Remove
</Button>
</Stack>
</Stack>
{profile === undefined || kind === undefined ? (
<Typography variant="body2" color="text.secondary" sx={{ flexGrow: 1 }}>
{profile === undefined ? 'No credentials. Add creates one.' : `The kind ${profile.kind} is not known; remove the credential.`}
</Typography>
) : (
<Stack sx={{ flexGrow: 1, gap: 1 }}>
<TextInput label="Name" value={profile.name} problem={problem('name')} help={`The name of the ${kind.label.toLowerCase()}.`} onChange={(name) => change({ name })} />
{kind.fields.map((field) => {
if (field.type === 'choice' && !field.secret) {
return (
<TextField
key={field.key}
select
label={field.label}
value={profile.values[field.key] ?? ''}
onChange={(event) => change({ values: { ...profile.values, [field.key]: event.target.value } })}
error={problem(field.key) !== undefined}
helperText={problem(field.key) ?? field.help}
>
{field.choices?.map((choice) => (
<MenuItem key={choice} value={choice}>{choice}</MenuItem>
))}
</TextField>
)
}
if (!field.secret) {
return (
<TextInput
key={field.key}
label={field.label}
value={profile.values[field.key] ?? ''}
problem={problem(field.key)}
help={field.help}
onChange={(value) => change({ values: { ...profile.values, [field.key]: value } })}
/>
)
}
const secret = profile.secrets[field.key] ?? null
const stored = profile.stored[field.key] ?? false
const setSecret = (value: string | null, keep = stored) => change({ secrets: { ...profile.secrets, [field.key]: value }, stored: { ...profile.stored, [field.key]: keep } })
return secret === null ? (
<Box key={field.key}>
<Typography variant="body2" sx={{ mb: 1 }}>
{field.label}: {stored ? 'saved, encrypted.' : 'none.'}
</Typography>
<Stack direction="row" sx={{ gap: 1 }}>
<Button size="small" variant="outlined" disabled={!document.encryption} onClick={() => setSecret('')}>
{stored ? 'Change' : 'Set'}
</Button>
{stored && (
<Button size="small" color="error" onClick={() => setSecret('', false)}>
Remove
</Button>
)}
</Stack>
</Box>
) : (
<TextInput
key={field.key}
label={field.label}
kind="secret"
value={secret}
problem={problem(field.key)}
help={stored ? `The new ${field.label.toLowerCase()}; empty: none.` : `${field.help} Empty: none.`}
disabled={!document.encryption}
onChange={(value) => setSecret(value)}
/>
)
})}
</Stack>
)}
</Stack>
</>
)
}}
/>
)} )}
<Snackbar open={error !== ''} autoHideDuration={8000} onClose={() => setError('')}> <Snackbar open={error !== ''} autoHideDuration={8000} onClose={() => setError('')}>
<Alert severity="error" onClose={() => setError('')}> <Alert severity="error" onClose={() => setError('')}>
+1 -1
View File
@@ -2,7 +2,7 @@ export type { CredentialField, CredentialKind, CredentialProfile, CredentialsDoc
export { ApiError, request, type Validation } from './api.ts' export { ApiError, request, type Validation } from './api.ts'
export { createBrowserSettings } from './browserSettings.ts' export { createBrowserSettings } from './browserSettings.ts'
export { default as Card } from './Card.tsx' export { default as Card } from './Card.tsx'
export { default as CredentialsButton } from './CredentialsButton.tsx' export { default as CredentialsButton, type CredentialsStoreTab } from './CredentialsButton.tsx'
export { MONOSPACED, REGULAR, verbatim } from './fonts.ts' export { MONOSPACED, REGULAR, verbatim } from './fonts.ts'
export { appTheme, renderApp } from './renderApp.tsx' export { appTheme, renderApp } from './renderApp.tsx'
export { default as RowDialog } from './RowDialog.tsx' export { default as RowDialog } from './RowDialog.tsx'