feature: the credential store keeps credentials of any kind in config.json with encrypted secrets, with its routes and its dialog.
Release / release (push) Successful in 1m4s

This commit is contained in:
slawek committed 2026-10-28 20:28:22 +01:00
1 parent 57ac5296cb
commit b0b7a4bb7f
13 files changed
+723 -30

No files matched your search

+195
View File
@@ -0,0 +1,195 @@
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 { request } from './api.ts'
import RowDialog from './RowDialog.tsx'
import TextInput from './TextInput.tsx'
/** 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 {
let number = profiles.length + 1
while (profiles.some((profile) => profile.name === `${kind.kind}${number}`)) {
number++
}
const plain = kind.fields.filter((field) => !field.secret)
const secret = kind.fields.filter((field) => field.secret)
return {
original: null,
name: `${kind.kind}${number}`,
kind: kind.kind,
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])),
}
}
/**
* 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.
*/
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)
const [selected, setSelected] = useState(0)
const [error, setError] = useState('')
function open() {
setSelected(0)
request<CredentialsDocument>('GET', `${prefix}/read`).then(setDocument, (caught: Error) => setError(caught.message))
}
async function save(value: CredentialProfile[]) {
await request<CredentialsDocument>('POST', `${prefix}/write`, { version: document?.version, value })
setDocument(null)
props.onSaved?.()
}
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)
}
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={{ 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'}
</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('')}>
<Alert severity="error" onClose={() => setError('')}>
{error}
</Alert>
</Snackbar>
</>
)
}