Compare commits

...

3 Commits

29 changed files with 1950 additions and 1432 deletions
+2 -2
View File
@@ -1,11 +1,11 @@
{
"name": "codepot-codit-frontend",
"name": "codit-frontend",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codepot-codit-frontend",
"name": "codit-frontend",
"version": "0.1.0",
"dependencies": {
"@emotion/react": "^11.11.4",
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "codepot-codit-frontend",
"name": "codit-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
@@ -6,6 +6,7 @@ import DialogTitle from '@mui/material/DialogTitle'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import FormDialogContent from './FormDialogContent'
import { ReactNode } from 'react'
type ConfirmDeleteDialogProps = {
open: boolean
@@ -20,6 +21,7 @@ type ConfirmDeleteDialogProps = {
confirmText?: string
busyText?: string
confirmDisabled?: boolean
children?: ReactNode
onClose: () => void
onConfirm: () => void
}
@@ -51,6 +53,7 @@ export default function ConfirmDeleteDialog(props: ConfirmDeleteDialogProps) {
/>
</>
) : null}
{props.children}
</FormDialogContent>
<DialogActions>
<Button
@@ -0,0 +1,150 @@
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog'
import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle'
import MenuItem from '@mui/material/MenuItem'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import FormDialogContent from './FormDialogContent'
import SelectField from './SelectField'
import { ACMEOrder, ACMEProfile } from '../api'
type PKIACMEOrderCreateDialogProps = {
open: boolean
profiles: ACMEProfile[]
error?: string | null
busy: boolean
profileID: string
onProfileIDChange: (value: string) => void
commonName: string
onCommonNameChange: (value: string) => void
sanDNS: string
onSANDNSChange: (value: string) => void
onClose: () => void
onCreate: () => void
}
type PKIACMEOrderFinalizeDialogProps = {
open: boolean
error?: string | null
busy: boolean
mode: 'override' | 'new_cert'
onModeChange: (value: 'override' | 'new_cert') => void
onClose: () => void
onFinalize: () => void
}
type PKIACMEOrderDetailsDialogProps = {
order: ACMEOrder | null
busy: boolean
onClose: () => void
onFinalize: (item: ACMEOrder) => void
}
export function PKIACMEOrderCreateDialog(props: PKIACMEOrderCreateDialogProps) {
return (
<Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">New ACME Order</Typography>
{props.error ? <Alert severity="error">{props.error}</Alert> : null}
</Box>
</DialogTitle>
<FormDialogContent>
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}>
<SelectField select label="ACME Profile" value={props.profileID} onChange={(event) => props.onProfileIDChange(event.target.value)}>
{props.profiles.map((item) => (
<MenuItem key={item.id} value={item.id}>{item.name}</MenuItem>
))}
</SelectField>
<TextField label="Common Name" value={props.commonName} onChange={(event) => props.onCommonNameChange(event.target.value)} />
<TextField
label="SAN DNS (comma-separated, optional)"
value={props.sanDNS}
onChange={(event) => props.onSANDNSChange(event.target.value)}
/>
</Box>
</FormDialogContent>
<DialogActions>
<Button variant="contained" onClick={props.onCreate} disabled={props.busy || !props.profileID || !props.commonName.trim()}>
{props.busy ? 'Saving...' : 'Create'}
</Button>
<Button onClick={props.onClose}>Cancel</Button>
</DialogActions>
</Dialog>
)
}
export function PKIACMEOrderFinalizeDialog(props: PKIACMEOrderFinalizeDialogProps) {
return (
<Dialog open={props.open} onClose={props.onClose} maxWidth="xs" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">Finalize Renewal Order</Typography>
{props.error ? <Alert severity="error">{props.error}</Alert> : null}
</Box>
</DialogTitle>
<FormDialogContent>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="body2" color="text.secondary">
Choose how to store the renewed certificate.
</Typography>
<SelectField
select
size="small"
label="Finalize Mode"
value={props.mode}
onChange={(event) => props.onModeChange(event.target.value as 'override' | 'new_cert')}
>
<MenuItem value="override">Override existing cert ID (recommended)</MenuItem>
<MenuItem value="new_cert">Create a new cert ID</MenuItem>
</SelectField>
</Box>
</FormDialogContent>
<DialogActions>
<Button variant="contained" onClick={props.onFinalize} disabled={props.busy || !props.open}>
{props.busy ? 'Working...' : 'Finalize'}
</Button>
<Button onClick={props.onClose}>Cancel</Button>
</DialogActions>
</Dialog>
)
}
export function PKIACMEOrderDetailsDialog(props: PKIACMEOrderDetailsDialogProps) {
return (
<Dialog open={Boolean(props.order)} onClose={props.onClose} maxWidth="md" fullWidth>
<DialogTitle>DNS-01 Instructions</DialogTitle>
<FormDialogContent>
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}>
<TextField label="Order ID" value={props.order?.id || ''} InputProps={{ readOnly: true }} />
<TextField label="Status" value={props.order?.status || ''} InputProps={{ readOnly: true }} />
<TextField label="Common Name" value={props.order?.common_name || ''} InputProps={{ readOnly: true }} />
{(props.order?.challenges || []).map((challenge, index) => (
<Box key={`${challenge.authorization_url}-${index}`} sx={{ display: 'grid', gap: 1, p: 1, border: '1px solid', borderColor: 'divider' }}>
<Typography variant="subtitle2">{challenge.identifier}</Typography>
<TextField label="DNS TXT Name" value={challenge.dns_name} InputProps={{ readOnly: true }} />
<TextField
label="DNS TXT Value"
value={challenge.dns_value}
InputProps={{ readOnly: true }}
sx={{ '& .MuiInputBase-input': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' } }}
/>
<TextField label="Challenge Status" value={challenge.status} InputProps={{ readOnly: true }} />
</Box>
))}
</Box>
</FormDialogContent>
<DialogActions>
{props.order ? (
<Button variant="contained" onClick={() => props.onFinalize(props.order as ACMEOrder)} disabled={props.busy || props.order.status === 'valid'}>
{props.busy ? 'Working...' : 'Finalize'}
</Button>
) : null}
<Button onClick={props.onClose}>Cancel</Button>
</DialogActions>
</Dialog>
)
}
@@ -0,0 +1,115 @@
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Checkbox from '@mui/material/Checkbox'
import Dialog from '@mui/material/Dialog'
import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle'
import FormControlLabel from '@mui/material/FormControlLabel'
import MenuItem from '@mui/material/MenuItem'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import FormDialogContent from './FormDialogContent'
import SelectField from './SelectField'
type PKIACMEProfileDialogProps = {
open: boolean
editID: string
error?: string | null
busy: boolean
name: string
onNameChange: (value: string) => void
directoryURL: string
onDirectoryURLChange: (value: string) => void
email: string
onEmailChange: (value: string) => void
enabled: boolean
onEnabledChange: (value: boolean) => void
solverType: 'manual' | 'acme_dns'
onSolverTypeChange: (value: 'manual' | 'acme_dns') => void
dnsAPIURL: string
onDNSAPIURLChange: (value: string) => void
dnsUser: string
onDNSUserChange: (value: string) => void
dnsKey: string
onDNSKeyChange: (value: string) => void
dnsSubdomain: string
onDNSSubdomainChange: (value: string) => void
dnsFullDomain: string
onDNSFullDomainChange: (value: string) => void
onClose: () => void
onSave: () => void
}
export default function PKIACMEProfileDialog(props: PKIACMEProfileDialogProps) {
const saveDisabled: boolean =
props.busy ||
!props.name.trim() ||
!props.directoryURL.trim() ||
(props.solverType === 'acme_dns' &&
(!props.dnsAPIURL.trim() || !props.dnsUser.trim() || !props.dnsKey.trim() || !props.dnsSubdomain.trim() || !props.dnsFullDomain.trim()))
return (
<Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">{props.editID ? 'Edit ACME Profile' : 'New ACME Profile'}</Typography>
{props.error ? <Alert severity="error">{props.error}</Alert> : null}
</Box>
</DialogTitle>
<FormDialogContent>
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}>
<TextField label="Name" value={props.name} onChange={(event) => props.onNameChange(event.target.value)} />
<TextField label="Directory URL" value={props.directoryURL} onChange={(event) => props.onDirectoryURLChange(event.target.value)} />
<TextField label="Email" value={props.email} onChange={(event) => props.onEmailChange(event.target.value)} />
<SelectField select label="DNS Solver" value={props.solverType} onChange={(event) => props.onSolverTypeChange(event.target.value as 'manual' | 'acme_dns')}>
<MenuItem value="manual">Manual DNS-01</MenuItem>
<MenuItem value="acme_dns">acme-dns (automated)</MenuItem>
</SelectField>
{props.solverType === 'acme_dns' ? (
<Box sx={{ display: 'grid', gap: 1 }}>
<TextField
label="ACME-DNS API URL"
value={props.dnsAPIURL}
onChange={(event) => props.onDNSAPIURLChange(event.target.value)}
helperText="Example: https://auth.acme-dns.io"
/>
<TextField
label="ACME-DNS User"
value={props.dnsUser}
onChange={(event) => props.onDNSUserChange(event.target.value)}
helperText="Value used as X-Api-User"
/>
<TextField
label="ACME-DNS API Key"
type="password"
value={props.dnsKey}
onChange={(event) => props.onDNSKeyChange(event.target.value)}
helperText="Value used as X-Api-Key"
/>
<TextField
label="ACME-DNS Subdomain"
value={props.dnsSubdomain}
onChange={(event) => props.onDNSSubdomainChange(event.target.value)}
helperText="Subdomain registered in acme-dns account"
/>
<TextField
label="ACME-DNS Full Domain"
value={props.dnsFullDomain}
onChange={(event) => props.onDNSFullDomainChange(event.target.value)}
helperText="Set CNAME for each _acme-challenge.<domain> to this value"
/>
</Box>
) : null}
<FormControlLabel control={<Checkbox checked={props.enabled} onChange={(event) => props.onEnabledChange(event.target.checked)} />} label="Enabled" />
</Box>
</FormDialogContent>
<DialogActions>
<Button variant="contained" onClick={props.onSave} disabled={saveDisabled}>
{props.busy ? 'Saving...' : 'Save'}
</Button>
<Button onClick={props.onClose}>Cancel</Button>
</DialogActions>
</Dialog>
)
}
@@ -0,0 +1,195 @@
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog'
import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle'
import MenuItem from '@mui/material/MenuItem'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { ChangeEvent } from 'react'
import FormDialogContent from './FormDialogContent'
import SelectField from './SelectField'
import { PKICA } from '../api'
type PKIRootCADialogProps = {
open: boolean
error?: string | null
busy: boolean
name: string
onNameChange: (value: string) => void
commonName: string
onCommonNameChange: (value: string) => void
days: string
onDaysChange: (value: string) => void
certPEM: string
onCertPEMChange: (value: string) => void
keyPEM: string
onKeyPEMChange: (value: string) => void
onClose: () => void
onCreate: () => void
}
type PKIIntermediateCADialogProps = {
open: boolean
error?: string | null
busy: boolean
cas: PKICA[]
name: string
onNameChange: (value: string) => void
parentID: string
onParentIDChange: (value: string) => void
commonName: string
onCommonNameChange: (value: string) => void
days: string
onDaysChange: (value: string) => void
certPEM: string
onCertPEMChange: (value: string) => void
keyPEM: string
onKeyPEMChange: (value: string) => void
onClose: () => void
onCreate: () => void
}
type PKICAEditDialogProps = {
open: boolean
error?: string | null
busy: boolean
cas: PKICA[]
editID: string
name: string
onNameChange: (value: string) => void
parentID: string
onParentIDChange: (value: string) => void
onClose: () => void
onSave: () => void
}
async function readTextFile(event: ChangeEvent<HTMLInputElement>, setter: (value: string) => void): Promise<void> {
const file: File | undefined = event.target.files ? event.target.files[0] : undefined
let text: string
if (!file) {
return
}
text = await file.text()
setter(text)
event.target.value = ''
}
function pemSx() {
return { '& .MuiInputBase-input': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' } }
}
export function PKIRootCADialog(props: PKIRootCADialogProps) {
return (
<Dialog open={props.open} onClose={props.onClose} maxWidth="md" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">New Root CA</Typography>
{props.error ? <Alert severity="error">{props.error}</Alert> : null}
</Box>
</DialogTitle>
<FormDialogContent>
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}>
<TextField label="Name" value={props.name} onChange={(event) => props.onNameChange(event.target.value)} />
<TextField label="Common Name" value={props.commonName} onChange={(event) => props.onCommonNameChange(event.target.value)} />
<TextField label="Validity Days" value={props.days} onChange={(event) => props.onDaysChange(event.target.value)} />
<TextField label="Import Certificate PEM (optional)" multiline minRows={6} value={props.certPEM} onChange={(event) => props.onCertPEMChange(event.target.value)} sx={pemSx()} />
<Box>
<Button variant="outlined" component="label" size="small">
Load Certificate File
<input hidden type="file" accept=".pem,.crt,.cer,.txt" onChange={(event) => void readTextFile(event, props.onCertPEMChange)} />
</Button>
</Box>
<TextField label="Import Private Key PEM (optional)" multiline minRows={6} value={props.keyPEM} onChange={(event) => props.onKeyPEMChange(event.target.value)} sx={pemSx()} />
<Box>
<Button variant="outlined" component="label" size="small">
Load Private Key File
<input hidden type="file" accept=".pem,.key,.txt" onChange={(event) => void readTextFile(event, props.onKeyPEMChange)} />
</Button>
</Box>
</Box>
</FormDialogContent>
<DialogActions>
<Button variant="contained" onClick={props.onCreate} disabled={props.busy}>{props.busy ? 'Saving...' : 'Create'}</Button>
<Button onClick={props.onClose}>Cancel</Button>
</DialogActions>
</Dialog>
)
}
export function PKIIntermediateCADialog(props: PKIIntermediateCADialogProps) {
return (
<Dialog open={props.open} onClose={props.onClose} maxWidth="md" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">New Intermediate CA</Typography>
{props.error ? <Alert severity="error">{props.error}</Alert> : null}
</Box>
</DialogTitle>
<FormDialogContent>
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}>
<TextField label="Name" value={props.name} onChange={(event) => props.onNameChange(event.target.value)} />
<SelectField select label="Parent CA" value={props.parentID} onChange={(event) => props.onParentIDChange(event.target.value)}>
{props.cas.map((ca) => (
<MenuItem key={ca.id} value={ca.id}>{ca.name}</MenuItem>
))}
</SelectField>
<TextField label="Common Name" value={props.commonName} onChange={(event) => props.onCommonNameChange(event.target.value)} />
<TextField label="Validity Days" value={props.days} onChange={(event) => props.onDaysChange(event.target.value)} />
<TextField label="Import Intermediate Certificate PEM (optional)" multiline minRows={6} value={props.certPEM} onChange={(event) => props.onCertPEMChange(event.target.value)} sx={pemSx()} />
<Box>
<Button variant="outlined" component="label" size="small">
Load Intermediate Certificate File
<input hidden type="file" accept=".pem,.crt,.cer,.txt" onChange={(event) => void readTextFile(event, props.onCertPEMChange)} />
</Button>
</Box>
<TextField label="Import Intermediate Private Key PEM (optional)" multiline minRows={6} value={props.keyPEM} onChange={(event) => props.onKeyPEMChange(event.target.value)} sx={pemSx()} />
<Box>
<Button variant="outlined" component="label" size="small">
Load Intermediate Private Key File
<input hidden type="file" accept=".pem,.key,.txt" onChange={(event) => void readTextFile(event, props.onKeyPEMChange)} />
</Button>
</Box>
</Box>
</FormDialogContent>
<DialogActions>
<Button variant="contained" onClick={props.onCreate} disabled={props.busy}>{props.busy ? 'Saving...' : 'Create'}</Button>
<Button onClick={props.onClose}>Cancel</Button>
</DialogActions>
</Dialog>
)
}
export function PKICAEditDialog(props: PKICAEditDialogProps) {
return (
<Dialog open={props.open} onClose={props.onClose} maxWidth="xs" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">Edit CA</Typography>
{props.error ? <Alert severity="error">{props.error}</Alert> : null}
</Box>
</DialogTitle>
<FormDialogContent>
<TextField fullWidth label="Name" value={props.name} onChange={(event) => props.onNameChange(event.target.value)} sx={{ mt: 1 }} />
<SelectField select fullWidth label="Parent CA" value={props.parentID} onChange={(event) => props.onParentIDChange(event.target.value)}>
<MenuItem value="">(none)</MenuItem>
{props.cas
.filter((item) => item.id !== props.editID)
.map((item) => (
<MenuItem key={item.id} value={item.id}>
{item.name} ({item.id})
</MenuItem>
))}
</SelectField>
</FormDialogContent>
<DialogActions>
<Button variant="contained" onClick={props.onSave} disabled={props.busy || !props.name.trim()}>
{props.busy ? 'Saving...' : 'Save'}
</Button>
<Button onClick={props.onClose}>Cancel</Button>
</DialogActions>
</Dialog>
)
}
@@ -0,0 +1,169 @@
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Checkbox from '@mui/material/Checkbox'
import Dialog from '@mui/material/Dialog'
import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle'
import FormControlLabel from '@mui/material/FormControlLabel'
import MenuItem from '@mui/material/MenuItem'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { ChangeEvent } from 'react'
import FormDialogContent from './FormDialogContent'
import SelectField from './SelectField'
import { PKICA } from '../api'
type PKICertIssueDialogProps = {
open: boolean
error?: string | null
busy: boolean
cas: PKICA[]
caID: string
onCAIDChange: (value: string) => void
commonName: string
onCommonNameChange: (value: string) => void
sanDNS: string
onSANDNSChange: (value: string) => void
sanIPs: string
onSANIPsChange: (value: string) => void
validSeconds: string
onValidSecondsChange: (value: string) => void
isCA: boolean
onIsCAChange: (value: boolean) => void
onClose: () => void
onIssue: () => void
}
type PKICertImportDialogProps = {
open: boolean
error?: string | null
busy: boolean
cas: PKICA[]
caID: string
onCAIDChange: (value: string) => void
certPEM: string
onCertPEMChange: (value: string) => void
keyPEM: string
onKeyPEMChange: (value: string) => void
onClose: () => void
onImport: () => void
}
type PKICertRevokeDialogProps = {
open: boolean
error?: string | null
busy: boolean
reason: string
onReasonChange: (value: string) => void
onClose: () => void
onRevoke: () => void
}
async function readTextFile(event: ChangeEvent<HTMLInputElement>, setter: (value: string) => void): Promise<void> {
const file: File | undefined = event.target.files ? event.target.files[0] : undefined
let text: string
if (!file) {
return
}
text = await file.text()
setter(text)
event.target.value = ''
}
function pemSx() {
return { '& .MuiInputBase-input': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' } }
}
export function PKICertIssueDialog(props: PKICertIssueDialogProps) {
return (
<Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">Issue Certificate</Typography>
{props.error ? <Alert severity="error">{props.error}</Alert> : null}
</Box>
</DialogTitle>
<FormDialogContent>
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}>
<SelectField select label="Issuer CA" value={props.caID} onChange={(event) => props.onCAIDChange(event.target.value)}>
{props.cas.map((ca) => (
<MenuItem key={ca.id} value={ca.id}>{ca.name}</MenuItem>
))}
</SelectField>
<TextField label="Common Name" value={props.commonName} onChange={(event) => props.onCommonNameChange(event.target.value)} />
<TextField label="SAN DNS (comma-separated)" value={props.sanDNS} onChange={(event) => props.onSANDNSChange(event.target.value)} />
<TextField label="SAN IPs (comma-separated)" value={props.sanIPs} onChange={(event) => props.onSANIPsChange(event.target.value)} />
<TextField label="Validity Seconds" value={props.validSeconds} onChange={(event) => props.onValidSecondsChange(event.target.value)} helperText="Default 31536000 (365 days)" />
<FormControlLabel control={<Checkbox checked={props.isCA} onChange={(event) => props.onIsCAChange(event.target.checked)} />} label="Issue as CA certificate" />
</Box>
</FormDialogContent>
<DialogActions>
<Button variant="contained" onClick={props.onIssue} disabled={props.busy}>{props.busy ? 'Saving...' : 'Issue'}</Button>
<Button onClick={props.onClose}>Cancel</Button>
</DialogActions>
</Dialog>
)
}
export function PKICertImportDialog(props: PKICertImportDialogProps) {
return (
<Dialog open={props.open} onClose={props.onClose} maxWidth="md" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">Import Certificate</Typography>
{props.error ? <Alert severity="error">{props.error}</Alert> : null}
</Box>
</DialogTitle>
<FormDialogContent>
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}>
<SelectField select label="Issuer CA (optional)" value={props.caID} onChange={(event) => props.onCAIDChange(event.target.value)}>
<MenuItem value="">(none, standalone)</MenuItem>
{props.cas.map((ca) => (
<MenuItem key={ca.id} value={ca.id}>{ca.name}</MenuItem>
))}
</SelectField>
<TextField label="Certificate PEM" multiline minRows={6} value={props.certPEM} onChange={(event) => props.onCertPEMChange(event.target.value)} sx={pemSx()} />
<Box>
<Button variant="outlined" component="label" size="small">
Load Certificate File
<input hidden type="file" accept=".pem,.crt,.cer,.txt" onChange={(event) => void readTextFile(event, props.onCertPEMChange)} />
</Button>
</Box>
<TextField label="Private Key PEM" multiline minRows={6} value={props.keyPEM} onChange={(event) => props.onKeyPEMChange(event.target.value)} sx={pemSx()} />
<Box>
<Button variant="outlined" component="label" size="small">
Load Private Key File
<input hidden type="file" accept=".pem,.key,.txt" onChange={(event) => void readTextFile(event, props.onKeyPEMChange)} />
</Button>
</Box>
</Box>
</FormDialogContent>
<DialogActions>
<Button variant="contained" onClick={props.onImport} disabled={props.busy}>{props.busy ? 'Saving...' : 'Import'}</Button>
<Button onClick={props.onClose}>Cancel</Button>
</DialogActions>
</Dialog>
)
}
export function PKICertRevokeDialog(props: PKICertRevokeDialogProps) {
return (
<Dialog open={props.open} onClose={props.onClose} maxWidth="xs" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">Revoke Certificate</Typography>
{props.error ? <Alert severity="error">{props.error}</Alert> : null}
</Box>
</DialogTitle>
<FormDialogContent>
<TextField fullWidth label="Reason (optional)" value={props.reason} onChange={(event) => props.onReasonChange(event.target.value)} />
</FormDialogContent>
<DialogActions>
<Button color="warning" variant="contained" onClick={props.onRevoke} disabled={props.busy}>{props.busy ? 'Working...' : 'Revoke'}</Button>
<Button onClick={props.onClose}>Cancel</Button>
</DialogActions>
</Dialog>
)
}
@@ -0,0 +1,125 @@
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog'
import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle'
import MenuItem from '@mui/material/MenuItem'
import Paper from '@mui/material/Paper'
import Tab from '@mui/material/Tab'
import Tabs from '@mui/material/Tabs'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import FormDialogContent from './FormDialogContent'
import SelectField from './SelectField'
export type ProjectHomePageValue = 'info' | 'repos' | 'issues' | 'wiki' | 'files'
export type ProjectDescriptionTab = 'write' | 'preview'
type ProjectFormDialogProps = {
open: boolean
title: string
error?: string | null
slug: string
onSlugChange: (value: string) => void
name: string
onNameChange: (value: string) => void
description: string
onDescriptionChange: (value: string) => void
homePage: ProjectHomePageValue
onHomePageChange: (value: ProjectHomePageValue) => void
descriptionTab: ProjectDescriptionTab
onDescriptionTabChange: (value: ProjectDescriptionTab) => void
busy: boolean
saveText: string
busyText: string
onSave: () => void
onClose: () => void
}
export default function ProjectFormDialog(props: ProjectFormDialogProps) {
const slugHasWhitespace: boolean = /\s/.test(props.slug)
return (
<Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth>
<DialogTitle>{props.title}</DialogTitle>
<FormDialogContent>
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}>
{props.error ? <Alert severity="error">{props.error}</Alert> : null}
<TextField
margin="dense"
label="Slug"
fullWidth
error={slugHasWhitespace}
helperText={slugHasWhitespace ? 'Slug cannot contain whitespace.' : ''}
value={props.slug}
onChange={(event) => props.onSlugChange(event.target.value)}
/>
<TextField
margin="dense"
label="Name"
fullWidth
value={props.name}
onChange={(event) => props.onNameChange(event.target.value)}
/>
<SelectField
margin="dense"
select
label="Default Project Page"
fullWidth
value={props.homePage}
onChange={(event) => props.onHomePageChange(event.target.value as ProjectHomePageValue)}
>
<MenuItem value="info">Info</MenuItem>
<MenuItem value="repos">Repositories</MenuItem>
<MenuItem value="issues">Issues</MenuItem>
<MenuItem value="wiki">Wiki</MenuItem>
<MenuItem value="files">Files</MenuItem>
</SelectField>
<Box sx={{ mt: 1 }}>
<Tabs
value={props.descriptionTab}
onChange={(_, value) => props.onDescriptionTabChange(value)}
textColor="primary"
indicatorColor="primary"
>
<Tab label="Write" value="write" />
<Tab label="Preview" value="preview" />
</Tabs>
{props.descriptionTab === 'write' ? (
<TextField
margin="dense"
label="Description (Markdown)"
fullWidth
multiline
rows={6}
value={props.description}
onChange={(event) => props.onDescriptionChange(event.target.value)}
/>
) : (
<Paper variant="outlined" sx={{ p: 1, mt: 1, minHeight: 140 }}>
{props.description.trim() ? (
<Box sx={{ '& p': { m: 0 } }}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{props.description}</ReactMarkdown>
</Box>
) : (
<Typography variant="body2" color="text.secondary">
Nothing to preview yet.
</Typography>
)}
</Paper>
)}
</Box>
</Box>
</FormDialogContent>
<DialogActions>
<Button onClick={props.onSave} variant="contained" disabled={props.busy}>
{props.busy ? props.busyText : props.saveText}
</Button>
<Button onClick={props.onClose}>Cancel</Button>
</DialogActions>
</Dialog>
)
}
@@ -0,0 +1,34 @@
import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog'
import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle'
import TextField from '@mui/material/TextField'
import FormDialogContent from './FormDialogContent'
type SSHCertificateInspectDialogProps = {
open: boolean
dump: string
closeText?: string
onClose: () => void
}
export default function SSHCertificateInspectDialog(props: SSHCertificateInspectDialogProps) {
return (
<Dialog open={props.open} onClose={props.onClose} maxWidth="md" fullWidth>
<DialogTitle>Certificate Inspect</DialogTitle>
<FormDialogContent>
<TextField
multiline
minRows={12}
value={props.dump}
InputProps={{ readOnly: true }}
fullWidth
sx={{ mt: 1, '& .MuiInputBase-input': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' } }}
/>
</FormDialogContent>
<DialogActions>
<Button onClick={props.onClose}>{props.closeText || 'Close'}</Button>
</DialogActions>
</Dialog>
)
}
@@ -0,0 +1,69 @@
import Alert from '@mui/material/Alert'
import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog'
import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import FormDialogContent from './FormDialogContent'
type SSHCredentialsPromptDialogProps = {
open: boolean
title?: string
targetText: string
error?: string | null
passwordRequired: boolean
password: string
onPasswordChange: (value: string) => void
otpRequired: boolean
otpCode: string
onOTPCodeChange: (value: string) => void
busy?: boolean
busyText?: string
onClose: () => void
onConfirm: () => void
}
export default function SSHCredentialsPromptDialog(props: SSHCredentialsPromptDialogProps) {
const confirmDisabled: boolean =
Boolean(props.busy) ||
(props.passwordRequired && props.password === '') ||
(props.otpRequired && props.otpCode.trim() === '')
return (
<Dialog open={props.open} onClose={props.onClose} maxWidth="xs" fullWidth>
<DialogTitle>{props.title || 'Enter SSH Credentials'}</DialogTitle>
<FormDialogContent>
{props.error ? <Alert severity="error">{props.error}</Alert> : null}
<Typography variant="body2" color="text.secondary">
{props.targetText}
</Typography>
{props.passwordRequired ? (
<TextField
label="Password"
type="password"
value={props.password}
onChange={(event) => props.onPasswordChange(event.target.value)}
autoFocus
/>
) : null}
{props.otpRequired ? (
<TextField
label="OTP Code"
value={props.otpCode}
onChange={(event) => props.onOTPCodeChange(event.target.value)}
autoFocus={!props.passwordRequired}
/>
) : null}
</FormDialogContent>
<DialogActions>
<Button variant="contained" onClick={props.onConfirm} disabled={confirmDisabled}>
{props.busy ? (props.busyText || 'Connecting...') : 'Connect'}
</Button>
<Button onClick={props.onClose} disabled={Boolean(props.busy)}>
Cancel
</Button>
</DialogActions>
</Dialog>
)
}
@@ -0,0 +1,122 @@
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog'
import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle'
import Paper from '@mui/material/Paper'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { ListRowActionButton, ListRowActions } from './ListRowActions'
import FormDialogContent from './FormDialogContent'
import { SSHServer, SSHServerHostKey } from '../api'
type SSHHostKeysDialogProps = {
server: SSHServer | null
hostKeys: SSHServerHostKey[]
loading: boolean
error?: string | null
input: string
onInputChange: (value: string) => void
saving: boolean
discoveredHostKey: SSHServerHostKey | null
closeText?: string
onClose: () => void
onDiscover: () => void
onAdd: (publicKey: string) => void
onDelete: (hostKeyID: string) => void
}
export default function SSHHostKeysDialog(props: SSHHostKeysDialogProps) {
return (
<Dialog open={Boolean(props.server)} onClose={props.onClose} maxWidth="md" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">Pinned Host Keys</Typography>
<Typography variant="body2" color="text.secondary">
{props.server ? `${props.server.name} (${props.server.host}:${props.server.port})` : ''}
</Typography>
{props.error ? <Alert severity="error">{props.error}</Alert> : null}
</Box>
</DialogTitle>
<FormDialogContent>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Button variant="outlined" onClick={props.onDiscover} disabled={props.saving || props.loading}>
Discover
</Button>
<Button
variant="outlined"
onClick={() => props.onAdd(props.input)}
disabled={props.saving || !props.input.trim()}
>
Add Manual Key
</Button>
</Box>
{props.discoveredHostKey ? (
<Alert
severity="info"
action={
<Button color="inherit" size="small" onClick={() => props.onAdd(props.discoveredHostKey?.public_key || '')} disabled={props.saving}>
Pin
</Button>
}
>
Discovered {props.discoveredHostKey.algorithm} · {props.discoveredHostKey.fingerprint}
</Alert>
) : null}
<TextField
label="Manual Host Public Key"
multiline
minRows={3}
value={props.input}
onChange={(event) => props.onInputChange(event.target.value)}
helperText="Paste an authorized_keys style SSH host public key."
/>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="subtitle2">Pinned Keys</Typography>
{props.loading ? (
<Typography variant="body2" color="text.secondary">
Loading...
</Typography>
) : null}
{!props.loading && props.hostKeys.length === 0 ? (
<Typography variant="body2" color="text.secondary">
No pinned host keys.
</Typography>
) : null}
{props.hostKeys.map((item) => (
<Paper
key={item.id}
variant="outlined"
sx={{
p: 1,
display: 'grid',
gap: 0.5,
backgroundColor: 'transparent',
backgroundImage: 'none'
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="subtitle2">{item.fingerprint}</Typography>
<ListRowActions>
<ListRowActionButton color="error" onClick={() => props.onDelete(item.id)} disabled={props.saving}>
Delete
</ListRowActionButton>
</ListRowActions>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
{item.algorithm}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
{item.public_key}
</Typography>
</Paper>
))}
</Box>
</FormDialogContent>
<DialogActions>
<Button onClick={props.onClose}>{props.closeText || 'Close'}</Button>
</DialogActions>
</Dialog>
)
}
@@ -0,0 +1,66 @@
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog'
import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle'
import Typography from '@mui/material/Typography'
import FormDialogContent from './FormDialogContent'
type SSHTranscriptDialogProps = {
open: boolean
title: string
text: string
error?: string | null
loading?: boolean
downloadDisabled?: boolean
closeText?: string
onClose: () => void
onDownload: () => void
}
export default function SSHTranscriptDialog(props: SSHTranscriptDialogProps) {
const downloadDisabled: boolean = Boolean(props.loading) || props.text === '' || Boolean(props.downloadDisabled)
return (
<Dialog open={props.open} onClose={props.onClose} fullWidth maxWidth="md">
<DialogTitle>{props.title}</DialogTitle>
<FormDialogContent>
{props.error ? <Alert severity="error">{props.error}</Alert> : null}
{props.loading ? (
<Typography variant="body2" color="text.secondary">
Loading transcript...
</Typography>
) : null}
{!props.loading && !props.error ? (
<Box
component="pre"
sx={{
m: 0,
p: 1.5,
borderRadius: 1,
bgcolor: 'background.default',
border: (theme) => `1px solid ${theme.palette.divider}`,
overflow: 'auto',
maxHeight: '70vh',
fontFamily: 'monospace',
fontSize: 13,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
}}
>
{props.text || '(empty transcript)'}
</Box>
) : null}
</FormDialogContent>
<DialogActions>
<Button variant="contained" onClick={props.onDownload} disabled={downloadDisabled}>
Download
</Button>
<Button onClick={props.onClose}>
{props.closeText || 'Close'}
</Button>
</DialogActions>
</Dialog>
)
}
+36 -58
View File
@@ -1,12 +1,9 @@
import Alert from '@mui/material/Alert'
import FormDialogContent from '../components/FormDialogContent'
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
import {
Box,
Button,
Checkbox,
Dialog,
DialogActions,
DialogTitle,
List,
ListItemIcon,
ListItem,
@@ -252,60 +249,41 @@ export default function AdminApiKeysPage() {
</List>
)}
</SectionCard>
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)} maxWidth="xs" fullWidth>
<DialogTitle>Delete API Key</DialogTitle>
<FormDialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Type the API key name to confirm deletion.
</Typography>
<TextField
fullWidth
label="Key name"
value={deleteConfirm}
onChange={(event) => setDeleteConfirm(event.target.value)}
/>
</FormDialogContent>
<DialogActions>
<Button
variant="contained"
color="error"
disabled={deleting || !deleteTarget || deleteConfirm !== deleteTarget.name}
onClick={handleDelete}
>
{deleting ? 'Deleting...' : 'Delete'}
</Button>
<Button onClick={() => { setDeleteTarget(null); setDeleteConfirm('') }}>
Cancel
</Button>
</DialogActions>
</Dialog>
<Dialog open={bulkOpen} onClose={() => setBulkOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle>Delete Selected API Keys</DialogTitle>
<FormDialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Type DELETE to delete {selected.length} selected API key(s).
</Typography>
<TextField
fullWidth
label="Confirmation"
value={bulkConfirm}
onChange={(event) => setBulkConfirm(event.target.value)}
/>
</FormDialogContent>
<DialogActions>
<Button
variant="contained"
color="error"
disabled={bulkDeleting || !selected.length || bulkConfirm !== 'DELETE'}
onClick={handleBulkDelete}
>
{bulkDeleting ? 'Deleting...' : 'Delete Selected'}
</Button>
<Button onClick={() => { setBulkOpen(false); setBulkConfirm('') }}>
Cancel
</Button>
</DialogActions>
</Dialog>
<ConfirmDeleteDialog
open={Boolean(deleteTarget)}
title="Delete API Key"
prompt="Type the API key name to confirm deletion."
expectedText={deleteTarget?.name || ''}
confirmLabel="Key Name"
confirmValue={deleteConfirm}
onConfirmValueChange={setDeleteConfirm}
busy={deleting}
onConfirm={handleDelete}
onClose={() => {
if (deleting) return
setDeleteTarget(null)
setDeleteConfirm('')
}}
/>
<ConfirmDeleteDialog
open={bulkOpen}
title="Delete Selected API Keys"
prompt={`Type DELETE to delete ${selected.length} selected API key(s).`}
expectedText="DELETE"
confirmLabel="Confirmation"
confirmValue={bulkConfirm}
onConfirmValueChange={setBulkConfirm}
busy={bulkDeleting}
confirmText="Delete Selected"
busyText="Deleting..."
confirmDisabled={!selected.length}
onConfirm={handleBulkDelete}
onClose={() => {
if (bulkDeleting) return
setBulkOpen(false)
setBulkConfirm('')
}}
/>
</Box>
)
}
@@ -1,17 +1,13 @@
import AddIcon from '@mui/icons-material/Add'
import Alert from '@mui/material/Alert'
import FormDialogContent from '../components/FormDialogContent'
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
import {
Box,
Button,
Dialog,
DialogActions,
DialogTitle,
Link,
List,
ListItem,
ListItemText,
TextField,
Typography
} from '@mui/material'
import { useEffect, useState } from 'react'
@@ -364,28 +360,24 @@ export default function AdminPKIClientProfilesPage() {
onSave={save}
/>
<Dialog open={Boolean(deleteItem)} onClose={() => { if (!busy) { setDeleteItem(null); setDeleteConfirm('') } }} fullWidth maxWidth="sm">
<DialogTitle>Delete Client Certificate Profile</DialogTitle>
<FormDialogContent>
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
<Typography variant="body2" color="text.secondary">
Type the profile name to confirm deletion.
</Typography>
<Typography variant="body2">{deleteItem?.name}</Typography>
<TextField label="Confirm Name" value={deleteConfirm} onChange={(event) => setDeleteConfirm(event.target.value)} autoFocus />
</FormDialogContent>
<DialogActions>
<Button
color="error"
variant="contained"
onClick={remove}
disabled={busy || deleteConfirm.trim() !== (deleteItem?.name || '')}
>
{busy ? 'Deleting...' : 'Delete'}
</Button>
<Button onClick={() => { setDeleteItem(null); setDeleteConfirm('') }} disabled={busy}>Cancel</Button>
</DialogActions>
</Dialog>
<ConfirmDeleteDialog
open={Boolean(deleteItem)}
title="Delete Client Certificate Profile"
prompt="Type the profile name to confirm deletion."
expectedText={deleteItem?.name || ''}
confirmLabel="Profile Name"
confirmValue={deleteConfirm}
onConfirmValueChange={setDeleteConfirm}
error={dialogError}
busy={busy}
onClose={() => {
if (busy) return
setDeleteItem(null)
setDeleteConfirm('')
setDialogError(null)
}}
onConfirm={remove}
/>
</Box>
)
}
+283 -498
View File
@@ -1,15 +1,11 @@
import DownloadIcon from '@mui/icons-material/Download'
import Alert from '@mui/material/Alert'
import FormDialogContent from '../components/FormDialogContent'
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
import Autocomplete from '../components/Autocomplete'
import {
Box,
Button,
Checkbox,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControlLabel,
Link,
List,
@@ -25,6 +21,10 @@ import HeaderActionButton from '../components/HeaderActionButton'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import PKICertDetailsDialog from '../components/PKICertDetailsDialog'
import PKICADetailsDialog from '../components/PKICADetailsDialog'
import PKIACMEProfileDialog from '../components/PKIACMEProfileDialog'
import { PKIACMEOrderCreateDialog, PKIACMEOrderDetailsDialog, PKIACMEOrderFinalizeDialog } from '../components/PKIACMEOrderDialogs'
import { PKICAEditDialog, PKIIntermediateCADialog, PKIRootCADialog } from '../components/PKICAFormDialogs'
import { PKICertImportDialog, PKICertIssueDialog, PKICertRevokeDialog } from '../components/PKICertFormDialogs'
import SectionCard from '../components/SectionCard'
import { ACMEOrder, ACMEOrderCreatePayload, ACMEProfile, ACMEProfileUpsertPayload, api, PKICA, PKICADetail, PKICert, PKICertDetail, PKICertImportPayload, PKICertIssuePayload, PKIIntermediateCARequest, PKIRootCARequest } from '../api'
import SelectField from '../components/SelectField'
@@ -83,10 +83,6 @@ function downloadBinary(filename: string, data: ArrayBuffer, contentType: string
URL.revokeObjectURL(url)
}
async function readFileText(file: File): Promise<string> {
return file.text()
}
export default function AdminPKIPage() {
const [cas, setCAs] = useState<PKICA[]>([])
const [certs, setCerts] = useState<PKICert[]>([])
@@ -107,6 +103,7 @@ export default function AdminPKIPage() {
const [revokeID, setRevokeID] = useState('')
const [revokeReason, setRevokeReason] = useState('')
const [deleteID, setDeleteID] = useState('')
const [deleteConfirm, setDeleteConfirm] = useState('')
const [deleteCAID, setDeleteCAID] = useState('')
const [deleteCAName, setDeleteCAName] = useState('')
const [deleteCAConfirm, setDeleteCAConfirm] = useState('')
@@ -119,9 +116,11 @@ export default function AdminPKIPage() {
const [viewCertDumpLoading, setViewCertDumpLoading] = useState(false)
const [acmeOpen, setACMEOpen] = useState(false)
const [acmeDeleteID, setACMEDeleteID] = useState('')
const [acmeDeleteConfirm, setACMEDeleteConfirm] = useState('')
const [acmeEditID, setACMEEditID] = useState('')
const [acmeOrderOpen, setACMEOrderOpen] = useState(false)
const [acmeOrderDeleteID, setACMEOrderDeleteID] = useState('')
const [acmeOrderDeleteConfirm, setACMEOrderDeleteConfirm] = useState('')
const [acmeFinalizeID, setACMEFinalizeID] = useState('')
const [acmeFinalizeMode, setACMEFinalizeMode] = useState<'override' | 'new_cert'>('override')
const [acmeOrderProfileID, setACMEOrderProfileID] = useState('')
@@ -165,17 +164,6 @@ export default function AdminPKIPage() {
const [importCertPEM, setImportCertPEM] = useState('')
const [importKeyPEM, setImportKeyPEM] = useState('')
const loadTextFile = async (event: React.ChangeEvent<HTMLInputElement>, setter: (value: string) => void) => {
const file = event.target.files && event.target.files[0]
let text: string
if (!file) {
return
}
text = await readFileText(file)
setter(text)
event.target.value = ''
}
const load = async () => {
let listCAs: PKICA[]
let listCerts: PKICert[]
@@ -353,6 +341,7 @@ export default function AdminPKIPage() {
try {
await api.deletePKICert(deleteID)
setDeleteID('')
setDeleteConfirm('')
await load()
} catch (err) {
setDialogError(err instanceof Error ? err.message : 'Failed to delete certificate')
@@ -534,6 +523,7 @@ export default function AdminPKIPage() {
try {
await api.deleteACMEProfile(acmeDeleteID)
setACMEDeleteID('')
setACMEDeleteConfirm('')
await load()
} catch (err) {
setDialogError(err instanceof Error ? err.message : 'Failed to delete ACME profile')
@@ -611,6 +601,7 @@ export default function AdminPKIPage() {
try {
await api.deleteACMEOrder(acmeOrderDeleteID)
setACMEOrderDeleteID('')
setACMEOrderDeleteConfirm('')
await load()
} catch (err) {
setDialogError(err instanceof Error ? err.message : 'Failed to delete ACME order')
@@ -665,6 +656,24 @@ export default function AdminPKIPage() {
].some((value) => value.toLowerCase().includes(q))
})
const certConfirmText = (id: string): string => {
const cert: PKICert | undefined = certs.find((item) => item.id === id)
if (!cert) return id
return cert.common_name || cert.id
}
const acmeProfileConfirmText = (id: string): string => {
const profile: ACMEProfile | undefined = acmeProfiles.find((item) => item.id === id)
if (!profile) return id
return profile.name || profile.id
}
const acmeOrderConfirmText = (id: string): string => {
const order: ACMEOrder | undefined = acmeOrders.find((item) => item.id === id)
if (!order) return id
return order.common_name || order.id
}
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
@@ -888,7 +897,14 @@ export default function AdminPKIPage() {
>
Revoke
</ListRowActionButton>
<ListRowActionButton color="error" onClick={() => setDeleteID(cert.id)}>
<ListRowActionButton
color="error"
onClick={() => {
setDialogError(null)
setDeleteID(cert.id)
setDeleteConfirm('')
}}
>
Delete
</ListRowActionButton>
</ListRowActions>
@@ -924,7 +940,14 @@ export default function AdminPKIPage() {
<ListRowActionButton onClick={() => openEditACME(item)}>
Edit
</ListRowActionButton>
<ListRowActionButton color="error" onClick={() => setACMEDeleteID(item.id)}>
<ListRowActionButton
color="error"
onClick={() => {
setDialogError(null)
setACMEDeleteID(item.id)
setACMEDeleteConfirm('')
}}
>
Delete
</ListRowActionButton>
</ListRowActions>
@@ -935,93 +958,54 @@ export default function AdminPKIPage() {
</SectionCard>
</Box>
<Dialog open={acmeOpen} onClose={() => setACMEOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">{acmeEditID ? 'Edit ACME Profile' : 'New ACME Profile'}</Typography>
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
</Box>
</DialogTitle>
<FormDialogContent>
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}>
<TextField label="Name" value={acmeName} onChange={(event) => setACMEName(event.target.value)} />
<TextField label="Directory URL" value={acmeDirectoryURL} onChange={(event) => setACMEDirectoryURL(event.target.value)} />
<TextField label="Email" value={acmeEmail} onChange={(event) => setACMEEmail(event.target.value)} />
<SelectField select label="DNS Solver" value={acmeSolverType} onChange={(event) => setACMESolverType(event.target.value as 'manual' | 'acme_dns')}>
<MenuItem value="manual">Manual DNS-01</MenuItem>
<MenuItem value="acme_dns">acme-dns (automated)</MenuItem>
</SelectField>
{acmeSolverType === 'acme_dns' ? (
<Box sx={{ display: 'grid', gap: 1 }}>
<TextField
label="ACME-DNS API URL"
value={acmeDNSAPIURL}
onChange={(event) => setACMEDNSAPIURL(event.target.value)}
helperText="Example: https://auth.acme-dns.io"
/>
<TextField
label="ACME-DNS User"
value={acmeDNSUser}
onChange={(event) => setACMEDNSUser(event.target.value)}
helperText="Value used as X-Api-User"
/>
<TextField
label="ACME-DNS API Key"
type="password"
value={acmeDNSKey}
onChange={(event) => setACMEDNSKey(event.target.value)}
helperText="Value used as X-Api-Key"
/>
<TextField
label="ACME-DNS Subdomain"
value={acmeDNSSubdomain}
onChange={(event) => setACMEDNSSubdomain(event.target.value)}
helperText="Subdomain registered in acme-dns account"
/>
<TextField
label="ACME-DNS Full Domain"
value={acmeDNSFullDomain}
onChange={(event) => setACMEDNSFullDomain(event.target.value)}
helperText="Set CNAME for each _acme-challenge.<domain> to this value"
/>
</Box>
) : null}
<FormControlLabel control={<Checkbox checked={acmeEnabled} onChange={(event) => setACMEEnabled(event.target.checked)} />} label="Enabled" />
</Box>
</FormDialogContent>
<DialogActions>
<Button
variant="contained"
onClick={saveACME}
disabled={
busy ||
!acmeName.trim() ||
!acmeDirectoryURL.trim() ||
(acmeSolverType === 'acme_dns' &&
(!acmeDNSAPIURL.trim() || !acmeDNSUser.trim() || !acmeDNSKey.trim() || !acmeDNSSubdomain.trim() || !acmeDNSFullDomain.trim()))
}
>
{busy ? 'Saving...' : 'Save'}
</Button>
<Button onClick={() => setACMEOpen(false)}>Cancel</Button>
</DialogActions>
</Dialog>
<PKIACMEProfileDialog
open={acmeOpen}
editID={acmeEditID}
error={dialogError}
busy={busy}
name={acmeName}
onNameChange={setACMEName}
directoryURL={acmeDirectoryURL}
onDirectoryURLChange={setACMEDirectoryURL}
email={acmeEmail}
onEmailChange={setACMEEmail}
enabled={acmeEnabled}
onEnabledChange={setACMEEnabled}
solverType={acmeSolverType}
onSolverTypeChange={setACMESolverType}
dnsAPIURL={acmeDNSAPIURL}
onDNSAPIURLChange={setACMEDNSAPIURL}
dnsUser={acmeDNSUser}
onDNSUserChange={setACMEDNSUser}
dnsKey={acmeDNSKey}
onDNSKeyChange={setACMEDNSKey}
dnsSubdomain={acmeDNSSubdomain}
onDNSSubdomainChange={setACMEDNSSubdomain}
dnsFullDomain={acmeDNSFullDomain}
onDNSFullDomainChange={setACMEDNSFullDomain}
onClose={() => setACMEOpen(false)}
onSave={saveACME}
/>
<Dialog open={Boolean(acmeDeleteID)} onClose={() => setACMEDeleteID('')} maxWidth="xs" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">Delete ACME Profile</Typography>
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
</Box>
</DialogTitle>
<FormDialogContent>
<Typography variant="body2" color="text.secondary">Delete profile permanently?</Typography>
</FormDialogContent>
<DialogActions>
<Button color="error" variant="contained" onClick={deleteACME} disabled={busy}>{busy ? 'Working...' : 'Delete'}</Button>
<Button onClick={() => setACMEDeleteID('')}>Cancel</Button>
</DialogActions>
</Dialog>
<ConfirmDeleteDialog
open={Boolean(acmeDeleteID)}
title="Delete ACME Profile"
prompt="Type the ACME profile name to confirm deletion."
expectedText={acmeDeleteID ? acmeProfileConfirmText(acmeDeleteID) : ''}
confirmLabel="Profile Name"
confirmValue={acmeDeleteConfirm}
onConfirmValueChange={setACMEDeleteConfirm}
error={dialogError}
busy={busy}
busyText="Working..."
onClose={() => {
if (busy) return
setACMEDeleteID('')
setACMEDeleteConfirm('')
setDialogError(null)
}}
onConfirm={deleteACME}
/>
<SectionCard
title="ACME Orders (DNS-01)"
@@ -1047,7 +1031,14 @@ export default function AdminPKIPage() {
<ListRowActionButton onClick={() => requestFinalizeACMEOrder(item)} disabled={busy || item.status === 'valid'}>
Finalize
</ListRowActionButton>
<ListRowActionButton color="error" onClick={() => setACMEOrderDeleteID(item.id)}>
<ListRowActionButton
color="error"
onClick={() => {
setDialogError(null)
setACMEOrderDeleteID(item.id)
setACMEOrderDeleteConfirm('')
}}
>
Delete
</ListRowActionButton>
</ListRowActions>
@@ -1057,370 +1048,190 @@ export default function AdminPKIPage() {
</List>
</SectionCard>
<Dialog open={acmeOrderOpen} onClose={() => setACMEOrderOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">New ACME Order</Typography>
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
</Box>
</DialogTitle>
<FormDialogContent>
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}>
<SelectField
select
label="ACME Profile"
value={acmeOrderProfileID}
onChange={(event) => setACMEOrderProfileID(event.target.value)}
>
{acmeProfiles.map((item) => (
<MenuItem key={item.id} value={item.id}>{item.name}</MenuItem>
))}
</SelectField>
<TextField label="Common Name" value={acmeOrderCommonName} onChange={(event) => setACMEOrderCommonName(event.target.value)} />
<TextField
label="SAN DNS (comma-separated, optional)"
value={acmeOrderSANDNS}
onChange={(event) => setACMEOrderSANDNS(event.target.value)}
/>
</Box>
</FormDialogContent>
<DialogActions>
<Button variant="contained" onClick={createACMEOrder} disabled={busy || !acmeOrderProfileID || !acmeOrderCommonName.trim()}>
{busy ? 'Saving...' : 'Create'}
</Button>
<Button onClick={() => setACMEOrderOpen(false)}>Cancel</Button>
</DialogActions>
</Dialog>
<PKIACMEOrderCreateDialog
open={acmeOrderOpen}
profiles={acmeProfiles}
error={dialogError}
busy={busy}
profileID={acmeOrderProfileID}
onProfileIDChange={setACMEOrderProfileID}
commonName={acmeOrderCommonName}
onCommonNameChange={setACMEOrderCommonName}
sanDNS={acmeOrderSANDNS}
onSANDNSChange={setACMEOrderSANDNS}
onClose={() => setACMEOrderOpen(false)}
onCreate={createACMEOrder}
/>
<Dialog open={Boolean(acmeOrderDeleteID)} onClose={() => setACMEOrderDeleteID('')} maxWidth="xs" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">Delete ACME Order</Typography>
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
</Box>
</DialogTitle>
<FormDialogContent>
<Typography variant="body2" color="text.secondary">Delete order permanently?</Typography>
</FormDialogContent>
<DialogActions>
<Button color="error" variant="contained" onClick={deleteACMEOrder} disabled={busy}>{busy ? 'Working...' : 'Delete'}</Button>
<Button onClick={() => setACMEOrderDeleteID('')}>Cancel</Button>
</DialogActions>
</Dialog>
<ConfirmDeleteDialog
open={Boolean(acmeOrderDeleteID)}
title="Delete ACME Order"
prompt="Type the ACME order common name to confirm deletion."
expectedText={acmeOrderDeleteID ? acmeOrderConfirmText(acmeOrderDeleteID) : ''}
confirmLabel="Common Name"
confirmValue={acmeOrderDeleteConfirm}
onConfirmValueChange={setACMEOrderDeleteConfirm}
error={dialogError}
busy={busy}
busyText="Working..."
onClose={() => {
if (busy) return
setACMEOrderDeleteID('')
setACMEOrderDeleteConfirm('')
setDialogError(null)
}}
onConfirm={deleteACMEOrder}
/>
<Dialog open={Boolean(acmeFinalizeID)} onClose={() => setACMEFinalizeID('')} maxWidth="xs" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">Finalize Renewal Order</Typography>
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
</Box>
</DialogTitle>
<FormDialogContent>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="body2" color="text.secondary">
Choose how to store the renewed certificate.
</Typography>
<SelectField
select
size="small"
label="Finalize Mode"
value={acmeFinalizeMode}
onChange={(event) => setACMEFinalizeMode(event.target.value as 'override' | 'new_cert')}
>
<MenuItem value="override">Override existing cert ID (recommended)</MenuItem>
<MenuItem value="new_cert">Create a new cert ID</MenuItem>
</SelectField>
</Box>
</FormDialogContent>
<DialogActions>
<Button
variant="contained"
onClick={() => finalizeACMEOrder(acmeFinalizeID, acmeFinalizeMode)}
disabled={busy || !acmeFinalizeID}
>
{busy ? 'Working...' : 'Finalize'}
</Button>
<Button onClick={() => setACMEFinalizeID('')}>Cancel</Button>
</DialogActions>
</Dialog>
<PKIACMEOrderFinalizeDialog
open={Boolean(acmeFinalizeID)}
error={dialogError}
busy={busy}
mode={acmeFinalizeMode}
onModeChange={setACMEFinalizeMode}
onClose={() => setACMEFinalizeID('')}
onFinalize={() => finalizeACMEOrder(acmeFinalizeID, acmeFinalizeMode)}
/>
<Dialog open={Boolean(acmeOrderView)} onClose={() => setACMEOrderView(null)} maxWidth="md" fullWidth>
<DialogTitle>DNS-01 Instructions</DialogTitle>
<FormDialogContent>
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}>
<TextField label="Order ID" value={acmeOrderView?.id || ''} InputProps={{ readOnly: true }} />
<TextField label="Status" value={acmeOrderView?.status || ''} InputProps={{ readOnly: true }} />
<TextField label="Common Name" value={acmeOrderView?.common_name || ''} InputProps={{ readOnly: true }} />
{(acmeOrderView?.challenges || []).map((challenge, index) => (
<Box key={`${challenge.authorization_url}-${index}`} sx={{ display: 'grid', gap: 1, p: 1, border: '1px solid', borderColor: 'divider' }}>
<Typography variant="subtitle2">{challenge.identifier}</Typography>
<TextField label="DNS TXT Name" value={challenge.dns_name} InputProps={{ readOnly: true }} />
<TextField
label="DNS TXT Value"
value={challenge.dns_value}
InputProps={{ readOnly: true }}
sx={{ '& .MuiInputBase-input': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' } }}
/>
<TextField label="Challenge Status" value={challenge.status} InputProps={{ readOnly: true }} />
</Box>
))}
</Box>
</FormDialogContent>
<DialogActions>
{acmeOrderView ? (
<Button variant="contained" onClick={() => requestFinalizeACMEOrder(acmeOrderView)} disabled={busy || acmeOrderView.status === 'valid'}>
{busy ? 'Working...' : 'Finalize'}
</Button>
) : null}
<Button onClick={() => setACMEOrderView(null)}>Close</Button>
</DialogActions>
</Dialog>
<PKIACMEOrderDetailsDialog
order={acmeOrderView}
busy={busy}
onClose={() => setACMEOrderView(null)}
onFinalize={requestFinalizeACMEOrder}
/>
<Dialog open={rootOpen} onClose={() => setRootOpen(false)} maxWidth="md" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">New Root CA</Typography>
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
</Box>
</DialogTitle>
<FormDialogContent>
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}>
<TextField label="Name" value={rootName} onChange={(event) => setRootName(event.target.value)} />
<TextField label="Common Name" value={rootCN} onChange={(event) => setRootCN(event.target.value)} />
<TextField label="Validity Days" value={rootDays} onChange={(event) => setRootDays(event.target.value)} />
<TextField
label="Import Certificate PEM (optional)"
multiline
minRows={6}
value={rootCertPEM}
onChange={(event) => setRootCertPEM(event.target.value)}
sx={{ '& .MuiInputBase-input': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' } }}
/>
<Box>
<Button variant="outlined" component="label" size="small">
Load Certificate File
<input hidden type="file" accept=".pem,.crt,.cer,.txt" onChange={(event) => loadTextFile(event, setRootCertPEM)} />
</Button>
</Box>
<TextField
label="Import Private Key PEM (optional)"
multiline
minRows={6}
value={rootKeyPEM}
onChange={(event) => setRootKeyPEM(event.target.value)}
sx={{ '& .MuiInputBase-input': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' } }}
/>
<Box>
<Button variant="outlined" component="label" size="small">
Load Private Key File
<input hidden type="file" accept=".pem,.key,.txt" onChange={(event) => loadTextFile(event, setRootKeyPEM)} />
</Button>
</Box>
</Box>
</FormDialogContent>
<DialogActions>
<Button variant="contained" onClick={createRoot} disabled={busy}>{busy ? 'Saving...' : 'Create'}</Button>
<Button onClick={() => setRootOpen(false)}>Cancel</Button>
</DialogActions>
</Dialog>
<PKIRootCADialog
open={rootOpen}
error={dialogError}
busy={busy}
name={rootName}
onNameChange={setRootName}
commonName={rootCN}
onCommonNameChange={setRootCN}
days={rootDays}
onDaysChange={setRootDays}
certPEM={rootCertPEM}
onCertPEMChange={setRootCertPEM}
keyPEM={rootKeyPEM}
onKeyPEMChange={setRootKeyPEM}
onClose={() => setRootOpen(false)}
onCreate={createRoot}
/>
<Dialog open={interOpen} onClose={() => setInterOpen(false)} maxWidth="md" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">New Intermediate CA</Typography>
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
</Box>
</DialogTitle>
<FormDialogContent>
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}>
<TextField label="Name" value={interName} onChange={(event) => setInterName(event.target.value)} />
<SelectField
select
label="Parent CA"
value={interParent}
onChange={(event) => setInterParent(event.target.value)}
>
{cas.map((ca) => (
<MenuItem key={ca.id} value={ca.id}>{ca.name}</MenuItem>
))}
</SelectField>
<TextField label="Common Name" value={interCN} onChange={(event) => setInterCN(event.target.value)} />
<TextField label="Validity Days" value={interDays} onChange={(event) => setInterDays(event.target.value)} />
<TextField
label="Import Intermediate Certificate PEM (optional)"
multiline
minRows={6}
value={interCertPEM}
onChange={(event) => setInterCertPEM(event.target.value)}
sx={{ '& .MuiInputBase-input': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' } }}
/>
<Box>
<Button variant="outlined" component="label" size="small">
Load Intermediate Certificate File
<input hidden type="file" accept=".pem,.crt,.cer,.txt" onChange={(event) => loadTextFile(event, setInterCertPEM)} />
</Button>
</Box>
<TextField
label="Import Intermediate Private Key PEM (optional)"
multiline
minRows={6}
value={interKeyPEM}
onChange={(event) => setInterKeyPEM(event.target.value)}
sx={{ '& .MuiInputBase-input': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' } }}
/>
<Box>
<Button variant="outlined" component="label" size="small">
Load Intermediate Private Key File
<input hidden type="file" accept=".pem,.key,.txt" onChange={(event) => loadTextFile(event, setInterKeyPEM)} />
</Button>
</Box>
</Box>
</FormDialogContent>
<DialogActions>
<Button variant="contained" onClick={createIntermediate} disabled={busy}>{busy ? 'Saving...' : 'Create'}</Button>
<Button onClick={() => setInterOpen(false)}>Cancel</Button>
</DialogActions>
</Dialog>
<PKIIntermediateCADialog
open={interOpen}
error={dialogError}
busy={busy}
cas={cas}
name={interName}
onNameChange={setInterName}
parentID={interParent}
onParentIDChange={setInterParent}
commonName={interCN}
onCommonNameChange={setInterCN}
days={interDays}
onDaysChange={setInterDays}
certPEM={interCertPEM}
onCertPEMChange={setInterCertPEM}
keyPEM={interKeyPEM}
onKeyPEMChange={setInterKeyPEM}
onClose={() => setInterOpen(false)}
onCreate={createIntermediate}
/>
<Dialog open={issueOpen} onClose={() => setIssueOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">Issue Certificate</Typography>
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
</Box>
</DialogTitle>
<FormDialogContent>
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}>
<SelectField select label="Issuer CA" value={issueCA} onChange={(event) => setIssueCA(event.target.value)}>
{cas.map((ca) => (
<MenuItem key={ca.id} value={ca.id}>{ca.name}</MenuItem>
))}
</SelectField>
<TextField label="Common Name" value={issueCN} onChange={(event) => setIssueCN(event.target.value)} />
<TextField label="SAN DNS (comma-separated)" value={issueDNS} onChange={(event) => setIssueDNS(event.target.value)} />
<TextField label="SAN IPs (comma-separated)" value={issueIPs} onChange={(event) => setIssueIPs(event.target.value)} />
<TextField label="Validity Seconds" value={issueValidSeconds} onChange={(event) => setIssueValidSeconds(event.target.value)} helperText="Default 31536000 (365 days)" />
<FormControlLabel control={<Checkbox checked={issueIsCA} onChange={(event) => setIssueIsCA(event.target.checked)} />} label="Issue as CA certificate" />
</Box>
</FormDialogContent>
<DialogActions>
<Button variant="contained" onClick={issueCert} disabled={busy}>{busy ? 'Saving...' : 'Issue'}</Button>
<Button onClick={() => setIssueOpen(false)}>Cancel</Button>
</DialogActions>
</Dialog>
<PKICertIssueDialog
open={issueOpen}
error={dialogError}
busy={busy}
cas={cas}
caID={issueCA}
onCAIDChange={setIssueCA}
commonName={issueCN}
onCommonNameChange={setIssueCN}
sanDNS={issueDNS}
onSANDNSChange={setIssueDNS}
sanIPs={issueIPs}
onSANIPsChange={setIssueIPs}
validSeconds={issueValidSeconds}
onValidSecondsChange={setIssueValidSeconds}
isCA={issueIsCA}
onIsCAChange={setIssueIsCA}
onClose={() => setIssueOpen(false)}
onIssue={issueCert}
/>
<Dialog open={importOpen} onClose={() => setImportOpen(false)} maxWidth="md" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">Import Certificate</Typography>
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
</Box>
</DialogTitle>
<FormDialogContent>
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}>
<SelectField select label="Issuer CA (optional)" value={importCA} onChange={(event) => setImportCA(event.target.value)}>
<MenuItem value="">(none, standalone)</MenuItem>
{cas.map((ca) => (
<MenuItem key={ca.id} value={ca.id}>{ca.name}</MenuItem>
))}
</SelectField>
<TextField
label="Certificate PEM"
multiline
minRows={6}
value={importCertPEM}
onChange={(event) => setImportCertPEM(event.target.value)}
sx={{ '& .MuiInputBase-input': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' } }}
/>
<Box>
<Button variant="outlined" component="label" size="small">
Load Certificate File
<input hidden type="file" accept=".pem,.crt,.cer,.txt" onChange={(event) => loadTextFile(event, setImportCertPEM)} />
</Button>
</Box>
<TextField
label="Private Key PEM"
multiline
minRows={6}
value={importKeyPEM}
onChange={(event) => setImportKeyPEM(event.target.value)}
sx={{ '& .MuiInputBase-input': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' } }}
/>
<Box>
<Button variant="outlined" component="label" size="small">
Load Private Key File
<input hidden type="file" accept=".pem,.key,.txt" onChange={(event) => loadTextFile(event, setImportKeyPEM)} />
</Button>
</Box>
</Box>
</FormDialogContent>
<DialogActions>
<Button variant="contained" onClick={importCert} disabled={busy}>{busy ? 'Saving...' : 'Import'}</Button>
<Button onClick={() => setImportOpen(false)}>Cancel</Button>
</DialogActions>
</Dialog>
<PKICertImportDialog
open={importOpen}
error={dialogError}
busy={busy}
cas={cas}
caID={importCA}
onCAIDChange={setImportCA}
certPEM={importCertPEM}
onCertPEMChange={setImportCertPEM}
keyPEM={importKeyPEM}
onKeyPEMChange={setImportKeyPEM}
onClose={() => setImportOpen(false)}
onImport={importCert}
/>
<Dialog open={Boolean(revokeID)} onClose={() => setRevokeID('')} maxWidth="xs" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">Revoke Certificate</Typography>
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
</Box>
</DialogTitle>
<FormDialogContent>
<TextField fullWidth label="Reason (optional)" value={revokeReason} onChange={(event) => setRevokeReason(event.target.value)} />
</FormDialogContent>
<DialogActions>
<Button color="warning" variant="contained" onClick={revokeCert} disabled={busy}>{busy ? 'Working...' : 'Revoke'}</Button>
<Button onClick={() => setRevokeID('')}>Cancel</Button>
</DialogActions>
</Dialog>
<PKICertRevokeDialog
open={Boolean(revokeID)}
error={dialogError}
busy={busy}
reason={revokeReason}
onReasonChange={setRevokeReason}
onClose={() => setRevokeID('')}
onRevoke={revokeCert}
/>
<Dialog open={Boolean(deleteID)} onClose={() => setDeleteID('')} maxWidth="xs" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">Delete Certificate</Typography>
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
</Box>
</DialogTitle>
<FormDialogContent>
<Typography variant="body2" color="text.secondary">Delete certificate permanently?</Typography>
</FormDialogContent>
<DialogActions>
<Button color="error" variant="contained" onClick={deleteCert} disabled={busy}>{busy ? 'Working...' : 'Delete'}</Button>
<Button onClick={() => setDeleteID('')}>Cancel</Button>
</DialogActions>
</Dialog>
<ConfirmDeleteDialog
open={Boolean(deleteID)}
title="Delete Certificate"
prompt="Type the certificate common name to confirm deletion."
expectedText={deleteID ? certConfirmText(deleteID) : ''}
confirmLabel="Common Name"
confirmValue={deleteConfirm}
onConfirmValueChange={setDeleteConfirm}
error={dialogError}
busy={busy}
busyText="Working..."
onClose={() => {
if (busy) return
setDeleteID('')
setDeleteConfirm('')
setDialogError(null)
}}
onConfirm={deleteCert}
/>
<Dialog open={Boolean(deleteCAID)} onClose={() => setDeleteCAID('')} maxWidth="xs" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">Delete Certificate Authority</Typography>
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
</Box>
</DialogTitle>
<FormDialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Type the CA name to confirm deletion.
</Typography>
<TextField fullWidth label="CA Name" value={deleteCAConfirm} onChange={(event) => setDeleteCAConfirm(event.target.value)} />
<ConfirmDeleteDialog
open={Boolean(deleteCAID)}
title="Delete Certificate Authority"
prompt="Type the CA name to confirm deletion."
expectedText={deleteCAName}
confirmLabel="CA Name"
confirmValue={deleteCAConfirm}
onConfirmValueChange={setDeleteCAConfirm}
error={dialogError}
busy={busy}
confirmText="Delete CA"
busyText="Working..."
onClose={() => {
if (busy) return
setDeleteCAID('')
setDeleteCAName('')
setDeleteCAConfirm('')
setDeleteCAForce(false)
setDialogError(null)
}}
onConfirm={deleteCA}
>
<FormControlLabel
control={<Checkbox checked={deleteCAForce} onChange={(event) => setDeleteCAForce(event.target.checked)} />}
label="Force delete (includes child CAs and issued certs)"
/>
</FormDialogContent>
<DialogActions>
<Button
color="error"
variant="contained"
onClick={deleteCA}
disabled={busy || deleteCAConfirm !== deleteCAName}
>
{busy ? 'Working...' : 'Delete CA'}
</Button>
<Button onClick={() => setDeleteCAID('')}>Cancel</Button>
</DialogActions>
</Dialog>
</ConfirmDeleteDialog>
<PKICADetailsDialog
item={viewCA}
@@ -1435,45 +1246,19 @@ export default function AdminPKIPage() {
onDownloadKey={(item) => downloadText(`${item.name || item.id}.ca.key.pem`, item.key_pem || '')}
/>
<Dialog open={Boolean(editCAID)} onClose={() => { setEditCAID(''); setEditCAParent('') }} maxWidth="xs" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">Edit CA</Typography>
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
</Box>
</DialogTitle>
<FormDialogContent>
<TextField
fullWidth
label="Name"
value={editCAName}
onChange={(event) => setEditCAName(event.target.value)}
sx={{ mt: 1 }}
/>
<SelectField
select
fullWidth
label="Parent CA"
value={editCAParent}
onChange={(event) => setEditCAParent(event.target.value)}
>
<MenuItem value="">(none)</MenuItem>
{cas
.filter((item) => item.id !== editCAID)
.map((item) => (
<MenuItem key={item.id} value={item.id}>
{item.name} ({item.id})
</MenuItem>
))}
</SelectField>
</FormDialogContent>
<DialogActions>
<Button variant="contained" onClick={saveCAEdit} disabled={busy || !editCAName.trim()}>
{busy ? 'Saving...' : 'Save'}
</Button>
<Button onClick={() => { setEditCAID(''); setEditCAParent('') }}>Cancel</Button>
</DialogActions>
</Dialog>
<PKICAEditDialog
open={Boolean(editCAID)}
error={dialogError}
busy={busy}
cas={cas}
editID={editCAID}
name={editCAName}
onNameChange={setEditCAName}
parentID={editCAParent}
onParentIDChange={setEditCAParent}
onClose={() => { setEditCAID(''); setEditCAParent('') }}
onSave={saveCAEdit}
/>
<PKICertDetailsDialog
item={viewCert}
+49 -110
View File
@@ -1,19 +1,17 @@
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog'
import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle'
import Paper from '@mui/material/Paper'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { useEffect, useMemo, useState } from 'react'
import FormDialogContent from '../components/FormDialogContent'
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard'
import SSHServerDetailsDialog from '../components/SSHServerDetailsDialog'
import SSHServerFormDialog from '../components/SSHServerFormDialog'
import { SSHServerFormState } from '../components/SSHServerFormDialog'
import SSHHostKeysDialog from '../components/SSHHostKeysDialog'
import { api, SSHServer, SSHServerHostKey, SSHServerUpsertPayload } from '../api'
const emptyForm = (): SSHServerFormState => ({
@@ -36,6 +34,9 @@ export default function AdminSSHServersPage() {
const [dialogError, setDialogError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [deleteItem, setDeleteItem] = useState<SSHServer | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState('')
const [deleteError, setDeleteError] = useState<string | null>(null)
const [deleting, setDeleting] = useState(false)
const [viewItem, setViewItem] = useState<SSHServer | null>(null)
const [hostKeyServer, setHostKeyServer] = useState<SSHServer | null>(null)
const [hostKeys, setHostKeys] = useState<SSHServerHostKey[]>([])
@@ -153,13 +154,17 @@ export default function AdminSSHServersPage() {
if (!deleteItem) {
return
}
setDeleting(true)
setDeleteError(null)
try {
await api.deleteSSHServerAdmin(deleteItem.id)
setDeleteItem(null)
setDeleteConfirm('')
await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete SSH server')
setDeleteItem(null)
setDeleteError(err instanceof Error ? err.message : 'Failed to delete SSH server')
} finally {
setDeleting(false)
}
}
@@ -303,7 +308,11 @@ export default function AdminSSHServersPage() {
<ListRowActions>
<ListRowActionButton onClick={() => setViewItem(item)}>View</ListRowActionButton>
<ListRowActionButton onClick={() => openEdit(item)}>Edit</ListRowActionButton>
<ListRowActionButton color="error" onClick={() => setDeleteItem(item)}>
<ListRowActionButton color="error" onClick={() => {
setDeleteError(null)
setDeleteConfirm('')
setDeleteItem(item)
}}>
Delete
</ListRowActionButton>
<ListRowActionButton onClick={() => void openHostKeys(item)}>Host Keys</ListRowActionButton>
@@ -342,110 +351,40 @@ export default function AdminSSHServersPage() {
onSave={handleSave}
/>
<Dialog open={Boolean(deleteItem)} onClose={() => setDeleteItem(null)} maxWidth="xs" fullWidth>
<DialogTitle>Delete SSH Server</DialogTitle>
<FormDialogContent>
<Typography variant="body2">
Delete <strong>{deleteItem?.name}</strong>?
</Typography>
</FormDialogContent>
<DialogActions>
<Button variant="contained" color="error" onClick={handleDelete}>
Delete
</Button>
<Button onClick={() => setDeleteItem(null)}>Cancel</Button>
</DialogActions>
</Dialog>
<ConfirmDeleteDialog
open={Boolean(deleteItem)}
title="Delete SSH Server"
prompt="Type the SSH server name to confirm deletion."
expectedText={deleteItem?.name || ''}
confirmLabel="Server Name"
confirmValue={deleteConfirm}
onConfirmValueChange={setDeleteConfirm}
error={deleteError}
busy={deleting}
onClose={() => {
if (deleting) return
setDeleteItem(null)
setDeleteConfirm('')
setDeleteError(null)
}}
onConfirm={handleDelete}
/>
<Dialog open={Boolean(hostKeyServer)} onClose={closeHostKeys} maxWidth="md" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">Pinned Host Keys</Typography>
<Typography variant="body2" color="text.secondary">
{hostKeyServer ? `${hostKeyServer.name} (${hostKeyServer.host}:${hostKeyServer.port})` : ''}
</Typography>
{hostKeysError ? <Alert severity="error">{hostKeysError}</Alert> : null}
</Box>
</DialogTitle>
<FormDialogContent>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Button variant="outlined" onClick={handleDiscoverHostKey} disabled={hostKeySaving || hostKeysLoading}>
Discover
</Button>
<Button
variant="outlined"
onClick={() => void handleAddHostKey(hostKeyInput)}
disabled={hostKeySaving || !hostKeyInput.trim()}
>
Add Manual Key
</Button>
</Box>
{discoveredHostKey ? (
<Alert
severity="info"
action={
<Button color="inherit" size="small" onClick={() => void handleAddHostKey(discoveredHostKey.public_key)} disabled={hostKeySaving}>
Pin
</Button>
}
>
Discovered {discoveredHostKey.algorithm} · {discoveredHostKey.fingerprint}
</Alert>
) : null}
<TextField
label="Manual Host Public Key"
multiline
minRows={3}
value={hostKeyInput}
onChange={(event) => setHostKeyInput(event.target.value)}
helperText="Paste an authorized_keys style SSH host public key."
/>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="subtitle2">Pinned Keys</Typography>
{hostKeysLoading ? (
<Typography variant="body2" color="text.secondary">
Loading...
</Typography>
) : null}
{!hostKeysLoading && hostKeys.length === 0 ? (
<Typography variant="body2" color="text.secondary">
No pinned host keys.
</Typography>
) : null}
{hostKeys.map((item) => (
<Paper
key={item.id}
variant="outlined"
sx={{
p: 1,
display: 'grid',
gap: 0.5,
backgroundColor: 'transparent',
backgroundImage: 'none'
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="subtitle2">{item.fingerprint}</Typography>
<ListRowActions>
<ListRowActionButton color="error" onClick={() => void handleDeleteHostKey(item.id)} disabled={hostKeySaving}>
Delete
</ListRowActionButton>
</ListRowActions>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
{item.algorithm}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
{item.public_key}
</Typography>
</Paper>
))}
</Box>
</FormDialogContent>
<DialogActions>
<Button onClick={closeHostKeys}>Cancel</Button>
</DialogActions>
</Dialog>
<SSHHostKeysDialog
server={hostKeyServer}
hostKeys={hostKeys}
loading={hostKeysLoading}
error={hostKeysError}
input={hostKeyInput}
onInputChange={setHostKeyInput}
saving={hostKeySaving}
discoveredHostKey={discoveredHostKey}
closeText="Cancel"
onClose={closeHostKeys}
onDiscover={() => void handleDiscoverHostKey()}
onAdd={(publicKey: string) => void handleAddHostKey(publicKey)}
onDelete={(hostKeyID: string) => void handleDeleteHostKey(hostKeyID)}
/>
</Box>
)
}
+12 -43
View File
@@ -2,8 +2,6 @@ import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Chip from '@mui/material/Chip'
import Dialog from '@mui/material/Dialog'
import DialogTitle from '@mui/material/DialogTitle'
import Link from '@mui/material/Link'
import MenuItem from '@mui/material/MenuItem'
import TextField from '@mui/material/TextField'
@@ -13,6 +11,7 @@ import { useCallback, useEffect, useState } from 'react'
import SectionCard from '../components/SectionCard'
import SSHAccessProfileDetailsDialog from '../components/SSHAccessProfileDetailsDialog'
import SSHServerDetailsDialog from '../components/SSHServerDetailsDialog'
import SSHTranscriptDialog from '../components/SSHTranscriptDialog'
import { api, SSHAccessProfile, SSHServer, SSHSession, SSHSessionListResponse } from '../api'
function fmt(value: number): string {
@@ -321,47 +320,17 @@ export default function AdminSSHSessionsPage() {
onClose={() => setViewServer(null)}
showAdminFields
/>
<Dialog open={Boolean(transcriptItem)} onClose={closeTranscript} fullWidth maxWidth="md">
<DialogTitle>
{transcriptItem ? `Transcript: ${sessionTitle(transcriptItem)}` : 'Transcript'}
</DialogTitle>
<Box sx={{ display: 'grid', gap: 1.5, p: 2 }}>
{transcriptError ? <Alert severity="error">{transcriptError}</Alert> : null}
{loadingTranscript ? (
<Typography variant="body2" color="text.secondary">
Loading transcript...
</Typography>
) : null}
{!loadingTranscript && !transcriptError ? (
<Box
component="pre"
sx={{
m: 0,
p: 1.5,
borderRadius: 1,
bgcolor: 'background.default',
border: (theme) => `1px solid ${theme.palette.divider}`,
overflow: 'auto',
maxHeight: '70vh',
fontFamily: 'monospace',
fontSize: 13,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
}}
>
{transcriptText || '(empty transcript)'}
</Box>
) : null}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
<Button onClick={closeTranscript}>
Close
</Button>
<Button variant="contained" onClick={downloadTranscript} disabled={!transcriptItem || loadingTranscript || transcriptText === ''}>
Download
</Button>
</Box>
</Box>
</Dialog>
<SSHTranscriptDialog
open={Boolean(transcriptItem)}
title={transcriptItem ? `Transcript: ${sessionTitle(transcriptItem)}` : 'Transcript'}
text={transcriptText}
error={transcriptError}
loading={loadingTranscript}
downloadDisabled={!transcriptItem}
closeText="Cancel"
onClose={closeTranscript}
onDownload={downloadTranscript}
/>
</Box>
)
}
+6 -16
View File
@@ -18,6 +18,7 @@ import { ListRowActionButton, ListRowActions } from '../components/ListRowAction
import SectionCard from '../components/SectionCard'
import SSHUserCADetailsDialog from '../components/SSHUserCADetailsDialog'
import SSHPrincipalGrantDetailsDialog from '../components/SSHPrincipalGrantDetailsDialog'
import SSHCertificateInspectDialog from '../components/SSHCertificateInspectDialog'
import { api, SSHPrincipalGrant, SSHUserCA, SSHUserCAIssuance } from '../api'
import SelectField from '../components/SelectField'
@@ -268,22 +269,11 @@ export default function AdminSSHSignHistoryPage() {
onClose={() => setViewPrincipalGrant(null)}
/>
<Dialog open={inspectOpen} onClose={() => setInspectOpen(false)} maxWidth="md" fullWidth>
<DialogTitle>Certificate Inspect</DialogTitle>
<FormDialogContent>
<TextField
multiline
minRows={12}
value={inspectDump}
InputProps={{ readOnly: true }}
fullWidth
sx={{ mt: 1, '& .MuiInputBase-input': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' } }}
/>
</FormDialogContent>
<DialogActions>
<Button onClick={() => setInspectOpen(false)}>Close</Button>
</DialogActions>
</Dialog>
<SSHCertificateInspectDialog
open={inspectOpen}
dump={inspectDump}
onClose={() => setInspectOpen(false)}
/>
</Box>
)
}
+7 -16
View File
@@ -21,6 +21,7 @@ import { ListRowActionButton, ListRowActions } from '../components/ListRowAction
import SectionCard from '../components/SectionCard'
import SSHUserCADetailsDialog from '../components/SSHUserCADetailsDialog'
import SSHUserCAFormDialog, { SSHUserCAFormState } from '../components/SSHUserCAFormDialog'
import SSHCertificateInspectDialog from '../components/SSHCertificateInspectDialog'
import { api, SSHSignUserKeyResponse, SSHUserCA, SSHUserCACreatePayload, SSHUserCASignPayload, SSHUserCAUpdatePayload } from '../api'
function fmt(value: number): string {
@@ -442,22 +443,12 @@ export default function AdminSSHUserCAPage() {
</DialogActions>
</Dialog>
<Dialog open={inspectOpen} onClose={() => setInspectOpen(false)} maxWidth="md" fullWidth>
<DialogTitle>Certificate Inspect</DialogTitle>
<FormDialogContent>
<TextField
multiline
minRows={12}
value={inspectDump}
InputProps={{ readOnly: true }}
fullWidth
sx={{ mt: 1, '& .MuiInputBase-input': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' } }}
/>
</FormDialogContent>
<DialogActions>
<Button onClick={() => setInspectOpen(false)}>Cancel</Button>
</DialogActions>
</Dialog>
<SSHCertificateInspectDialog
open={inspectOpen}
dump={inspectDump}
closeText="Cancel"
onClose={() => setInspectOpen(false)}
/>
</Box>
)
}
+174 -19
View File
@@ -1,5 +1,6 @@
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import Alert from '@mui/material/Alert'
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
import FormDialogContent from '../components/FormDialogContent'
import Link from '@mui/material/Link'
import PKICertDetailsDialog from '../components/PKICertDetailsDialog'
@@ -109,6 +110,15 @@ export default function AdminServicePrincipalsPage() {
const [viewCertDump, setViewCertDump] = useState('')
const [viewCertDumpOpen, setViewCertDumpOpen] = useState(false)
const [viewCertDumpLoading, setViewCertDumpLoading] = useState(false)
const [deleteError, setDeleteError] = useState<string | null>(null)
const [deletePrincipalTarget, setDeletePrincipalTarget] = useState<ServicePrincipal | null>(null)
const [deletePrincipalConfirm, setDeletePrincipalConfirm] = useState('')
const [deleteBindingTarget, setDeleteBindingTarget] = useState<CertPrincipalBinding | null>(null)
const [deleteBindingConfirm, setDeleteBindingConfirm] = useState('')
const [deleteRoleTarget, setDeleteRoleTarget] = useState<{ principalID: string; projectID: string } | null>(null)
const [deleteRoleConfirm, setDeleteRoleConfirm] = useState('')
const [deletePrincipalKeyTarget, setDeletePrincipalKeyTarget] = useState<PrincipalAPIKey | null>(null)
const [deletePrincipalKeyConfirm, setDeletePrincipalKeyConfirm] = useState('')
const load = async () => {
let allProjectsPage: { items: Project[]; total: number }
@@ -261,18 +271,23 @@ export default function AdminServicePrincipalsPage() {
}
}
const deletePrincipalKey = async (item: PrincipalAPIKey) => {
const deletePrincipalKey = async () => {
if (!keysPrincipal) {
return
}
if (!window.confirm(`Delete API key "${item.name}"?`)) return
if (!deletePrincipalKeyTarget) {
return
}
setKeysBusy(true)
setDeleteError(null)
setKeysError(null)
try {
await api.deleteServicePrincipalAPIKey(keysPrincipal.id, item.id)
await api.deleteServicePrincipalAPIKey(keysPrincipal.id, deletePrincipalKeyTarget.id)
setDeletePrincipalKeyTarget(null)
setDeletePrincipalKeyConfirm('')
await loadPrincipalKeys(keysPrincipal.id)
} catch (err) {
setKeysError(err instanceof Error ? err.message : 'Failed to delete API key')
setDeleteError(err instanceof Error ? err.message : 'Failed to delete API key')
} finally {
setKeysBusy(false)
}
@@ -308,14 +323,22 @@ export default function AdminServicePrincipalsPage() {
}
}
const deletePrincipal = async (item: ServicePrincipal) => {
if (!window.confirm(`Delete principal "${item.name}"?`)) return
const deletePrincipal = async () => {
if (!deletePrincipalTarget) {
return
}
setBusy(true)
setDeleteError(null)
setError(null)
try {
await api.deleteServicePrincipal(item.id)
await api.deleteServicePrincipal(deletePrincipalTarget.id)
setDeletePrincipalTarget(null)
setDeletePrincipalConfirm('')
await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete principal')
setDeleteError(err instanceof Error ? err.message : 'Failed to delete principal')
} finally {
setBusy(false)
}
}
@@ -364,14 +387,22 @@ export default function AdminServicePrincipalsPage() {
}
}
const deleteBinding = async (item: CertPrincipalBinding) => {
if (!window.confirm(`Delete binding for fingerprint ${item.fingerprint}?`)) return
const deleteBinding = async () => {
if (!deleteBindingTarget) {
return
}
setBusy(true)
setDeleteError(null)
setError(null)
try {
await api.deleteCertPrincipalBinding(item.fingerprint)
await api.deleteCertPrincipalBinding(deleteBindingTarget.fingerprint)
setDeleteBindingTarget(null)
setDeleteBindingConfirm('')
await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete binding')
setDeleteError(err instanceof Error ? err.message : 'Failed to delete binding')
} finally {
setBusy(false)
}
}
@@ -410,13 +441,22 @@ export default function AdminServicePrincipalsPage() {
}
}
const deleteRole = async (principalID: string, projectID: string) => {
const deleteRole = async () => {
if (!deleteRoleTarget) {
return
}
setBusy(true)
setDeleteError(null)
setError(null)
try {
await api.deletePrincipalProjectRole(principalID, projectID)
await api.deletePrincipalProjectRole(deleteRoleTarget.principalID, deleteRoleTarget.projectID)
setDeleteRoleTarget(null)
setDeleteRoleConfirm('')
await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete role assignment')
setDeleteError(err instanceof Error ? err.message : 'Failed to delete role assignment')
} finally {
setBusy(false)
}
}
@@ -426,6 +466,12 @@ export default function AdminServicePrincipalsPage() {
return `${p.name} (${p.slug})`
}
const projectConfirmText = (id: string): string => {
const p = projects.find((item) => item.id === id)
if (!p) return id
return p.slug || p.name || id
}
const pkiCertByFingerprint = (fingerprint: string): PKICert | undefined => {
return pkiCerts.find((item) => (item.fingerprint || '').toLowerCase() === (fingerprint || '').toLowerCase())
}
@@ -545,7 +591,16 @@ export default function AdminServicePrincipalsPage() {
<ListRowActionButton color={item.disabled ? 'success' : 'warning'} onClick={() => togglePrincipal(item)}>
{item.disabled ? 'Enable' : 'Disable'}
</ListRowActionButton>
<ListRowActionButton color="error" onClick={() => deletePrincipal(item)}>Delete</ListRowActionButton>
<ListRowActionButton
color="error"
onClick={() => {
setDeleteError(null)
setDeletePrincipalTarget(item)
setDeletePrincipalConfirm('')
}}
>
Delete
</ListRowActionButton>
</ListRowActions>
</Box>
<Typography variant="caption" color="text.secondary">
@@ -647,7 +702,16 @@ export default function AdminServicePrincipalsPage() {
(principalRoles[principal.id] || []).map((role) => (
<Box key={`${role.principal_id}:${role.project_id}`} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="caption" color="text.secondary">{projectName(role.project_id)} · {role.role}</Typography>
<ListRowActionButton color="error" onClick={() => deleteRole(role.principal_id, role.project_id)}>Remove</ListRowActionButton>
<ListRowActionButton
color="error"
onClick={() => {
setDeleteError(null)
setDeleteRoleTarget({ principalID: role.principal_id, projectID: role.project_id })
setDeleteRoleConfirm('')
}}
>
Delete
</ListRowActionButton>
</Box>
))
)}
@@ -712,7 +776,16 @@ export default function AdminServicePrincipalsPage() {
<ListRowActionButton color={item.enabled ? 'warning' : 'success'} onClick={() => toggleBinding(item)}>
{item.enabled ? 'Disable' : 'Enable'}
</ListRowActionButton>
<ListRowActionButton color="error" onClick={() => deleteBinding(item)}>Delete</ListRowActionButton>
<ListRowActionButton
color="error"
onClick={() => {
setDeleteError(null)
setDeleteBindingTarget(item)
setDeleteBindingConfirm('')
}}
>
Delete
</ListRowActionButton>
</ListRowActions>
</Box>
</Paper>
@@ -903,7 +976,15 @@ export default function AdminServicePrincipalsPage() {
<ListRowActionButton color={item.disabled ? 'success' : 'warning'} onClick={() => togglePrincipalKey(item)} disabled={keysBusy}>
{item.disabled ? 'Enable' : 'Disable'}
</ListRowActionButton>
<ListRowActionButton color="error" onClick={() => deletePrincipalKey(item)} disabled={keysBusy}>
<ListRowActionButton
color="error"
onClick={() => {
setDeleteError(null)
setDeletePrincipalKeyTarget(item)
setDeletePrincipalKeyConfirm('')
}}
disabled={keysBusy}
>
Delete
</ListRowActionButton>
</ListRowActions>
@@ -920,6 +1001,80 @@ export default function AdminServicePrincipalsPage() {
<Button onClick={closeKeys}>{newPrincipalToken ? 'Done' : 'Close'}</Button>
</DialogActions>
</Dialog>
<ConfirmDeleteDialog
open={!!deletePrincipalTarget}
title="Delete Service Principal"
prompt="Type the service principal name to confirm deletion."
expectedText={deletePrincipalTarget?.name || ''}
confirmLabel="Principal Name"
confirmValue={deletePrincipalConfirm}
onConfirmValueChange={setDeletePrincipalConfirm}
error={deleteError}
busy={busy}
onClose={() => {
if (busy) return
setDeletePrincipalTarget(null)
setDeletePrincipalConfirm('')
setDeleteError(null)
}}
onConfirm={deletePrincipal}
/>
<ConfirmDeleteDialog
open={!!deleteBindingTarget}
title="Delete Cert Binding"
prompt="Type the bound principal name to confirm deletion."
expectedText={deleteBindingTarget ? principalName(deleteBindingTarget.principal_id) : ''}
confirmLabel="Principal Name"
confirmValue={deleteBindingConfirm}
onConfirmValueChange={setDeleteBindingConfirm}
error={deleteError}
busy={busy}
onClose={() => {
if (busy) return
setDeleteBindingTarget(null)
setDeleteBindingConfirm('')
setDeleteError(null)
}}
onConfirm={deleteBinding}
/>
<ConfirmDeleteDialog
open={!!deleteRoleTarget}
title="Delete Project Role Assignment"
prompt="Type the project slug to confirm deleting this role assignment."
expectedText={deleteRoleTarget ? projectConfirmText(deleteRoleTarget.projectID) : ''}
confirmLabel="Project Slug"
confirmValue={deleteRoleConfirm}
onConfirmValueChange={setDeleteRoleConfirm}
error={deleteError}
busy={busy}
confirmText="Delete Assignment"
busyText="Deleting..."
onClose={() => {
if (busy) return
setDeleteRoleTarget(null)
setDeleteRoleConfirm('')
setDeleteError(null)
}}
onConfirm={deleteRole}
/>
<ConfirmDeleteDialog
open={!!deletePrincipalKeyTarget}
title="Delete API Key"
prompt="Type the API key name to confirm deletion."
expectedText={deletePrincipalKeyTarget?.name || ''}
confirmLabel="Key Name"
confirmValue={deletePrincipalKeyConfirm}
onConfirmValueChange={setDeletePrincipalKeyConfirm}
error={deleteError}
busy={keysBusy}
onClose={() => {
if (keysBusy) return
setDeletePrincipalKeyTarget(null)
setDeletePrincipalKeyConfirm('')
setDeleteError(null)
}}
onConfirm={deletePrincipalKey}
/>
</Box>
)
}
+30 -21
View File
@@ -11,6 +11,7 @@ import Paper from '@mui/material/Paper'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { useEffect, useMemo, useState } from 'react'
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
import FormDialogContent from '../components/FormDialogContent'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard'
@@ -45,8 +46,10 @@ export default function AdminTLSAuthPoliciesPage() {
const [permissionsText, setPermissionsText] = useState('')
const [dialogError, setDialogError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [confirmOpen, setConfirmOpen] = useState(false)
const [confirmPolicy, setConfirmPolicy] = useState<TLSAuthPolicy | null>(null)
const [confirmText, setConfirmText] = useState('')
const [deleteError, setDeleteError] = useState<string | null>(null)
const [deleting, setDeleting] = useState(false)
const load = async () => {
setLoading(true)
@@ -157,18 +160,19 @@ export default function AdminTLSAuthPoliciesPage() {
const handleDelete = async () => {
if (!confirmPolicy) {
setConfirmOpen(false)
return
}
setDeleting(true)
setDeleteError(null)
try {
await api.deleteTLSAuthPolicy(confirmPolicy.id)
setConfirmOpen(false)
setConfirmPolicy(null)
setConfirmText('')
await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete TLS auth policy')
setConfirmOpen(false)
setConfirmPolicy(null)
setDeleteError(err instanceof Error ? err.message : 'Failed to delete TLS auth policy')
} finally {
setDeleting(false)
}
}
@@ -233,8 +237,9 @@ export default function AdminTLSAuthPoliciesPage() {
<ListRowActions>
<ListRowActionButton onClick={() => openEdit(item)}>Edit</ListRowActionButton>
<ListRowActionButton color="error" onClick={() => {
setDeleteError(null)
setConfirmPolicy(item)
setConfirmOpen(true)
setConfirmText('')
}}>
Delete
</ListRowActionButton>
@@ -361,20 +366,24 @@ export default function AdminTLSAuthPoliciesPage() {
</DialogActions>
</Dialog>
<Dialog open={confirmOpen} onClose={() => setConfirmOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle>Delete TLS Auth Policy</DialogTitle>
<FormDialogContent>
<Typography variant="body2">
Delete policy "{confirmPolicy?.name}"?
</Typography>
</FormDialogContent>
<DialogActions>
<Button variant="contained" color="error" onClick={handleDelete}>
Delete
</Button>
<Button onClick={() => setConfirmOpen(false)}>Cancel</Button>
</DialogActions>
</Dialog>
<ConfirmDeleteDialog
open={!!confirmPolicy}
title="Delete TLS Auth Policy"
prompt="Type the policy name to confirm deletion."
expectedText={confirmPolicy?.name || ''}
confirmLabel="Policy Name"
confirmValue={confirmText}
onConfirmValueChange={setConfirmText}
error={deleteError}
busy={deleting}
onClose={() => {
if (deleting) return
setConfirmPolicy(null)
setConfirmText('')
setDeleteError(null)
}}
onConfirm={handleDelete}
/>
</Box>
)
}
+47 -49
View File
@@ -1,4 +1,5 @@
import Alert from '@mui/material/Alert'
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
import FormDialogContent from '../components/FormDialogContent'
import {
Box,
@@ -74,10 +75,11 @@ export default function AdminTLSSettingsPage() {
const [confirmMessage, setConfirmMessage] = useState('')
const [confirmLabel, setConfirmLabel] = useState('Confirm')
const [confirmColor, setConfirmColor] = useState<'primary' | 'warning' | 'error'>('primary')
const [confirmRequiresText, setConfirmRequiresText] = useState(false)
const [confirmExpectedText, setConfirmExpectedText] = useState('')
const [confirmInput, setConfirmInput] = useState('')
const [confirmAction, setConfirmAction] = useState<null | (() => Promise<void>)>(null)
const [deleteListenerItem, setDeleteListenerItem] = useState<TLSListener | null>(null)
const [deleteListenerConfirm, setDeleteListenerConfirm] = useState('')
const [deleteListenerError, setDeleteListenerError] = useState<string | null>(null)
const [deleteListenerBusy, setDeleteListenerBusy] = useState(false)
const policyNameByID = useMemo(() => {
const map: Record<string, string> = {}
authPolicies.forEach((item) => {
@@ -327,9 +329,6 @@ export default function AdminTLSSettingsPage() {
setConfirmMessage(`Do you want to ${nextEnabled ? 'enable' : 'disable'} listener "${item.name}"?`)
setConfirmLabel(nextEnabled ? 'Enable' : 'Disable')
setConfirmColor(nextEnabled ? 'primary' : 'warning')
setConfirmRequiresText(false)
setConfirmExpectedText('')
setConfirmInput('')
setConfirmAction(() => async () => {
setError(null)
try {
@@ -345,25 +344,30 @@ export default function AdminTLSSettingsPage() {
}
const handleDeleteListener = async (item: TLSListener) => {
setConfirmTitle('Delete Listener')
setConfirmMessage('Type the listener name to confirm deletion.')
setConfirmLabel('Delete')
setConfirmColor('error')
setConfirmRequiresText(true)
setConfirmExpectedText(item.name)
setConfirmInput('')
setConfirmAction(() => async () => {
setError(null)
try {
await api.deleteTLSListener(item.id)
await loadListeners()
await loadRuntimeStatus()
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete listener'
setError(message)
}
})
setConfirmOpen(true)
setDeleteListenerItem(item)
setDeleteListenerConfirm('')
setDeleteListenerError(null)
}
const deleteListener = async () => {
if (!deleteListenerItem) {
return
}
setDeleteListenerBusy(true)
setDeleteListenerError(null)
setError(null)
try {
await api.deleteTLSListener(deleteListenerItem.id)
setDeleteListenerItem(null)
setDeleteListenerConfirm('')
await loadListeners()
await loadRuntimeStatus()
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete listener'
setDeleteListenerError(message)
} finally {
setDeleteListenerBusy(false)
}
}
const handleConfirm = async () => {
@@ -373,9 +377,6 @@ export default function AdminTLSSettingsPage() {
}
await confirmAction()
setConfirmOpen(false)
setConfirmRequiresText(false)
setConfirmExpectedText('')
setConfirmInput('')
setConfirmAction(null)
}
@@ -626,9 +627,6 @@ export default function AdminTLSSettingsPage() {
open={confirmOpen}
onClose={() => {
setConfirmOpen(false)
setConfirmRequiresText(false)
setConfirmExpectedText('')
setConfirmInput('')
}}
maxWidth="xs"
fullWidth
@@ -636,42 +634,42 @@ export default function AdminTLSSettingsPage() {
<DialogTitle>{confirmTitle}</DialogTitle>
<FormDialogContent>
<Typography variant="body2">{confirmMessage}</Typography>
{confirmRequiresText ? (
<>
<Typography variant="caption" color="text.secondary">
{confirmExpectedText}
</Typography>
<TextField
autoFocus
size="small"
label="Listener Name"
value={confirmInput}
onChange={(event) => setConfirmInput(event.target.value)}
/>
</>
) : null}
</FormDialogContent>
<DialogActions>
<Button
variant="contained"
color={confirmColor}
onClick={handleConfirm}
disabled={confirmRequiresText && confirmInput.trim() !== confirmExpectedText}
>
{confirmLabel}
</Button>
<Button
onClick={() => {
setConfirmOpen(false)
setConfirmRequiresText(false)
setConfirmExpectedText('')
setConfirmInput('')
}}
>
Cancel
</Button>
</DialogActions>
</Dialog>
<ConfirmDeleteDialog
open={Boolean(deleteListenerItem)}
title="Delete Listener"
prompt="Type the listener name to confirm deletion."
expectedText={deleteListenerItem?.name || ''}
confirmLabel="Listener Name"
confirmValue={deleteListenerConfirm}
onConfirmValueChange={setDeleteListenerConfirm}
error={deleteListenerError}
busy={deleteListenerBusy}
onClose={() => {
if (deleteListenerBusy) return
setDeleteListenerItem(null)
setDeleteListenerConfirm('')
setDeleteListenerError(null)
}}
onConfirm={deleteListener}
/>
</Box>
)
}
+65 -27
View File
@@ -2,6 +2,7 @@ import AddIcon from '@mui/icons-material/Add'
import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit'
import Alert from '@mui/material/Alert'
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
import FormDialogContent from '../components/FormDialogContent'
import {
Box,
@@ -58,6 +59,8 @@ export default function AdminUserGroupsPage() {
const [deleteItem, setDeleteItem] = useState<UserGroup | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState('')
const [deleteMemberItem, setDeleteMemberItem] = useState<UserGroupMember | null>(null)
const [deleteMemberConfirm, setDeleteMemberConfirm] = useState('')
const selectedGroup = useMemo(() => {
return groups.find((item) => item.id === selectedGroupID) || null
@@ -226,12 +229,15 @@ export default function AdminUserGroupsPage() {
return
}
setBusy(true)
setDialogError(null)
setError(null)
try {
await api.removeUserGroupMember(selectedGroupID, userID)
setDeleteMemberItem(null)
setDeleteMemberConfirm('')
await loadMembers(selectedGroupID)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to remove member')
setDialogError(err instanceof Error ? err.message : 'Failed to remove member')
} finally {
setBusy(false)
}
@@ -243,6 +249,13 @@ export default function AdminUserGroupsPage() {
return user.display_name ? `${user.display_name} (${user.username})` : user.username
}
const memberConfirmText = (member: UserGroupMember | null) => {
if (!member) {
return ''
}
return member.username || member.user_id
}
const hasDirectProjectCreate = (groupID: string) => {
return subjectPermissions.some((item) => item.permission === projectCreatePermission && item.subject_id === groupID)
}
@@ -271,6 +284,7 @@ export default function AdminUserGroupsPage() {
}
const availableUsers = users.filter((user) => !members.some((member) => member.user_id === user.id))
const deleteMemberExpected = memberConfirmText(deleteMemberItem)
return (
<Box>
@@ -419,7 +433,17 @@ export default function AdminUserGroupsPage() {
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}
>
<Typography variant="body2">{userLabelByID(member.user_id)}</Typography>
<Button size="small" variant="outlined" color="error" onClick={() => removeMember(member.user_id)} disabled={busy}>
<Button
size="small"
variant="outlined"
color="error"
onClick={() => {
setDialogError(null)
setDeleteMemberItem(member)
setDeleteMemberConfirm('')
}}
disabled={busy}
>
Delete
</Button>
</Box>
@@ -484,31 +508,45 @@ export default function AdminUserGroupsPage() {
</DialogActions>
</Dialog>
<Dialog open={!!deleteItem} onClose={() => setDeleteItem(null)} maxWidth="xs" fullWidth>
<DialogTitle>Delete Group</DialogTitle>
<FormDialogContent>
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
<Typography variant="body2">Type the group name to confirm deletion.</Typography>
<TextField
autoFocus
size="small"
label="Group Name"
value={deleteConfirm}
onChange={(event) => setDeleteConfirm(event.target.value)}
/>
</FormDialogContent>
<DialogActions>
<Button
color="error"
variant="contained"
onClick={removeGroup}
disabled={busy || !deleteItem || deleteConfirm.trim() !== deleteItem.name}
>
{busy ? 'Deleting...' : 'Delete'}
</Button>
<Button onClick={() => setDeleteItem(null)}>Cancel</Button>
</DialogActions>
</Dialog>
<ConfirmDeleteDialog
open={!!deleteItem}
title="Delete Group"
prompt="Type the group name to confirm deletion."
expectedText={deleteItem?.name || ''}
confirmLabel="Group Name"
confirmValue={deleteConfirm}
onConfirmValueChange={setDeleteConfirm}
error={dialogError}
busy={busy}
onClose={() => {
if (busy) return
setDeleteItem(null)
setDeleteConfirm('')
}}
onConfirm={removeGroup}
/>
<ConfirmDeleteDialog
open={!!deleteMemberItem}
title="Delete Member"
prompt={`Type the ${deleteMemberItem?.username ? 'username' : 'user ID'} to confirm removal from this group.`}
expectedText={deleteMemberExpected}
confirmLabel={deleteMemberItem?.username ? 'Username' : 'User ID'}
confirmValue={deleteMemberConfirm}
onConfirmValueChange={setDeleteMemberConfirm}
error={dialogError}
busy={busy}
busyText="Removing..."
confirmText="Remove"
onClose={() => {
if (busy) return
setDeleteMemberItem(null)
setDeleteMemberConfirm('')
}}
onConfirm={() => {
if (!deleteMemberItem) return
void removeMember(deleteMemberItem.user_id)
}}
/>
</Box>
)
}
+26 -81
View File
@@ -1,5 +1,5 @@
import Alert from '@mui/material/Alert'
import { Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, MenuItem, Paper, Tab, Tabs, TextField, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'
import { Box, Button, Chip, DialogContent, MenuItem, Paper, TextField, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'
import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { api, Project, ProjectGroupRole, ProjectMember, ProjectUpdatePayload, User, UserGroup } from '../api'
@@ -13,7 +13,8 @@ import { ListRowActionButton, ListRowActions } from '../components/ListRowAction
import SectionCard from '../components/SectionCard'
import DeleteIcon from '@mui/icons-material/Delete'
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
import FormDialogContent from '../components/FormDialogContent'
import ProjectFormDialog from '../components/ProjectFormDialog'
import { ProjectDescriptionTab, ProjectHomePageValue } from '../components/ProjectFormDialog'
import SelectField from '../components/SelectField'
export default function ProjectHomePage() {
@@ -23,8 +24,8 @@ export default function ProjectHomePage() {
const [editSlug, setEditSlug] = useState('')
const [editName, setEditName] = useState('')
const [editDescription, setEditDescription] = useState('')
const [editHomePage, setEditHomePage] = useState<'info' | 'repos' | 'issues' | 'wiki' | 'files'>('info')
const [editDescTab, setEditDescTab] = useState<'write' | 'preview'>('write')
const [editHomePage, setEditHomePage] = useState<ProjectHomePageValue>('info')
const [editDescTab, setEditDescTab] = useState<ProjectDescriptionTab>('write')
const [editError, setEditError] = useState<string | null>(null)
const [savingEdit, setSavingEdit] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
@@ -573,87 +574,31 @@ export default function ProjectHomePage() {
</Box>
</SectionCard>
</Box>
<Dialog open={editOpen} onClose={() => setEditOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Edit Project</DialogTitle>
<FormDialogContent>
{editError ? <Alert severity="error">{editError}</Alert> : null}
<TextField
margin="dense"
label="Slug"
fullWidth
error={/\s/.test(editSlug)}
helperText={/\s/.test(editSlug) ? 'Slug cannot contain whitespace.' : ''}
value={editSlug}
onChange={(event) => setEditSlug(event.target.value)}
/>
<TextField
margin="dense"
label="Name"
fullWidth
value={editName}
onChange={(event) => setEditName(event.target.value)}
/>
<SelectField
margin="dense"
select
label="Default Project Page"
fullWidth
value={editHomePage}
onChange={(event) => setEditHomePage(event.target.value as 'info' | 'repos' | 'issues' | 'wiki' | 'files')}
>
<MenuItem value="info">Info</MenuItem>
<MenuItem value="repos">Repositories</MenuItem>
<MenuItem value="issues">Issues</MenuItem>
<MenuItem value="wiki">Wiki</MenuItem>
<MenuItem value="files">Files</MenuItem>
</SelectField>
<Box sx={{ mt: 1 }}>
<Tabs
value={editDescTab}
onChange={(_, value) => setEditDescTab(value)}
textColor="primary"
indicatorColor="primary"
>
<Tab label="Write" value="write" />
<Tab label="Preview" value="preview" />
</Tabs>
{editDescTab === 'write' ? (
<TextField
margin="dense"
label="Description (Markdown)"
fullWidth
multiline
rows={6}
value={editDescription}
onChange={(event) => setEditDescription(event.target.value)}
/>
) : (
<Paper variant="outlined" sx={{ p: 1, mt: 1, minHeight: 140 }}>
{editDescription.trim() ? (
<Box sx={{ '& p': { m: 0 } }}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{editDescription}</ReactMarkdown>
</Box>
) : (
<Typography variant="body2" color="text.secondary">
Nothing to preview yet.
</Typography>
)}
</Paper>
)}
</Box>
</FormDialogContent>
<DialogActions>
<Button onClick={handleEditSave} variant="contained" disabled={savingEdit}>
{savingEdit ? 'Saving...' : 'Save'}
</Button>
<Button onClick={() => setEditOpen(false)}>Cancel</Button>
</DialogActions>
</Dialog>
<ProjectFormDialog
open={editOpen}
title="Edit Project"
error={editError}
slug={editSlug}
onSlugChange={setEditSlug}
name={editName}
onNameChange={setEditName}
description={editDescription}
onDescriptionChange={setEditDescription}
homePage={editHomePage}
onHomePageChange={setEditHomePage}
descriptionTab={editDescTab}
onDescriptionTabChange={setEditDescTab}
busy={savingEdit}
saveText="Save"
busyText="Saving..."
onSave={handleEditSave}
onClose={() => setEditOpen(false)}
/>
<ConfirmDeleteDialog
open={deleteOpen}
title="Delete Project"
prompt={deleteLoading ? 'Checking project contents...' : 'Type the project slug to confirm deletion.'}
expectedText={deleteCounts && deleteCounts.repos + deleteCounts.issues + deleteCounts.wiki + deleteCounts.uploads > 0 ? (project?.slug || undefined) : undefined}
expectedText={!deleteLoading && deleteCounts ? (project?.slug || '') : undefined}
confirmLabel="Project Slug"
confirmValue={deleteConfirm}
onConfirmValueChange={setDeleteConfirm}
+57 -160
View File
@@ -3,7 +3,7 @@ import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit'
import Alert from '@mui/material/Alert'
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
import { Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, List, ListItem, ListItemText, MenuItem, Paper, Tab, Tabs, TextField, Typography } from '@mui/material'
import { Box, Button, Chip, DialogContent, List, ListItem, ListItemText, Paper, TextField, Typography } from '@mui/material'
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { api, Project, ProjectCreatePayload, ProjectUpdatePayload, User } from '../api'
@@ -11,10 +11,8 @@ import Autocomplete from '../components/Autocomplete'
import HeaderActionButton from '../components/HeaderActionButton'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import FormDialogContent from '../components/FormDialogContent'
import SelectField from '../components/SelectField'
import ProjectFormDialog from '../components/ProjectFormDialog'
import { ProjectDescriptionTab, ProjectHomePageValue } from '../components/ProjectFormDialog'
export default function ProjectsPage() {
const [projects, setProjects] = useState<Project[]>([])
@@ -23,15 +21,17 @@ export default function ProjectsPage() {
const [editSlug, setEditSlug] = useState('')
const [editName, setEditName] = useState('')
const [editDescription, setEditDescription] = useState('')
const [editHomePage, setEditHomePage] = useState<'info' | 'repos' | 'issues' | 'wiki' | 'files'>('info')
const [editDescTab, setEditDescTab] = useState<'write' | 'preview'>('write')
const [editHomePage, setEditHomePage] = useState<ProjectHomePageValue>('info')
const [editDescTab, setEditDescTab] = useState<ProjectDescriptionTab>('write')
const [editError, setEditError] = useState<string | null>(null)
const [createOpen, setCreateOpen] = useState(false)
const [createSlug, setCreateSlug] = useState('')
const [createName, setCreateName] = useState('')
const [createError, setCreateError] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
const [createDescription, setCreateDescription] = useState('')
const [createHomePage, setCreateHomePage] = useState<'info' | 'repos' | 'issues' | 'wiki' | 'files'>('info')
const [createDescTab, setCreateDescTab] = useState<'write' | 'preview'>('write')
const [createHomePage, setCreateHomePage] = useState<ProjectHomePageValue>('info')
const [createDescTab, setCreateDescTab] = useState<ProjectDescriptionTab>('write')
const [savingEdit, setSavingEdit] = useState(false)
const [deleteProject, setDeleteProject] = useState<Project | null>(null)
const [deleteCounts, setDeleteCounts] = useState<{ repos: number; issues: number; wiki: number; uploads: number } | null>(
@@ -83,13 +83,10 @@ export default function ProjectsPage() {
.catch(() => setUser(null))
}, [])
const handleCreate = async (evt: React.FormEvent<HTMLFormElement>) => {
evt.preventDefault()
const form = evt.currentTarget
const data = new FormData(form)
const handleCreate = async () => {
const payload: ProjectCreatePayload = {
slug: String(data.get('slug') || ''),
name: String(data.get('name') || ''),
slug: createSlug.trim(),
name: createName.trim(),
description: createDescription,
home_page: createHomePage
}
@@ -103,7 +100,8 @@ export default function ProjectsPage() {
const created = await api.createProject(payload)
setProjects((prev) => [...prev, created])
setTotalProjects((prev) => prev + 1)
form.reset()
setCreateSlug('')
setCreateName('')
setCreateDescription('')
setCreateHomePage('info')
setCreateDescTab('write')
@@ -212,6 +210,8 @@ export default function ProjectsPage() {
const closeCreate = () => {
setCreateOpen(false)
setCreateError(null)
setCreateSlug('')
setCreateName('')
setCreateDescription('')
setCreateHomePage('info')
setCreateDescTab('write')
@@ -378,154 +378,51 @@ export default function ProjectsPage() {
</Button>
</Box>
</SectionCard>
<Dialog open={Boolean(editProject)} onClose={() => setEditProject(null)} maxWidth="sm" fullWidth>
<DialogTitle>Edit Project</DialogTitle>
<FormDialogContent>
{editError ? <Alert severity="error">{editError}</Alert> : null}
<TextField
margin="dense"
label="Slug"
fullWidth
error={/\s/.test(editSlug)}
helperText={/\s/.test(editSlug) ? 'Slug cannot contain whitespace.' : ''}
value={editSlug}
onChange={(event) => setEditSlug(event.target.value)}
/>
<TextField
margin="dense"
label="Name"
fullWidth
value={editName}
onChange={(event) => setEditName(event.target.value)}
/>
<SelectField
margin="dense"
select
label="Default Project Page"
fullWidth
value={editHomePage}
onChange={(event) => setEditHomePage(event.target.value as 'info' | 'repos' | 'issues' | 'wiki' | 'files')}
>
<MenuItem value="info">Info</MenuItem>
<MenuItem value="repos">Repositories</MenuItem>
<MenuItem value="issues">Issues</MenuItem>
<MenuItem value="wiki">Wiki</MenuItem>
<MenuItem value="files">Files</MenuItem>
</SelectField>
<Box sx={{ mt: 1 }}>
<Tabs
value={editDescTab}
onChange={(_, value) => setEditDescTab(value)}
textColor="primary"
indicatorColor="primary"
>
<Tab label="Write" value="write" />
<Tab label="Preview" value="preview" />
</Tabs>
{editDescTab === 'write' ? (
<TextField
margin="dense"
label="Description (Markdown)"
fullWidth
multiline
rows={6}
value={editDescription}
onChange={(event) => setEditDescription(event.target.value)}
/>
) : (
<Paper variant="outlined" sx={{ p: 1, mt: 1, minHeight: 140 }}>
{editDescription.trim() ? (
<Box sx={{ '& p': { m: 0 } }}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{editDescription}</ReactMarkdown>
</Box>
) : (
<Typography variant="body2" color="text.secondary">
Nothing to preview yet.
</Typography>
)}
</Paper>
)}
</Box>
</FormDialogContent>
<DialogActions>
<Button onClick={handleEditSave} variant="contained" disabled={savingEdit}>
{savingEdit ? 'Saving...' : 'Save'}
</Button>
<Button onClick={() => setEditProject(null)}>Cancel</Button>
</DialogActions>
</Dialog>
<Dialog open={createOpen} onClose={closeCreate} maxWidth="sm" fullWidth>
<DialogTitle>New Project</DialogTitle>
<FormDialogContent>
<Box component="form" onSubmit={handleCreate} sx={{ display: 'grid', gap: 1, mt: 1 }}>
{createError ? <Alert severity="error">{createError}</Alert> : null}
<TextField
name="slug"
label="Slug"
error={Boolean(createError && createError.toLowerCase().includes('slug'))}
helperText={createError && createError.toLowerCase().includes('slug') ? createError : ''}
/>
<TextField name="name" label="Name" />
<SelectField
select
label="Default Project Page"
value={createHomePage}
onChange={(event) => setCreateHomePage(event.target.value as 'info' | 'repos' | 'issues' | 'wiki' | 'files')}
>
<MenuItem value="info">Info</MenuItem>
<MenuItem value="repos">Repositories</MenuItem>
<MenuItem value="issues">Issues</MenuItem>
<MenuItem value="wiki">Wiki</MenuItem>
<MenuItem value="files">Files</MenuItem>
</SelectField>
<Box sx={{ mt: 1 }}>
<Tabs
value={createDescTab}
onChange={(_, value) => setCreateDescTab(value)}
textColor="primary"
indicatorColor="primary"
>
<Tab label="Write" value="write" />
<Tab label="Preview" value="preview" />
</Tabs>
{createDescTab === 'write' ? (
<TextField
margin="dense"
label="Description (Markdown)"
fullWidth
multiline
rows={6}
value={createDescription}
onChange={(event) => setCreateDescription(event.target.value)}
/>
) : (
<Paper variant="outlined" sx={{ p: 1, mt: 1, minHeight: 140 }}>
{createDescription.trim() ? (
<Box sx={{ '& p': { m: 0 } }}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{createDescription}</ReactMarkdown>
</Box>
) : (
<Typography variant="body2" color="text.secondary">
Nothing to preview yet.
</Typography>
)}
</Paper>
)}
</Box>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, mt: 1 }}>
<Button type="submit" variant="contained" disabled={creating}>
{creating ? 'Creating...' : 'Create'}
</Button>
<Button onClick={closeCreate}>Cancel</Button>
</Box>
</Box>
</FormDialogContent>
</Dialog>
<ProjectFormDialog
open={Boolean(editProject)}
title="Edit Project"
error={editError}
slug={editSlug}
onSlugChange={setEditSlug}
name={editName}
onNameChange={setEditName}
description={editDescription}
onDescriptionChange={setEditDescription}
homePage={editHomePage}
onHomePageChange={setEditHomePage}
descriptionTab={editDescTab}
onDescriptionTabChange={setEditDescTab}
busy={savingEdit}
saveText="Save"
busyText="Saving..."
onSave={handleEditSave}
onClose={() => setEditProject(null)}
/>
<ProjectFormDialog
open={createOpen}
title="New Project"
error={createError}
slug={createSlug}
onSlugChange={setCreateSlug}
name={createName}
onNameChange={setCreateName}
description={createDescription}
onDescriptionChange={setCreateDescription}
homePage={createHomePage}
onHomePageChange={setCreateHomePage}
descriptionTab={createDescTab}
onDescriptionTabChange={setCreateDescTab}
busy={creating}
saveText="Create"
busyText="Creating..."
onSave={handleCreate}
onClose={closeCreate}
/>
<ConfirmDeleteDialog
open={Boolean(deleteProject)}
title="Delete Project"
prompt={deleteLoading ? 'Checking project contents...' : 'Type the project slug to confirm deletion.'}
expectedText={deleteCounts && deleteCounts.repos + deleteCounts.issues + deleteCounts.wiki + deleteCounts.uploads > 0 ? (deleteProject?.slug || undefined) : undefined}
expectedText={!deleteLoading && deleteCounts ? (deleteProject?.slug || '') : undefined}
confirmLabel="Project Slug"
confirmValue={deleteConfirm}
onConfirmValueChange={setDeleteConfirm}
+2 -2
View File
@@ -618,8 +618,8 @@ export default function ReposPage() {
<ConfirmDeleteDialog
open={bulkDeleteOpen}
title="Delete Repositories"
prompt={`Type DELETE ${selectedRepoIds.length} to confirm.`}
expectedText={`DELETE ${selectedRepoIds.length}`}
prompt={`Type DELETE to delete ${selectedRepoIds.length} selected repository/repositories.`}
expectedText="DELETE"
confirmLabel="Confirmation"
confirmValue={bulkDeleteConfirm}
onConfirmValueChange={setBulkDeleteConfirm}
+1 -1
View File
@@ -469,7 +469,7 @@ export default function SSHCertificatesPage() {
) : null}
</FormDialogContent>
<DialogActions>
<Button onClick={() => setInspectOpen(false)}>Close</Button>
<Button onClick={() => setInspectOpen(false)}>Cancel</Button>
</DialogActions>
</Dialog>
+30 -134
View File
@@ -2,9 +2,6 @@ import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Checkbox from '@mui/material/Checkbox'
import Dialog from '@mui/material/Dialog'
import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle'
import FormControlLabel from '@mui/material/FormControlLabel'
import Link from '@mui/material/Link'
import Paper from '@mui/material/Paper'
@@ -14,6 +11,7 @@ import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
import FormDialogContent from '../components/FormDialogContent'
import SSHCredentialsPromptDialog from '../components/SSHCredentialsPromptDialog'
import SSHAccessProfileDetailsDialog from '../components/SSHAccessProfileDetailsDialog'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard'
@@ -22,6 +20,7 @@ import { SSHAccessProfileFormState } from '../components/SSHAccessProfileFormDia
import SSHServerDetailsDialog from '../components/SSHServerDetailsDialog'
import SSHServerFormDialog from '../components/SSHServerFormDialog'
import { SSHServerFormState } from '../components/SSHServerFormDialog'
import SSHHostKeysDialog from '../components/SSHHostKeysDialog'
import { api, SSHAccessProfile, SSHAccessProfileSelfUpsertPayload, SSHConnectPayload, SSHServer, SSHServerHostKey, SSHServerUpsertPayload } from '../api'
const emptyForm = (): SSHAccessProfileFormState => ({
@@ -700,138 +699,35 @@ export default function SSHServersPage() {
onClose={() => { setDeleteServerItem(null); setDeleteServerConfirm('') }}
/>
<Dialog open={Boolean(connectPasswordItem)} onClose={closeConnectPasswordPrompt} maxWidth="xs" fullWidth>
<DialogTitle>Enter SSH Credentials</DialogTitle>
<FormDialogContent>
{connectPasswordError ? <Alert severity="error">{connectPasswordError}</Alert> : null}
<Typography variant="body2" color="text.secondary">
{connectPasswordItem ? `${connectPasswordItem.remote_username}@${connectPasswordItem.server?.host || connectPasswordItem.server_id}:${connectPasswordItem.server?.port || 22}` : 'Enter the password for this SSH access profile.'}
</Typography>
{connectPasswordItem?.auth_method === 'prompted_password' ? (
<TextField
label="Password"
type="password"
value={connectPassword}
onChange={(event) => setConnectPassword(event.target.value)}
autoFocus
/>
) : null}
{connectPasswordItem?.second_factor_mode === 'prompted_totp' ? (
<TextField
label="OTP Code"
value={connectOTPCode}
onChange={(event) => setConnectOTPCode(event.target.value)}
autoFocus={connectPasswordItem?.auth_method !== 'prompted_password'}
/>
) : null}
</FormDialogContent>
<DialogActions>
<Button onClick={closeConnectPasswordPrompt} disabled={Boolean(connectingID)}>
Cancel
</Button>
<Button
variant="contained"
onClick={() => connectPasswordItem ? void connectToProfile(connectPasswordItem, connectPassword, connectOTPCode) : undefined}
disabled={
Boolean(connectingID) ||
(connectPasswordItem?.auth_method === 'prompted_password' && connectPassword === '') ||
(connectPasswordItem?.second_factor_mode === 'prompted_totp' && connectOTPCode.trim() === '')
}
>
{connectingID ? 'Connecting...' : 'Connect'}
</Button>
</DialogActions>
</Dialog>
<SSHCredentialsPromptDialog
open={Boolean(connectPasswordItem)}
targetText={connectPasswordItem ? `${connectPasswordItem.remote_username}@${connectPasswordItem.server?.host || connectPasswordItem.server_id}:${connectPasswordItem.server?.port || 22}` : 'Enter the password for this SSH access profile.'}
error={connectPasswordError}
passwordRequired={Boolean(connectPasswordItem?.auth_method === 'prompted_password')}
password={connectPassword}
onPasswordChange={setConnectPassword}
otpRequired={Boolean(connectPasswordItem?.second_factor_mode === 'prompted_totp')}
otpCode={connectOTPCode}
onOTPCodeChange={setConnectOTPCode}
busy={Boolean(connectingID)}
onClose={closeConnectPasswordPrompt}
onConfirm={() => connectPasswordItem ? void connectToProfile(connectPasswordItem, connectPassword, connectOTPCode) : undefined}
/>
<Dialog open={Boolean(hostKeyServer)} onClose={closeHostKeys} maxWidth="md" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">Pinned Host Keys</Typography>
<Typography variant="body2" color="text.secondary">
{hostKeyServer ? `${hostKeyServer.name} (${hostKeyServer.host}:${hostKeyServer.port})` : ''}
</Typography>
{hostKeysError ? <Alert severity="error">{hostKeysError}</Alert> : null}
</Box>
</DialogTitle>
<FormDialogContent>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Button variant="outlined" onClick={handleDiscoverHostKey} disabled={hostKeySaving || hostKeysLoading}>
Discover
</Button>
<Button
variant="outlined"
onClick={() => void handleAddHostKey(hostKeyInput)}
disabled={hostKeySaving || !hostKeyInput.trim()}
>
Add Manual Key
</Button>
</Box>
{discoveredHostKey ? (
<Alert
severity="info"
action={
<Button color="inherit" size="small" onClick={() => void handleAddHostKey(discoveredHostKey.public_key)} disabled={hostKeySaving}>
Pin
</Button>
}
>
Discovered {discoveredHostKey.algorithm} · {discoveredHostKey.fingerprint}
</Alert>
) : null}
<TextField
label="Manual Host Public Key"
multiline
minRows={3}
value={hostKeyInput}
onChange={(event) => setHostKeyInput(event.target.value)}
helperText="Paste an authorized_keys style SSH host public key."
/>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="subtitle2">Pinned Keys</Typography>
{hostKeysLoading ? (
<Typography variant="body2" color="text.secondary">
Loading...
</Typography>
) : null}
{!hostKeysLoading && hostKeys.length === 0 ? (
<Typography variant="body2" color="text.secondary">
No pinned host keys.
</Typography>
) : null}
{hostKeys.map((item) => (
<Paper
key={item.id}
variant="outlined"
sx={{
p: 1,
display: 'grid',
gap: 0.5,
backgroundColor: 'transparent',
backgroundImage: 'none'
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="subtitle2">{item.fingerprint}</Typography>
<ListRowActions>
<ListRowActionButton color="error" onClick={() => void handleDeleteHostKey(item.id)} disabled={hostKeySaving}>
Delete
</ListRowActionButton>
</ListRowActions>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
{item.algorithm}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
{item.public_key}
</Typography>
</Paper>
))}
</Box>
</FormDialogContent>
<DialogActions>
<Button onClick={closeHostKeys}>Close</Button>
</DialogActions>
</Dialog>
<SSHHostKeysDialog
server={hostKeyServer}
hostKeys={hostKeys}
loading={hostKeysLoading}
error={hostKeysError}
input={hostKeyInput}
onInputChange={setHostKeyInput}
saving={hostKeySaving}
discoveredHostKey={discoveredHostKey}
onClose={closeHostKeys}
onDiscover={() => void handleDiscoverHostKey()}
onAdd={(publicKey: string) => void handleAddHostKey(publicKey)}
onDelete={(hostKeyID: string) => void handleDeleteHostKey(hostKeyID)}
/>
</Box>
)
}
+55 -167
View File
@@ -22,6 +22,8 @@ import { Unicode11Addon } from '@xterm/addon-unicode11'
import { Terminal } from '@xterm/xterm'
import Autocomplete from '../components/Autocomplete'
import FormDialogContent from '../components/FormDialogContent'
import SSHCredentialsPromptDialog from '../components/SSHCredentialsPromptDialog'
import SSHTranscriptDialog from '../components/SSHTranscriptDialog'
import TintedPanel from '../components/TintedPanel'
import { api, SSHAccessProfile, SSHSession, SSHSessionConnectResponse, SSHSessionListResponse } from '../api'
@@ -604,48 +606,20 @@ function SessionTerminalPanel(props: SessionPanelProps) {
</Typography>
) : null}
</Box>
<Dialog open={passwordPromptOpen} onClose={closePasswordPrompt} maxWidth="xs" fullWidth>
<DialogTitle>Enter SSH Credentials</DialogTitle>
<FormDialogContent>
{passwordPromptError ? <Alert severity="error">{passwordPromptError}</Alert> : null}
<Typography variant="body2" color="text.secondary">
{sessionTitle}
</Typography>
{needsPasswordPrompt ? (
<TextField
label="Password"
type="password"
value={promptedPassword}
onChange={(event) => setPromptedPassword(event.target.value)}
autoFocus
/>
) : null}
{needsOTPPrompt ? (
<TextField
label="OTP Code"
value={promptedOTPCode}
onChange={(event) => setPromptedOTPCode(event.target.value)}
autoFocus={!needsPasswordPrompt}
/>
) : null}
</FormDialogContent>
<DialogActions>
<Button onClick={closePasswordPrompt} disabled={actionPending}>
Cancel
</Button>
<Button
variant="contained"
onClick={() => void handleConfirmPasswordPrompt()}
disabled={
actionPending ||
(needsPasswordPrompt && promptedPassword === '') ||
(needsOTPPrompt && promptedOTPCode.trim() === '')
}
>
{actionPending ? 'Connecting...' : 'Connect'}
</Button>
</DialogActions>
</Dialog>
<SSHCredentialsPromptDialog
open={passwordPromptOpen}
targetText={sessionTitle}
error={passwordPromptError}
passwordRequired={needsPasswordPrompt}
password={promptedPassword}
onPasswordChange={setPromptedPassword}
otpRequired={needsOTPPrompt}
otpCode={promptedOTPCode}
onOTPCodeChange={setPromptedOTPCode}
busy={actionPending}
onClose={closePasswordPrompt}
onConfirm={() => void handleConfirmPasswordPrompt()}
/>
</TintedPanel>
)
}
@@ -1608,90 +1582,32 @@ export default function SSHSessionPage() {
</Drawer>
)}
<Dialog open={Boolean(historyConnectItem)} onClose={closeHistoryConnectPrompt} maxWidth="xs" fullWidth>
<DialogTitle>Enter SSH Credentials</DialogTitle>
<FormDialogContent>
{historyConnectPasswordError ? <Alert severity="error">{historyConnectPasswordError}</Alert> : null}
<Typography variant="body2" color="text.secondary">
{historyConnectItem ? buildHistorySessionTitle(historyConnectItem) : 'Enter the password for this SSH access profile.'}
</Typography>
{historyConnectItem && requiresPromptedPassword(historyConnectItem) ? (
<TextField
label="Password"
type="password"
value={historyConnectPassword}
onChange={(event) => setHistoryConnectPassword(event.target.value)}
autoFocus
/>
) : null}
{historyConnectItem && requiresPromptedTOTP(historyConnectItem) ? (
<TextField
label="OTP Code"
value={historyConnectOTPCode}
onChange={(event) => setHistoryConnectOTPCode(event.target.value)}
autoFocus={!requiresPromptedPassword(historyConnectItem)}
/>
) : null}
</FormDialogContent>
<DialogActions>
<Button
variant="contained"
onClick={() => historyConnectItem ? void connectHistorySession(historyConnectItem, historyConnectPassword, historyConnectOTPCode) : undefined}
disabled={
connectingHistorySession ||
(Boolean(historyConnectItem && requiresPromptedPassword(historyConnectItem)) && historyConnectPassword === '') ||
(Boolean(historyConnectItem && requiresPromptedTOTP(historyConnectItem)) && historyConnectOTPCode.trim() === '')
}
>
{connectingHistorySession ? 'Connecting...' : 'Connect'}
</Button>
<Button onClick={closeHistoryConnectPrompt} disabled={connectingHistorySession}>
Cancel
</Button>
</DialogActions>
</Dialog>
<SSHCredentialsPromptDialog
open={Boolean(historyConnectItem)}
targetText={historyConnectItem ? buildHistorySessionTitle(historyConnectItem) : 'Enter the password for this SSH access profile.'}
error={historyConnectPasswordError}
passwordRequired={Boolean(historyConnectItem && requiresPromptedPassword(historyConnectItem))}
password={historyConnectPassword}
onPasswordChange={setHistoryConnectPassword}
otpRequired={Boolean(historyConnectItem && requiresPromptedTOTP(historyConnectItem))}
otpCode={historyConnectOTPCode}
onOTPCodeChange={setHistoryConnectOTPCode}
busy={connectingHistorySession}
onClose={closeHistoryConnectPrompt}
onConfirm={() => historyConnectItem ? void connectHistorySession(historyConnectItem, historyConnectPassword, historyConnectOTPCode) : undefined}
/>
<Dialog open={Boolean(transcriptSession)} onClose={handleCloseTranscript} fullWidth maxWidth="md">
<DialogTitle>
{transcriptSession ? `Transcript: ${buildHistorySessionTitle(transcriptSession)}` : 'Transcript'}
</DialogTitle>
<FormDialogContent>
{transcriptError ? <Alert severity="error">{transcriptError}</Alert> : null}
{loadingTranscript ? (
<Typography variant="body2" color="text.secondary">
Loading transcript...
</Typography>
) : null}
{!loadingTranscript && !transcriptError ? (
<Box
component="pre"
sx={{
m: 0,
p: 1.5,
borderRadius: 1,
bgcolor: 'background.default',
border: (theme) => `1px solid ${theme.palette.divider}`,
overflow: 'auto',
maxHeight: '70vh',
fontFamily: 'monospace',
fontSize: 13,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
}}
>
{transcriptText || '(empty transcript)'}
</Box>
) : null}
</FormDialogContent>
<DialogActions>
<Button variant="contained" onClick={handleDownloadTranscript} disabled={!transcriptSession || loadingTranscript || transcriptText === ''}>
Download
</Button>
<Button onClick={handleCloseTranscript}>
Close
</Button>
</DialogActions>
</Dialog>
<SSHTranscriptDialog
open={Boolean(transcriptSession)}
title={transcriptSession ? `Transcript: ${buildHistorySessionTitle(transcriptSession)}` : 'Transcript'}
text={transcriptText}
error={transcriptError}
loading={loadingTranscript}
downloadDisabled={!transcriptSession}
closeText="Cancel"
onClose={handleCloseTranscript}
onDownload={handleDownloadTranscript}
/>
<Dialog open={newSessionOpen} onClose={() => setNewSessionOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>New SSH Session</DialogTitle>
@@ -1714,48 +1630,20 @@ export default function SSHSessionPage() {
</DialogActions>
</Dialog>
<Dialog open={newSessionPasswordOpen} onClose={closeNewSessionPasswordPrompt} maxWidth="xs" fullWidth>
<DialogTitle>Enter SSH Credentials</DialogTitle>
<FormDialogContent>
{newSessionPasswordError ? <Alert severity="error">{newSessionPasswordError}</Alert> : null}
<Typography variant="body2" color="text.secondary">
{selectedProfile ? `${selectedProfile.remote_username}@${selectedProfile.server?.host || selectedProfile.server_id}:${selectedProfile.server?.port || 22}` : 'Enter the password for this SSH access profile.'}
</Typography>
{selectedProfile && requiresPromptedPassword(selectedProfile) ? (
<TextField
label="Password"
type="password"
value={newSessionPassword}
onChange={(event) => setNewSessionPassword(event.target.value)}
autoFocus
/>
) : null}
{selectedProfile && requiresPromptedTOTP(selectedProfile) ? (
<TextField
label="OTP Code"
value={newSessionOTPCode}
onChange={(event) => setNewSessionOTPCode(event.target.value)}
autoFocus={!requiresPromptedPassword(selectedProfile)}
/>
) : null}
</FormDialogContent>
<DialogActions>
<Button
variant="contained"
onClick={() => void handleCreateSession(newSessionPassword, newSessionOTPCode)}
disabled={
creatingSession ||
(Boolean(selectedProfile && requiresPromptedPassword(selectedProfile)) && newSessionPassword === '') ||
(Boolean(selectedProfile && requiresPromptedTOTP(selectedProfile)) && newSessionOTPCode.trim() === '')
}
>
{creatingSession ? 'Connecting...' : 'Connect'}
</Button>
<Button onClick={closeNewSessionPasswordPrompt} disabled={creatingSession}>
Cancel
</Button>
</DialogActions>
</Dialog>
<SSHCredentialsPromptDialog
open={newSessionPasswordOpen}
targetText={selectedProfile ? `${selectedProfile.remote_username}@${selectedProfile.server?.host || selectedProfile.server_id}:${selectedProfile.server?.port || 22}` : 'Enter the password for this SSH access profile.'}
error={newSessionPasswordError}
passwordRequired={Boolean(selectedProfile && requiresPromptedPassword(selectedProfile))}
password={newSessionPassword}
onPasswordChange={setNewSessionPassword}
otpRequired={Boolean(selectedProfile && requiresPromptedTOTP(selectedProfile))}
otpCode={newSessionOTPCode}
onOTPCodeChange={setNewSessionOTPCode}
busy={creatingSession}
onClose={closeNewSessionPasswordPrompt}
onConfirm={() => void handleCreateSession(newSessionPassword, newSessionOTPCode)}
/>
</Box>
)
}