Compare commits

...

2 Commits

4 changed files with 340 additions and 220 deletions
@@ -0,0 +1,121 @@
import Alert from '@mui/material/Alert'
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 TextField from '@mui/material/TextField'
import { ReactNode } from 'react'
import Autocomplete from './Autocomplete'
import FormDialogContent from './FormDialogContent'
export type SSHPrincipalGrantFormOption = {
id: string
label: string
}
export type SSHPrincipalGrantFormState = {
name: string
principalNames: string
validAfter: string
validBefore: string
maxCertValidSeconds: string
maxUses: string
disabled: boolean
reason: string
targetUserIDs: string[]
targetGroupIDs: string[]
}
type SSHPrincipalGrantFormDialogProps = {
open: boolean
busy: boolean
dialogError: string | null
editID: string
form: SSHPrincipalGrantFormState
setForm: React.Dispatch<React.SetStateAction<SSHPrincipalGrantFormState>>
userOptions: SSHPrincipalGrantFormOption[]
groupOptions: SSHPrincipalGrantFormOption[]
onClose: () => void
onSave: () => void
}
export default function SSHPrincipalGrantFormDialog(props: SSHPrincipalGrantFormDialogProps) {
let usersInput: (params: object) => ReactNode
let groupsInput: (params: object) => ReactNode
usersInput = (params) => <TextField {...params} label="Target Users" />
groupsInput = (params) => <TextField {...params} label="Target Groups" />
return (
<Dialog open={props.open} onClose={() => { if (!props.busy) props.onClose() }} maxWidth="md" fullWidth>
<DialogTitle>{props.editID ? 'Edit Principal Grant' : 'New Principal Grant'}</DialogTitle>
<FormDialogContent>
{props.dialogError ? <Alert severity="error">{props.dialogError}</Alert> : null}
<TextField label="Grant Name" value={props.form.name} onChange={(event) => props.setForm((prev) => ({ ...prev, name: event.target.value }))} />
<TextField
label="Principal Names (comma-separated)"
value={props.form.principalNames}
onChange={(event) => props.setForm((prev) => ({ ...prev, principalNames: event.target.value }))}
helperText="These are the actual SSH principals embedded in the issued certificate."
/>
<Autocomplete<SSHPrincipalGrantFormOption, true, false, false>
multiple
options={props.userOptions}
getOptionLabel={(option) => option.label}
value={props.userOptions.filter((item) => props.form.targetUserIDs.includes(item.id))}
onChange={(_event, value) => props.setForm((prev) => ({ ...prev, targetUserIDs: value.map((item) => item.id) }))}
renderInput={usersInput}
/>
<Autocomplete<SSHPrincipalGrantFormOption, true, false, false>
multiple
options={props.groupOptions}
getOptionLabel={(option) => option.label}
value={props.groupOptions.filter((item) => props.form.targetGroupIDs.includes(item.id))}
onChange={(_event, value) => props.setForm((prev) => ({ ...prev, targetGroupIDs: value.map((item) => item.id) }))}
renderInput={groupsInput}
/>
<TextField
label="Valid After (optional)"
type="datetime-local"
value={props.form.validAfter}
onChange={(event) => props.setForm((prev) => ({ ...prev, validAfter: event.target.value }))}
InputLabelProps={{ shrink: true }}
/>
<TextField
label="Valid Before (optional)"
type="datetime-local"
value={props.form.validBefore}
onChange={(event) => props.setForm((prev) => ({ ...prev, validBefore: event.target.value }))}
InputLabelProps={{ shrink: true }}
/>
<TextField
label="Max Cert Valid Seconds (0 = no extra cap)"
value={props.form.maxCertValidSeconds}
onChange={(event) => props.setForm((prev) => ({ ...prev, maxCertValidSeconds: event.target.value }))}
/>
<TextField
label="Max Uses (0 = unlimited)"
value={props.form.maxUses}
onChange={(event) => props.setForm((prev) => ({ ...prev, maxUses: event.target.value }))}
/>
<FormControlLabel
control={<Checkbox checked={props.form.disabled} onChange={(event) => props.setForm((prev) => ({ ...prev, disabled: event.target.checked }))} />}
label="Disabled"
/>
<TextField label="Reason (optional)" value={props.form.reason} onChange={(event) => props.setForm((prev) => ({ ...prev, reason: event.target.value }))} />
</FormDialogContent>
<DialogActions>
<Button
variant="contained"
onClick={props.onSave}
disabled={props.busy || !props.form.name.trim() || !props.form.principalNames.trim() || (props.form.targetUserIDs.length + props.form.targetGroupIDs.length) === 0}
>
{props.busy ? 'Saving...' : 'Save'}
</Button>
<Button onClick={props.onClose} disabled={props.busy}>Close</Button>
</DialogActions>
</Dialog>
)
}
@@ -0,0 +1,118 @@
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'
export type SSHUserCAFormState = {
name: string
enabled: boolean
allowUserSign: boolean
maxUserValidSeconds: string
privateKeyPEM: string
createMode: 'generate' | 'import'
createAlgorithm: 'ed25519' | 'rsa-4096' | 'ecdsa-p256'
}
type SSHUserCAFormDialogProps = {
open: boolean
editID: string
dialogError: string | null
busy: boolean
form: SSHUserCAFormState
setForm: React.Dispatch<React.SetStateAction<SSHUserCAFormState>>
onClose: () => void
onSave: () => void
onLoadPrivateKeyFile: (event: ChangeEvent<HTMLInputElement>) => void
}
export default function SSHUserCAFormDialog(props: SSHUserCAFormDialogProps) {
return (
<Dialog open={props.open} onClose={props.onClose} maxWidth="md" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">{props.editID ? 'Edit SSH User CA' : 'New SSH User CA'}</Typography>
{props.dialogError ? <Alert severity="error">{props.dialogError}</Alert> : null}
</Box>
</DialogTitle>
<FormDialogContent>
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}>
<TextField label="Name" value={props.form.name} onChange={(event) => props.setForm((prev) => ({ ...prev, name: event.target.value }))} />
<FormControlLabel control={<Checkbox checked={props.form.enabled} onChange={(event) => props.setForm((prev) => ({ ...prev, enabled: event.target.checked }))} />} label="Enabled" />
<FormControlLabel
control={<Checkbox checked={props.form.allowUserSign} onChange={(event) => props.setForm((prev) => ({ ...prev, allowUserSign: event.target.checked }))} />}
label="Allow normal users to self-sign"
/>
<TextField
label="Max Self-Sign Validity Seconds"
value={props.form.maxUserValidSeconds}
onChange={(event) => props.setForm((prev) => ({ ...prev, maxUserValidSeconds: event.target.value }))}
helperText="Range: 60..604800"
/>
{!props.editID ? (
<Box sx={{ display: 'grid', gap: 1 }}>
<SelectField
select
label="Mode"
value={props.form.createMode}
onChange={(event) => props.setForm((prev) => ({ ...prev, createMode: event.target.value as SSHUserCAFormState['createMode'] }))}
>
<MenuItem value="generate">Generate</MenuItem>
<MenuItem value="import">Import</MenuItem>
</SelectField>
{props.form.createMode === 'generate' ? (
<SelectField
select
label="Algorithm"
value={props.form.createAlgorithm}
onChange={(event) => props.setForm((prev) => ({ ...prev, createAlgorithm: event.target.value as SSHUserCAFormState['createAlgorithm'] }))}
helperText="ed25519 is recommended"
>
<MenuItem value="ed25519">ed25519</MenuItem>
<MenuItem value="rsa-4096">rsa-4096</MenuItem>
<MenuItem value="ecdsa-p256">ecdsa-p256</MenuItem>
</SelectField>
) : (
<Box sx={{ display: 'grid', gap: 1 }}>
<TextField
label="Private Key PEM"
multiline
minRows={6}
value={props.form.privateKeyPEM}
onChange={(event) => props.setForm((prev) => ({ ...prev, privateKeyPEM: 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={props.onLoadPrivateKeyFile} />
</Button>
</Box>
</Box>
)}
</Box>
) : null}
</Box>
</FormDialogContent>
<DialogActions>
<Button
variant="contained"
onClick={props.onSave}
disabled={props.busy || !props.form.name.trim() || (!props.editID && props.form.createMode === 'import' && !props.form.privateKeyPEM.trim())}
>
{props.busy ? 'Saving...' : 'Save'}
</Button>
<Button onClick={props.onClose}>Close</Button>
</DialogActions>
</Dialog>
)
}
@@ -4,12 +4,10 @@ import FormDialogContent from '../components/FormDialogContent'
import {
Box,
Button,
Checkbox,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControlLabel,
List,
ListItem,
ListItemText,
@@ -18,11 +16,11 @@ import {
Typography
} from '@mui/material'
import { useEffect, useState } from 'react'
import Autocomplete from '../components/Autocomplete'
import HeaderActionButton from '../components/HeaderActionButton'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard'
import SSHPrincipalGrantDetailsDialog from '../components/SSHPrincipalGrantDetailsDialog'
import SSHPrincipalGrantFormDialog, { SSHPrincipalGrantFormState } from '../components/SSHPrincipalGrantFormDialog'
import { api, SSHPrincipalGrant, SSHPrincipalGrantUpsertPayload, User, UserGroup } from '../api'
import SelectField from '../components/SelectField'
@@ -53,6 +51,21 @@ function parseDateTimeInput(value: string): number {
return Math.floor(ts / 1000)
}
function createEmptyForm(): SSHPrincipalGrantFormState {
return {
name: '',
principalNames: '',
validAfter: '',
validBefore: '',
maxCertValidSeconds: '0',
maxUses: '0',
disabled: false,
reason: '',
targetUserIDs: [],
targetGroupIDs: []
}
}
export default function AdminSSHPrincipalGrantsPage() {
const [users, setUsers] = useState<User[]>([])
const [groups, setGroups] = useState<UserGroup[]>([])
@@ -68,16 +81,7 @@ export default function AdminSSHPrincipalGrantsPage() {
const [viewItem, setViewItem] = useState<SSHPrincipalGrant | null>(null)
const [editOpen, setEditOpen] = useState(false)
const [editID, setEditID] = useState('')
const [name, setName] = useState('')
const [principalNames, setPrincipalNames] = useState('')
const [validAfter, setValidAfter] = useState('')
const [validBefore, setValidBefore] = useState('')
const [maxCertValidSeconds, setMaxCertValidSeconds] = useState('0')
const [maxUses, setMaxUses] = useState('0')
const [disabled, setDisabled] = useState(false)
const [reason, setReason] = useState('')
const [targetUserIDs, setTargetUserIDs] = useState<string[]>([])
const [targetGroupIDs, setTargetGroupIDs] = useState<string[]>([])
const [form, setForm] = useState<SSHPrincipalGrantFormState>(createEmptyForm)
const [deleteItem, setDeleteItem] = useState<SSHPrincipalGrant | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState('')
@@ -124,16 +128,7 @@ export default function AdminSSHPrincipalGrantsPage() {
const openCreate = () => {
setDialogError(null)
setEditID('')
setName('')
setPrincipalNames('')
setValidAfter('')
setValidBefore('')
setMaxCertValidSeconds('0')
setMaxUses('0')
setDisabled(false)
setReason('')
setTargetUserIDs([])
setTargetGroupIDs([])
setForm(createEmptyForm())
setEditOpen(true)
}
@@ -151,16 +146,18 @@ export default function AdminSSHPrincipalGrantsPage() {
const openEdit = (item: SSHPrincipalGrant) => {
setDialogError(null)
setEditID(item.id)
setName(item.name || item.principal)
setPrincipalNames((item.principals || []).join(', '))
setValidAfter(toDateTimeInput(item.valid_after))
setValidBefore(toDateTimeInput(item.valid_before))
setMaxCertValidSeconds(String(item.max_cert_valid_seconds || 0))
setMaxUses(String(item.max_uses || 0))
setDisabled(item.disabled)
setReason(item.reason || '')
setTargetUserIDs((item.targets || []).filter((target) => target.target_type === 'user').map((target) => target.target_id))
setTargetGroupIDs((item.targets || []).filter((target) => target.target_type === 'group').map((target) => target.target_id))
setForm({
name: item.name || item.principal,
principalNames: (item.principals || []).join(', '),
validAfter: toDateTimeInput(item.valid_after),
validBefore: toDateTimeInput(item.valid_before),
maxCertValidSeconds: String(item.max_cert_valid_seconds || 0),
maxUses: String(item.max_uses || 0),
disabled: item.disabled,
reason: item.reason || '',
targetUserIDs: (item.targets || []).filter((target) => target.target_type === 'user').map((target) => target.target_id),
targetGroupIDs: (item.targets || []).filter((target) => target.target_type === 'group').map((target) => target.target_id)
})
setEditOpen(true)
}
@@ -169,31 +166,31 @@ export default function AdminSSHPrincipalGrantsPage() {
let maxValid: number
let uses: number
let principals: string[]
maxValid = Number(maxCertValidSeconds)
maxValid = Number(form.maxCertValidSeconds)
if (!Number.isFinite(maxValid)) {
maxValid = 0
}
uses = Number(maxUses)
uses = Number(form.maxUses)
if (!Number.isFinite(uses)) {
uses = 0
}
principals = principalNames
principals = form.principalNames
.split(',')
.map((value) => value.trim())
.filter((value) => value)
payload = {
name: name.trim(),
principal: name.trim(),
name: form.name.trim(),
principal: form.name.trim(),
principals,
valid_after: parseDateTimeInput(validAfter),
valid_before: parseDateTimeInput(validBefore),
valid_after: parseDateTimeInput(form.validAfter),
valid_before: parseDateTimeInput(form.validBefore),
max_cert_valid_seconds: Math.max(0, Math.floor(maxValid)),
max_uses: Math.max(0, Math.floor(uses)),
disabled,
reason: reason.trim(),
disabled: form.disabled,
reason: form.reason.trim(),
targets: [
...targetUserIDs.map((id) => ({ target_type: 'user' as const, target_id: id })),
...targetGroupIDs.map((id) => ({ target_type: 'group' as const, target_id: id }))
...form.targetUserIDs.map((id) => ({ target_type: 'user' as const, target_id: id })),
...form.targetGroupIDs.map((id) => ({ target_type: 'group' as const, target_id: id }))
]
}
setBusy(true)
@@ -339,70 +336,18 @@ export default function AdminSSHPrincipalGrantsPage() {
onClose={() => setViewItem(null)}
/>
<Dialog open={editOpen} onClose={() => { if (!busy) setEditOpen(false) }} maxWidth="md" fullWidth>
<DialogTitle>{editID ? 'Edit Principal Grant' : 'New Principal Grant'}</DialogTitle>
<FormDialogContent>
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
<TextField label="Grant Name" value={name} onChange={(event) => setName(event.target.value)} />
<TextField
label="Principal Names (comma-separated)"
value={principalNames}
onChange={(event) => setPrincipalNames(event.target.value)}
helperText="These are the actual SSH principals embedded in the issued certificate."
/>
<Autocomplete<{ id: string; label: string }, true, false, false>
multiple
options={userOptions}
getOptionLabel={(option) => option.label}
value={userOptions.filter((item) => targetUserIDs.includes(item.id))}
onChange={(_event, value) => setTargetUserIDs(value.map((item) => item.id))}
renderInput={(params) => <TextField {...params} label="Target Users" />}
/>
<Autocomplete<{ id: string; label: string }, true, false, false>
multiple
options={groupOptions}
getOptionLabel={(option) => option.label}
value={groupOptions.filter((item) => targetGroupIDs.includes(item.id))}
onChange={(_event, value) => setTargetGroupIDs(value.map((item) => item.id))}
renderInput={(params) => <TextField {...params} label="Target Groups" />}
/>
<TextField
label="Valid After (optional)"
type="datetime-local"
value={validAfter}
onChange={(event) => setValidAfter(event.target.value)}
InputLabelProps={{ shrink: true }}
/>
<TextField
label="Valid Before (optional)"
type="datetime-local"
value={validBefore}
onChange={(event) => setValidBefore(event.target.value)}
InputLabelProps={{ shrink: true }}
/>
<TextField
label="Max Cert Valid Seconds (0 = no extra cap)"
value={maxCertValidSeconds}
onChange={(event) => setMaxCertValidSeconds(event.target.value)}
/>
<TextField
label="Max Uses (0 = unlimited)"
value={maxUses}
onChange={(event) => setMaxUses(event.target.value)}
/>
<FormControlLabel
control={<Checkbox checked={disabled} onChange={(event) => setDisabled(event.target.checked)} />}
label="Disabled"
/>
<TextField label="Reason (optional)" value={reason} onChange={(event) => setReason(event.target.value)} />
</FormDialogContent>
<DialogActions>
<Button onClick={() => setEditOpen(false)} disabled={busy}>Cancel</Button>
<Button variant="contained" onClick={save} disabled={busy || !name.trim() || !principalNames.trim() || (targetUserIDs.length + targetGroupIDs.length) === 0}>
{busy ? 'Saving...' : 'Save'}
</Button>
</DialogActions>
</Dialog>
<SSHPrincipalGrantFormDialog
open={editOpen}
busy={busy}
dialogError={dialogError}
editID={editID}
form={form}
setForm={setForm}
userOptions={userOptions}
groupOptions={groupOptions}
onClose={() => setEditOpen(false)}
onSave={save}
/>
<Dialog open={!!deleteItem} onClose={() => { if (!busy) setDeleteItem(null) }} maxWidth="xs" fullWidth>
<DialogTitle>Delete Principal Grant</DialogTitle>
@@ -423,7 +368,6 @@ export default function AdminSSHPrincipalGrantsPage() {
/>
</FormDialogContent>
<DialogActions>
<Button onClick={() => setDeleteItem(null)} disabled={busy}>Cancel</Button>
<Button
color="error"
variant="contained"
@@ -432,6 +376,7 @@ export default function AdminSSHPrincipalGrantsPage() {
>
{busy ? 'Deleting...' : 'Delete'}
</Button>
<Button onClick={() => setDeleteItem(null)} disabled={busy}>Close</Button>
</DialogActions>
</Dialog>
</Box>
+47 -111
View File
@@ -4,16 +4,13 @@ import FormDialogContent from '../components/FormDialogContent'
import {
Box,
Button,
Checkbox,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControlLabel,
List,
ListItem,
ListItemText,
MenuItem,
TextField,
Typography
} from '@mui/material'
@@ -22,8 +19,8 @@ import HeaderActionButton from '../components/HeaderActionButton'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard'
import SSHUserCADetailsDialog from '../components/SSHUserCADetailsDialog'
import SSHUserCAFormDialog, { SSHUserCAFormState } from '../components/SSHUserCAFormDialog'
import { api, SSHSignUserKeyResponse, SSHUserCA, SSHUserCACreatePayload, SSHUserCASignPayload, SSHUserCAUpdatePayload } from '../api'
import SelectField from '../components/SelectField'
function fmt(value: number): string {
if (!value || value <= 0) {
@@ -48,6 +45,18 @@ async function readFileText(file: File): Promise<string> {
return file.text()
}
function createEmptyForm(): SSHUserCAFormState {
return {
name: '',
enabled: true,
allowUserSign: false,
maxUserValidSeconds: '1800',
privateKeyPEM: '',
createMode: 'generate',
createAlgorithm: 'ed25519'
}
}
export default function AdminSSHUserCAPage() {
const [items, setItems] = useState<SSHUserCA[]>([])
const [loading, setLoading] = useState(false)
@@ -57,13 +66,7 @@ export default function AdminSSHUserCAPage() {
const [editOpen, setEditOpen] = useState(false)
const [editID, setEditID] = useState('')
const [name, setName] = useState('')
const [enabled, setEnabled] = useState(true)
const [allowUserSign, setAllowUserSign] = useState(false)
const [maxUserValidSeconds, setMaxUserValidSeconds] = useState('1800')
const [privateKeyPEM, setPrivateKeyPEM] = useState('')
const [createMode, setCreateMode] = useState<'generate' | 'import'>('generate')
const [createAlgorithm, setCreateAlgorithm] = useState<'ed25519' | 'rsa-4096' | 'ecdsa-p256'>('ed25519')
const [form, setForm] = useState<SSHUserCAFormState>(createEmptyForm)
const [viewItem, setViewItem] = useState<SSHUserCA | null>(null)
const [deleteItem, setDeleteItem] = useState<SSHUserCA | null>(null)
@@ -101,26 +104,22 @@ export default function AdminSSHUserCAPage() {
const openCreate = () => {
setDialogError(null)
setEditID('')
setName('')
setEnabled(true)
setAllowUserSign(false)
setMaxUserValidSeconds('1800')
setPrivateKeyPEM('')
setCreateMode('generate')
setCreateAlgorithm('ed25519')
setForm(createEmptyForm())
setEditOpen(true)
}
const openEdit = (item: SSHUserCA) => {
setDialogError(null)
setEditID(item.id)
setName(item.name)
setEnabled(item.enabled)
setAllowUserSign(item.allow_user_sign)
setMaxUserValidSeconds(String(item.max_user_valid_seconds || 1800))
setPrivateKeyPEM('')
setCreateMode('generate')
setCreateAlgorithm('ed25519')
setForm({
name: item.name,
enabled: item.enabled,
allowUserSign: item.allow_user_sign,
maxUserValidSeconds: String(item.max_user_valid_seconds || 1800),
privateKeyPEM: '',
createMode: 'generate',
createAlgorithm: 'ed25519'
})
setEditOpen(true)
}
@@ -131,7 +130,7 @@ export default function AdminSSHUserCAPage() {
return
}
text = await readFileText(file)
setPrivateKeyPEM(text)
setForm((prev) => ({ ...prev, privateKeyPEM: text }))
event.target.value = ''
}
@@ -139,27 +138,27 @@ export default function AdminSSHUserCAPage() {
let maxSeconds: number
let createPayload: SSHUserCACreatePayload
let updatePayload: SSHUserCAUpdatePayload
maxSeconds = Number(maxUserValidSeconds) || 1800
maxSeconds = Number(form.maxUserValidSeconds) || 1800
setBusy(true)
setDialogError(null)
setError(null)
try {
if (editID) {
updatePayload = {
name: name.trim(),
enabled,
allow_user_sign: allowUserSign,
name: form.name.trim(),
enabled: form.enabled,
allow_user_sign: form.allowUserSign,
max_user_valid_seconds: maxSeconds
}
await api.updateSSHUserCA(editID, updatePayload)
} else {
createPayload = {
name: name.trim(),
enabled,
allow_user_sign: allowUserSign,
name: form.name.trim(),
enabled: form.enabled,
allow_user_sign: form.allowUserSign,
max_user_valid_seconds: maxSeconds,
algorithm: createMode === 'generate' ? createAlgorithm : undefined,
private_key_pem: createMode === 'import' ? (privateKeyPEM.trim() || undefined) : undefined
algorithm: form.createMode === 'generate' ? form.createAlgorithm : undefined,
private_key_pem: form.createMode === 'import' ? (form.privateKeyPEM.trim() || undefined) : undefined
}
await api.createSSHUserCA(createPayload)
}
@@ -351,80 +350,17 @@ export default function AdminSSHUserCAPage() {
)}
</SectionCard>
<Dialog open={editOpen} onClose={() => setEditOpen(false)} maxWidth="md" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">{editID ? 'Edit SSH User CA' : 'New SSH User CA'}</Typography>
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
</Box>
</DialogTitle>
<FormDialogContent>
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}>
<TextField label="Name" value={name} onChange={(event) => setName(event.target.value)} />
<FormControlLabel control={<Checkbox checked={enabled} onChange={(event) => setEnabled(event.target.checked)} />} label="Enabled" />
<FormControlLabel control={<Checkbox checked={allowUserSign} onChange={(event) => setAllowUserSign(event.target.checked)} />} label="Allow normal users to self-sign" />
<TextField
label="Max Self-Sign Validity Seconds"
value={maxUserValidSeconds}
onChange={(event) => setMaxUserValidSeconds(event.target.value)}
helperText="Range: 60..604800"
/>
{!editID ? (
<Box sx={{ display: 'grid', gap: 1 }}>
<SelectField
select
label="Mode"
value={createMode}
onChange={(event) => setCreateMode(event.target.value as 'generate' | 'import')}
>
<MenuItem value="generate">Generate</MenuItem>
<MenuItem value="import">Import</MenuItem>
</SelectField>
{createMode === 'generate' ? (
<SelectField
select
label="Algorithm"
value={createAlgorithm}
onChange={(event) => setCreateAlgorithm(event.target.value as 'ed25519' | 'rsa-4096' | 'ecdsa-p256')}
helperText="ed25519 is recommended"
>
<MenuItem value="ed25519">ed25519</MenuItem>
<MenuItem value="rsa-4096">rsa-4096</MenuItem>
<MenuItem value="ecdsa-p256">ecdsa-p256</MenuItem>
</SelectField>
) : (
<Box sx={{ display: 'grid', gap: 1 }}>
<TextField
label="Private Key PEM"
multiline
minRows={6}
value={privateKeyPEM}
onChange={(event) => setPrivateKeyPEM(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={loadPrivateKeyFile} />
</Button>
</Box>
</Box>
)}
</Box>
) : null}
</Box>
</FormDialogContent>
<DialogActions>
<Button onClick={() => setEditOpen(false)}>Cancel</Button>
<Button
variant="contained"
onClick={save}
disabled={busy || !name.trim() || (!editID && createMode === 'import' && !privateKeyPEM.trim())}
>
{busy ? 'Saving...' : 'Save'}
</Button>
</DialogActions>
</Dialog>
<SSHUserCAFormDialog
open={editOpen}
editID={editID}
dialogError={dialogError}
busy={busy}
form={form}
setForm={setForm}
onClose={() => setEditOpen(false)}
onSave={save}
onLoadPrivateKeyFile={loadPrivateKeyFile}
/>
<Dialog open={Boolean(deleteItem)} onClose={() => setDeleteItem(null)} maxWidth="xs" fullWidth>
<DialogTitle>
@@ -440,7 +376,6 @@ export default function AdminSSHUserCAPage() {
</Box>
</FormDialogContent>
<DialogActions>
<Button onClick={() => setDeleteItem(null)}>Cancel</Button>
<Button
variant="contained"
color="error"
@@ -449,6 +384,7 @@ export default function AdminSSHUserCAPage() {
>
{busy ? 'Working...' : 'Delete'}
</Button>
<Button onClick={() => setDeleteItem(null)}>Cancel</Button>
</DialogActions>
</Dialog>
@@ -502,7 +438,6 @@ export default function AdminSSHUserCAPage() {
</Box>
</FormDialogContent>
<DialogActions>
<Button onClick={() => setSignOpen(false)}>Close</Button>
{signResult ? <Button onClick={copyResultCert}>Copy Certificate</Button> : null}
{signResult ? <Button onClick={inspectResultCert} disabled={inspectBusy}>{inspectBusy ? 'Inspecting...' : 'Inspect'}</Button> : null}
<Button
@@ -512,6 +447,7 @@ export default function AdminSSHUserCAPage() {
>
{busy ? 'Working...' : 'Sign'}
</Button>
<Button onClick={() => setSignOpen(false)}>Close</Button>
</DialogActions>
</Dialog>