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 { import {
Box, Box,
Button, Button,
Checkbox,
Dialog, Dialog,
DialogActions, DialogActions,
DialogContent, DialogContent,
DialogTitle, DialogTitle,
FormControlLabel,
List, List,
ListItem, ListItem,
ListItemText, ListItemText,
@@ -18,11 +16,11 @@ import {
Typography Typography
} from '@mui/material' } from '@mui/material'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
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 SSHPrincipalGrantDetailsDialog from '../components/SSHPrincipalGrantDetailsDialog' import SSHPrincipalGrantDetailsDialog from '../components/SSHPrincipalGrantDetailsDialog'
import SSHPrincipalGrantFormDialog, { SSHPrincipalGrantFormState } from '../components/SSHPrincipalGrantFormDialog'
import { api, SSHPrincipalGrant, SSHPrincipalGrantUpsertPayload, User, UserGroup } from '../api' import { api, SSHPrincipalGrant, SSHPrincipalGrantUpsertPayload, User, UserGroup } from '../api'
import SelectField from '../components/SelectField' import SelectField from '../components/SelectField'
@@ -53,6 +51,21 @@ function parseDateTimeInput(value: string): number {
return Math.floor(ts / 1000) 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() { export default function AdminSSHPrincipalGrantsPage() {
const [users, setUsers] = useState<User[]>([]) const [users, setUsers] = useState<User[]>([])
const [groups, setGroups] = useState<UserGroup[]>([]) const [groups, setGroups] = useState<UserGroup[]>([])
@@ -68,16 +81,7 @@ export default function AdminSSHPrincipalGrantsPage() {
const [viewItem, setViewItem] = useState<SSHPrincipalGrant | null>(null) const [viewItem, setViewItem] = useState<SSHPrincipalGrant | null>(null)
const [editOpen, setEditOpen] = useState(false) const [editOpen, setEditOpen] = useState(false)
const [editID, setEditID] = useState('') const [editID, setEditID] = useState('')
const [name, setName] = useState('') const [form, setForm] = useState<SSHPrincipalGrantFormState>(createEmptyForm)
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 [deleteItem, setDeleteItem] = useState<SSHPrincipalGrant | null>(null) const [deleteItem, setDeleteItem] = useState<SSHPrincipalGrant | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState('') const [deleteConfirm, setDeleteConfirm] = useState('')
@@ -124,16 +128,7 @@ export default function AdminSSHPrincipalGrantsPage() {
const openCreate = () => { const openCreate = () => {
setDialogError(null) setDialogError(null)
setEditID('') setEditID('')
setName('') setForm(createEmptyForm())
setPrincipalNames('')
setValidAfter('')
setValidBefore('')
setMaxCertValidSeconds('0')
setMaxUses('0')
setDisabled(false)
setReason('')
setTargetUserIDs([])
setTargetGroupIDs([])
setEditOpen(true) setEditOpen(true)
} }
@@ -151,16 +146,18 @@ export default function AdminSSHPrincipalGrantsPage() {
const openEdit = (item: SSHPrincipalGrant) => { const openEdit = (item: SSHPrincipalGrant) => {
setDialogError(null) setDialogError(null)
setEditID(item.id) setEditID(item.id)
setName(item.name || item.principal) setForm({
setPrincipalNames((item.principals || []).join(', ')) name: item.name || item.principal,
setValidAfter(toDateTimeInput(item.valid_after)) principalNames: (item.principals || []).join(', '),
setValidBefore(toDateTimeInput(item.valid_before)) validAfter: toDateTimeInput(item.valid_after),
setMaxCertValidSeconds(String(item.max_cert_valid_seconds || 0)) validBefore: toDateTimeInput(item.valid_before),
setMaxUses(String(item.max_uses || 0)) maxCertValidSeconds: String(item.max_cert_valid_seconds || 0),
setDisabled(item.disabled) maxUses: String(item.max_uses || 0),
setReason(item.reason || '') disabled: item.disabled,
setTargetUserIDs((item.targets || []).filter((target) => target.target_type === 'user').map((target) => target.target_id)) reason: item.reason || '',
setTargetGroupIDs((item.targets || []).filter((target) => target.target_type === 'group').map((target) => target.target_id)) 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) setEditOpen(true)
} }
@@ -169,31 +166,31 @@ export default function AdminSSHPrincipalGrantsPage() {
let maxValid: number let maxValid: number
let uses: number let uses: number
let principals: string[] let principals: string[]
maxValid = Number(maxCertValidSeconds) maxValid = Number(form.maxCertValidSeconds)
if (!Number.isFinite(maxValid)) { if (!Number.isFinite(maxValid)) {
maxValid = 0 maxValid = 0
} }
uses = Number(maxUses) uses = Number(form.maxUses)
if (!Number.isFinite(uses)) { if (!Number.isFinite(uses)) {
uses = 0 uses = 0
} }
principals = principalNames principals = form.principalNames
.split(',') .split(',')
.map((value) => value.trim()) .map((value) => value.trim())
.filter((value) => value) .filter((value) => value)
payload = { payload = {
name: name.trim(), name: form.name.trim(),
principal: name.trim(), principal: form.name.trim(),
principals, principals,
valid_after: parseDateTimeInput(validAfter), valid_after: parseDateTimeInput(form.validAfter),
valid_before: parseDateTimeInput(validBefore), valid_before: parseDateTimeInput(form.validBefore),
max_cert_valid_seconds: Math.max(0, Math.floor(maxValid)), max_cert_valid_seconds: Math.max(0, Math.floor(maxValid)),
max_uses: Math.max(0, Math.floor(uses)), max_uses: Math.max(0, Math.floor(uses)),
disabled, disabled: form.disabled,
reason: reason.trim(), reason: form.reason.trim(),
targets: [ targets: [
...targetUserIDs.map((id) => ({ target_type: 'user' as const, target_id: id })), ...form.targetUserIDs.map((id) => ({ target_type: 'user' as const, target_id: id })),
...targetGroupIDs.map((id) => ({ target_type: 'group' as const, target_id: id })) ...form.targetGroupIDs.map((id) => ({ target_type: 'group' as const, target_id: id }))
] ]
} }
setBusy(true) setBusy(true)
@@ -339,70 +336,18 @@ export default function AdminSSHPrincipalGrantsPage() {
onClose={() => setViewItem(null)} onClose={() => setViewItem(null)}
/> />
<Dialog open={editOpen} onClose={() => { if (!busy) setEditOpen(false) }} maxWidth="md" fullWidth> <SSHPrincipalGrantFormDialog
<DialogTitle>{editID ? 'Edit Principal Grant' : 'New Principal Grant'}</DialogTitle> open={editOpen}
<FormDialogContent> busy={busy}
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null} dialogError={dialogError}
<TextField label="Grant Name" value={name} onChange={(event) => setName(event.target.value)} /> editID={editID}
<TextField form={form}
label="Principal Names (comma-separated)" setForm={setForm}
value={principalNames} userOptions={userOptions}
onChange={(event) => setPrincipalNames(event.target.value)} groupOptions={groupOptions}
helperText="These are the actual SSH principals embedded in the issued certificate." onClose={() => setEditOpen(false)}
/> onSave={save}
<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>
<Dialog open={!!deleteItem} onClose={() => { if (!busy) setDeleteItem(null) }} maxWidth="xs" fullWidth> <Dialog open={!!deleteItem} onClose={() => { if (!busy) setDeleteItem(null) }} maxWidth="xs" fullWidth>
<DialogTitle>Delete Principal Grant</DialogTitle> <DialogTitle>Delete Principal Grant</DialogTitle>
@@ -423,7 +368,6 @@ export default function AdminSSHPrincipalGrantsPage() {
/> />
</FormDialogContent> </FormDialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => setDeleteItem(null)} disabled={busy}>Cancel</Button>
<Button <Button
color="error" color="error"
variant="contained" variant="contained"
@@ -432,6 +376,7 @@ export default function AdminSSHPrincipalGrantsPage() {
> >
{busy ? 'Deleting...' : 'Delete'} {busy ? 'Deleting...' : 'Delete'}
</Button> </Button>
<Button onClick={() => setDeleteItem(null)} disabled={busy}>Close</Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
</Box> </Box>
+47 -111
View File
@@ -4,16 +4,13 @@ import FormDialogContent from '../components/FormDialogContent'
import { import {
Box, Box,
Button, Button,
Checkbox,
Dialog, Dialog,
DialogActions, DialogActions,
DialogContent, DialogContent,
DialogTitle, DialogTitle,
FormControlLabel,
List, List,
ListItem, ListItem,
ListItemText, ListItemText,
MenuItem,
TextField, TextField,
Typography Typography
} from '@mui/material' } from '@mui/material'
@@ -22,8 +19,8 @@ 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 SSHUserCADetailsDialog from '../components/SSHUserCADetailsDialog' import SSHUserCADetailsDialog from '../components/SSHUserCADetailsDialog'
import SSHUserCAFormDialog, { SSHUserCAFormState } from '../components/SSHUserCAFormDialog'
import { api, SSHSignUserKeyResponse, SSHUserCA, SSHUserCACreatePayload, SSHUserCASignPayload, SSHUserCAUpdatePayload } from '../api' import { api, SSHSignUserKeyResponse, SSHUserCA, SSHUserCACreatePayload, SSHUserCASignPayload, SSHUserCAUpdatePayload } from '../api'
import SelectField from '../components/SelectField'
function fmt(value: number): string { function fmt(value: number): string {
if (!value || value <= 0) { if (!value || value <= 0) {
@@ -48,6 +45,18 @@ async function readFileText(file: File): Promise<string> {
return file.text() return file.text()
} }
function createEmptyForm(): SSHUserCAFormState {
return {
name: '',
enabled: true,
allowUserSign: false,
maxUserValidSeconds: '1800',
privateKeyPEM: '',
createMode: 'generate',
createAlgorithm: 'ed25519'
}
}
export default function AdminSSHUserCAPage() { export default function AdminSSHUserCAPage() {
const [items, setItems] = useState<SSHUserCA[]>([]) const [items, setItems] = useState<SSHUserCA[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -57,13 +66,7 @@ export default function AdminSSHUserCAPage() {
const [editOpen, setEditOpen] = useState(false) const [editOpen, setEditOpen] = useState(false)
const [editID, setEditID] = useState('') const [editID, setEditID] = useState('')
const [name, setName] = useState('') const [form, setForm] = useState<SSHUserCAFormState>(createEmptyForm)
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 [viewItem, setViewItem] = useState<SSHUserCA | null>(null) const [viewItem, setViewItem] = useState<SSHUserCA | null>(null)
const [deleteItem, setDeleteItem] = useState<SSHUserCA | null>(null) const [deleteItem, setDeleteItem] = useState<SSHUserCA | null>(null)
@@ -101,26 +104,22 @@ export default function AdminSSHUserCAPage() {
const openCreate = () => { const openCreate = () => {
setDialogError(null) setDialogError(null)
setEditID('') setEditID('')
setName('') setForm(createEmptyForm())
setEnabled(true)
setAllowUserSign(false)
setMaxUserValidSeconds('1800')
setPrivateKeyPEM('')
setCreateMode('generate')
setCreateAlgorithm('ed25519')
setEditOpen(true) setEditOpen(true)
} }
const openEdit = (item: SSHUserCA) => { const openEdit = (item: SSHUserCA) => {
setDialogError(null) setDialogError(null)
setEditID(item.id) setEditID(item.id)
setName(item.name) setForm({
setEnabled(item.enabled) name: item.name,
setAllowUserSign(item.allow_user_sign) enabled: item.enabled,
setMaxUserValidSeconds(String(item.max_user_valid_seconds || 1800)) allowUserSign: item.allow_user_sign,
setPrivateKeyPEM('') maxUserValidSeconds: String(item.max_user_valid_seconds || 1800),
setCreateMode('generate') privateKeyPEM: '',
setCreateAlgorithm('ed25519') createMode: 'generate',
createAlgorithm: 'ed25519'
})
setEditOpen(true) setEditOpen(true)
} }
@@ -131,7 +130,7 @@ export default function AdminSSHUserCAPage() {
return return
} }
text = await readFileText(file) text = await readFileText(file)
setPrivateKeyPEM(text) setForm((prev) => ({ ...prev, privateKeyPEM: text }))
event.target.value = '' event.target.value = ''
} }
@@ -139,27 +138,27 @@ export default function AdminSSHUserCAPage() {
let maxSeconds: number let maxSeconds: number
let createPayload: SSHUserCACreatePayload let createPayload: SSHUserCACreatePayload
let updatePayload: SSHUserCAUpdatePayload let updatePayload: SSHUserCAUpdatePayload
maxSeconds = Number(maxUserValidSeconds) || 1800 maxSeconds = Number(form.maxUserValidSeconds) || 1800
setBusy(true) setBusy(true)
setDialogError(null) setDialogError(null)
setError(null) setError(null)
try { try {
if (editID) { if (editID) {
updatePayload = { updatePayload = {
name: name.trim(), name: form.name.trim(),
enabled, enabled: form.enabled,
allow_user_sign: allowUserSign, allow_user_sign: form.allowUserSign,
max_user_valid_seconds: maxSeconds max_user_valid_seconds: maxSeconds
} }
await api.updateSSHUserCA(editID, updatePayload) await api.updateSSHUserCA(editID, updatePayload)
} else { } else {
createPayload = { createPayload = {
name: name.trim(), name: form.name.trim(),
enabled, enabled: form.enabled,
allow_user_sign: allowUserSign, allow_user_sign: form.allowUserSign,
max_user_valid_seconds: maxSeconds, max_user_valid_seconds: maxSeconds,
algorithm: createMode === 'generate' ? createAlgorithm : undefined, algorithm: form.createMode === 'generate' ? form.createAlgorithm : undefined,
private_key_pem: createMode === 'import' ? (privateKeyPEM.trim() || undefined) : undefined private_key_pem: form.createMode === 'import' ? (form.privateKeyPEM.trim() || undefined) : undefined
} }
await api.createSSHUserCA(createPayload) await api.createSSHUserCA(createPayload)
} }
@@ -351,80 +350,17 @@ export default function AdminSSHUserCAPage() {
)} )}
</SectionCard> </SectionCard>
<Dialog open={editOpen} onClose={() => setEditOpen(false)} maxWidth="md" fullWidth> <SSHUserCAFormDialog
<DialogTitle> open={editOpen}
<Box sx={{ display: 'grid', gap: 1 }}> editID={editID}
<Typography variant="h6">{editID ? 'Edit SSH User CA' : 'New SSH User CA'}</Typography> dialogError={dialogError}
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null} busy={busy}
</Box> form={form}
</DialogTitle> setForm={setForm}
<FormDialogContent> onClose={() => setEditOpen(false)}
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}> onSave={save}
<TextField label="Name" value={name} onChange={(event) => setName(event.target.value)} /> onLoadPrivateKeyFile={loadPrivateKeyFile}
<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>
<Dialog open={Boolean(deleteItem)} onClose={() => setDeleteItem(null)} maxWidth="xs" fullWidth> <Dialog open={Boolean(deleteItem)} onClose={() => setDeleteItem(null)} maxWidth="xs" fullWidth>
<DialogTitle> <DialogTitle>
@@ -440,7 +376,6 @@ export default function AdminSSHUserCAPage() {
</Box> </Box>
</FormDialogContent> </FormDialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => setDeleteItem(null)}>Cancel</Button>
<Button <Button
variant="contained" variant="contained"
color="error" color="error"
@@ -449,6 +384,7 @@ export default function AdminSSHUserCAPage() {
> >
{busy ? 'Working...' : 'Delete'} {busy ? 'Working...' : 'Delete'}
</Button> </Button>
<Button onClick={() => setDeleteItem(null)}>Cancel</Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
@@ -502,7 +438,6 @@ export default function AdminSSHUserCAPage() {
</Box> </Box>
</FormDialogContent> </FormDialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => setSignOpen(false)}>Close</Button>
{signResult ? <Button onClick={copyResultCert}>Copy Certificate</Button> : null} {signResult ? <Button onClick={copyResultCert}>Copy Certificate</Button> : null}
{signResult ? <Button onClick={inspectResultCert} disabled={inspectBusy}>{inspectBusy ? 'Inspecting...' : 'Inspect'}</Button> : null} {signResult ? <Button onClick={inspectResultCert} disabled={inspectBusy}>{inspectBusy ? 'Inspecting...' : 'Inspect'}</Button> : null}
<Button <Button
@@ -512,6 +447,7 @@ export default function AdminSSHUserCAPage() {
> >
{busy ? 'Working...' : 'Sign'} {busy ? 'Working...' : 'Sign'}
</Button> </Button>
<Button onClick={() => setSignOpen(false)}>Close</Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>