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 {
|
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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user