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, 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 TextInput from './TextInput.tsx' import { useValidation } from './useValidation.ts' /** 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 === `credential${number}`)) { number++ } const plain = document.kind.fields.filter((field) => !field.secret) const secret = document.kind.fields.filter((field) => field.secret) return { original: null, 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])), } } /** * 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. */ 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() { 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() { 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 ( <> {props.icon ?? } {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('')}> {error} ) }