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
Release / release (push) Successful in 19s
This commit is contained in:
1 parent
b0b7a4bb7f
commit
7959c90012
9 files changed
+319
-224
No files matched your search
+236
-152
@@ -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,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<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 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('')
|
||||
|
||||
function open() {
|
||||
setSelected(0)
|
||||
request<CredentialsDocument>('GET', `${prefix}/read`).then(setDocument, (caught: Error) => setError(caught.message))
|
||||
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(value: CredentialProfile[]) {
|
||||
await request<CredentialsDocument>('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<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}>
|
||||
@@ -57,133 +226,48 @@ export default function CredentialsButton(props: { prefix: string; title: string
|
||||
{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>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
{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('')}>
|
||||
|
||||
Reference in new issue
Block a user