Compare commits
3 Commits
25f6dd27de
...
c44b361e54
| Author | SHA1 | Date | |
|---|---|---|---|
| c44b361e54 | |||
| c1f30a246f | |||
| 1acc0d376c |
Generated
+2
-2
@@ -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,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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user