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",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "codepot-codit-frontend",
|
"name": "codit-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.11.4",
|
"@emotion/react": "^11.11.4",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "codepot-codit-frontend",
|
"name": "codit-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import DialogTitle from '@mui/material/DialogTitle'
|
|||||||
import TextField from '@mui/material/TextField'
|
import TextField from '@mui/material/TextField'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import FormDialogContent from './FormDialogContent'
|
import FormDialogContent from './FormDialogContent'
|
||||||
|
import { ReactNode } from 'react'
|
||||||
|
|
||||||
type ConfirmDeleteDialogProps = {
|
type ConfirmDeleteDialogProps = {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -20,6 +21,7 @@ type ConfirmDeleteDialogProps = {
|
|||||||
confirmText?: string
|
confirmText?: string
|
||||||
busyText?: string
|
busyText?: string
|
||||||
confirmDisabled?: boolean
|
confirmDisabled?: boolean
|
||||||
|
children?: ReactNode
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onConfirm: () => void
|
onConfirm: () => void
|
||||||
}
|
}
|
||||||
@@ -51,6 +53,7 @@ export default function ConfirmDeleteDialog(props: ConfirmDeleteDialogProps) {
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
{props.children}
|
||||||
</FormDialogContent>
|
</FormDialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button
|
<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 Alert from '@mui/material/Alert'
|
||||||
import FormDialogContent from '../components/FormDialogContent'
|
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Dialog,
|
|
||||||
DialogActions,
|
|
||||||
DialogTitle,
|
|
||||||
List,
|
List,
|
||||||
ListItemIcon,
|
ListItemIcon,
|
||||||
ListItem,
|
ListItem,
|
||||||
@@ -252,60 +249,41 @@ export default function AdminApiKeysPage() {
|
|||||||
</List>
|
</List>
|
||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)} maxWidth="xs" fullWidth>
|
<ConfirmDeleteDialog
|
||||||
<DialogTitle>Delete API Key</DialogTitle>
|
open={Boolean(deleteTarget)}
|
||||||
<FormDialogContent>
|
title="Delete API Key"
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
prompt="Type the API key name to confirm deletion."
|
||||||
Type the API key name to confirm deletion.
|
expectedText={deleteTarget?.name || ''}
|
||||||
</Typography>
|
confirmLabel="Key Name"
|
||||||
<TextField
|
confirmValue={deleteConfirm}
|
||||||
fullWidth
|
onConfirmValueChange={setDeleteConfirm}
|
||||||
label="Key name"
|
busy={deleting}
|
||||||
value={deleteConfirm}
|
onConfirm={handleDelete}
|
||||||
onChange={(event) => setDeleteConfirm(event.target.value)}
|
onClose={() => {
|
||||||
|
if (deleting) return
|
||||||
|
setDeleteTarget(null)
|
||||||
|
setDeleteConfirm('')
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</FormDialogContent>
|
<ConfirmDeleteDialog
|
||||||
<DialogActions>
|
open={bulkOpen}
|
||||||
<Button
|
title="Delete Selected API Keys"
|
||||||
variant="contained"
|
prompt={`Type DELETE to delete ${selected.length} selected API key(s).`}
|
||||||
color="error"
|
expectedText="DELETE"
|
||||||
disabled={deleting || !deleteTarget || deleteConfirm !== deleteTarget.name}
|
confirmLabel="Confirmation"
|
||||||
onClick={handleDelete}
|
confirmValue={bulkConfirm}
|
||||||
>
|
onConfirmValueChange={setBulkConfirm}
|
||||||
{deleting ? 'Deleting...' : 'Delete'}
|
busy={bulkDeleting}
|
||||||
</Button>
|
confirmText="Delete Selected"
|
||||||
<Button onClick={() => { setDeleteTarget(null); setDeleteConfirm('') }}>
|
busyText="Deleting..."
|
||||||
Cancel
|
confirmDisabled={!selected.length}
|
||||||
</Button>
|
onConfirm={handleBulkDelete}
|
||||||
</DialogActions>
|
onClose={() => {
|
||||||
</Dialog>
|
if (bulkDeleting) return
|
||||||
<Dialog open={bulkOpen} onClose={() => setBulkOpen(false)} maxWidth="xs" fullWidth>
|
setBulkOpen(false)
|
||||||
<DialogTitle>Delete Selected API Keys</DialogTitle>
|
setBulkConfirm('')
|
||||||
<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>
|
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
import AddIcon from '@mui/icons-material/Add'
|
import AddIcon from '@mui/icons-material/Add'
|
||||||
import Alert from '@mui/material/Alert'
|
import Alert from '@mui/material/Alert'
|
||||||
import FormDialogContent from '../components/FormDialogContent'
|
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Dialog,
|
|
||||||
DialogActions,
|
|
||||||
DialogTitle,
|
|
||||||
Link,
|
Link,
|
||||||
List,
|
List,
|
||||||
ListItem,
|
ListItem,
|
||||||
ListItemText,
|
ListItemText,
|
||||||
TextField,
|
|
||||||
Typography
|
Typography
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
@@ -364,28 +360,24 @@ export default function AdminPKIClientProfilesPage() {
|
|||||||
onSave={save}
|
onSave={save}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Dialog open={Boolean(deleteItem)} onClose={() => { if (!busy) { setDeleteItem(null); setDeleteConfirm('') } }} fullWidth maxWidth="sm">
|
<ConfirmDeleteDialog
|
||||||
<DialogTitle>Delete Client Certificate Profile</DialogTitle>
|
open={Boolean(deleteItem)}
|
||||||
<FormDialogContent>
|
title="Delete Client Certificate Profile"
|
||||||
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
|
prompt="Type the profile name to confirm deletion."
|
||||||
<Typography variant="body2" color="text.secondary">
|
expectedText={deleteItem?.name || ''}
|
||||||
Type the profile name to confirm deletion.
|
confirmLabel="Profile Name"
|
||||||
</Typography>
|
confirmValue={deleteConfirm}
|
||||||
<Typography variant="body2">{deleteItem?.name}</Typography>
|
onConfirmValueChange={setDeleteConfirm}
|
||||||
<TextField label="Confirm Name" value={deleteConfirm} onChange={(event) => setDeleteConfirm(event.target.value)} autoFocus />
|
error={dialogError}
|
||||||
</FormDialogContent>
|
busy={busy}
|
||||||
<DialogActions>
|
onClose={() => {
|
||||||
<Button
|
if (busy) return
|
||||||
color="error"
|
setDeleteItem(null)
|
||||||
variant="contained"
|
setDeleteConfirm('')
|
||||||
onClick={remove}
|
setDialogError(null)
|
||||||
disabled={busy || deleteConfirm.trim() !== (deleteItem?.name || '')}
|
}}
|
||||||
>
|
onConfirm={remove}
|
||||||
{busy ? 'Deleting...' : 'Delete'}
|
/>
|
||||||
</Button>
|
|
||||||
<Button onClick={() => { setDeleteItem(null); setDeleteConfirm('') }} disabled={busy}>Cancel</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+290
-505
@@ -1,15 +1,11 @@
|
|||||||
import DownloadIcon from '@mui/icons-material/Download'
|
import DownloadIcon from '@mui/icons-material/Download'
|
||||||
import Alert from '@mui/material/Alert'
|
import Alert from '@mui/material/Alert'
|
||||||
import FormDialogContent from '../components/FormDialogContent'
|
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
|
||||||
import Autocomplete from '../components/Autocomplete'
|
import Autocomplete from '../components/Autocomplete'
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Dialog,
|
|
||||||
DialogActions,
|
|
||||||
DialogContent,
|
|
||||||
DialogTitle,
|
|
||||||
FormControlLabel,
|
FormControlLabel,
|
||||||
Link,
|
Link,
|
||||||
List,
|
List,
|
||||||
@@ -25,6 +21,10 @@ import HeaderActionButton from '../components/HeaderActionButton'
|
|||||||
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
|
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
|
||||||
import PKICertDetailsDialog from '../components/PKICertDetailsDialog'
|
import PKICertDetailsDialog from '../components/PKICertDetailsDialog'
|
||||||
import PKICADetailsDialog from '../components/PKICADetailsDialog'
|
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 SectionCard from '../components/SectionCard'
|
||||||
import { ACMEOrder, ACMEOrderCreatePayload, ACMEProfile, ACMEProfileUpsertPayload, api, PKICA, PKICADetail, PKICert, PKICertDetail, PKICertImportPayload, PKICertIssuePayload, PKIIntermediateCARequest, PKIRootCARequest } from '../api'
|
import { ACMEOrder, ACMEOrderCreatePayload, ACMEProfile, ACMEProfileUpsertPayload, api, PKICA, PKICADetail, PKICert, PKICertDetail, PKICertImportPayload, PKICertIssuePayload, PKIIntermediateCARequest, PKIRootCARequest } from '../api'
|
||||||
import SelectField from '../components/SelectField'
|
import SelectField from '../components/SelectField'
|
||||||
@@ -83,10 +83,6 @@ function downloadBinary(filename: string, data: ArrayBuffer, contentType: string
|
|||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readFileText(file: File): Promise<string> {
|
|
||||||
return file.text()
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AdminPKIPage() {
|
export default function AdminPKIPage() {
|
||||||
const [cas, setCAs] = useState<PKICA[]>([])
|
const [cas, setCAs] = useState<PKICA[]>([])
|
||||||
const [certs, setCerts] = useState<PKICert[]>([])
|
const [certs, setCerts] = useState<PKICert[]>([])
|
||||||
@@ -107,6 +103,7 @@ export default function AdminPKIPage() {
|
|||||||
const [revokeID, setRevokeID] = useState('')
|
const [revokeID, setRevokeID] = useState('')
|
||||||
const [revokeReason, setRevokeReason] = useState('')
|
const [revokeReason, setRevokeReason] = useState('')
|
||||||
const [deleteID, setDeleteID] = useState('')
|
const [deleteID, setDeleteID] = useState('')
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState('')
|
||||||
const [deleteCAID, setDeleteCAID] = useState('')
|
const [deleteCAID, setDeleteCAID] = useState('')
|
||||||
const [deleteCAName, setDeleteCAName] = useState('')
|
const [deleteCAName, setDeleteCAName] = useState('')
|
||||||
const [deleteCAConfirm, setDeleteCAConfirm] = useState('')
|
const [deleteCAConfirm, setDeleteCAConfirm] = useState('')
|
||||||
@@ -119,9 +116,11 @@ export default function AdminPKIPage() {
|
|||||||
const [viewCertDumpLoading, setViewCertDumpLoading] = useState(false)
|
const [viewCertDumpLoading, setViewCertDumpLoading] = useState(false)
|
||||||
const [acmeOpen, setACMEOpen] = useState(false)
|
const [acmeOpen, setACMEOpen] = useState(false)
|
||||||
const [acmeDeleteID, setACMEDeleteID] = useState('')
|
const [acmeDeleteID, setACMEDeleteID] = useState('')
|
||||||
|
const [acmeDeleteConfirm, setACMEDeleteConfirm] = useState('')
|
||||||
const [acmeEditID, setACMEEditID] = useState('')
|
const [acmeEditID, setACMEEditID] = useState('')
|
||||||
const [acmeOrderOpen, setACMEOrderOpen] = useState(false)
|
const [acmeOrderOpen, setACMEOrderOpen] = useState(false)
|
||||||
const [acmeOrderDeleteID, setACMEOrderDeleteID] = useState('')
|
const [acmeOrderDeleteID, setACMEOrderDeleteID] = useState('')
|
||||||
|
const [acmeOrderDeleteConfirm, setACMEOrderDeleteConfirm] = useState('')
|
||||||
const [acmeFinalizeID, setACMEFinalizeID] = useState('')
|
const [acmeFinalizeID, setACMEFinalizeID] = useState('')
|
||||||
const [acmeFinalizeMode, setACMEFinalizeMode] = useState<'override' | 'new_cert'>('override')
|
const [acmeFinalizeMode, setACMEFinalizeMode] = useState<'override' | 'new_cert'>('override')
|
||||||
const [acmeOrderProfileID, setACMEOrderProfileID] = useState('')
|
const [acmeOrderProfileID, setACMEOrderProfileID] = useState('')
|
||||||
@@ -165,17 +164,6 @@ export default function AdminPKIPage() {
|
|||||||
const [importCertPEM, setImportCertPEM] = useState('')
|
const [importCertPEM, setImportCertPEM] = useState('')
|
||||||
const [importKeyPEM, setImportKeyPEM] = 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 () => {
|
const load = async () => {
|
||||||
let listCAs: PKICA[]
|
let listCAs: PKICA[]
|
||||||
let listCerts: PKICert[]
|
let listCerts: PKICert[]
|
||||||
@@ -353,6 +341,7 @@ export default function AdminPKIPage() {
|
|||||||
try {
|
try {
|
||||||
await api.deletePKICert(deleteID)
|
await api.deletePKICert(deleteID)
|
||||||
setDeleteID('')
|
setDeleteID('')
|
||||||
|
setDeleteConfirm('')
|
||||||
await load()
|
await load()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setDialogError(err instanceof Error ? err.message : 'Failed to delete certificate')
|
setDialogError(err instanceof Error ? err.message : 'Failed to delete certificate')
|
||||||
@@ -534,6 +523,7 @@ export default function AdminPKIPage() {
|
|||||||
try {
|
try {
|
||||||
await api.deleteACMEProfile(acmeDeleteID)
|
await api.deleteACMEProfile(acmeDeleteID)
|
||||||
setACMEDeleteID('')
|
setACMEDeleteID('')
|
||||||
|
setACMEDeleteConfirm('')
|
||||||
await load()
|
await load()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setDialogError(err instanceof Error ? err.message : 'Failed to delete ACME profile')
|
setDialogError(err instanceof Error ? err.message : 'Failed to delete ACME profile')
|
||||||
@@ -611,6 +601,7 @@ export default function AdminPKIPage() {
|
|||||||
try {
|
try {
|
||||||
await api.deleteACMEOrder(acmeOrderDeleteID)
|
await api.deleteACMEOrder(acmeOrderDeleteID)
|
||||||
setACMEOrderDeleteID('')
|
setACMEOrderDeleteID('')
|
||||||
|
setACMEOrderDeleteConfirm('')
|
||||||
await load()
|
await load()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setDialogError(err instanceof Error ? err.message : 'Failed to delete ACME order')
|
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))
|
].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 (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||||
@@ -888,7 +897,14 @@ export default function AdminPKIPage() {
|
|||||||
>
|
>
|
||||||
Revoke
|
Revoke
|
||||||
</ListRowActionButton>
|
</ListRowActionButton>
|
||||||
<ListRowActionButton color="error" onClick={() => setDeleteID(cert.id)}>
|
<ListRowActionButton
|
||||||
|
color="error"
|
||||||
|
onClick={() => {
|
||||||
|
setDialogError(null)
|
||||||
|
setDeleteID(cert.id)
|
||||||
|
setDeleteConfirm('')
|
||||||
|
}}
|
||||||
|
>
|
||||||
Delete
|
Delete
|
||||||
</ListRowActionButton>
|
</ListRowActionButton>
|
||||||
</ListRowActions>
|
</ListRowActions>
|
||||||
@@ -924,7 +940,14 @@ export default function AdminPKIPage() {
|
|||||||
<ListRowActionButton onClick={() => openEditACME(item)}>
|
<ListRowActionButton onClick={() => openEditACME(item)}>
|
||||||
Edit
|
Edit
|
||||||
</ListRowActionButton>
|
</ListRowActionButton>
|
||||||
<ListRowActionButton color="error" onClick={() => setACMEDeleteID(item.id)}>
|
<ListRowActionButton
|
||||||
|
color="error"
|
||||||
|
onClick={() => {
|
||||||
|
setDialogError(null)
|
||||||
|
setACMEDeleteID(item.id)
|
||||||
|
setACMEDeleteConfirm('')
|
||||||
|
}}
|
||||||
|
>
|
||||||
Delete
|
Delete
|
||||||
</ListRowActionButton>
|
</ListRowActionButton>
|
||||||
</ListRowActions>
|
</ListRowActions>
|
||||||
@@ -935,93 +958,54 @@ export default function AdminPKIPage() {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Dialog open={acmeOpen} onClose={() => setACMEOpen(false)} maxWidth="sm" fullWidth>
|
<PKIACMEProfileDialog
|
||||||
<DialogTitle>
|
open={acmeOpen}
|
||||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
editID={acmeEditID}
|
||||||
<Typography variant="h6">{acmeEditID ? 'Edit ACME Profile' : 'New ACME Profile'}</Typography>
|
error={dialogError}
|
||||||
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
|
busy={busy}
|
||||||
</Box>
|
name={acmeName}
|
||||||
</DialogTitle>
|
onNameChange={setACMEName}
|
||||||
<FormDialogContent>
|
directoryURL={acmeDirectoryURL}
|
||||||
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}>
|
onDirectoryURLChange={setACMEDirectoryURL}
|
||||||
<TextField label="Name" value={acmeName} onChange={(event) => setACMEName(event.target.value)} />
|
email={acmeEmail}
|
||||||
<TextField label="Directory URL" value={acmeDirectoryURL} onChange={(event) => setACMEDirectoryURL(event.target.value)} />
|
onEmailChange={setACMEEmail}
|
||||||
<TextField label="Email" value={acmeEmail} onChange={(event) => setACMEEmail(event.target.value)} />
|
enabled={acmeEnabled}
|
||||||
<SelectField select label="DNS Solver" value={acmeSolverType} onChange={(event) => setACMESolverType(event.target.value as 'manual' | 'acme_dns')}>
|
onEnabledChange={setACMEEnabled}
|
||||||
<MenuItem value="manual">Manual DNS-01</MenuItem>
|
solverType={acmeSolverType}
|
||||||
<MenuItem value="acme_dns">acme-dns (automated)</MenuItem>
|
onSolverTypeChange={setACMESolverType}
|
||||||
</SelectField>
|
dnsAPIURL={acmeDNSAPIURL}
|
||||||
{acmeSolverType === 'acme_dns' ? (
|
onDNSAPIURLChange={setACMEDNSAPIURL}
|
||||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
dnsUser={acmeDNSUser}
|
||||||
<TextField
|
onDNSUserChange={setACMEDNSUser}
|
||||||
label="ACME-DNS API URL"
|
dnsKey={acmeDNSKey}
|
||||||
value={acmeDNSAPIURL}
|
onDNSKeyChange={setACMEDNSKey}
|
||||||
onChange={(event) => setACMEDNSAPIURL(event.target.value)}
|
dnsSubdomain={acmeDNSSubdomain}
|
||||||
helperText="Example: https://auth.acme-dns.io"
|
onDNSSubdomainChange={setACMEDNSSubdomain}
|
||||||
|
dnsFullDomain={acmeDNSFullDomain}
|
||||||
|
onDNSFullDomainChange={setACMEDNSFullDomain}
|
||||||
|
onClose={() => setACMEOpen(false)}
|
||||||
|
onSave={saveACME}
|
||||||
/>
|
/>
|
||||||
<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>
|
|
||||||
|
|
||||||
<Dialog open={Boolean(acmeDeleteID)} onClose={() => setACMEDeleteID('')} maxWidth="xs" fullWidth>
|
<ConfirmDeleteDialog
|
||||||
<DialogTitle>
|
open={Boolean(acmeDeleteID)}
|
||||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
title="Delete ACME Profile"
|
||||||
<Typography variant="h6">Delete ACME Profile</Typography>
|
prompt="Type the ACME profile name to confirm deletion."
|
||||||
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
|
expectedText={acmeDeleteID ? acmeProfileConfirmText(acmeDeleteID) : ''}
|
||||||
</Box>
|
confirmLabel="Profile Name"
|
||||||
</DialogTitle>
|
confirmValue={acmeDeleteConfirm}
|
||||||
<FormDialogContent>
|
onConfirmValueChange={setACMEDeleteConfirm}
|
||||||
<Typography variant="body2" color="text.secondary">Delete profile permanently?</Typography>
|
error={dialogError}
|
||||||
</FormDialogContent>
|
busy={busy}
|
||||||
<DialogActions>
|
busyText="Working..."
|
||||||
<Button color="error" variant="contained" onClick={deleteACME} disabled={busy}>{busy ? 'Working...' : 'Delete'}</Button>
|
onClose={() => {
|
||||||
<Button onClick={() => setACMEDeleteID('')}>Cancel</Button>
|
if (busy) return
|
||||||
</DialogActions>
|
setACMEDeleteID('')
|
||||||
</Dialog>
|
setACMEDeleteConfirm('')
|
||||||
|
setDialogError(null)
|
||||||
|
}}
|
||||||
|
onConfirm={deleteACME}
|
||||||
|
/>
|
||||||
|
|
||||||
<SectionCard
|
<SectionCard
|
||||||
title="ACME Orders (DNS-01)"
|
title="ACME Orders (DNS-01)"
|
||||||
@@ -1047,7 +1031,14 @@ export default function AdminPKIPage() {
|
|||||||
<ListRowActionButton onClick={() => requestFinalizeACMEOrder(item)} disabled={busy || item.status === 'valid'}>
|
<ListRowActionButton onClick={() => requestFinalizeACMEOrder(item)} disabled={busy || item.status === 'valid'}>
|
||||||
Finalize
|
Finalize
|
||||||
</ListRowActionButton>
|
</ListRowActionButton>
|
||||||
<ListRowActionButton color="error" onClick={() => setACMEOrderDeleteID(item.id)}>
|
<ListRowActionButton
|
||||||
|
color="error"
|
||||||
|
onClick={() => {
|
||||||
|
setDialogError(null)
|
||||||
|
setACMEOrderDeleteID(item.id)
|
||||||
|
setACMEOrderDeleteConfirm('')
|
||||||
|
}}
|
||||||
|
>
|
||||||
Delete
|
Delete
|
||||||
</ListRowActionButton>
|
</ListRowActionButton>
|
||||||
</ListRowActions>
|
</ListRowActions>
|
||||||
@@ -1057,370 +1048,190 @@ export default function AdminPKIPage() {
|
|||||||
</List>
|
</List>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<Dialog open={acmeOrderOpen} onClose={() => setACMEOrderOpen(false)} maxWidth="sm" fullWidth>
|
<PKIACMEOrderCreateDialog
|
||||||
<DialogTitle>
|
open={acmeOrderOpen}
|
||||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
profiles={acmeProfiles}
|
||||||
<Typography variant="h6">New ACME Order</Typography>
|
error={dialogError}
|
||||||
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
|
busy={busy}
|
||||||
</Box>
|
profileID={acmeOrderProfileID}
|
||||||
</DialogTitle>
|
onProfileIDChange={setACMEOrderProfileID}
|
||||||
<FormDialogContent>
|
commonName={acmeOrderCommonName}
|
||||||
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}>
|
onCommonNameChange={setACMEOrderCommonName}
|
||||||
<SelectField
|
sanDNS={acmeOrderSANDNS}
|
||||||
select
|
onSANDNSChange={setACMEOrderSANDNS}
|
||||||
label="ACME Profile"
|
onClose={() => setACMEOrderOpen(false)}
|
||||||
value={acmeOrderProfileID}
|
onCreate={createACMEOrder}
|
||||||
onChange={(event) => setACMEOrderProfileID(event.target.value)}
|
/>
|
||||||
|
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PKIACMEOrderFinalizeDialog
|
||||||
|
open={Boolean(acmeFinalizeID)}
|
||||||
|
error={dialogError}
|
||||||
|
busy={busy}
|
||||||
|
mode={acmeFinalizeMode}
|
||||||
|
onModeChange={setACMEFinalizeMode}
|
||||||
|
onClose={() => setACMEFinalizeID('')}
|
||||||
|
onFinalize={() => finalizeACMEOrder(acmeFinalizeID, acmeFinalizeMode)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PKIACMEOrderDetailsDialog
|
||||||
|
order={acmeOrderView}
|
||||||
|
busy={busy}
|
||||||
|
onClose={() => setACMEOrderView(null)}
|
||||||
|
onFinalize={requestFinalizeACMEOrder}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PKICertRevokeDialog
|
||||||
|
open={Boolean(revokeID)}
|
||||||
|
error={dialogError}
|
||||||
|
busy={busy}
|
||||||
|
reason={revokeReason}
|
||||||
|
onReasonChange={setRevokeReason}
|
||||||
|
onClose={() => setRevokeID('')}
|
||||||
|
onRevoke={revokeCert}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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}
|
||||||
>
|
>
|
||||||
{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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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)} />
|
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={<Checkbox checked={deleteCAForce} onChange={(event) => setDeleteCAForce(event.target.checked)} />}
|
control={<Checkbox checked={deleteCAForce} onChange={(event) => setDeleteCAForce(event.target.checked)} />}
|
||||||
label="Force delete (includes child CAs and issued certs)"
|
label="Force delete (includes child CAs and issued certs)"
|
||||||
/>
|
/>
|
||||||
</FormDialogContent>
|
</ConfirmDeleteDialog>
|
||||||
<DialogActions>
|
|
||||||
<Button
|
|
||||||
color="error"
|
|
||||||
variant="contained"
|
|
||||||
onClick={deleteCA}
|
|
||||||
disabled={busy || deleteCAConfirm !== deleteCAName}
|
|
||||||
>
|
|
||||||
{busy ? 'Working...' : 'Delete CA'}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => setDeleteCAID('')}>Cancel</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<PKICADetailsDialog
|
<PKICADetailsDialog
|
||||||
item={viewCA}
|
item={viewCA}
|
||||||
@@ -1435,45 +1246,19 @@ export default function AdminPKIPage() {
|
|||||||
onDownloadKey={(item) => downloadText(`${item.name || item.id}.ca.key.pem`, item.key_pem || '')}
|
onDownloadKey={(item) => downloadText(`${item.name || item.id}.ca.key.pem`, item.key_pem || '')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Dialog open={Boolean(editCAID)} onClose={() => { setEditCAID(''); setEditCAParent('') }} maxWidth="xs" fullWidth>
|
<PKICAEditDialog
|
||||||
<DialogTitle>
|
open={Boolean(editCAID)}
|
||||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
error={dialogError}
|
||||||
<Typography variant="h6">Edit CA</Typography>
|
busy={busy}
|
||||||
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
|
cas={cas}
|
||||||
</Box>
|
editID={editCAID}
|
||||||
</DialogTitle>
|
name={editCAName}
|
||||||
<FormDialogContent>
|
onNameChange={setEditCAName}
|
||||||
<TextField
|
parentID={editCAParent}
|
||||||
fullWidth
|
onParentIDChange={setEditCAParent}
|
||||||
label="Name"
|
onClose={() => { setEditCAID(''); setEditCAParent('') }}
|
||||||
value={editCAName}
|
onSave={saveCAEdit}
|
||||||
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>
|
|
||||||
|
|
||||||
<PKICertDetailsDialog
|
<PKICertDetailsDialog
|
||||||
item={viewCert}
|
item={viewCert}
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
import Alert from '@mui/material/Alert'
|
import Alert from '@mui/material/Alert'
|
||||||
import Box from '@mui/material/Box'
|
import Box from '@mui/material/Box'
|
||||||
import Button from '@mui/material/Button'
|
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 Paper from '@mui/material/Paper'
|
||||||
import TextField from '@mui/material/TextField'
|
import TextField from '@mui/material/TextField'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import FormDialogContent from '../components/FormDialogContent'
|
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
|
||||||
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
|
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
|
||||||
import SectionCard from '../components/SectionCard'
|
import SectionCard from '../components/SectionCard'
|
||||||
import SSHServerDetailsDialog from '../components/SSHServerDetailsDialog'
|
import SSHServerDetailsDialog from '../components/SSHServerDetailsDialog'
|
||||||
import SSHServerFormDialog from '../components/SSHServerFormDialog'
|
import SSHServerFormDialog from '../components/SSHServerFormDialog'
|
||||||
import { SSHServerFormState } from '../components/SSHServerFormDialog'
|
import { SSHServerFormState } from '../components/SSHServerFormDialog'
|
||||||
|
import SSHHostKeysDialog from '../components/SSHHostKeysDialog'
|
||||||
import { api, SSHServer, SSHServerHostKey, SSHServerUpsertPayload } from '../api'
|
import { api, SSHServer, SSHServerHostKey, SSHServerUpsertPayload } from '../api'
|
||||||
|
|
||||||
const emptyForm = (): SSHServerFormState => ({
|
const emptyForm = (): SSHServerFormState => ({
|
||||||
@@ -36,6 +34,9 @@ export default function AdminSSHServersPage() {
|
|||||||
const [dialogError, setDialogError] = useState<string | null>(null)
|
const [dialogError, setDialogError] = useState<string | null>(null)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [deleteItem, setDeleteItem] = useState<SSHServer | null>(null)
|
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 [viewItem, setViewItem] = useState<SSHServer | null>(null)
|
||||||
const [hostKeyServer, setHostKeyServer] = useState<SSHServer | null>(null)
|
const [hostKeyServer, setHostKeyServer] = useState<SSHServer | null>(null)
|
||||||
const [hostKeys, setHostKeys] = useState<SSHServerHostKey[]>([])
|
const [hostKeys, setHostKeys] = useState<SSHServerHostKey[]>([])
|
||||||
@@ -153,13 +154,17 @@ export default function AdminSSHServersPage() {
|
|||||||
if (!deleteItem) {
|
if (!deleteItem) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
setDeleting(true)
|
||||||
|
setDeleteError(null)
|
||||||
try {
|
try {
|
||||||
await api.deleteSSHServerAdmin(deleteItem.id)
|
await api.deleteSSHServerAdmin(deleteItem.id)
|
||||||
setDeleteItem(null)
|
setDeleteItem(null)
|
||||||
|
setDeleteConfirm('')
|
||||||
await load()
|
await load()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to delete SSH server')
|
setDeleteError(err instanceof Error ? err.message : 'Failed to delete SSH server')
|
||||||
setDeleteItem(null)
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,7 +308,11 @@ export default function AdminSSHServersPage() {
|
|||||||
<ListRowActions>
|
<ListRowActions>
|
||||||
<ListRowActionButton onClick={() => setViewItem(item)}>View</ListRowActionButton>
|
<ListRowActionButton onClick={() => setViewItem(item)}>View</ListRowActionButton>
|
||||||
<ListRowActionButton onClick={() => openEdit(item)}>Edit</ListRowActionButton>
|
<ListRowActionButton onClick={() => openEdit(item)}>Edit</ListRowActionButton>
|
||||||
<ListRowActionButton color="error" onClick={() => setDeleteItem(item)}>
|
<ListRowActionButton color="error" onClick={() => {
|
||||||
|
setDeleteError(null)
|
||||||
|
setDeleteConfirm('')
|
||||||
|
setDeleteItem(item)
|
||||||
|
}}>
|
||||||
Delete
|
Delete
|
||||||
</ListRowActionButton>
|
</ListRowActionButton>
|
||||||
<ListRowActionButton onClick={() => void openHostKeys(item)}>Host Keys</ListRowActionButton>
|
<ListRowActionButton onClick={() => void openHostKeys(item)}>Host Keys</ListRowActionButton>
|
||||||
@@ -342,110 +351,40 @@ export default function AdminSSHServersPage() {
|
|||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Dialog open={Boolean(deleteItem)} onClose={() => setDeleteItem(null)} maxWidth="xs" fullWidth>
|
<ConfirmDeleteDialog
|
||||||
<DialogTitle>Delete SSH Server</DialogTitle>
|
open={Boolean(deleteItem)}
|
||||||
<FormDialogContent>
|
title="Delete SSH Server"
|
||||||
<Typography variant="body2">
|
prompt="Type the SSH server name to confirm deletion."
|
||||||
Delete <strong>{deleteItem?.name}</strong>?
|
expectedText={deleteItem?.name || ''}
|
||||||
</Typography>
|
confirmLabel="Server Name"
|
||||||
</FormDialogContent>
|
confirmValue={deleteConfirm}
|
||||||
<DialogActions>
|
onConfirmValueChange={setDeleteConfirm}
|
||||||
<Button variant="contained" color="error" onClick={handleDelete}>
|
error={deleteError}
|
||||||
Delete
|
busy={deleting}
|
||||||
</Button>
|
onClose={() => {
|
||||||
<Button onClick={() => setDeleteItem(null)}>Cancel</Button>
|
if (deleting) return
|
||||||
</DialogActions>
|
setDeleteItem(null)
|
||||||
</Dialog>
|
setDeleteConfirm('')
|
||||||
|
setDeleteError(null)
|
||||||
<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'
|
|
||||||
}}
|
}}
|
||||||
>
|
onConfirm={handleDelete}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
|
/>
|
||||||
<Typography variant="subtitle2">{item.fingerprint}</Typography>
|
|
||||||
<ListRowActions>
|
<SSHHostKeysDialog
|
||||||
<ListRowActionButton color="error" onClick={() => void handleDeleteHostKey(item.id)} disabled={hostKeySaving}>
|
server={hostKeyServer}
|
||||||
Delete
|
hostKeys={hostKeys}
|
||||||
</ListRowActionButton>
|
loading={hostKeysLoading}
|
||||||
</ListRowActions>
|
error={hostKeysError}
|
||||||
</Box>
|
input={hostKeyInput}
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
onInputChange={setHostKeyInput}
|
||||||
{item.algorithm}
|
saving={hostKeySaving}
|
||||||
</Typography>
|
discoveredHostKey={discoveredHostKey}
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
closeText="Cancel"
|
||||||
{item.public_key}
|
onClose={closeHostKeys}
|
||||||
</Typography>
|
onDiscover={() => void handleDiscoverHostKey()}
|
||||||
</Paper>
|
onAdd={(publicKey: string) => void handleAddHostKey(publicKey)}
|
||||||
))}
|
onDelete={(hostKeyID: string) => void handleDeleteHostKey(hostKeyID)}
|
||||||
</Box>
|
/>
|
||||||
</FormDialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={closeHostKeys}>Cancel</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import Alert from '@mui/material/Alert'
|
|||||||
import Box from '@mui/material/Box'
|
import Box from '@mui/material/Box'
|
||||||
import Button from '@mui/material/Button'
|
import Button from '@mui/material/Button'
|
||||||
import Chip from '@mui/material/Chip'
|
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 Link from '@mui/material/Link'
|
||||||
import MenuItem from '@mui/material/MenuItem'
|
import MenuItem from '@mui/material/MenuItem'
|
||||||
import TextField from '@mui/material/TextField'
|
import TextField from '@mui/material/TextField'
|
||||||
@@ -13,6 +11,7 @@ import { useCallback, useEffect, useState } from 'react'
|
|||||||
import SectionCard from '../components/SectionCard'
|
import SectionCard from '../components/SectionCard'
|
||||||
import SSHAccessProfileDetailsDialog from '../components/SSHAccessProfileDetailsDialog'
|
import SSHAccessProfileDetailsDialog from '../components/SSHAccessProfileDetailsDialog'
|
||||||
import SSHServerDetailsDialog from '../components/SSHServerDetailsDialog'
|
import SSHServerDetailsDialog from '../components/SSHServerDetailsDialog'
|
||||||
|
import SSHTranscriptDialog from '../components/SSHTranscriptDialog'
|
||||||
import { api, SSHAccessProfile, SSHServer, SSHSession, SSHSessionListResponse } from '../api'
|
import { api, SSHAccessProfile, SSHServer, SSHSession, SSHSessionListResponse } from '../api'
|
||||||
|
|
||||||
function fmt(value: number): string {
|
function fmt(value: number): string {
|
||||||
@@ -321,47 +320,17 @@ export default function AdminSSHSessionsPage() {
|
|||||||
onClose={() => setViewServer(null)}
|
onClose={() => setViewServer(null)}
|
||||||
showAdminFields
|
showAdminFields
|
||||||
/>
|
/>
|
||||||
<Dialog open={Boolean(transcriptItem)} onClose={closeTranscript} fullWidth maxWidth="md">
|
<SSHTranscriptDialog
|
||||||
<DialogTitle>
|
open={Boolean(transcriptItem)}
|
||||||
{transcriptItem ? `Transcript: ${sessionTitle(transcriptItem)}` : 'Transcript'}
|
title={transcriptItem ? `Transcript: ${sessionTitle(transcriptItem)}` : 'Transcript'}
|
||||||
</DialogTitle>
|
text={transcriptText}
|
||||||
<Box sx={{ display: 'grid', gap: 1.5, p: 2 }}>
|
error={transcriptError}
|
||||||
{transcriptError ? <Alert severity="error">{transcriptError}</Alert> : null}
|
loading={loadingTranscript}
|
||||||
{loadingTranscript ? (
|
downloadDisabled={!transcriptItem}
|
||||||
<Typography variant="body2" color="text.secondary">
|
closeText="Cancel"
|
||||||
Loading transcript...
|
onClose={closeTranscript}
|
||||||
</Typography>
|
onDownload={downloadTranscript}
|
||||||
) : 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>
|
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { ListRowActionButton, ListRowActions } from '../components/ListRowAction
|
|||||||
import SectionCard from '../components/SectionCard'
|
import SectionCard from '../components/SectionCard'
|
||||||
import SSHUserCADetailsDialog from '../components/SSHUserCADetailsDialog'
|
import SSHUserCADetailsDialog from '../components/SSHUserCADetailsDialog'
|
||||||
import SSHPrincipalGrantDetailsDialog from '../components/SSHPrincipalGrantDetailsDialog'
|
import SSHPrincipalGrantDetailsDialog from '../components/SSHPrincipalGrantDetailsDialog'
|
||||||
|
import SSHCertificateInspectDialog from '../components/SSHCertificateInspectDialog'
|
||||||
import { api, SSHPrincipalGrant, SSHUserCA, SSHUserCAIssuance } from '../api'
|
import { api, SSHPrincipalGrant, SSHUserCA, SSHUserCAIssuance } from '../api'
|
||||||
import SelectField from '../components/SelectField'
|
import SelectField from '../components/SelectField'
|
||||||
|
|
||||||
@@ -268,22 +269,11 @@ export default function AdminSSHSignHistoryPage() {
|
|||||||
onClose={() => setViewPrincipalGrant(null)}
|
onClose={() => setViewPrincipalGrant(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Dialog open={inspectOpen} onClose={() => setInspectOpen(false)} maxWidth="md" fullWidth>
|
<SSHCertificateInspectDialog
|
||||||
<DialogTitle>Certificate Inspect</DialogTitle>
|
open={inspectOpen}
|
||||||
<FormDialogContent>
|
dump={inspectDump}
|
||||||
<TextField
|
onClose={() => setInspectOpen(false)}
|
||||||
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>
|
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { ListRowActionButton, ListRowActions } from '../components/ListRowAction
|
|||||||
import SectionCard from '../components/SectionCard'
|
import SectionCard from '../components/SectionCard'
|
||||||
import SSHUserCADetailsDialog from '../components/SSHUserCADetailsDialog'
|
import SSHUserCADetailsDialog from '../components/SSHUserCADetailsDialog'
|
||||||
import SSHUserCAFormDialog, { SSHUserCAFormState } from '../components/SSHUserCAFormDialog'
|
import SSHUserCAFormDialog, { SSHUserCAFormState } from '../components/SSHUserCAFormDialog'
|
||||||
|
import SSHCertificateInspectDialog from '../components/SSHCertificateInspectDialog'
|
||||||
import { api, SSHSignUserKeyResponse, SSHUserCA, SSHUserCACreatePayload, SSHUserCASignPayload, SSHUserCAUpdatePayload } from '../api'
|
import { api, SSHSignUserKeyResponse, SSHUserCA, SSHUserCACreatePayload, SSHUserCASignPayload, SSHUserCAUpdatePayload } from '../api'
|
||||||
|
|
||||||
function fmt(value: number): string {
|
function fmt(value: number): string {
|
||||||
@@ -442,22 +443,12 @@ export default function AdminSSHUserCAPage() {
|
|||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={inspectOpen} onClose={() => setInspectOpen(false)} maxWidth="md" fullWidth>
|
<SSHCertificateInspectDialog
|
||||||
<DialogTitle>Certificate Inspect</DialogTitle>
|
open={inspectOpen}
|
||||||
<FormDialogContent>
|
dump={inspectDump}
|
||||||
<TextField
|
closeText="Cancel"
|
||||||
multiline
|
onClose={() => setInspectOpen(false)}
|
||||||
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>
|
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
||||||
import Alert from '@mui/material/Alert'
|
import Alert from '@mui/material/Alert'
|
||||||
|
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
|
||||||
import FormDialogContent from '../components/FormDialogContent'
|
import FormDialogContent from '../components/FormDialogContent'
|
||||||
import Link from '@mui/material/Link'
|
import Link from '@mui/material/Link'
|
||||||
import PKICertDetailsDialog from '../components/PKICertDetailsDialog'
|
import PKICertDetailsDialog from '../components/PKICertDetailsDialog'
|
||||||
@@ -109,6 +110,15 @@ export default function AdminServicePrincipalsPage() {
|
|||||||
const [viewCertDump, setViewCertDump] = useState('')
|
const [viewCertDump, setViewCertDump] = useState('')
|
||||||
const [viewCertDumpOpen, setViewCertDumpOpen] = useState(false)
|
const [viewCertDumpOpen, setViewCertDumpOpen] = useState(false)
|
||||||
const [viewCertDumpLoading, setViewCertDumpLoading] = 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 () => {
|
const load = async () => {
|
||||||
let allProjectsPage: { items: Project[]; total: number }
|
let allProjectsPage: { items: Project[]; total: number }
|
||||||
@@ -261,18 +271,23 @@ export default function AdminServicePrincipalsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const deletePrincipalKey = async (item: PrincipalAPIKey) => {
|
const deletePrincipalKey = async () => {
|
||||||
if (!keysPrincipal) {
|
if (!keysPrincipal) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!window.confirm(`Delete API key "${item.name}"?`)) return
|
if (!deletePrincipalKeyTarget) {
|
||||||
|
return
|
||||||
|
}
|
||||||
setKeysBusy(true)
|
setKeysBusy(true)
|
||||||
|
setDeleteError(null)
|
||||||
setKeysError(null)
|
setKeysError(null)
|
||||||
try {
|
try {
|
||||||
await api.deleteServicePrincipalAPIKey(keysPrincipal.id, item.id)
|
await api.deleteServicePrincipalAPIKey(keysPrincipal.id, deletePrincipalKeyTarget.id)
|
||||||
|
setDeletePrincipalKeyTarget(null)
|
||||||
|
setDeletePrincipalKeyConfirm('')
|
||||||
await loadPrincipalKeys(keysPrincipal.id)
|
await loadPrincipalKeys(keysPrincipal.id)
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
setKeysBusy(false)
|
setKeysBusy(false)
|
||||||
}
|
}
|
||||||
@@ -308,14 +323,22 @@ export default function AdminServicePrincipalsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const deletePrincipal = async (item: ServicePrincipal) => {
|
const deletePrincipal = async () => {
|
||||||
if (!window.confirm(`Delete principal "${item.name}"?`)) return
|
if (!deletePrincipalTarget) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBusy(true)
|
||||||
|
setDeleteError(null)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
await api.deleteServicePrincipal(item.id)
|
await api.deleteServicePrincipal(deletePrincipalTarget.id)
|
||||||
|
setDeletePrincipalTarget(null)
|
||||||
|
setDeletePrincipalConfirm('')
|
||||||
await load()
|
await load()
|
||||||
} catch (err) {
|
} 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) => {
|
const deleteBinding = async () => {
|
||||||
if (!window.confirm(`Delete binding for fingerprint ${item.fingerprint}?`)) return
|
if (!deleteBindingTarget) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBusy(true)
|
||||||
|
setDeleteError(null)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
await api.deleteCertPrincipalBinding(item.fingerprint)
|
await api.deleteCertPrincipalBinding(deleteBindingTarget.fingerprint)
|
||||||
|
setDeleteBindingTarget(null)
|
||||||
|
setDeleteBindingConfirm('')
|
||||||
await load()
|
await load()
|
||||||
} catch (err) {
|
} 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)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
await api.deletePrincipalProjectRole(principalID, projectID)
|
await api.deletePrincipalProjectRole(deleteRoleTarget.principalID, deleteRoleTarget.projectID)
|
||||||
|
setDeleteRoleTarget(null)
|
||||||
|
setDeleteRoleConfirm('')
|
||||||
await load()
|
await load()
|
||||||
} catch (err) {
|
} 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})`
|
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 => {
|
const pkiCertByFingerprint = (fingerprint: string): PKICert | undefined => {
|
||||||
return pkiCerts.find((item) => (item.fingerprint || '').toLowerCase() === (fingerprint || '').toLowerCase())
|
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)}>
|
<ListRowActionButton color={item.disabled ? 'success' : 'warning'} onClick={() => togglePrincipal(item)}>
|
||||||
{item.disabled ? 'Enable' : 'Disable'}
|
{item.disabled ? 'Enable' : 'Disable'}
|
||||||
</ListRowActionButton>
|
</ListRowActionButton>
|
||||||
<ListRowActionButton color="error" onClick={() => deletePrincipal(item)}>Delete</ListRowActionButton>
|
<ListRowActionButton
|
||||||
|
color="error"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteError(null)
|
||||||
|
setDeletePrincipalTarget(item)
|
||||||
|
setDeletePrincipalConfirm('')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</ListRowActionButton>
|
||||||
</ListRowActions>
|
</ListRowActions>
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
@@ -647,7 +702,16 @@ export default function AdminServicePrincipalsPage() {
|
|||||||
(principalRoles[principal.id] || []).map((role) => (
|
(principalRoles[principal.id] || []).map((role) => (
|
||||||
<Box key={`${role.principal_id}:${role.project_id}`} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<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>
|
<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>
|
</Box>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -712,7 +776,16 @@ export default function AdminServicePrincipalsPage() {
|
|||||||
<ListRowActionButton color={item.enabled ? 'warning' : 'success'} onClick={() => toggleBinding(item)}>
|
<ListRowActionButton color={item.enabled ? 'warning' : 'success'} onClick={() => toggleBinding(item)}>
|
||||||
{item.enabled ? 'Disable' : 'Enable'}
|
{item.enabled ? 'Disable' : 'Enable'}
|
||||||
</ListRowActionButton>
|
</ListRowActionButton>
|
||||||
<ListRowActionButton color="error" onClick={() => deleteBinding(item)}>Delete</ListRowActionButton>
|
<ListRowActionButton
|
||||||
|
color="error"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteError(null)
|
||||||
|
setDeleteBindingTarget(item)
|
||||||
|
setDeleteBindingConfirm('')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</ListRowActionButton>
|
||||||
</ListRowActions>
|
</ListRowActions>
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
@@ -903,7 +976,15 @@ export default function AdminServicePrincipalsPage() {
|
|||||||
<ListRowActionButton color={item.disabled ? 'success' : 'warning'} onClick={() => togglePrincipalKey(item)} disabled={keysBusy}>
|
<ListRowActionButton color={item.disabled ? 'success' : 'warning'} onClick={() => togglePrincipalKey(item)} disabled={keysBusy}>
|
||||||
{item.disabled ? 'Enable' : 'Disable'}
|
{item.disabled ? 'Enable' : 'Disable'}
|
||||||
</ListRowActionButton>
|
</ListRowActionButton>
|
||||||
<ListRowActionButton color="error" onClick={() => deletePrincipalKey(item)} disabled={keysBusy}>
|
<ListRowActionButton
|
||||||
|
color="error"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteError(null)
|
||||||
|
setDeletePrincipalKeyTarget(item)
|
||||||
|
setDeletePrincipalKeyConfirm('')
|
||||||
|
}}
|
||||||
|
disabled={keysBusy}
|
||||||
|
>
|
||||||
Delete
|
Delete
|
||||||
</ListRowActionButton>
|
</ListRowActionButton>
|
||||||
</ListRowActions>
|
</ListRowActions>
|
||||||
@@ -920,6 +1001,80 @@ export default function AdminServicePrincipalsPage() {
|
|||||||
<Button onClick={closeKeys}>{newPrincipalToken ? 'Done' : 'Close'}</Button>
|
<Button onClick={closeKeys}>{newPrincipalToken ? 'Done' : 'Close'}</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</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>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import Paper from '@mui/material/Paper'
|
|||||||
import TextField from '@mui/material/TextField'
|
import TextField from '@mui/material/TextField'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
|
||||||
import FormDialogContent from '../components/FormDialogContent'
|
import FormDialogContent from '../components/FormDialogContent'
|
||||||
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
|
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
|
||||||
import SectionCard from '../components/SectionCard'
|
import SectionCard from '../components/SectionCard'
|
||||||
@@ -45,8 +46,10 @@ export default function AdminTLSAuthPoliciesPage() {
|
|||||||
const [permissionsText, setPermissionsText] = useState('')
|
const [permissionsText, setPermissionsText] = useState('')
|
||||||
const [dialogError, setDialogError] = useState<string | null>(null)
|
const [dialogError, setDialogError] = useState<string | null>(null)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
|
||||||
const [confirmPolicy, setConfirmPolicy] = useState<TLSAuthPolicy | null>(null)
|
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 () => {
|
const load = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -157,18 +160,19 @@ export default function AdminTLSAuthPoliciesPage() {
|
|||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!confirmPolicy) {
|
if (!confirmPolicy) {
|
||||||
setConfirmOpen(false)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
setDeleting(true)
|
||||||
|
setDeleteError(null)
|
||||||
try {
|
try {
|
||||||
await api.deleteTLSAuthPolicy(confirmPolicy.id)
|
await api.deleteTLSAuthPolicy(confirmPolicy.id)
|
||||||
setConfirmOpen(false)
|
|
||||||
setConfirmPolicy(null)
|
setConfirmPolicy(null)
|
||||||
|
setConfirmText('')
|
||||||
await load()
|
await load()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to delete TLS auth policy')
|
setDeleteError(err instanceof Error ? err.message : 'Failed to delete TLS auth policy')
|
||||||
setConfirmOpen(false)
|
} finally {
|
||||||
setConfirmPolicy(null)
|
setDeleting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,8 +237,9 @@ export default function AdminTLSAuthPoliciesPage() {
|
|||||||
<ListRowActions>
|
<ListRowActions>
|
||||||
<ListRowActionButton onClick={() => openEdit(item)}>Edit</ListRowActionButton>
|
<ListRowActionButton onClick={() => openEdit(item)}>Edit</ListRowActionButton>
|
||||||
<ListRowActionButton color="error" onClick={() => {
|
<ListRowActionButton color="error" onClick={() => {
|
||||||
|
setDeleteError(null)
|
||||||
setConfirmPolicy(item)
|
setConfirmPolicy(item)
|
||||||
setConfirmOpen(true)
|
setConfirmText('')
|
||||||
}}>
|
}}>
|
||||||
Delete
|
Delete
|
||||||
</ListRowActionButton>
|
</ListRowActionButton>
|
||||||
@@ -361,20 +366,24 @@ export default function AdminTLSAuthPoliciesPage() {
|
|||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={confirmOpen} onClose={() => setConfirmOpen(false)} maxWidth="xs" fullWidth>
|
<ConfirmDeleteDialog
|
||||||
<DialogTitle>Delete TLS Auth Policy</DialogTitle>
|
open={!!confirmPolicy}
|
||||||
<FormDialogContent>
|
title="Delete TLS Auth Policy"
|
||||||
<Typography variant="body2">
|
prompt="Type the policy name to confirm deletion."
|
||||||
Delete policy "{confirmPolicy?.name}"?
|
expectedText={confirmPolicy?.name || ''}
|
||||||
</Typography>
|
confirmLabel="Policy Name"
|
||||||
</FormDialogContent>
|
confirmValue={confirmText}
|
||||||
<DialogActions>
|
onConfirmValueChange={setConfirmText}
|
||||||
<Button variant="contained" color="error" onClick={handleDelete}>
|
error={deleteError}
|
||||||
Delete
|
busy={deleting}
|
||||||
</Button>
|
onClose={() => {
|
||||||
<Button onClick={() => setConfirmOpen(false)}>Cancel</Button>
|
if (deleting) return
|
||||||
</DialogActions>
|
setConfirmPolicy(null)
|
||||||
</Dialog>
|
setConfirmText('')
|
||||||
|
setDeleteError(null)
|
||||||
|
}}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Alert from '@mui/material/Alert'
|
import Alert from '@mui/material/Alert'
|
||||||
|
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
|
||||||
import FormDialogContent from '../components/FormDialogContent'
|
import FormDialogContent from '../components/FormDialogContent'
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
@@ -74,10 +75,11 @@ export default function AdminTLSSettingsPage() {
|
|||||||
const [confirmMessage, setConfirmMessage] = useState('')
|
const [confirmMessage, setConfirmMessage] = useState('')
|
||||||
const [confirmLabel, setConfirmLabel] = useState('Confirm')
|
const [confirmLabel, setConfirmLabel] = useState('Confirm')
|
||||||
const [confirmColor, setConfirmColor] = useState<'primary' | 'warning' | 'error'>('primary')
|
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 [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 policyNameByID = useMemo(() => {
|
||||||
const map: Record<string, string> = {}
|
const map: Record<string, string> = {}
|
||||||
authPolicies.forEach((item) => {
|
authPolicies.forEach((item) => {
|
||||||
@@ -327,9 +329,6 @@ export default function AdminTLSSettingsPage() {
|
|||||||
setConfirmMessage(`Do you want to ${nextEnabled ? 'enable' : 'disable'} listener "${item.name}"?`)
|
setConfirmMessage(`Do you want to ${nextEnabled ? 'enable' : 'disable'} listener "${item.name}"?`)
|
||||||
setConfirmLabel(nextEnabled ? 'Enable' : 'Disable')
|
setConfirmLabel(nextEnabled ? 'Enable' : 'Disable')
|
||||||
setConfirmColor(nextEnabled ? 'primary' : 'warning')
|
setConfirmColor(nextEnabled ? 'primary' : 'warning')
|
||||||
setConfirmRequiresText(false)
|
|
||||||
setConfirmExpectedText('')
|
|
||||||
setConfirmInput('')
|
|
||||||
setConfirmAction(() => async () => {
|
setConfirmAction(() => async () => {
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
@@ -345,25 +344,30 @@ export default function AdminTLSSettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteListener = async (item: TLSListener) => {
|
const handleDeleteListener = async (item: TLSListener) => {
|
||||||
setConfirmTitle('Delete Listener')
|
setDeleteListenerItem(item)
|
||||||
setConfirmMessage('Type the listener name to confirm deletion.')
|
setDeleteListenerConfirm('')
|
||||||
setConfirmLabel('Delete')
|
setDeleteListenerError(null)
|
||||||
setConfirmColor('error')
|
}
|
||||||
setConfirmRequiresText(true)
|
|
||||||
setConfirmExpectedText(item.name)
|
const deleteListener = async () => {
|
||||||
setConfirmInput('')
|
if (!deleteListenerItem) {
|
||||||
setConfirmAction(() => async () => {
|
return
|
||||||
|
}
|
||||||
|
setDeleteListenerBusy(true)
|
||||||
|
setDeleteListenerError(null)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
await api.deleteTLSListener(item.id)
|
await api.deleteTLSListener(deleteListenerItem.id)
|
||||||
|
setDeleteListenerItem(null)
|
||||||
|
setDeleteListenerConfirm('')
|
||||||
await loadListeners()
|
await loadListeners()
|
||||||
await loadRuntimeStatus()
|
await loadRuntimeStatus()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Failed to delete listener'
|
const message = err instanceof Error ? err.message : 'Failed to delete listener'
|
||||||
setError(message)
|
setDeleteListenerError(message)
|
||||||
|
} finally {
|
||||||
|
setDeleteListenerBusy(false)
|
||||||
}
|
}
|
||||||
})
|
|
||||||
setConfirmOpen(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
const handleConfirm = async () => {
|
||||||
@@ -373,9 +377,6 @@ export default function AdminTLSSettingsPage() {
|
|||||||
}
|
}
|
||||||
await confirmAction()
|
await confirmAction()
|
||||||
setConfirmOpen(false)
|
setConfirmOpen(false)
|
||||||
setConfirmRequiresText(false)
|
|
||||||
setConfirmExpectedText('')
|
|
||||||
setConfirmInput('')
|
|
||||||
setConfirmAction(null)
|
setConfirmAction(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,9 +627,6 @@ export default function AdminTLSSettingsPage() {
|
|||||||
open={confirmOpen}
|
open={confirmOpen}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setConfirmOpen(false)
|
setConfirmOpen(false)
|
||||||
setConfirmRequiresText(false)
|
|
||||||
setConfirmExpectedText('')
|
|
||||||
setConfirmInput('')
|
|
||||||
}}
|
}}
|
||||||
maxWidth="xs"
|
maxWidth="xs"
|
||||||
fullWidth
|
fullWidth
|
||||||
@@ -636,42 +634,42 @@ export default function AdminTLSSettingsPage() {
|
|||||||
<DialogTitle>{confirmTitle}</DialogTitle>
|
<DialogTitle>{confirmTitle}</DialogTitle>
|
||||||
<FormDialogContent>
|
<FormDialogContent>
|
||||||
<Typography variant="body2">{confirmMessage}</Typography>
|
<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>
|
</FormDialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color={confirmColor}
|
color={confirmColor}
|
||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
disabled={confirmRequiresText && confirmInput.trim() !== confirmExpectedText}
|
|
||||||
>
|
>
|
||||||
{confirmLabel}
|
{confirmLabel}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setConfirmOpen(false)
|
setConfirmOpen(false)
|
||||||
setConfirmRequiresText(false)
|
|
||||||
setConfirmExpectedText('')
|
|
||||||
setConfirmInput('')
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</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>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import AddIcon from '@mui/icons-material/Add'
|
|||||||
import DeleteIcon from '@mui/icons-material/Delete'
|
import DeleteIcon from '@mui/icons-material/Delete'
|
||||||
import EditIcon from '@mui/icons-material/Edit'
|
import EditIcon from '@mui/icons-material/Edit'
|
||||||
import Alert from '@mui/material/Alert'
|
import Alert from '@mui/material/Alert'
|
||||||
|
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
|
||||||
import FormDialogContent from '../components/FormDialogContent'
|
import FormDialogContent from '../components/FormDialogContent'
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
@@ -58,6 +59,8 @@ export default function AdminUserGroupsPage() {
|
|||||||
|
|
||||||
const [deleteItem, setDeleteItem] = useState<UserGroup | null>(null)
|
const [deleteItem, setDeleteItem] = useState<UserGroup | null>(null)
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState('')
|
const [deleteConfirm, setDeleteConfirm] = useState('')
|
||||||
|
const [deleteMemberItem, setDeleteMemberItem] = useState<UserGroupMember | null>(null)
|
||||||
|
const [deleteMemberConfirm, setDeleteMemberConfirm] = useState('')
|
||||||
|
|
||||||
const selectedGroup = useMemo(() => {
|
const selectedGroup = useMemo(() => {
|
||||||
return groups.find((item) => item.id === selectedGroupID) || null
|
return groups.find((item) => item.id === selectedGroupID) || null
|
||||||
@@ -226,12 +229,15 @@ export default function AdminUserGroupsPage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
|
setDialogError(null)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
await api.removeUserGroupMember(selectedGroupID, userID)
|
await api.removeUserGroupMember(selectedGroupID, userID)
|
||||||
|
setDeleteMemberItem(null)
|
||||||
|
setDeleteMemberConfirm('')
|
||||||
await loadMembers(selectedGroupID)
|
await loadMembers(selectedGroupID)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to remove member')
|
setDialogError(err instanceof Error ? err.message : 'Failed to remove member')
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
@@ -243,6 +249,13 @@ export default function AdminUserGroupsPage() {
|
|||||||
return user.display_name ? `${user.display_name} (${user.username})` : user.username
|
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) => {
|
const hasDirectProjectCreate = (groupID: string) => {
|
||||||
return subjectPermissions.some((item) => item.permission === projectCreatePermission && item.subject_id === groupID)
|
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 availableUsers = users.filter((user) => !members.some((member) => member.user_id === user.id))
|
||||||
|
const deleteMemberExpected = memberConfirmText(deleteMemberItem)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
@@ -419,7 +433,17 @@ export default function AdminUserGroupsPage() {
|
|||||||
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}
|
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}
|
||||||
>
|
>
|
||||||
<Typography variant="body2">{userLabelByID(member.user_id)}</Typography>
|
<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
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -484,31 +508,45 @@ export default function AdminUserGroupsPage() {
|
|||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={!!deleteItem} onClose={() => setDeleteItem(null)} maxWidth="xs" fullWidth>
|
<ConfirmDeleteDialog
|
||||||
<DialogTitle>Delete Group</DialogTitle>
|
open={!!deleteItem}
|
||||||
<FormDialogContent>
|
title="Delete Group"
|
||||||
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
|
prompt="Type the group name to confirm deletion."
|
||||||
<Typography variant="body2">Type the group name to confirm deletion.</Typography>
|
expectedText={deleteItem?.name || ''}
|
||||||
<TextField
|
confirmLabel="Group Name"
|
||||||
autoFocus
|
confirmValue={deleteConfirm}
|
||||||
size="small"
|
onConfirmValueChange={setDeleteConfirm}
|
||||||
label="Group Name"
|
error={dialogError}
|
||||||
value={deleteConfirm}
|
busy={busy}
|
||||||
onChange={(event) => setDeleteConfirm(event.target.value)}
|
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)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</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>
|
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Alert from '@mui/material/Alert'
|
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 { useEffect, useState } from 'react'
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
import { api, Project, ProjectGroupRole, ProjectMember, ProjectUpdatePayload, User, UserGroup } from '../api'
|
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 SectionCard from '../components/SectionCard'
|
||||||
import DeleteIcon from '@mui/icons-material/Delete'
|
import DeleteIcon from '@mui/icons-material/Delete'
|
||||||
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
|
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'
|
import SelectField from '../components/SelectField'
|
||||||
|
|
||||||
export default function ProjectHomePage() {
|
export default function ProjectHomePage() {
|
||||||
@@ -23,8 +24,8 @@ export default function ProjectHomePage() {
|
|||||||
const [editSlug, setEditSlug] = useState('')
|
const [editSlug, setEditSlug] = useState('')
|
||||||
const [editName, setEditName] = useState('')
|
const [editName, setEditName] = useState('')
|
||||||
const [editDescription, setEditDescription] = useState('')
|
const [editDescription, setEditDescription] = useState('')
|
||||||
const [editHomePage, setEditHomePage] = useState<'info' | 'repos' | 'issues' | 'wiki' | 'files'>('info')
|
const [editHomePage, setEditHomePage] = useState<ProjectHomePageValue>('info')
|
||||||
const [editDescTab, setEditDescTab] = useState<'write' | 'preview'>('write')
|
const [editDescTab, setEditDescTab] = useState<ProjectDescriptionTab>('write')
|
||||||
const [editError, setEditError] = useState<string | null>(null)
|
const [editError, setEditError] = useState<string | null>(null)
|
||||||
const [savingEdit, setSavingEdit] = useState(false)
|
const [savingEdit, setSavingEdit] = useState(false)
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||||
@@ -573,87 +574,31 @@ export default function ProjectHomePage() {
|
|||||||
</Box>
|
</Box>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
</Box>
|
</Box>
|
||||||
<Dialog open={editOpen} onClose={() => setEditOpen(false)} maxWidth="sm" fullWidth>
|
<ProjectFormDialog
|
||||||
<DialogTitle>Edit Project</DialogTitle>
|
open={editOpen}
|
||||||
<FormDialogContent>
|
title="Edit Project"
|
||||||
{editError ? <Alert severity="error">{editError}</Alert> : null}
|
error={editError}
|
||||||
<TextField
|
slug={editSlug}
|
||||||
margin="dense"
|
onSlugChange={setEditSlug}
|
||||||
label="Slug"
|
name={editName}
|
||||||
fullWidth
|
onNameChange={setEditName}
|
||||||
error={/\s/.test(editSlug)}
|
description={editDescription}
|
||||||
helperText={/\s/.test(editSlug) ? 'Slug cannot contain whitespace.' : ''}
|
onDescriptionChange={setEditDescription}
|
||||||
value={editSlug}
|
homePage={editHomePage}
|
||||||
onChange={(event) => setEditSlug(event.target.value)}
|
onHomePageChange={setEditHomePage}
|
||||||
|
descriptionTab={editDescTab}
|
||||||
|
onDescriptionTabChange={setEditDescTab}
|
||||||
|
busy={savingEdit}
|
||||||
|
saveText="Save"
|
||||||
|
busyText="Saving..."
|
||||||
|
onSave={handleEditSave}
|
||||||
|
onClose={() => setEditOpen(false)}
|
||||||
/>
|
/>
|
||||||
<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>
|
|
||||||
<ConfirmDeleteDialog
|
<ConfirmDeleteDialog
|
||||||
open={deleteOpen}
|
open={deleteOpen}
|
||||||
title="Delete Project"
|
title="Delete Project"
|
||||||
prompt={deleteLoading ? 'Checking project contents...' : 'Type the project slug to confirm deletion.'}
|
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"
|
confirmLabel="Project Slug"
|
||||||
confirmValue={deleteConfirm}
|
confirmValue={deleteConfirm}
|
||||||
onConfirmValueChange={setDeleteConfirm}
|
onConfirmValueChange={setDeleteConfirm}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import DeleteIcon from '@mui/icons-material/Delete'
|
|||||||
import EditIcon from '@mui/icons-material/Edit'
|
import EditIcon from '@mui/icons-material/Edit'
|
||||||
import Alert from '@mui/material/Alert'
|
import Alert from '@mui/material/Alert'
|
||||||
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
|
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 { useEffect, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { api, Project, ProjectCreatePayload, ProjectUpdatePayload, User } from '../api'
|
import { api, Project, ProjectCreatePayload, ProjectUpdatePayload, User } from '../api'
|
||||||
@@ -11,10 +11,8 @@ import Autocomplete from '../components/Autocomplete'
|
|||||||
import HeaderActionButton from '../components/HeaderActionButton'
|
import HeaderActionButton from '../components/HeaderActionButton'
|
||||||
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
|
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
|
||||||
import SectionCard from '../components/SectionCard'
|
import SectionCard from '../components/SectionCard'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ProjectFormDialog from '../components/ProjectFormDialog'
|
||||||
import remarkGfm from 'remark-gfm'
|
import { ProjectDescriptionTab, ProjectHomePageValue } from '../components/ProjectFormDialog'
|
||||||
import FormDialogContent from '../components/FormDialogContent'
|
|
||||||
import SelectField from '../components/SelectField'
|
|
||||||
|
|
||||||
export default function ProjectsPage() {
|
export default function ProjectsPage() {
|
||||||
const [projects, setProjects] = useState<Project[]>([])
|
const [projects, setProjects] = useState<Project[]>([])
|
||||||
@@ -23,15 +21,17 @@ export default function ProjectsPage() {
|
|||||||
const [editSlug, setEditSlug] = useState('')
|
const [editSlug, setEditSlug] = useState('')
|
||||||
const [editName, setEditName] = useState('')
|
const [editName, setEditName] = useState('')
|
||||||
const [editDescription, setEditDescription] = useState('')
|
const [editDescription, setEditDescription] = useState('')
|
||||||
const [editHomePage, setEditHomePage] = useState<'info' | 'repos' | 'issues' | 'wiki' | 'files'>('info')
|
const [editHomePage, setEditHomePage] = useState<ProjectHomePageValue>('info')
|
||||||
const [editDescTab, setEditDescTab] = useState<'write' | 'preview'>('write')
|
const [editDescTab, setEditDescTab] = useState<ProjectDescriptionTab>('write')
|
||||||
const [editError, setEditError] = useState<string | null>(null)
|
const [editError, setEditError] = useState<string | null>(null)
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
const [createSlug, setCreateSlug] = useState('')
|
||||||
|
const [createName, setCreateName] = useState('')
|
||||||
const [createError, setCreateError] = useState<string | null>(null)
|
const [createError, setCreateError] = useState<string | null>(null)
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
const [createDescription, setCreateDescription] = useState('')
|
const [createDescription, setCreateDescription] = useState('')
|
||||||
const [createHomePage, setCreateHomePage] = useState<'info' | 'repos' | 'issues' | 'wiki' | 'files'>('info')
|
const [createHomePage, setCreateHomePage] = useState<ProjectHomePageValue>('info')
|
||||||
const [createDescTab, setCreateDescTab] = useState<'write' | 'preview'>('write')
|
const [createDescTab, setCreateDescTab] = useState<ProjectDescriptionTab>('write')
|
||||||
const [savingEdit, setSavingEdit] = useState(false)
|
const [savingEdit, setSavingEdit] = useState(false)
|
||||||
const [deleteProject, setDeleteProject] = useState<Project | null>(null)
|
const [deleteProject, setDeleteProject] = useState<Project | null>(null)
|
||||||
const [deleteCounts, setDeleteCounts] = useState<{ repos: number; issues: number; wiki: number; uploads: number } | 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))
|
.catch(() => setUser(null))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleCreate = async (evt: React.FormEvent<HTMLFormElement>) => {
|
const handleCreate = async () => {
|
||||||
evt.preventDefault()
|
|
||||||
const form = evt.currentTarget
|
|
||||||
const data = new FormData(form)
|
|
||||||
const payload: ProjectCreatePayload = {
|
const payload: ProjectCreatePayload = {
|
||||||
slug: String(data.get('slug') || ''),
|
slug: createSlug.trim(),
|
||||||
name: String(data.get('name') || ''),
|
name: createName.trim(),
|
||||||
description: createDescription,
|
description: createDescription,
|
||||||
home_page: createHomePage
|
home_page: createHomePage
|
||||||
}
|
}
|
||||||
@@ -103,7 +100,8 @@ export default function ProjectsPage() {
|
|||||||
const created = await api.createProject(payload)
|
const created = await api.createProject(payload)
|
||||||
setProjects((prev) => [...prev, created])
|
setProjects((prev) => [...prev, created])
|
||||||
setTotalProjects((prev) => prev + 1)
|
setTotalProjects((prev) => prev + 1)
|
||||||
form.reset()
|
setCreateSlug('')
|
||||||
|
setCreateName('')
|
||||||
setCreateDescription('')
|
setCreateDescription('')
|
||||||
setCreateHomePage('info')
|
setCreateHomePage('info')
|
||||||
setCreateDescTab('write')
|
setCreateDescTab('write')
|
||||||
@@ -212,6 +210,8 @@ export default function ProjectsPage() {
|
|||||||
const closeCreate = () => {
|
const closeCreate = () => {
|
||||||
setCreateOpen(false)
|
setCreateOpen(false)
|
||||||
setCreateError(null)
|
setCreateError(null)
|
||||||
|
setCreateSlug('')
|
||||||
|
setCreateName('')
|
||||||
setCreateDescription('')
|
setCreateDescription('')
|
||||||
setCreateHomePage('info')
|
setCreateHomePage('info')
|
||||||
setCreateDescTab('write')
|
setCreateDescTab('write')
|
||||||
@@ -378,154 +378,51 @@ export default function ProjectsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
<Dialog open={Boolean(editProject)} onClose={() => setEditProject(null)} maxWidth="sm" fullWidth>
|
<ProjectFormDialog
|
||||||
<DialogTitle>Edit Project</DialogTitle>
|
open={Boolean(editProject)}
|
||||||
<FormDialogContent>
|
title="Edit Project"
|
||||||
{editError ? <Alert severity="error">{editError}</Alert> : null}
|
error={editError}
|
||||||
<TextField
|
slug={editSlug}
|
||||||
margin="dense"
|
onSlugChange={setEditSlug}
|
||||||
label="Slug"
|
name={editName}
|
||||||
fullWidth
|
onNameChange={setEditName}
|
||||||
error={/\s/.test(editSlug)}
|
description={editDescription}
|
||||||
helperText={/\s/.test(editSlug) ? 'Slug cannot contain whitespace.' : ''}
|
onDescriptionChange={setEditDescription}
|
||||||
value={editSlug}
|
homePage={editHomePage}
|
||||||
onChange={(event) => setEditSlug(event.target.value)}
|
onHomePageChange={setEditHomePage}
|
||||||
|
descriptionTab={editDescTab}
|
||||||
|
onDescriptionTabChange={setEditDescTab}
|
||||||
|
busy={savingEdit}
|
||||||
|
saveText="Save"
|
||||||
|
busyText="Saving..."
|
||||||
|
onSave={handleEditSave}
|
||||||
|
onClose={() => setEditProject(null)}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<ProjectFormDialog
|
||||||
margin="dense"
|
open={createOpen}
|
||||||
label="Name"
|
title="New Project"
|
||||||
fullWidth
|
error={createError}
|
||||||
value={editName}
|
slug={createSlug}
|
||||||
onChange={(event) => setEditName(event.target.value)}
|
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}
|
||||||
/>
|
/>
|
||||||
<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>
|
|
||||||
<ConfirmDeleteDialog
|
<ConfirmDeleteDialog
|
||||||
open={Boolean(deleteProject)}
|
open={Boolean(deleteProject)}
|
||||||
title="Delete Project"
|
title="Delete Project"
|
||||||
prompt={deleteLoading ? 'Checking project contents...' : 'Type the project slug to confirm deletion.'}
|
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"
|
confirmLabel="Project Slug"
|
||||||
confirmValue={deleteConfirm}
|
confirmValue={deleteConfirm}
|
||||||
onConfirmValueChange={setDeleteConfirm}
|
onConfirmValueChange={setDeleteConfirm}
|
||||||
|
|||||||
@@ -618,8 +618,8 @@ export default function ReposPage() {
|
|||||||
<ConfirmDeleteDialog
|
<ConfirmDeleteDialog
|
||||||
open={bulkDeleteOpen}
|
open={bulkDeleteOpen}
|
||||||
title="Delete Repositories"
|
title="Delete Repositories"
|
||||||
prompt={`Type DELETE ${selectedRepoIds.length} to confirm.`}
|
prompt={`Type DELETE to delete ${selectedRepoIds.length} selected repository/repositories.`}
|
||||||
expectedText={`DELETE ${selectedRepoIds.length}`}
|
expectedText="DELETE"
|
||||||
confirmLabel="Confirmation"
|
confirmLabel="Confirmation"
|
||||||
confirmValue={bulkDeleteConfirm}
|
confirmValue={bulkDeleteConfirm}
|
||||||
onConfirmValueChange={setBulkDeleteConfirm}
|
onConfirmValueChange={setBulkDeleteConfirm}
|
||||||
|
|||||||
@@ -469,7 +469,7 @@ export default function SSHCertificatesPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</FormDialogContent>
|
</FormDialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={() => setInspectOpen(false)}>Close</Button>
|
<Button onClick={() => setInspectOpen(false)}>Cancel</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,6 @@ import Alert from '@mui/material/Alert'
|
|||||||
import Box from '@mui/material/Box'
|
import Box from '@mui/material/Box'
|
||||||
import Button from '@mui/material/Button'
|
import Button from '@mui/material/Button'
|
||||||
import Checkbox from '@mui/material/Checkbox'
|
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 FormControlLabel from '@mui/material/FormControlLabel'
|
||||||
import Link from '@mui/material/Link'
|
import Link from '@mui/material/Link'
|
||||||
import Paper from '@mui/material/Paper'
|
import Paper from '@mui/material/Paper'
|
||||||
@@ -14,6 +11,7 @@ import { useEffect, useMemo, useState } from 'react'
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
|
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
|
||||||
import FormDialogContent from '../components/FormDialogContent'
|
import FormDialogContent from '../components/FormDialogContent'
|
||||||
|
import SSHCredentialsPromptDialog from '../components/SSHCredentialsPromptDialog'
|
||||||
import SSHAccessProfileDetailsDialog from '../components/SSHAccessProfileDetailsDialog'
|
import SSHAccessProfileDetailsDialog from '../components/SSHAccessProfileDetailsDialog'
|
||||||
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
|
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
|
||||||
import SectionCard from '../components/SectionCard'
|
import SectionCard from '../components/SectionCard'
|
||||||
@@ -22,6 +20,7 @@ import { SSHAccessProfileFormState } from '../components/SSHAccessProfileFormDia
|
|||||||
import SSHServerDetailsDialog from '../components/SSHServerDetailsDialog'
|
import SSHServerDetailsDialog from '../components/SSHServerDetailsDialog'
|
||||||
import SSHServerFormDialog from '../components/SSHServerFormDialog'
|
import SSHServerFormDialog from '../components/SSHServerFormDialog'
|
||||||
import { SSHServerFormState } from '../components/SSHServerFormDialog'
|
import { SSHServerFormState } from '../components/SSHServerFormDialog'
|
||||||
|
import SSHHostKeysDialog from '../components/SSHHostKeysDialog'
|
||||||
import { api, SSHAccessProfile, SSHAccessProfileSelfUpsertPayload, SSHConnectPayload, SSHServer, SSHServerHostKey, SSHServerUpsertPayload } from '../api'
|
import { api, SSHAccessProfile, SSHAccessProfileSelfUpsertPayload, SSHConnectPayload, SSHServer, SSHServerHostKey, SSHServerUpsertPayload } from '../api'
|
||||||
|
|
||||||
const emptyForm = (): SSHAccessProfileFormState => ({
|
const emptyForm = (): SSHAccessProfileFormState => ({
|
||||||
@@ -700,138 +699,35 @@ export default function SSHServersPage() {
|
|||||||
onClose={() => { setDeleteServerItem(null); setDeleteServerConfirm('') }}
|
onClose={() => { setDeleteServerItem(null); setDeleteServerConfirm('') }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Dialog open={Boolean(connectPasswordItem)} onClose={closeConnectPasswordPrompt} maxWidth="xs" fullWidth>
|
<SSHCredentialsPromptDialog
|
||||||
<DialogTitle>Enter SSH Credentials</DialogTitle>
|
open={Boolean(connectPasswordItem)}
|
||||||
<FormDialogContent>
|
targetText={connectPasswordItem ? `${connectPasswordItem.remote_username}@${connectPasswordItem.server?.host || connectPasswordItem.server_id}:${connectPasswordItem.server?.port || 22}` : 'Enter the password for this SSH access profile.'}
|
||||||
{connectPasswordError ? <Alert severity="error">{connectPasswordError}</Alert> : null}
|
error={connectPasswordError}
|
||||||
<Typography variant="body2" color="text.secondary">
|
passwordRequired={Boolean(connectPasswordItem?.auth_method === 'prompted_password')}
|
||||||
{connectPasswordItem ? `${connectPasswordItem.remote_username}@${connectPasswordItem.server?.host || connectPasswordItem.server_id}:${connectPasswordItem.server?.port || 22}` : 'Enter the password for this SSH access profile.'}
|
password={connectPassword}
|
||||||
</Typography>
|
onPasswordChange={setConnectPassword}
|
||||||
{connectPasswordItem?.auth_method === 'prompted_password' ? (
|
otpRequired={Boolean(connectPasswordItem?.second_factor_mode === 'prompted_totp')}
|
||||||
<TextField
|
otpCode={connectOTPCode}
|
||||||
label="Password"
|
onOTPCodeChange={setConnectOTPCode}
|
||||||
type="password"
|
busy={Boolean(connectingID)}
|
||||||
value={connectPassword}
|
onClose={closeConnectPasswordPrompt}
|
||||||
onChange={(event) => setConnectPassword(event.target.value)}
|
onConfirm={() => connectPasswordItem ? void connectToProfile(connectPasswordItem, connectPassword, connectOTPCode) : undefined}
|
||||||
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>
|
|
||||||
|
|
||||||
<Dialog open={Boolean(hostKeyServer)} onClose={closeHostKeys} maxWidth="md" fullWidth>
|
<SSHHostKeysDialog
|
||||||
<DialogTitle>
|
server={hostKeyServer}
|
||||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
hostKeys={hostKeys}
|
||||||
<Typography variant="h6">Pinned Host Keys</Typography>
|
loading={hostKeysLoading}
|
||||||
<Typography variant="body2" color="text.secondary">
|
error={hostKeysError}
|
||||||
{hostKeyServer ? `${hostKeyServer.name} (${hostKeyServer.host}:${hostKeyServer.port})` : ''}
|
input={hostKeyInput}
|
||||||
</Typography>
|
onInputChange={setHostKeyInput}
|
||||||
{hostKeysError ? <Alert severity="error">{hostKeysError}</Alert> : null}
|
saving={hostKeySaving}
|
||||||
</Box>
|
discoveredHostKey={discoveredHostKey}
|
||||||
</DialogTitle>
|
onClose={closeHostKeys}
|
||||||
<FormDialogContent>
|
onDiscover={() => void handleDiscoverHostKey()}
|
||||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
onAdd={(publicKey: string) => void handleAddHostKey(publicKey)}
|
||||||
<Button variant="outlined" onClick={handleDiscoverHostKey} disabled={hostKeySaving || hostKeysLoading}>
|
onDelete={(hostKeyID: string) => void handleDeleteHostKey(hostKeyID)}
|
||||||
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>
|
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import { Unicode11Addon } from '@xterm/addon-unicode11'
|
|||||||
import { Terminal } from '@xterm/xterm'
|
import { Terminal } from '@xterm/xterm'
|
||||||
import Autocomplete from '../components/Autocomplete'
|
import Autocomplete from '../components/Autocomplete'
|
||||||
import FormDialogContent from '../components/FormDialogContent'
|
import FormDialogContent from '../components/FormDialogContent'
|
||||||
|
import SSHCredentialsPromptDialog from '../components/SSHCredentialsPromptDialog'
|
||||||
|
import SSHTranscriptDialog from '../components/SSHTranscriptDialog'
|
||||||
import TintedPanel from '../components/TintedPanel'
|
import TintedPanel from '../components/TintedPanel'
|
||||||
import { api, SSHAccessProfile, SSHSession, SSHSessionConnectResponse, SSHSessionListResponse } from '../api'
|
import { api, SSHAccessProfile, SSHSession, SSHSessionConnectResponse, SSHSessionListResponse } from '../api'
|
||||||
|
|
||||||
@@ -604,48 +606,20 @@ function SessionTerminalPanel(props: SessionPanelProps) {
|
|||||||
</Typography>
|
</Typography>
|
||||||
) : null}
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
<Dialog open={passwordPromptOpen} onClose={closePasswordPrompt} maxWidth="xs" fullWidth>
|
<SSHCredentialsPromptDialog
|
||||||
<DialogTitle>Enter SSH Credentials</DialogTitle>
|
open={passwordPromptOpen}
|
||||||
<FormDialogContent>
|
targetText={sessionTitle}
|
||||||
{passwordPromptError ? <Alert severity="error">{passwordPromptError}</Alert> : null}
|
error={passwordPromptError}
|
||||||
<Typography variant="body2" color="text.secondary">
|
passwordRequired={needsPasswordPrompt}
|
||||||
{sessionTitle}
|
password={promptedPassword}
|
||||||
</Typography>
|
onPasswordChange={setPromptedPassword}
|
||||||
{needsPasswordPrompt ? (
|
otpRequired={needsOTPPrompt}
|
||||||
<TextField
|
otpCode={promptedOTPCode}
|
||||||
label="Password"
|
onOTPCodeChange={setPromptedOTPCode}
|
||||||
type="password"
|
busy={actionPending}
|
||||||
value={promptedPassword}
|
onClose={closePasswordPrompt}
|
||||||
onChange={(event) => setPromptedPassword(event.target.value)}
|
onConfirm={() => void handleConfirmPasswordPrompt()}
|
||||||
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>
|
|
||||||
</TintedPanel>
|
</TintedPanel>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1608,90 +1582,32 @@ export default function SSHSessionPage() {
|
|||||||
</Drawer>
|
</Drawer>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Dialog open={Boolean(historyConnectItem)} onClose={closeHistoryConnectPrompt} maxWidth="xs" fullWidth>
|
<SSHCredentialsPromptDialog
|
||||||
<DialogTitle>Enter SSH Credentials</DialogTitle>
|
open={Boolean(historyConnectItem)}
|
||||||
<FormDialogContent>
|
targetText={historyConnectItem ? buildHistorySessionTitle(historyConnectItem) : 'Enter the password for this SSH access profile.'}
|
||||||
{historyConnectPasswordError ? <Alert severity="error">{historyConnectPasswordError}</Alert> : null}
|
error={historyConnectPasswordError}
|
||||||
<Typography variant="body2" color="text.secondary">
|
passwordRequired={Boolean(historyConnectItem && requiresPromptedPassword(historyConnectItem))}
|
||||||
{historyConnectItem ? buildHistorySessionTitle(historyConnectItem) : 'Enter the password for this SSH access profile.'}
|
password={historyConnectPassword}
|
||||||
</Typography>
|
onPasswordChange={setHistoryConnectPassword}
|
||||||
{historyConnectItem && requiresPromptedPassword(historyConnectItem) ? (
|
otpRequired={Boolean(historyConnectItem && requiresPromptedTOTP(historyConnectItem))}
|
||||||
<TextField
|
otpCode={historyConnectOTPCode}
|
||||||
label="Password"
|
onOTPCodeChange={setHistoryConnectOTPCode}
|
||||||
type="password"
|
busy={connectingHistorySession}
|
||||||
value={historyConnectPassword}
|
onClose={closeHistoryConnectPrompt}
|
||||||
onChange={(event) => setHistoryConnectPassword(event.target.value)}
|
onConfirm={() => historyConnectItem ? void connectHistorySession(historyConnectItem, historyConnectPassword, historyConnectOTPCode) : undefined}
|
||||||
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>
|
|
||||||
|
|
||||||
<Dialog open={Boolean(transcriptSession)} onClose={handleCloseTranscript} fullWidth maxWidth="md">
|
<SSHTranscriptDialog
|
||||||
<DialogTitle>
|
open={Boolean(transcriptSession)}
|
||||||
{transcriptSession ? `Transcript: ${buildHistorySessionTitle(transcriptSession)}` : 'Transcript'}
|
title={transcriptSession ? `Transcript: ${buildHistorySessionTitle(transcriptSession)}` : 'Transcript'}
|
||||||
</DialogTitle>
|
text={transcriptText}
|
||||||
<FormDialogContent>
|
error={transcriptError}
|
||||||
{transcriptError ? <Alert severity="error">{transcriptError}</Alert> : null}
|
loading={loadingTranscript}
|
||||||
{loadingTranscript ? (
|
downloadDisabled={!transcriptSession}
|
||||||
<Typography variant="body2" color="text.secondary">
|
closeText="Cancel"
|
||||||
Loading transcript...
|
onClose={handleCloseTranscript}
|
||||||
</Typography>
|
onDownload={handleDownloadTranscript}
|
||||||
) : 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>
|
|
||||||
|
|
||||||
<Dialog open={newSessionOpen} onClose={() => setNewSessionOpen(false)} maxWidth="sm" fullWidth>
|
<Dialog open={newSessionOpen} onClose={() => setNewSessionOpen(false)} maxWidth="sm" fullWidth>
|
||||||
<DialogTitle>New SSH Session</DialogTitle>
|
<DialogTitle>New SSH Session</DialogTitle>
|
||||||
@@ -1714,48 +1630,20 @@ export default function SSHSessionPage() {
|
|||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={newSessionPasswordOpen} onClose={closeNewSessionPasswordPrompt} maxWidth="xs" fullWidth>
|
<SSHCredentialsPromptDialog
|
||||||
<DialogTitle>Enter SSH Credentials</DialogTitle>
|
open={newSessionPasswordOpen}
|
||||||
<FormDialogContent>
|
targetText={selectedProfile ? `${selectedProfile.remote_username}@${selectedProfile.server?.host || selectedProfile.server_id}:${selectedProfile.server?.port || 22}` : 'Enter the password for this SSH access profile.'}
|
||||||
{newSessionPasswordError ? <Alert severity="error">{newSessionPasswordError}</Alert> : null}
|
error={newSessionPasswordError}
|
||||||
<Typography variant="body2" color="text.secondary">
|
passwordRequired={Boolean(selectedProfile && requiresPromptedPassword(selectedProfile))}
|
||||||
{selectedProfile ? `${selectedProfile.remote_username}@${selectedProfile.server?.host || selectedProfile.server_id}:${selectedProfile.server?.port || 22}` : 'Enter the password for this SSH access profile.'}
|
password={newSessionPassword}
|
||||||
</Typography>
|
onPasswordChange={setNewSessionPassword}
|
||||||
{selectedProfile && requiresPromptedPassword(selectedProfile) ? (
|
otpRequired={Boolean(selectedProfile && requiresPromptedTOTP(selectedProfile))}
|
||||||
<TextField
|
otpCode={newSessionOTPCode}
|
||||||
label="Password"
|
onOTPCodeChange={setNewSessionOTPCode}
|
||||||
type="password"
|
busy={creatingSession}
|
||||||
value={newSessionPassword}
|
onClose={closeNewSessionPasswordPrompt}
|
||||||
onChange={(event) => setNewSessionPassword(event.target.value)}
|
onConfirm={() => void handleCreateSession(newSessionPassword, newSessionOTPCode)}
|
||||||
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>
|
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user