1 Commits
9 changed files with 234 additions and 139 deletions

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
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
has a name, a kind and the fields of its kind. The application gives the kinds 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 kinds, so a new kind needs no new code. Secret fields are encrypted with
Decision: A credential store is a list in a key of the application's `config.json`, all of one
kind; each entry has a name and the fields of the store's kind, and no kind of its own, as the key
of `config.json`, whose schema the application defines, already says what the entries are. An
application with several kinds of credentials has a store, a key, for each. The application gives
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
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
+13
View File
@@ -1,5 +1,18 @@
# 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
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
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
npm link --no-save ../sk-react-toolkit
npm --prefix web link --no-save ../../sk-react-toolkit
```json
{ "scripts": { "dev-link": "npm link --no-save --no-fund ../sk-react-toolkit && cd web && npm link --no-save --no-fund ../../sk-react-toolkit" } }
```
`package.json` and the lock file 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
toolkit and an update of the dependency.
`package.json` and the lock files keep the published version; `npm ci` or `npm install` replaces
the link with it again, and `npm run dev-link` links the checkout again. A change of the toolkit
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
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
A credential store is a list of credentials in a key of the application's `config.json`, of the
kinds the application gives, such as a database connection or a service principal. Each entry has
a `name`, unique and not one of the store's reserved names, a `kind`, and the fields of its kind:
A credential store is a list of credentials in a key of the application's `config.json`, all of
the one kind the application gives the store, such as a database connection or a service
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
{
"credentials": [
"pg_connections": [
{
"name": "state",
"kind": "postgresql",
"host": "db.example.com",
"port": 5432,
"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
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
@@ -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
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 `{}`.
- `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
<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
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
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
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
a dialog: a list at the left with Add for each kind and Remove, and the fields of the credential
selected, as its kind names them. A secret shows only whether it is kept: Set or Change replaces
it and Remove drops it.
`CredentialsButton` (`stores`, `title`, `icon`, `note`, `onSaved`) opens the credentials of the
stores in a dialog. `stores` lists the prefix of each store's routes and the label of its tab; the
tabs are shown when there are several stores, which share one `config.json`. Each tab has a list at
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
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@koszewscy/sk-react-toolkit",
"version": "0.2.0",
"version": "0.2.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@koszewscy/sk-react-toolkit",
"version": "0.2.0",
"version": "0.2.1",
"devDependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"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.",
"private": true,
"type": "module",
+21 -33
View File
@@ -7,27 +7,26 @@ import { readJsonObject } from './process.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
* names reserved for its own credentials. With missingAsEmpty a missing config.json reads as {}, and the first write
* creates it.
* A credential store: the list under key of config.json at configPath, every entry of the kind the application gives,
* with the names reserved for its own credentials. With missingAsEmpty a missing config.json reads as {}, and the
* first write creates it.
*/
export type CredentialStore = {
configPath: string
key: string
kinds: CredentialKind[]
kind: CredentialKind
reserved?: string[]
missingAsEmpty: boolean
}
/** 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
* are those libpq and node-postgres both take: disable, require (no check of the certificate) and verify-full.
*/
export const POSTGRESQL_KIND: CredentialKind = {
kind: 'postgresql',
label: 'PostgreSQL connection',
fields: [
{ 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>,
* 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) {
return {}
}
@@ -64,10 +63,11 @@ export function credentialProblems(entries: unknown, store: Pick<CredentialStore
}
const problems: Record<string, string> = {}
const reserved = store.reserved ?? []
const keys = ['name', ...store.kind.fields.map((field) => field.key)]
entries.forEach((entry: unknown, index) => {
const at = `${store.key}.${index}`
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
}
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) {
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))
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 path = `${at}.${field.key}`
if (value === undefined) {
@@ -146,19 +140,15 @@ function read(store: CredentialStore) {
/** The credentials of the entries, without their secrets. */
function toProfiles(data: Record<string, unknown>, store: CredentialStore): CredentialProfile[] {
return entriesOf(data, store).map((entry) => {
const kind = store.kinds.find((item) => item.kind === entry.kind)
const fields = kind?.fields ?? []
const { fields } = store.kind
const shown = (value: unknown) => (typeof value === 'string' || typeof value === 'number' ? String(value) : '')
return {
return entriesOf(data, store).map((entry) => ({
original: typeof entry.name === 'string' ? entry.name : null,
name: typeof entry.name === 'string' ? entry.name : '',
kind: typeof entry.kind === 'string' ? entry.kind : '',
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) {
const stored = entriesOf(data, store)
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 entry: Record<string, unknown> = { name: profile.name.trim(), kind: profile.kind }
for (const field of kind?.fields ?? []) {
const entry: Record<string, unknown> = { name: profile.name.trim() }
for (const field of store.kind.fields) {
if (field.secret) {
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) {
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)
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> = {}
for (const field of kind.fields) {
for (const field of store.kind.fields) {
const value = entry[field.key]
if (value === undefined) {
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.`)
}
}
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 profiles = {
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. */
@@ -267,7 +255,7 @@ export function registerCredentialRoutes(app: FastifyInstance, store: Credential
value: toProfiles(file.data, store),
path: store.configPath,
encryption: key() !== null,
kinds: store.kinds,
kind: store.kind,
reserved: store.reserved ?? [],
}
}
+6 -5
View File
@@ -14,9 +14,11 @@ export type CredentialField = {
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 = {
kind: string
label: string
fields: CredentialField[]
}
@@ -29,13 +31,12 @@ export type CredentialKind = {
export type CredentialProfile = {
original: string | null
name: string
kind: string
values: Record<string, string>
secrets: Record<string, string | null>
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 = {
/** Identifies the content of config.json; a write sends it back and is refused with 409 when the file changed since. */
version: string
@@ -44,6 +45,6 @@ export type CredentialsDocument = {
path: string
/** Whether ENCRYPTION_KEY or ENCRYPTION_KEY_FILE gives the server a key, which a secret needs. */
encryption: boolean
kinds: CredentialKind[]
kind: CredentialKind
reserved: string[]
}
+157 -73
View File
@@ -1,25 +1,33 @@
import Add from '@mui/icons-material/Add'
import Delete from '@mui/icons-material/Delete'
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 { useState, type ReactNode } from 'react'
import type { CredentialKind, CredentialProfile, CredentialsDocument } from '../shared/credentials.ts'
import {
Alert, Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, List, ListItemButton, ListItemText, MenuItem, Snackbar, Stack, Tab, Tabs,
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 RowDialog from './RowDialog.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. */
function newProfile(kind: CredentialKind, profiles: CredentialProfile[]): CredentialProfile {
/** A credential store the dialog edits: the prefix of its routes and the label of its tab. */
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
while (profiles.some((profile) => profile.name === `${kind.kind}${number}`)) {
while (profiles.some((profile) => profile.name === `credential${number}`)) {
number++
}
const plain = kind.fields.filter((field) => !field.secret)
const secret = kind.fields.filter((field) => field.secret)
const plain = document.kind.fields.filter((field) => !field.secret)
const secret = document.kind.fields.filter((field) => field.secret)
return {
original: null,
name: `${kind.kind}${number}`,
kind: kind.kind,
name: `credential${number}`,
values: Object.fromEntries(plain.map((field) => [field.key, field.type === 'choice' ? field.choices?.[0] ?? '' : ''])),
secrets: Object.fromEntries(secret.map((field) => [field.key, ''])),
stored: Object.fromEntries(secret.map((field) => [field.key, false])),
@@ -27,94 +35,70 @@ 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
* left, Add for each kind and Remove, and the fields of the one selected at the right, as its kind names them; a
* 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
* never shown. The server validates the credentials as they change and writes them on save, encrypting new secrets;
* onSaved follows a save. note is shown above the list.
* The editor of one store: the credentials in a list at the left, Add and Remove, and the fields of the one selected
* at the right, as the store's kind names them; a credential with a problem is red in the list. A secret shows only
* whether it is kept: Set or Change replaces it and Remove drops it. The server validates the credentials as they
* change, and onCheck follows the validation.
*/
export default function CredentialsButton(props: { prefix: string; title: string; icon?: ReactNode; note?: ReactNode; onSaved?: () => void }) {
const { prefix, title } = props
const [document, setDocument] = useState<CredentialsDocument | null>(null)
function StoreEditor(props: { prefix: string; document: CredentialsDocument; profiles: CredentialProfile[]; onChange: (profiles: CredentialProfile[]) => void; onCheck: (check: Check) => void }) {
const { document, profiles, onChange, onCheck } = props
const [selected, setSelected] = useState(0)
const [error, setError] = useState('')
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)
function open() {
setSelected(0)
request<CredentialsDocument>('GET', `${prefix}/read`).then(setDocument, (caught: Error) => setError(caught.message))
}
useEffect(() => {
report.current = onCheck
})
async function save(value: CredentialProfile[]) {
await request<CredentialsDocument>('POST', `${prefix}/write`, { version: document?.version, value })
setDocument(null)
props.onSaved?.()
}
useEffect(() => {
report.current({ problems: validation.result?.problems ?? {}, pending: validation.pending, error: validation.error })
}, [validation.result, validation.pending, validation.error])
return (
<>
<Tooltip title={title}>
<IconButton aria-label={title.toLowerCase()} onClick={open}>
{props.icon ?? <Key />}
</IconButton>
</Tooltip>
{document && (
<RowDialog<CredentialProfile[]>
title={title}
initial={document.value}
validatePath={`${prefix}/validate`}
saveLabel="Save"
onSave={save}
onCancel={() => setDocument(null)}
fields={(profiles, set, problems) => {
const index = Math.min(selected, profiles.length - 1)
const profile = profiles[index]
const kind = document.kinds.find((item) => item.kind === profile?.kind)
const problem = (field: string) => problems[`${index}.${field}`]
const change = (next: Partial<CredentialProfile>) => set(profiles.map((item, at) => (at === index ? { ...item, ...next } : item)))
const add = (item: CredentialKind) => {
set([...profiles, newProfile(item, profiles)])
setSelected(profiles.length)
}
const change = (next: Partial<CredentialProfile>) => onChange(profiles.map((item, at) => (at === index ? { ...item, ...next } : item)))
const { kind } = document
return (
<>
<Typography variant="body2" color="text.secondary">
Saved to <b>{document.path}</b>.
</Typography>
{props.note}
{!document.encryption && (
<Alert severity="info">The server has no ENCRYPTION_KEY or ENCRYPTION_KEY_FILE, so the credentials cannot have secrets.</Alert>
)}
<Stack direction="row" sx={{ gap: 2, minHeight: 420 }}>
<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)'}
secondary={document.kinds.length > 1 ? document.kinds.find((entry) => entry.kind === item.kind)?.label ?? item.kind : undefined}
slotProps={{ primary: { noWrap: true, color: Object.keys(problems).some((key) => key === `${at}` || key.startsWith(`${at}.`)) ? 'error' : undefined } }}
/>
</ListItemButton>
))}
</List>
<Stack sx={{ gap: 0.5, alignItems: 'flex-start' }}>
{document.kinds.map((item) => (
<Button key={item.kind} size="small" startIcon={<Add />} onClick={() => add(item)}>
{document.kinds.length > 1 ? item.label : 'Add'}
<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={() => set(profiles.filter((_item, at) => at !== index))}>
<Button size="small" color="error" startIcon={<Delete />} disabled={profile === undefined} onClick={() => onChange(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.`}
{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.toLowerCase()}.`} onChange={(name) => change({ name })} />
<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 (
@@ -180,10 +164,110 @@ export default function CredentialsButton(props: { prefix: string; title: string
</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('')
function open() {
setTab(0)
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() {
if (documents === null) {
return
}
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 (
<>
<Tooltip title={title}>
<IconButton aria-label={title.toLowerCase()} onClick={open}>
{props.icon ?? <Key />}
</IconButton>
</Tooltip>
{documents && (
<Dialog open onClose={(_event, reason) => reason !== 'backdropClick' && setDocuments(null)} maxWidth="sm" fullWidth>
<DialogTitle>{title}</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, pt: 1 }}>
{saveError && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{saveError}</Alert>}
<Typography variant="body2" color="text.secondary">
Saved to <b>{documents[0].path}</b>.
</Typography>
{props.note}
{!documents[0].encryption && (
<Alert severity="info">The server has no ENCRYPTION_KEY or ENCRYPTION_KEY_FILE, so the credentials cannot have secrets.</Alert>
)}
{stores.length > 1 && (
<Tabs value={tab} onChange={(_event, next: number) => setTab(next)} sx={{ borderBottom: 1, borderColor: 'divider' }}>
{stores.map((store, index) => (
<Tab key={store.prefix} value={index} label={store.label} sx={Object.keys(checks[index]?.problems ?? {}).length > 0 ? { color: 'error.main' } : undefined} />
))}
</Tabs>
)}
<Box sx={{ display: 'grid' }}>
{stores.map((store, index) => (
<Box key={store.prefix} sx={{ gridArea: '1 / 1', visibility: tab === index ? 'visible' : 'hidden' }}>
<StoreEditor
prefix={store.prefix}
document={documents[index]}
profiles={values[index]}
onChange={(profiles) => setValues((current) => current.map((item, at) => (at === index ? profiles : item)))}
onCheck={(check) => setChecks((current) => current.map((item, at) => (at === index ? check : item)))}
/>
</Box>
))}
</Box>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setDocuments(null)}>Cancel</Button>
<Button variant="contained" disabled={saving || blocked} onClick={() => void save()}>
Save
</Button>
</DialogActions>
</Dialog>
)}
<Snackbar open={error !== ''} autoHideDuration={8000} 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 { createBrowserSettings } from './browserSettings.ts'
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 { appTheme, renderApp } from './renderApp.tsx'
export { default as RowDialog } from './RowDialog.tsx'