Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4736ea0716 | |||
| 81d76f4625 |
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user