diff --git a/ADR.md b/ADR.md index 969e5dd..b1d3fc5 100644 --- a/ADR.md +++ b/ADR.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a19cb9..fa56d0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 91d6973..9ce6b06 100644 --- a/README.md +++ b/README.md @@ -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 `..`, @@ -131,16 +133,19 @@ names and whether a missing `config.json` reads as `{}`. - `registerCredentialRoutes(app, store, prefix, log)`: `GET /read`, `POST /validate` and `POST /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 diff --git a/package-lock.json b/package-lock.json index 226de92..0291af0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 4d75d43..14d75a5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/credentials.ts b/server/credentials.ts index 00ff1ab..b6b09d7 100644 --- a/server/credentials.ts +++ b/server/credentials.ts @@ -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 } +export type CredentialValues = { name: string; values: Record } /** * 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 , . or .., * each with what it must be; empty when every value is allowed. */ -export function credentialProblems(entries: unknown, store: Pick): Record { +export function credentialProblems(entries: unknown, store: Pick): Record { if (entries === undefined) { return {} } @@ -64,10 +63,11 @@ export function credentialProblems(entries: unknown, store: Pick = {} 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 @@ -79,17 +79,11 @@ export function credentialProblems(entries: unknown, store: Pick (other as Record | 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, store: CredentialStore): CredentialProfile[] { - return entriesOf(data, store).map((entry) => { - const kind = store.kinds.find((item) => item.kind === entry.kind) - const fields = kind?.fields ?? [] - const shown = (value: unknown) => (typeof value === 'string' || typeof value === 'number' ? String(value) : '') - return { - 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'])), - } - }) + const { fields } = store.kind + const shown = (value: unknown) => (typeof value === 'string' || typeof value === 'number' ? String(value) : '') + return entriesOf(data, store).map((entry) => ({ + original: typeof entry.name === 'string' ? entry.name : null, + name: typeof entry.name === 'string' ? entry.name : '', + 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, store: CredentialStore): Cred function toEntries(profiles: CredentialProfile[], data: Record, 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 = { name: profile.name.trim(), kind: profile.kind } - for (const field of kind?.fields ?? []) { + const entry: Record = { 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 = {} - 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 ., 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 ?? [], } } diff --git a/shared/credentials.ts b/shared/credentials.ts index 901a2d7..5a8ef57 100644 --- a/shared/credentials.ts +++ b/shared/credentials.ts @@ -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 secrets: Record stored: Record } -/** 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[] } diff --git a/web/CredentialsButton.tsx b/web/CredentialsButton.tsx index 6127edd..db851f5 100644 --- a/web/CredentialsButton.tsx +++ b/web/CredentialsButton.tsx @@ -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; 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,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 - * 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(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 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) => onChange(profiles.map((item, at) => (at === index ? { ...item, ...next } : item))) + const { kind } = document + + return ( + + {validation.error && {validation.error}} + + + + {profiles.map((item, at) => ( + setSelected(at)}> + key === `${at}` || key.startsWith(`${at}.`)) ? 'error' : undefined } }} + /> + + ))} + + + + + + + {profile === undefined ? ( + // Centered over the list and the fields together. + + + None yet. Add creates a {kind.label}. + + + ) : ( + + change({ name })} /> + {kind.fields.map((field) => { + if (field.type === 'choice' && !field.secret) { + return ( + change({ values: { ...profile.values, [field.key]: event.target.value } })} + error={problem(field.key) !== undefined} + helperText={problem(field.key) ?? field.help} + > + {field.choices?.map((choice) => ( + {choice} + ))} + + ) + } + if (!field.secret) { + return ( + 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 ? ( + + + {field.label}: {stored ? 'saved, encrypted.' : 'none.'} + + + + {stored && ( + + )} + + + ) : ( + setSecret(value)} + /> + ) + })} + + )} + + + ) +} + +/** + * 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(null) + const [values, setValues] = useState([]) + const [checks, setChecks] = useState([]) + const [tab, setTab] = useState(0) + const [saving, setSaving] = useState(false) + const [saveError, setSaveError] = useState('') const [error, setError] = useState('') function open() { - setSelected(0) - request('GET', `${prefix}/read`).then(setDocument, (caught: Error) => setError(caught.message)) + setTab(0) + setSaveError('') + Promise.all(stores.map((store) => request('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[]) { - await request('POST', `${prefix}/write`, { version: document?.version, value }) - setDocument(null) - props.onSaved?.() + 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('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 ( <> @@ -57,133 +226,48 @@ export default function CredentialsButton(props: { prefix: string; title: string {props.icon ?? } - {document && ( - - 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) => set(profiles.map((item, at) => (at === index ? { ...item, ...next } : item))) - const add = (item: CredentialKind) => { - set([...profiles, newProfile(item, profiles)]) - setSelected(profiles.length) - } - return ( - <> - - Saved to {document.path}. - - {props.note} - {!document.encryption && ( - The server has no ENCRYPTION_KEY or ENCRYPTION_KEY_FILE, so the credentials cannot have secrets. - )} - - - - {profiles.map((item, at) => ( - setSelected(at)}> - 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 } }} - /> - - ))} - - - {document.kinds.map((item) => ( - - ))} - - - - {profile === undefined || kind === undefined ? ( - - {profile === undefined ? 'No credentials. Add creates one.' : `The kind ${profile.kind} is not known; remove the credential.`} - - ) : ( - - change({ name })} /> - {kind.fields.map((field) => { - if (field.type === 'choice' && !field.secret) { - return ( - change({ values: { ...profile.values, [field.key]: event.target.value } })} - error={problem(field.key) !== undefined} - helperText={problem(field.key) ?? field.help} - > - {field.choices?.map((choice) => ( - {choice} - ))} - - ) - } - if (!field.secret) { - return ( - 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 ? ( - - - {field.label}: {stored ? 'saved, encrypted.' : 'none.'} - - - - {stored && ( - - )} - - - ) : ( - setSecret(value)} - /> - ) - })} - - )} - - - ) - }} - /> + {documents && ( + reason !== 'backdropClick' && setDocuments(null)} maxWidth="sm" fullWidth> + {title} + + + {saveError && {saveError}} + + Saved to {documents[0].path}. + + {props.note} + {!documents[0].encryption && ( + The server has no ENCRYPTION_KEY or ENCRYPTION_KEY_FILE, so the credentials cannot have secrets. + )} + {stores.length > 1 && ( + setTab(next)} sx={{ borderBottom: 1, borderColor: 'divider' }}> + {stores.map((store, index) => ( + 0 ? { color: 'error.main' } : undefined} /> + ))} + + )} + + {stores.map((store, index) => ( + + setValues((current) => current.map((item, at) => (at === index ? profiles : item)))} + onCheck={(check) => setChecks((current) => current.map((item, at) => (at === index ? check : item)))} + /> + + ))} + + + + + + + + )} setError('')}> setError('')}> diff --git a/web/index.ts b/web/index.ts index 4a11c8f..6ce86b0 100644 --- a/web/index.ts +++ b/web/index.ts @@ -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'