Compare commits

...

3 Commits

29 changed files with 1950 additions and 1432 deletions
+2 -2
View File
@@ -1,11 +1,11 @@
{ {
"name": "codepot-codit-frontend", "name": "codit-frontend",
"version": "0.1.0", "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 -1
View File
@@ -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>
)
}
+36 -58
View File
@@ -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
</FormDialogContent> setDeleteTarget(null)
<DialogActions> setDeleteConfirm('')
<Button }}
variant="contained" />
color="error" <ConfirmDeleteDialog
disabled={deleting || !deleteTarget || deleteConfirm !== deleteTarget.name} open={bulkOpen}
onClick={handleDelete} title="Delete Selected API Keys"
> prompt={`Type DELETE to delete ${selected.length} selected API key(s).`}
{deleting ? 'Deleting...' : 'Delete'} expectedText="DELETE"
</Button> confirmLabel="Confirmation"
<Button onClick={() => { setDeleteTarget(null); setDeleteConfirm('') }}> confirmValue={bulkConfirm}
Cancel onConfirmValueChange={setBulkConfirm}
</Button> busy={bulkDeleting}
</DialogActions> confirmText="Delete Selected"
</Dialog> busyText="Deleting..."
<Dialog open={bulkOpen} onClose={() => setBulkOpen(false)} maxWidth="xs" fullWidth> confirmDisabled={!selected.length}
<DialogTitle>Delete Selected API Keys</DialogTitle> onConfirm={handleBulkDelete}
<FormDialogContent> onClose={() => {
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}> if (bulkDeleting) return
Type DELETE to delete {selected.length} selected API key(s). setBulkOpen(false)
</Typography> setBulkConfirm('')
<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>
) )
} }
+283 -498
View File
@@ -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}
<TextField onDNSFullDomainChange={setACMEDNSFullDomain}
label="ACME-DNS User" onClose={() => setACMEOpen(false)}
value={acmeDNSUser} onSave={saveACME}
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)} />
>
{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> <ConfirmDeleteDialog
<DialogTitle> open={Boolean(acmeOrderDeleteID)}
<Box sx={{ display: 'grid', gap: 1 }}> title="Delete ACME Order"
<Typography variant="h6">Delete ACME Order</Typography> prompt="Type the ACME order common name to confirm deletion."
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null} expectedText={acmeOrderDeleteID ? acmeOrderConfirmText(acmeOrderDeleteID) : ''}
</Box> confirmLabel="Common Name"
</DialogTitle> confirmValue={acmeOrderDeleteConfirm}
<FormDialogContent> onConfirmValueChange={setACMEOrderDeleteConfirm}
<Typography variant="body2" color="text.secondary">Delete order permanently?</Typography> error={dialogError}
</FormDialogContent> busy={busy}
<DialogActions> busyText="Working..."
<Button color="error" variant="contained" onClick={deleteACMEOrder} disabled={busy}>{busy ? 'Working...' : 'Delete'}</Button> onClose={() => {
<Button onClick={() => setACMEOrderDeleteID('')}>Cancel</Button> if (busy) return
</DialogActions> setACMEOrderDeleteID('')
</Dialog> setACMEOrderDeleteConfirm('')
setDialogError(null)
}}
onConfirm={deleteACMEOrder}
/>
<Dialog open={Boolean(acmeFinalizeID)} onClose={() => setACMEFinalizeID('')} maxWidth="xs" fullWidth> <PKIACMEOrderFinalizeDialog
<DialogTitle> open={Boolean(acmeFinalizeID)}
<Box sx={{ display: 'grid', gap: 1 }}> error={dialogError}
<Typography variant="h6">Finalize Renewal Order</Typography> busy={busy}
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null} mode={acmeFinalizeMode}
</Box> onModeChange={setACMEFinalizeMode}
</DialogTitle> onClose={() => setACMEFinalizeID('')}
<FormDialogContent> onFinalize={() => finalizeACMEOrder(acmeFinalizeID, acmeFinalizeMode)}
<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> <PKIACMEOrderDetailsDialog
<DialogTitle>DNS-01 Instructions</DialogTitle> order={acmeOrderView}
<FormDialogContent> busy={busy}
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}> onClose={() => setACMEOrderView(null)}
<TextField label="Order ID" value={acmeOrderView?.id || ''} InputProps={{ readOnly: true }} /> onFinalize={requestFinalizeACMEOrder}
<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> <PKIRootCADialog
<DialogTitle> open={rootOpen}
<Box sx={{ display: 'grid', gap: 1 }}> error={dialogError}
<Typography variant="h6">New Root CA</Typography> busy={busy}
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null} name={rootName}
</Box> onNameChange={setRootName}
</DialogTitle> commonName={rootCN}
<FormDialogContent> onCommonNameChange={setRootCN}
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}> days={rootDays}
<TextField label="Name" value={rootName} onChange={(event) => setRootName(event.target.value)} /> onDaysChange={setRootDays}
<TextField label="Common Name" value={rootCN} onChange={(event) => setRootCN(event.target.value)} /> certPEM={rootCertPEM}
<TextField label="Validity Days" value={rootDays} onChange={(event) => setRootDays(event.target.value)} /> onCertPEMChange={setRootCertPEM}
<TextField keyPEM={rootKeyPEM}
label="Import Certificate PEM (optional)" onKeyPEMChange={setRootKeyPEM}
multiline onClose={() => setRootOpen(false)}
minRows={6} onCreate={createRoot}
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> <PKIIntermediateCADialog
<DialogTitle> open={interOpen}
<Box sx={{ display: 'grid', gap: 1 }}> error={dialogError}
<Typography variant="h6">New Intermediate CA</Typography> busy={busy}
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null} cas={cas}
</Box> name={interName}
</DialogTitle> onNameChange={setInterName}
<FormDialogContent> parentID={interParent}
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}> onParentIDChange={setInterParent}
<TextField label="Name" value={interName} onChange={(event) => setInterName(event.target.value)} /> commonName={interCN}
<SelectField onCommonNameChange={setInterCN}
select days={interDays}
label="Parent CA" onDaysChange={setInterDays}
value={interParent} certPEM={interCertPEM}
onChange={(event) => setInterParent(event.target.value)} onCertPEMChange={setInterCertPEM}
> keyPEM={interKeyPEM}
{cas.map((ca) => ( onKeyPEMChange={setInterKeyPEM}
<MenuItem key={ca.id} value={ca.id}>{ca.name}</MenuItem> onClose={() => setInterOpen(false)}
))} onCreate={createIntermediate}
</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> <PKICertIssueDialog
<DialogTitle> open={issueOpen}
<Box sx={{ display: 'grid', gap: 1 }}> error={dialogError}
<Typography variant="h6">Issue Certificate</Typography> busy={busy}
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null} cas={cas}
</Box> caID={issueCA}
</DialogTitle> onCAIDChange={setIssueCA}
<FormDialogContent> commonName={issueCN}
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}> onCommonNameChange={setIssueCN}
<SelectField select label="Issuer CA" value={issueCA} onChange={(event) => setIssueCA(event.target.value)}> sanDNS={issueDNS}
{cas.map((ca) => ( onSANDNSChange={setIssueDNS}
<MenuItem key={ca.id} value={ca.id}>{ca.name}</MenuItem> sanIPs={issueIPs}
))} onSANIPsChange={setIssueIPs}
</SelectField> validSeconds={issueValidSeconds}
<TextField label="Common Name" value={issueCN} onChange={(event) => setIssueCN(event.target.value)} /> onValidSecondsChange={setIssueValidSeconds}
<TextField label="SAN DNS (comma-separated)" value={issueDNS} onChange={(event) => setIssueDNS(event.target.value)} /> isCA={issueIsCA}
<TextField label="SAN IPs (comma-separated)" value={issueIPs} onChange={(event) => setIssueIPs(event.target.value)} /> onIsCAChange={setIssueIsCA}
<TextField label="Validity Seconds" value={issueValidSeconds} onChange={(event) => setIssueValidSeconds(event.target.value)} helperText="Default 31536000 (365 days)" /> onClose={() => setIssueOpen(false)}
<FormControlLabel control={<Checkbox checked={issueIsCA} onChange={(event) => setIssueIsCA(event.target.checked)} />} label="Issue as CA certificate" /> onIssue={issueCert}
</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> <PKICertImportDialog
<DialogTitle> open={importOpen}
<Box sx={{ display: 'grid', gap: 1 }}> error={dialogError}
<Typography variant="h6">Import Certificate</Typography> busy={busy}
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null} cas={cas}
</Box> caID={importCA}
</DialogTitle> onCAIDChange={setImportCA}
<FormDialogContent> certPEM={importCertPEM}
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}> onCertPEMChange={setImportCertPEM}
<SelectField select label="Issuer CA (optional)" value={importCA} onChange={(event) => setImportCA(event.target.value)}> keyPEM={importKeyPEM}
<MenuItem value="">(none, standalone)</MenuItem> onKeyPEMChange={setImportKeyPEM}
{cas.map((ca) => ( onClose={() => setImportOpen(false)}
<MenuItem key={ca.id} value={ca.id}>{ca.name}</MenuItem> onImport={importCert}
))} />
</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> <PKICertRevokeDialog
<DialogTitle> open={Boolean(revokeID)}
<Box sx={{ display: 'grid', gap: 1 }}> error={dialogError}
<Typography variant="h6">Revoke Certificate</Typography> busy={busy}
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null} reason={revokeReason}
</Box> onReasonChange={setRevokeReason}
</DialogTitle> onClose={() => setRevokeID('')}
<FormDialogContent> onRevoke={revokeCert}
<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> <ConfirmDeleteDialog
<DialogTitle> open={Boolean(deleteID)}
<Box sx={{ display: 'grid', gap: 1 }}> title="Delete Certificate"
<Typography variant="h6">Delete Certificate</Typography> prompt="Type the certificate common name to confirm deletion."
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null} expectedText={deleteID ? certConfirmText(deleteID) : ''}
</Box> confirmLabel="Common Name"
</DialogTitle> confirmValue={deleteConfirm}
<FormDialogContent> onConfirmValueChange={setDeleteConfirm}
<Typography variant="body2" color="text.secondary">Delete certificate permanently?</Typography> error={dialogError}
</FormDialogContent> busy={busy}
<DialogActions> busyText="Working..."
<Button color="error" variant="contained" onClick={deleteCert} disabled={busy}>{busy ? 'Working...' : 'Delete'}</Button> onClose={() => {
<Button onClick={() => setDeleteID('')}>Cancel</Button> if (busy) return
</DialogActions> setDeleteID('')
</Dialog> setDeleteConfirm('')
setDialogError(null)
}}
onConfirm={deleteCert}
/>
<Dialog open={Boolean(deleteCAID)} onClose={() => setDeleteCAID('')} maxWidth="xs" fullWidth> <ConfirmDeleteDialog
<DialogTitle> open={Boolean(deleteCAID)}
<Box sx={{ display: 'grid', gap: 1 }}> title="Delete Certificate Authority"
<Typography variant="h6">Delete Certificate Authority</Typography> prompt="Type the CA name to confirm deletion."
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null} expectedText={deleteCAName}
</Box> confirmLabel="CA Name"
</DialogTitle> confirmValue={deleteCAConfirm}
<FormDialogContent> onConfirmValueChange={setDeleteCAConfirm}
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}> error={dialogError}
Type the CA name to confirm deletion. busy={busy}
</Typography> confirmText="Delete CA"
<TextField fullWidth label="CA Name" value={deleteCAConfirm} onChange={(event) => setDeleteCAConfirm(event.target.value)} /> busyText="Working..."
onClose={() => {
if (busy) return
setDeleteCAID('')
setDeleteCAName('')
setDeleteCAConfirm('')
setDeleteCAForce(false)
setDialogError(null)
}}
onConfirm={deleteCA}
>
<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}
+49 -110
View File
@@ -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)
}}
onConfirm={handleDelete}
/>
<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> closeText="Cancel"
<FormDialogContent> onClose={closeHostKeys}
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}> onDiscover={() => void handleDiscoverHostKey()}
<Button variant="outlined" onClick={handleDiscoverHostKey} disabled={hostKeySaving || hostKeysLoading}> onAdd={(publicKey: string) => void handleAddHostKey(publicKey)}
Discover onDelete={(hostKeyID: string) => void handleDeleteHostKey(hostKeyID)}
</Button> />
<Button
variant="outlined"
onClick={() => void handleAddHostKey(hostKeyInput)}
disabled={hostKeySaving || !hostKeyInput.trim()}
>
Add Manual Key
</Button>
</Box>
{discoveredHostKey ? (
<Alert
severity="info"
action={
<Button color="inherit" size="small" onClick={() => void handleAddHostKey(discoveredHostKey.public_key)} disabled={hostKeySaving}>
Pin
</Button>
}
>
Discovered {discoveredHostKey.algorithm} · {discoveredHostKey.fingerprint}
</Alert>
) : null}
<TextField
label="Manual Host Public Key"
multiline
minRows={3}
value={hostKeyInput}
onChange={(event) => setHostKeyInput(event.target.value)}
helperText="Paste an authorized_keys style SSH host public key."
/>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="subtitle2">Pinned Keys</Typography>
{hostKeysLoading ? (
<Typography variant="body2" color="text.secondary">
Loading...
</Typography>
) : null}
{!hostKeysLoading && hostKeys.length === 0 ? (
<Typography variant="body2" color="text.secondary">
No pinned host keys.
</Typography>
) : null}
{hostKeys.map((item) => (
<Paper
key={item.id}
variant="outlined"
sx={{
p: 1,
display: 'grid',
gap: 0.5,
backgroundColor: 'transparent',
backgroundImage: 'none'
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="subtitle2">{item.fingerprint}</Typography>
<ListRowActions>
<ListRowActionButton color="error" onClick={() => void handleDeleteHostKey(item.id)} disabled={hostKeySaving}>
Delete
</ListRowActionButton>
</ListRowActions>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
{item.algorithm}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
{item.public_key}
</Typography>
</Paper>
))}
</Box>
</FormDialogContent>
<DialogActions>
<Button onClick={closeHostKeys}>Cancel</Button>
</DialogActions>
</Dialog>
</Box> </Box>
) )
} }
+12 -43
View File
@@ -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>
) )
} }
+6 -16
View File
@@ -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>
) )
} }
+7 -16
View File
@@ -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>
) )
} }
+174 -19
View File
@@ -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>
) )
} }
+30 -21
View File
@@ -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>
) )
} }
+47 -49
View File
@@ -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
setError(null) }
try { setDeleteListenerBusy(true)
await api.deleteTLSListener(item.id) setDeleteListenerError(null)
await loadListeners() setError(null)
await loadRuntimeStatus() try {
} catch (err) { await api.deleteTLSListener(deleteListenerItem.id)
const message = err instanceof Error ? err.message : 'Failed to delete listener' setDeleteListenerItem(null)
setError(message) setDeleteListenerConfirm('')
} await loadListeners()
}) await loadRuntimeStatus()
setConfirmOpen(true) } catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete listener'
setDeleteListenerError(message)
} finally {
setDeleteListenerBusy(false)
}
} }
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>
) )
} }
+65 -27
View File
@@ -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
</FormDialogContent> setDeleteItem(null)
<DialogActions> setDeleteConfirm('')
<Button }}
color="error" onConfirm={removeGroup}
variant="contained" />
onClick={removeGroup} <ConfirmDeleteDialog
disabled={busy || !deleteItem || deleteConfirm.trim() !== deleteItem.name} open={!!deleteMemberItem}
> title="Delete Member"
{busy ? 'Deleting...' : 'Delete'} prompt={`Type the ${deleteMemberItem?.username ? 'username' : 'user ID'} to confirm removal from this group.`}
</Button> expectedText={deleteMemberExpected}
<Button onClick={() => setDeleteItem(null)}>Cancel</Button> confirmLabel={deleteMemberItem?.username ? 'Username' : 'User ID'}
</DialogActions> confirmValue={deleteMemberConfirm}
</Dialog> onConfirmValueChange={setDeleteMemberConfirm}
error={dialogError}
busy={busy}
busyText="Removing..."
confirmText="Remove"
onClose={() => {
if (busy) return
setDeleteMemberItem(null)
setDeleteMemberConfirm('')
}}
onConfirm={() => {
if (!deleteMemberItem) return
void removeMember(deleteMemberItem.user_id)
}}
/>
</Box> </Box>
) )
} }
+26 -81
View File
@@ -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}
<TextField onDescriptionTabChange={setEditDescTab}
margin="dense" busy={savingEdit}
label="Name" saveText="Save"
fullWidth busyText="Saving..."
value={editName} onSave={handleEditSave}
onChange={(event) => setEditName(event.target.value)} onClose={() => setEditOpen(false)}
/> />
<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}
+57 -160
View File
@@ -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}
<TextField onDescriptionTabChange={setEditDescTab}
margin="dense" busy={savingEdit}
label="Name" saveText="Save"
fullWidth busyText="Saving..."
value={editName} onSave={handleEditSave}
onChange={(event) => setEditName(event.target.value)} onClose={() => setEditProject(null)}
/> />
<SelectField <ProjectFormDialog
margin="dense" open={createOpen}
select title="New Project"
label="Default Project Page" error={createError}
fullWidth slug={createSlug}
value={editHomePage} onSlugChange={setCreateSlug}
onChange={(event) => setEditHomePage(event.target.value as 'info' | 'repos' | 'issues' | 'wiki' | 'files')} name={createName}
> onNameChange={setCreateName}
<MenuItem value="info">Info</MenuItem> description={createDescription}
<MenuItem value="repos">Repositories</MenuItem> onDescriptionChange={setCreateDescription}
<MenuItem value="issues">Issues</MenuItem> homePage={createHomePage}
<MenuItem value="wiki">Wiki</MenuItem> onHomePageChange={setCreateHomePage}
<MenuItem value="files">Files</MenuItem> descriptionTab={createDescTab}
</SelectField> onDescriptionTabChange={setCreateDescTab}
<Box sx={{ mt: 1 }}> busy={creating}
<Tabs saveText="Create"
value={editDescTab} busyText="Creating..."
onChange={(_, value) => setEditDescTab(value)} onSave={handleCreate}
textColor="primary" onClose={closeCreate}
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}
+2 -2
View File
@@ -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}
+1 -1
View File
@@ -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>
+30 -134
View File
@@ -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>
) )
} }
+55 -167
View File
@@ -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>
) )
} }