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(null) const [selected, setSelected] = useState(0) const [error, setError] = useState('') function open() { setSelected(0) request('GET', `${prefix}/read`).then(setDocument, (caught: Error) => setError(caught.message)) } async function save(value: CredentialProfile[]) { await request('POST', `${prefix}/write`, { version: document?.version, value }) setDocument(null) props.onSaved?.() } return ( <> {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)} /> ) })} )} ) }} /> )} setError('')}> setError('')}> {error} ) }