Compare commits
3 Commits
2465fac858
...
cb42518aec
| Author | SHA1 | Date | |
|---|---|---|---|
| cb42518aec | |||
| aaf5c1b841 | |||
| 56db618202 |
@@ -625,6 +625,7 @@ func (app *App) InitHandlers() (*handlers.API, *http.ServeMux, error) {
|
||||
router.Handle("GET", "/api/ssh/user-cas/:id/public-key", api.DownloadSSHUserCAPublicKeyForSelf)
|
||||
router.Handle("GET", "/api/ssh/principal-grants", api.ListSSHPrincipalGrantsForSelf)
|
||||
router.Handle("GET", "/api/ssh/servers", api.ListSSHServersForSelf)
|
||||
router.Handle("GET", "/api/ssh/servers/:id", api.GetSSHServerForSelf)
|
||||
router.Handle("POST", "/api/ssh/servers", api.CreateSSHServerForSelf)
|
||||
router.Handle("PATCH", "/api/ssh/servers/:id", api.UpdateSSHServerForSelf)
|
||||
router.Handle("DELETE", "/api/ssh/servers/:id", api.DeleteSSHServerForSelf)
|
||||
|
||||
@@ -558,6 +558,25 @@ func (api *API) GetSSHAccessProfileForSelf(w http.ResponseWriter, r *http.Reques
|
||||
WriteJSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (api *API) GetSSHServerForSelf(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||
var user models.User
|
||||
var ok bool
|
||||
var item models.SSHServer
|
||||
var err error
|
||||
|
||||
user, ok = middleware.UserFromContext(r.Context())
|
||||
if !ok || user.Disabled {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
item, err = api.store(r).GetOwnedSSHServerForUser(user.ID, params["id"])
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
WriteJSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (api *API) ListSSHServersForSelf(w http.ResponseWriter, r *http.Request, _ map[string]string) {
|
||||
var user models.User
|
||||
var ok bool
|
||||
|
||||
@@ -1139,6 +1139,7 @@ export const api = {
|
||||
getSSHUserCAForSelf: (id: string) => request<SSHUserCA>(`/api/ssh/user-cas/${id}`),
|
||||
downloadSSHUserCAPublicKeyForSelf: (id: string) => requestText(`/api/ssh/user-cas/${id}/public-key`),
|
||||
listSSHPrincipalGrantsForSelf: () => request<SSHPrincipalGrant[]>('/api/ssh/principal-grants'),
|
||||
getSSHServerForSelf: (id: string) => request<SSHServer[]>(`/api/ssh/servers/${id}`),
|
||||
listSSHServersForSelf: () => request<SSHServer[]>('/api/ssh/servers'),
|
||||
createSSHServerForSelf: (payload: {
|
||||
name: string
|
||||
|
||||
@@ -78,6 +78,7 @@ export default function Layout() {
|
||||
adminItems.push({ label: 'User Groups', path: '/admin/user-groups', icon: <GroupWorkIcon fontSize="small" /> })
|
||||
adminItems.push({ label: 'Admin API Keys', path: '/admin/api-keys', icon: <AdminPanelSettingsIcon fontSize="small" /> })
|
||||
adminItems.push({ label: 'Admin PKI', path: '/admin/pki', icon: <SecurityIcon fontSize="small" /> })
|
||||
adminItems.push({ label: 'Admin Principals', path: '/admin/principals', icon: <VpnKeyIcon fontSize="small" /> })
|
||||
adminItems.push({ label: 'Client Cert Profiles', path: '/admin/pki/client-profiles', icon: <VerifiedUserIcon fontSize="small" /> })
|
||||
adminItems.push({ label: 'SSH Servers', path: '/admin/ssh-servers', icon: <DnsIcon fontSize="small" /> })
|
||||
adminItems.push({ label: 'SSH Access Profiles', path: '/admin/ssh-access-profiles', icon: <TerminalIcon fontSize="small" /> })
|
||||
@@ -85,7 +86,6 @@ export default function Layout() {
|
||||
adminItems.push({ label: 'SSH CA', path: '/admin/ssh-ca', icon: <BadgeOutlinedIcon fontSize="small" /> })
|
||||
adminItems.push({ label: 'SSH Principal Grants', path: '/admin/ssh-principal-grants', icon: <ManageAccountsIcon fontSize="small" /> })
|
||||
adminItems.push({ label: 'SSH Sign History', path: '/admin/ssh-sign-history', icon: <HistoryIcon fontSize="small" /> })
|
||||
adminItems.push({ label: 'Service Principals', path: '/admin/principals', icon: <VpnKeyIcon fontSize="small" /> })
|
||||
adminItems.push({ label: 'Site Auth', path: '/admin/auth', icon: <BadgeIcon fontSize="small" /> })
|
||||
adminItems.push({ label: 'TLS Auth Policies', path: '/admin/tls/auth-policies', icon: <SecurityIcon fontSize="small" /> })
|
||||
adminItems.push({ label: 'Site TLS', path: '/admin/tls', icon: <HttpsIcon fontSize="small" /> })
|
||||
|
||||
101
frontend/src/components/PKICertDetailsDialog.tsx
Normal file
101
frontend/src/components/PKICertDetailsDialog.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import FormDialogContent from './FormDialogContent'
|
||||
import { PKICertDetail } from '../api'
|
||||
|
||||
type PKICertDetailsDialogProps = {
|
||||
item: PKICertDetail | null
|
||||
dialogError?: string | null
|
||||
dump: string
|
||||
dumpOpen: boolean
|
||||
dumpLoading: boolean
|
||||
issuedBySubjectName: string
|
||||
issuanceSource: string
|
||||
onClose: () => void
|
||||
onToggleDump: () => void
|
||||
onDownloadBundle?: (item: PKICertDetail) => void
|
||||
onDownloadCert?: (item: PKICertDetail) => void
|
||||
onDownloadKey?: (item: PKICertDetail) => void
|
||||
onRenewWithACME?: () => void
|
||||
renewBusy?: boolean
|
||||
}
|
||||
|
||||
function fmt(value: number): string {
|
||||
if (!value || value <= 0) {
|
||||
return '-'
|
||||
}
|
||||
return new Date(value * 1000).toLocaleString()
|
||||
}
|
||||
|
||||
export default function PKICertDetailsDialog(props: PKICertDetailsDialogProps) {
|
||||
const item: PKICertDetail | null = props.item
|
||||
|
||||
return (
|
||||
<Dialog open={Boolean(item)} onClose={props.onClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle>Certificate Details</DialogTitle>
|
||||
<FormDialogContent>
|
||||
{props.dialogError ? <Alert severity="error" sx={{ mb: 1 }}>{props.dialogError}</Alert> : null}
|
||||
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}>
|
||||
<TextField label="ID" value={item?.id || ''} InputProps={{ readOnly: true }} />
|
||||
<TextField label="Issuer CA ID" value={item?.ca_id || 'standalone'} InputProps={{ readOnly: true }} />
|
||||
<TextField label="Issuance Source" value={props.issuanceSource} InputProps={{ readOnly: true }} />
|
||||
<TextField label="Issued By Kind" value={item?.created_by_kind || '-'} InputProps={{ readOnly: true }} />
|
||||
<TextField label="Issued By Subject ID" value={item?.created_by_subject_id || '-'} InputProps={{ readOnly: true }} />
|
||||
<TextField label="Issued By Subject Name" value={props.issuedBySubjectName} InputProps={{ readOnly: true }} />
|
||||
<TextField label="Serial" value={item?.serial_hex || ''} InputProps={{ readOnly: true }} />
|
||||
<TextField label="Common Name" value={item?.common_name || ''} InputProps={{ readOnly: true }} />
|
||||
<TextField label="SAN DNS" value={item?.san_dns || ''} InputProps={{ readOnly: true }} />
|
||||
<TextField label="SAN IPs" value={item?.san_ips || ''} InputProps={{ readOnly: true }} />
|
||||
<TextField label="Status" value={item?.status || ''} InputProps={{ readOnly: true }} />
|
||||
<TextField label="Not Before" value={fmt(item?.not_before || 0)} InputProps={{ readOnly: true }} />
|
||||
<TextField label="Not After" value={fmt(item?.not_after || 0)} InputProps={{ readOnly: true }} />
|
||||
<TextField
|
||||
label="Certificate PEM"
|
||||
multiline
|
||||
minRows={8}
|
||||
value={item?.cert_pem || ''}
|
||||
InputProps={{ readOnly: true }}
|
||||
sx={{ '& .MuiInputBase-input': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' } }}
|
||||
/>
|
||||
<TextField
|
||||
label="Private Key PEM"
|
||||
multiline
|
||||
minRows={8}
|
||||
value={item?.key_pem || ''}
|
||||
InputProps={{ readOnly: true }}
|
||||
sx={{ '& .MuiInputBase-input': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' } }}
|
||||
/>
|
||||
{props.dumpOpen ? (
|
||||
<TextField
|
||||
label="X509 Dump (OpenSSL-like)"
|
||||
multiline
|
||||
minRows={16}
|
||||
value={props.dump}
|
||||
InputProps={{ readOnly: true }}
|
||||
sx={{ '& .MuiInputBase-input': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' } }}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
</FormDialogContent>
|
||||
<DialogActions>
|
||||
{item?.can_renew_with_acme && props.onRenewWithACME ? (
|
||||
<Button onClick={props.onRenewWithACME} disabled={Boolean(props.renewBusy) || !item}>
|
||||
{props.renewBusy ? 'Working...' : 'Renew via ACME'}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button onClick={props.onToggleDump} disabled={props.dumpLoading}>
|
||||
{props.dumpLoading ? 'Loading Dump...' : props.dumpOpen ? 'Hide X509 Dump' : 'Show X509 Dump'}
|
||||
</Button>
|
||||
{item && props.onDownloadBundle ? <Button onClick={() => props.onDownloadBundle?.(item)}>Download Bundle</Button> : null}
|
||||
{item && props.onDownloadCert ? <Button onClick={() => props.onDownloadCert?.(item)}>Download Cert</Button> : null}
|
||||
{item && props.onDownloadKey ? <Button onClick={() => props.onDownloadKey?.(item)}>Download Key</Button> : null}
|
||||
<Button onClick={props.onClose}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -306,17 +306,17 @@ export default function AdminPKIClientProfilesPage() {
|
||||
primary={`${item.name} (${item.id})`}
|
||||
secondary={
|
||||
<>
|
||||
CA:{' '}
|
||||
<Link
|
||||
component="button"
|
||||
type="button"
|
||||
underline="hover"
|
||||
onClick={() => openCAView(item).catch(() => {})}
|
||||
>
|
||||
CA:{' '}
|
||||
<Link
|
||||
component="button"
|
||||
type="button"
|
||||
underline="hover"
|
||||
onClick={() => openCAView(item).catch(() => {})}
|
||||
>
|
||||
{item.ca_name}
|
||||
</Link>
|
||||
{' '}
|
||||
· server auth: {item.allow_server_auth ? 'yes' : 'no'} · perms: {(item.authz_permissions || []).join(', ') || '-'} · scope: {item.authz_scope || '-'} · validity: {item.default_valid_seconds}s / max {item.max_valid_seconds}s · enabled: {item.enabled ? 'yes' : 'no'} · targets: {item.targets.length} · updated: {fmt(item.updated_at)}
|
||||
</Link>
|
||||
{' '}
|
||||
· server auth: {item.allow_server_auth ? 'yes' : 'no'} · perms: {(item.authz_permissions || []).join(', ') || '-'} · scope: {item.authz_scope || '-'} · validity: {item.default_valid_seconds}s / max {item.max_valid_seconds}s · enabled: {item.enabled ? 'yes' : 'no'} · targets: {item.targets.length} · updated: {fmt(item.updated_at)}
|
||||
</>
|
||||
}
|
||||
sx={{ minWidth: 0, m: 0 }}
|
||||
|
||||
@@ -11,16 +11,19 @@ import {
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
FormControlLabel,
|
||||
Link,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
MenuItem,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography
|
||||
} from '@mui/material'
|
||||
import { useEffect, useState } from 'react'
|
||||
import HeaderActionButton from '../components/HeaderActionButton'
|
||||
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
|
||||
import PKICertDetailsDialog from '../components/PKICertDetailsDialog'
|
||||
import PKICADetailsDialog from '../components/PKICADetailsDialog'
|
||||
import SectionCard from '../components/SectionCard'
|
||||
import { ACMEOrder, ACMEProfile, api, PKICA, PKICADetail, PKICert, PKICertDetail } from '../api'
|
||||
@@ -711,7 +714,23 @@ export default function AdminPKIPage() {
|
||||
<ListItem key={ca.id} divider sx={{ alignItems: 'flex-start' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
|
||||
<ListItemText
|
||||
primary={`${ca.name} (${ca.id})`}
|
||||
primary={(
|
||||
<Box component="span">
|
||||
<Tooltip title={ca.id} arrow>
|
||||
<Link
|
||||
component="button"
|
||||
type="button"
|
||||
underline="hover"
|
||||
color="inherit"
|
||||
onClick={() => openCAView(ca.id)}
|
||||
sx={{ font: 'inherit', textAlign: 'left' }}
|
||||
>
|
||||
{ca.name}
|
||||
</Link>
|
||||
</Tooltip>
|
||||
{` (${ca.id})`}
|
||||
</Box>
|
||||
)}
|
||||
secondary={`${ca.is_root ? 'root' : 'intermediate'} · status: ${ca.status} · parent: ${ca.parent_ca_id || '-'} · updated: ${fmt(ca.updated_at)}`}
|
||||
sx={{ minWidth: 0, m: 0 }}
|
||||
/>
|
||||
@@ -835,7 +854,28 @@ export default function AdminPKIPage() {
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
|
||||
<ListItemText
|
||||
primary={`${cert.common_name} (${cert.id})`}
|
||||
secondary={`serial: ${cert.serial_hex} · ca: ${cert.ca_name || cert.ca_id || 'standalone'} · source: ${fmtPKICertSource(cert)} · actor: ${fmtPKICertActor(cert)} · status: ${cert.status} · valid: ${fmt(cert.not_before)} ~ ${fmt(cert.not_after)}${cert.revoked_at ? ` · revoked: ${fmt(cert.revoked_at)}` : ''}`}
|
||||
secondary={(
|
||||
<Box component="span">
|
||||
{`serial: ${cert.serial_hex} · ca: `}
|
||||
{cert.ca_id ? (
|
||||
<Tooltip title={cert.ca_id} arrow>
|
||||
<Link
|
||||
component="button"
|
||||
type="button"
|
||||
underline="hover"
|
||||
color="inherit"
|
||||
onClick={() => openCAView(cert.ca_id)}
|
||||
sx={{ font: 'inherit', textAlign: 'left' }}
|
||||
>
|
||||
{cert.ca_name || cert.ca_id}
|
||||
</Link>
|
||||
</Tooltip>
|
||||
) : (
|
||||
'standalone'
|
||||
)}
|
||||
{` · source: ${fmtPKICertSource(cert)} · actor: ${fmtPKICertActor(cert)} · status: ${cert.status} · valid: ${fmt(cert.not_before)} ~ ${fmt(cert.not_after)}${cert.revoked_at ? ` · revoked: ${fmt(cert.revoked_at)}` : ''}`}
|
||||
</Box>
|
||||
)}
|
||||
sx={{ minWidth: 0, m: 0 }}
|
||||
/>
|
||||
<ListRowActions>
|
||||
@@ -1436,92 +1476,29 @@ export default function AdminPKIPage() {
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(viewCert)} onClose={() => setViewCert(null)} maxWidth="md" fullWidth>
|
||||
<DialogTitle>Certificate Details</DialogTitle>
|
||||
<FormDialogContent>
|
||||
{dialogError ? <Alert severity="error" sx={{ mb: 1 }}>{dialogError}</Alert> : null}
|
||||
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}>
|
||||
<TextField label="ID" value={viewCert?.id || ''} InputProps={{ readOnly: true }} />
|
||||
<TextField label="Issuer CA ID" value={viewCert?.ca_id || 'standalone'} InputProps={{ readOnly: true }} />
|
||||
<TextField label="Issuance Source" value={fmtPKICertSource(viewCert)} InputProps={{ readOnly: true }} />
|
||||
<TextField label="Issued By Kind" value={viewCert?.created_by_kind || '-'} InputProps={{ readOnly: true }} />
|
||||
<TextField label="Issued By Subject ID" value={viewCert?.created_by_subject_id || '-'} InputProps={{ readOnly: true }} />
|
||||
<TextField label="Issued By Subject Name" value={fmtPKICertActor(viewCert)} InputProps={{ readOnly: true }} />
|
||||
<TextField label="Serial" value={viewCert?.serial_hex || ''} InputProps={{ readOnly: true }} />
|
||||
<TextField label="Common Name" value={viewCert?.common_name || ''} InputProps={{ readOnly: true }} />
|
||||
<TextField label="SAN DNS" value={viewCert?.san_dns || ''} InputProps={{ readOnly: true }} />
|
||||
<TextField label="SAN IPs" value={viewCert?.san_ips || ''} InputProps={{ readOnly: true }} />
|
||||
<TextField label="Status" value={viewCert?.status || ''} InputProps={{ readOnly: true }} />
|
||||
<TextField label="Not Before" value={fmt(viewCert?.not_before || 0)} InputProps={{ readOnly: true }} />
|
||||
<TextField label="Not After" value={fmt(viewCert?.not_after || 0)} InputProps={{ readOnly: true }} />
|
||||
<TextField
|
||||
label="Certificate PEM"
|
||||
multiline
|
||||
minRows={8}
|
||||
value={viewCert?.cert_pem || ''}
|
||||
InputProps={{ readOnly: true }}
|
||||
sx={{ '& .MuiInputBase-input': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' } }}
|
||||
/>
|
||||
<TextField
|
||||
label="Private Key PEM"
|
||||
multiline
|
||||
minRows={8}
|
||||
value={viewCert?.key_pem || ''}
|
||||
InputProps={{ readOnly: true }}
|
||||
sx={{ '& .MuiInputBase-input': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' } }}
|
||||
/>
|
||||
{viewCertDumpOpen ? (
|
||||
<TextField
|
||||
label="X509 Dump (OpenSSL-like)"
|
||||
multiline
|
||||
minRows={16}
|
||||
value={viewCertDump}
|
||||
InputProps={{ readOnly: true }}
|
||||
sx={{ '& .MuiInputBase-input': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' } }}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
</FormDialogContent>
|
||||
<DialogActions>
|
||||
{viewCert?.can_renew_with_acme ? (
|
||||
<Button onClick={renewCertWithACME} disabled={busy || !viewCert}>
|
||||
{busy ? 'Working...' : 'Renew via ACME'}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button onClick={toggleCertDump} disabled={viewCertDumpLoading}>
|
||||
{viewCertDumpLoading ? 'Loading Dump...' : viewCertDumpOpen ? 'Hide X509 Dump' : 'Show X509 Dump'}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={async () => {
|
||||
if (!viewCert) return
|
||||
const data = await api.downloadPKICertBundle(viewCert.id)
|
||||
downloadBinary(`${viewCert.common_name || viewCert.id}.bundle.zip`, data, 'application/zip')
|
||||
}}
|
||||
>
|
||||
Download Bundle
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={() => {
|
||||
if (!viewCert) return
|
||||
downloadText(`${viewCert.common_name || viewCert.id}.crt.pem`, viewCert.cert_pem || '')
|
||||
}}
|
||||
>
|
||||
Download Cert
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={() => {
|
||||
if (!viewCert) return
|
||||
downloadText(`${viewCert.common_name || viewCert.id}.key.pem`, viewCert.key_pem || '')
|
||||
}}
|
||||
>
|
||||
Download Key
|
||||
</Button>
|
||||
<Button onClick={() => setViewCert(null)}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<PKICertDetailsDialog
|
||||
item={viewCert}
|
||||
dialogError={dialogError}
|
||||
dump={viewCertDump}
|
||||
dumpOpen={viewCertDumpOpen}
|
||||
dumpLoading={viewCertDumpLoading}
|
||||
issuedBySubjectName={fmtPKICertActor(viewCert)}
|
||||
issuanceSource={fmtPKICertSource(viewCert)}
|
||||
onClose={() => setViewCert(null)}
|
||||
onToggleDump={toggleCertDump}
|
||||
onRenewWithACME={renewCertWithACME}
|
||||
renewBusy={busy}
|
||||
onDownloadBundle={async (item) => {
|
||||
const data = await api.downloadPKICertBundle(item.id)
|
||||
downloadBinary(`${item.common_name || item.id}.bundle.zip`, data, 'application/zip')
|
||||
}}
|
||||
onDownloadCert={(item) => {
|
||||
downloadText(`${item.common_name || item.id}.crt.pem`, item.cert_pem || '')
|
||||
}}
|
||||
onDownloadKey={(item) => {
|
||||
downloadText(`${item.common_name || item.id}.key.pem`, item.key_pem || '')
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import Button from '@mui/material/Button'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import Link from '@mui/material/Link'
|
||||
import Paper from '@mui/material/Paper'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
@@ -15,6 +16,8 @@ import { ListRowActionButton, ListRowActions } from '../components/ListRowAction
|
||||
import SectionCard from '../components/SectionCard'
|
||||
import SSHAccessProfileFormDialog from '../components/SSHAccessProfileFormDialog'
|
||||
import { SSHAccessProfileFormState } from '../components/SSHAccessProfileFormDialog'
|
||||
import SSHServerDetailsDialog from '../components/SSHServerDetailsDialog'
|
||||
import SSHUserCADetailsDialog from '../components/SSHUserCADetailsDialog'
|
||||
import { api, SSHAccessProfile, SSHPrincipalGrant, SSHServer, SSHUserCA, User, UserGroup } from '../api'
|
||||
|
||||
const emptyForm = (): SSHAccessProfileFormState => ({
|
||||
@@ -52,6 +55,8 @@ export default function AdminSSHAccessProfilesPage() {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [deleteItem, setDeleteItem] = useState<SSHAccessProfile | null>(null)
|
||||
const [viewItem, setViewItem] = useState<SSHAccessProfile | null>(null)
|
||||
const [viewServerItem, setViewServerItem] = useState<SSHServer | null>(null)
|
||||
const [viewCAItem, setViewCAItem] = useState<SSHUserCA | null>(null)
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
@@ -150,6 +155,70 @@ export default function AdminSSHAccessProfilesPage() {
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const openServerView = async (serverID: string) => {
|
||||
let detail: SSHServer
|
||||
|
||||
setError(null)
|
||||
try {
|
||||
detail = await api.getSSHServerAdmin(serverID)
|
||||
setViewServerItem(detail)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load SSH server')
|
||||
}
|
||||
}
|
||||
|
||||
const openCAView = async (caID: string) => {
|
||||
let detail: SSHUserCA
|
||||
|
||||
setError(null)
|
||||
try {
|
||||
detail = await api.getSSHUserCA(caID)
|
||||
setViewCAItem(detail)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load SSH user CA')
|
||||
}
|
||||
}
|
||||
|
||||
const downloadCAPublicKey = async (item: SSHUserCA) => {
|
||||
let text: string
|
||||
|
||||
setError(null)
|
||||
try {
|
||||
text = await api.downloadSSHUserCAPublicKey(item.id)
|
||||
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = `${item.name || item.id}.pub`
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
anchor.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to download public key')
|
||||
}
|
||||
}
|
||||
|
||||
const downloadCAPrivateKey = async (item: SSHUserCA) => {
|
||||
let text: string
|
||||
|
||||
setError(null)
|
||||
try {
|
||||
text = await api.downloadSSHUserCAPrivateKey(item.id)
|
||||
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = `${item.name || item.id}.key`
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
anchor.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to download private key')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
const payload = {
|
||||
server_id: form.server_id,
|
||||
@@ -285,7 +354,19 @@ export default function AdminSSHAccessProfilesPage() {
|
||||
</ListRowActions>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
||||
{item.server?.name} · {item.server?.host}:{item.server?.port} · {item.remote_username} · {item.auth_method} · 2FA: {item.second_factor_mode || 'none'} · {item.enabled ? 'enabled' : 'disabled'}
|
||||
<Tooltip title={item.server_id} arrow>
|
||||
<Link
|
||||
component="button"
|
||||
type="button"
|
||||
underline="hover"
|
||||
color="inherit"
|
||||
onClick={() => void openServerView(item.server_id)}
|
||||
sx={{ font: 'inherit', textAlign: 'left' }}
|
||||
>
|
||||
{item.server?.name || item.server_id}
|
||||
</Link>
|
||||
</Tooltip>
|
||||
{' '}· {item.server?.host}:{item.server?.port} · {item.remote_username} · {item.auth_method} · 2FA: {item.second_factor_mode || 'none'} · {item.enabled ? 'enabled' : 'disabled'}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
||||
Targets: {(item.targets || []).map((target) => `${target.target_type}:${target.target_name}`).join(', ') || '-'}
|
||||
@@ -295,9 +376,16 @@ export default function AdminSSHAccessProfilesPage() {
|
||||
CA:{' '}
|
||||
{item.ssh_user_ca_id ? (
|
||||
<Tooltip title={item.ssh_user_ca_id} arrow>
|
||||
<Box component="span" sx={{ cursor: 'default' }}>
|
||||
<Link
|
||||
component="button"
|
||||
type="button"
|
||||
underline="hover"
|
||||
color="inherit"
|
||||
onClick={() => void openCAView(item.ssh_user_ca_id)}
|
||||
sx={{ font: 'inherit', textAlign: 'left' }}
|
||||
>
|
||||
{caNameByID[item.ssh_user_ca_id] || item.ssh_user_ca_id}
|
||||
</Box>
|
||||
</Link>
|
||||
</Tooltip>
|
||||
) : '-'} · Grants: {(item.ssh_principal_grant_ids || []).join(', ') || '-'}
|
||||
</Typography>
|
||||
@@ -336,6 +424,19 @@ export default function AdminSSHAccessProfilesPage() {
|
||||
showAdminFields
|
||||
/>
|
||||
|
||||
<SSHServerDetailsDialog
|
||||
item={viewServerItem}
|
||||
onClose={() => setViewServerItem(null)}
|
||||
showAdminFields
|
||||
/>
|
||||
|
||||
<SSHUserCADetailsDialog
|
||||
item={viewCAItem}
|
||||
onClose={() => setViewCAItem(null)}
|
||||
onDownloadPublic={downloadCAPublicKey}
|
||||
onDownloadPrivate={downloadCAPrivateKey}
|
||||
/>
|
||||
|
||||
<Dialog open={Boolean(deleteItem)} onClose={() => setDeleteItem(null)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Delete SSH Access Profile</DialogTitle>
|
||||
<FormDialogContent>
|
||||
|
||||
@@ -10,8 +10,9 @@ import TextField from '@mui/material/TextField'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link as RouterLink } from 'react-router-dom'
|
||||
import SectionCard from '../components/SectionCard'
|
||||
import SSHAccessProfileDetailsDialog from '../components/SSHAccessProfileDetailsDialog'
|
||||
import SSHServerDetailsDialog from '../components/SSHServerDetailsDialog'
|
||||
import { api, SSHAccessProfile, SSHServer, SSHSession, SSHSessionListResponse } from '../api'
|
||||
|
||||
function fmt(value: number): string {
|
||||
@@ -48,6 +49,8 @@ export default function AdminSSHSessionsPage() {
|
||||
const [transcriptText, setTranscriptText] = useState('')
|
||||
const [transcriptError, setTranscriptError] = useState<string | null>(null)
|
||||
const [loadingTranscript, setLoadingTranscript] = useState(false)
|
||||
const [viewProfile, setViewProfile] = useState<SSHAccessProfile | null>(null)
|
||||
const [viewServer, setViewServer] = useState<SSHServer | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [status, setStatus] = useState('')
|
||||
const [limit, setLimit] = useState(25)
|
||||
@@ -141,6 +144,14 @@ export default function AdminSSHSessionsPage() {
|
||||
downloadTextFile(`ssh-session-${transcriptItem.id}.txt`, transcriptText)
|
||||
}, [transcriptItem, transcriptText])
|
||||
|
||||
const openProfileView = useCallback((profileID: string) => {
|
||||
setViewProfile(profiles[profileID] || null)
|
||||
}, [profiles])
|
||||
|
||||
const openServerView = useCallback((serverID: string) => {
|
||||
setViewServer(servers[serverID] || null)
|
||||
}, [servers])
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
||||
<Typography variant="h5">Admin: SSH Sessions</Typography>
|
||||
@@ -234,10 +245,11 @@ export default function AdminSSHSessionsPage() {
|
||||
Session: {item.id} · Profile:{' '}
|
||||
<Tooltip title={item.profile_id} arrow>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to="/admin/ssh-access-profiles"
|
||||
component="button"
|
||||
underline="hover"
|
||||
color="inherit"
|
||||
onClick={() => openProfileView(item.profile_id)}
|
||||
sx={{ background: 'none', border: 0, p: 0, cursor: 'pointer', textAlign: 'left' }}
|
||||
>
|
||||
{profiles[item.profile_id]?.name || item.profile_id}
|
||||
</Link>
|
||||
@@ -245,10 +257,11 @@ export default function AdminSSHSessionsPage() {
|
||||
· Server:{' '}
|
||||
<Tooltip title={item.server_id} arrow>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to="/admin/ssh-servers"
|
||||
component="button"
|
||||
underline="hover"
|
||||
color="inherit"
|
||||
onClick={() => openServerView(item.server_id)}
|
||||
sx={{ background: 'none', border: 0, p: 0, cursor: 'pointer', textAlign: 'left' }}
|
||||
>
|
||||
{servers[item.server_id]?.name || item.server_id}
|
||||
</Link>
|
||||
@@ -298,6 +311,16 @@ export default function AdminSSHSessionsPage() {
|
||||
</Box>
|
||||
</Box>
|
||||
</SectionCard>
|
||||
<SSHAccessProfileDetailsDialog
|
||||
item={viewProfile}
|
||||
onClose={() => setViewProfile(null)}
|
||||
showAdminFields
|
||||
/>
|
||||
<SSHServerDetailsDialog
|
||||
item={viewServer}
|
||||
onClose={() => setViewServer(null)}
|
||||
showAdminFields
|
||||
/>
|
||||
<Dialog open={Boolean(transcriptItem)} onClose={closeTranscript} fullWidth maxWidth="md">
|
||||
<DialogTitle>
|
||||
{transcriptItem ? `Transcript: ${sessionTitle(transcriptItem)}` : 'Transcript'}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
||||
import Alert from '@mui/material/Alert'
|
||||
import FormDialogContent from '../components/FormDialogContent'
|
||||
import Link from '@mui/material/Link'
|
||||
import PKICertDetailsDialog from '../components/PKICertDetailsDialog'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -17,7 +19,7 @@ import {
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
|
||||
import SectionCard from '../components/SectionCard'
|
||||
import { api, CertPrincipalBinding, PKICert, PrincipalAPIKey, PrincipalProjectRole, Project, ServicePrincipal } from '../api'
|
||||
import { api, CertPrincipalBinding, PKICert, PKICertDetail, PrincipalAPIKey, PrincipalProjectRole, Project, ServicePrincipal } from '../api'
|
||||
import SelectField from '../components/SelectField'
|
||||
|
||||
function fmt(ts: number): string {
|
||||
@@ -30,6 +32,43 @@ function formatUnix(ts: number): string {
|
||||
return new Date(ts * 1000).toLocaleString()
|
||||
}
|
||||
|
||||
function fmtPKICertActor(cert: PKICert | PKICertDetail | null | undefined): string {
|
||||
if (!cert) return '-'
|
||||
if (cert.created_by_subject_name) return cert.created_by_subject_name
|
||||
if (cert.created_by_subject_id) return cert.created_by_subject_id
|
||||
if (cert.created_by_kind) return cert.created_by_kind
|
||||
return '-'
|
||||
}
|
||||
|
||||
function fmtPKICertSource(cert: PKICert | PKICertDetail | null | undefined): string {
|
||||
if (!cert) return '-'
|
||||
return cert.issuance_source || '-'
|
||||
}
|
||||
|
||||
function downloadText(filename: string, text: string) {
|
||||
const blob: Blob = new Blob([text], { type: 'text/plain;charset=utf-8' })
|
||||
const url: string = URL.createObjectURL(blob)
|
||||
const link: HTMLAnchorElement = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function downloadBinary(filename: string, data: ArrayBuffer, contentType: string) {
|
||||
const blob: Blob = new Blob([data], { type: contentType })
|
||||
const url: string = URL.createObjectURL(blob)
|
||||
const link: HTMLAnchorElement = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export default function AdminServicePrincipalsPage() {
|
||||
const [principals, setPrincipals] = useState<ServicePrincipal[]>([])
|
||||
const [bindings, setBindings] = useState<CertPrincipalBinding[]>([])
|
||||
@@ -66,6 +105,10 @@ export default function AdminServicePrincipalsPage() {
|
||||
const [keysBusy, setKeysBusy] = useState(false)
|
||||
const [newPrincipalToken, setNewPrincipalToken] = useState<string | null>(null)
|
||||
const [principalTokenCopied, setPrincipalTokenCopied] = useState(false)
|
||||
const [viewCert, setViewCert] = useState<PKICertDetail | null>(null)
|
||||
const [viewCertDump, setViewCertDump] = useState('')
|
||||
const [viewCertDumpOpen, setViewCertDumpOpen] = useState(false)
|
||||
const [viewCertDumpLoading, setViewCertDumpLoading] = useState(false)
|
||||
|
||||
const load = async () => {
|
||||
let allProjectsPage: { items: Project[]; total: number }
|
||||
@@ -387,6 +430,52 @@ export default function AdminServicePrincipalsPage() {
|
||||
return pkiCerts.find((item) => (item.fingerprint || '').toLowerCase() === (fingerprint || '').toLowerCase())
|
||||
}
|
||||
|
||||
const openCertView = async (id: string) => {
|
||||
let detail: PKICertDetail
|
||||
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
setViewCertDump('')
|
||||
setViewCertDumpOpen(false)
|
||||
setViewCertDumpLoading(false)
|
||||
try {
|
||||
detail = await api.getPKICert(id)
|
||||
setViewCert(detail)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load certificate details')
|
||||
setViewCert(null)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleCertDump = async () => {
|
||||
let data: { dump: string }
|
||||
|
||||
if (!viewCert) {
|
||||
return
|
||||
}
|
||||
if (viewCertDumpOpen) {
|
||||
setViewCertDumpOpen(false)
|
||||
return
|
||||
}
|
||||
if (viewCertDump.trim() !== '') {
|
||||
setViewCertDumpOpen(true)
|
||||
return
|
||||
}
|
||||
setViewCertDumpLoading(true)
|
||||
setDialogError(null)
|
||||
try {
|
||||
data = await api.getPKICertInspect(viewCert.id)
|
||||
setViewCertDump(data.dump || '')
|
||||
setViewCertDumpOpen(true)
|
||||
} catch (err) {
|
||||
setDialogError(err instanceof Error ? err.message : 'Failed to load certificate dump')
|
||||
} finally {
|
||||
setViewCertDumpLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const bindablePKICerts = pkiCerts.filter((item) => !item.is_ca && item.status === 'active' && item.has_client_auth)
|
||||
|
||||
const filteredPrincipals = principals.filter((principal) => {
|
||||
@@ -596,7 +685,16 @@ export default function AdminServicePrincipalsPage() {
|
||||
<Box sx={{ display: 'grid' }}>
|
||||
{pkiCertByFingerprint(item.fingerprint) ? (
|
||||
<Typography variant="body2">
|
||||
{pkiCertByFingerprint(item.fingerprint)?.common_name || pkiCertByFingerprint(item.fingerprint)?.serial_hex} ({pkiCertByFingerprint(item.fingerprint)?.id})
|
||||
<Link
|
||||
component="button"
|
||||
type="button"
|
||||
underline="hover"
|
||||
color="inherit"
|
||||
onClick={() => void openCertView(pkiCertByFingerprint(item.fingerprint)?.id || '')}
|
||||
sx={{ font: 'inherit', textAlign: 'left' }}
|
||||
>
|
||||
{pkiCertByFingerprint(item.fingerprint)?.common_name || pkiCertByFingerprint(item.fingerprint)?.serial_hex} ({pkiCertByFingerprint(item.fingerprint)?.id})
|
||||
</Link>
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="body2">{item.fingerprint}</Typography>
|
||||
@@ -640,6 +738,28 @@ export default function AdminServicePrincipalsPage() {
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<PKICertDetailsDialog
|
||||
item={viewCert}
|
||||
dialogError={dialogError}
|
||||
dump={viewCertDump}
|
||||
dumpOpen={viewCertDumpOpen}
|
||||
dumpLoading={viewCertDumpLoading}
|
||||
issuedBySubjectName={fmtPKICertActor(viewCert)}
|
||||
issuanceSource={fmtPKICertSource(viewCert)}
|
||||
onClose={() => setViewCert(null)}
|
||||
onToggleDump={toggleCertDump}
|
||||
onDownloadBundle={async (item) => {
|
||||
const data = await api.downloadPKICertBundle(item.id)
|
||||
downloadBinary(`${item.common_name || item.id}.bundle.zip`, data, 'application/zip')
|
||||
}}
|
||||
onDownloadCert={(item) => {
|
||||
downloadText(`${item.common_name || item.id}.crt.pem`, item.cert_pem || '')
|
||||
}}
|
||||
onDownloadKey={(item) => {
|
||||
downloadText(`${item.common_name || item.id}.key.pem`, item.key_pem || '')
|
||||
}}
|
||||
/>
|
||||
|
||||
<Dialog open={bindingOpen} onClose={() => setBindingOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>
|
||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
||||
|
||||
@@ -286,7 +286,7 @@ export default function AdminUserGroupsPage() {
|
||||
value={groupQuery}
|
||||
onChange={(event) => setGroupQuery(event.target.value)}
|
||||
fullWidth
|
||||
sx={{ mb: 1 }}
|
||||
sx={{ mb: 1, mt: '13px' /* having these mt mb here doesn't look nice */ }}
|
||||
/>
|
||||
{loading ? <Typography variant="body2" color="text.secondary">Loading groups...</Typography> : null}
|
||||
{!loading && groups.length === 0 ? <Typography variant="body2" color="text.secondary">No groups.</Typography> : null}
|
||||
|
||||
@@ -204,14 +204,14 @@ export default function ClientCertificatesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const openCAView = async (profile: PKIClientProfile) => {
|
||||
const openCAViewByID = async (caID: string) => {
|
||||
let detail: PKICADetail
|
||||
|
||||
setBusy(true)
|
||||
setDialogError(null)
|
||||
setError(null)
|
||||
try {
|
||||
detail = await api.getPKICAForSelf(profile.ca_id)
|
||||
detail = await api.getPKICAForSelf(caID)
|
||||
setViewCA(detail)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load CA details')
|
||||
@@ -221,6 +221,10 @@ export default function ClientCertificatesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const openCAView = async (profile: PKIClientProfile) => {
|
||||
await openCAViewByID(profile.ca_id)
|
||||
}
|
||||
|
||||
const openProfileView = (profileID: string) => {
|
||||
let i: number
|
||||
let item: PKIClientProfile | null
|
||||
@@ -345,7 +349,20 @@ export default function ClientCertificatesPage() {
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
|
||||
<ListItemText
|
||||
primary={`${profile.name} (${profile.id})`}
|
||||
primary={(
|
||||
<Tooltip title={profile.id} arrow>
|
||||
<Link
|
||||
component="button"
|
||||
type="button"
|
||||
underline="hover"
|
||||
color="inherit"
|
||||
onClick={() => openProfileView(profile.id)}
|
||||
sx={{ font: 'inherit', textAlign: 'left' }}
|
||||
>
|
||||
{`${profile.name} (${profile.id})`}
|
||||
</Link>
|
||||
</Tooltip>
|
||||
)}
|
||||
secondary={(
|
||||
<Typography component="span" variant="body2" color="text.secondary">
|
||||
CA:{' '}
|
||||
@@ -537,7 +554,22 @@ export default function ClientCertificatesPage() {
|
||||
<DialogTitle>Client Certificate Issued</DialogTitle>
|
||||
<FormDialogContent>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Profile: {issueResult?.issuance.profile_name} · SAN URI: {issueResult?.issuance.san_uri}
|
||||
Profile:{' '}
|
||||
{issueResult ? (
|
||||
<Tooltip title={issueResult.issuance.profile_id} arrow>
|
||||
<Link
|
||||
component="button"
|
||||
type="button"
|
||||
underline="hover"
|
||||
color="inherit"
|
||||
onClick={() => openProfileView(issueResult.issuance.profile_id)}
|
||||
sx={{ font: 'inherit', textAlign: 'left' }}
|
||||
>
|
||||
{issueResult.issuance.profile_name}
|
||||
</Link>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{' '}· SAN URI: {issueResult?.issuance.san_uri}
|
||||
</Typography>
|
||||
<TextField label="Certificate" value={issueResult?.cert_pem || ''} multiline minRows={6} InputProps={{ sx: { fontFamily: 'monospace' } }} />
|
||||
<TextField label="Private Key" value={issueResult?.key_pem || ''} multiline minRows={6} InputProps={{ sx: { fontFamily: 'monospace' } }} />
|
||||
@@ -564,8 +596,27 @@ export default function ClientCertificatesPage() {
|
||||
<FormDialogContent>
|
||||
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
|
||||
<TextField label="ID" value={viewCert?.id || ''} InputProps={{ readOnly: true }} />
|
||||
<TextField label="Issuer CA" value={viewCert?.ca_name || viewCert?.ca_id || 'standalone'} InputProps={{ readOnly: true }} />
|
||||
<TextField label="Issuer CA ID" value={viewCert?.ca_id || 'standalone'} InputProps={{ readOnly: true }} />
|
||||
<Box sx={{ display: 'grid', gap: 0.5 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Issuer CA
|
||||
</Typography>
|
||||
{viewCert?.ca_id ? (
|
||||
<Tooltip title={viewCert.ca_id} arrow>
|
||||
<Link
|
||||
component="button"
|
||||
type="button"
|
||||
underline="hover"
|
||||
color="inherit"
|
||||
onClick={() => { void openCAViewByID(viewCert.ca_id) }}
|
||||
sx={{ font: 'inherit', textAlign: 'left', justifySelf: 'start' }}
|
||||
>
|
||||
{viewCert.ca_name || viewCert.ca_id}
|
||||
</Link>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Typography variant="body2">standalone</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<TextField label="Serial" value={viewCert?.serial_hex || ''} InputProps={{ readOnly: true }} />
|
||||
<TextField label="Common Name" value={viewCert?.common_name || ''} InputProps={{ readOnly: true }} />
|
||||
<TextField label="SAN DNS" value={viewCert?.san_dns || ''} InputProps={{ readOnly: true }} />
|
||||
|
||||
@@ -285,18 +285,22 @@ export default function SSHCertificatesPage() {
|
||||
await openCertificateView(item.certificate, item.source_public_key || '', item.source_public_key_fingerprint || '')
|
||||
}
|
||||
|
||||
const openCAView = async (item: SSHUserCAIssuance) => {
|
||||
const openCAViewByID = async (caID: string) => {
|
||||
let detail: SSHUserCA
|
||||
|
||||
setError(null)
|
||||
try {
|
||||
detail = await api.getSSHUserCAForSelf(item.ca_id)
|
||||
detail = await api.getSSHUserCAForSelf(caID)
|
||||
setViewCA(detail)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load SSH User CA')
|
||||
}
|
||||
}
|
||||
|
||||
const openCAView = async (item: SSHUserCAIssuance) => {
|
||||
await openCAViewByID(item.ca_id)
|
||||
}
|
||||
|
||||
const downloadCAPublicKey = async (item: SSHUserCA) => {
|
||||
let text: string
|
||||
|
||||
@@ -477,7 +481,27 @@ export default function SSHCertificatesPage() {
|
||||
<DialogTitle>SSH Certificate Issued</DialogTitle>
|
||||
<FormDialogContent>
|
||||
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}>
|
||||
<TextField label="CA" value={selectedCA?.name || result?.ca_id || ''} InputProps={{ readOnly: true }} fullWidth />
|
||||
<Box sx={{ display: 'grid', gap: 0.5 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
CA
|
||||
</Typography>
|
||||
{result?.ca_id ? (
|
||||
<Tooltip title={result.ca_id} arrow>
|
||||
<Link
|
||||
component="button"
|
||||
type="button"
|
||||
underline="hover"
|
||||
color="inherit"
|
||||
onClick={() => { void openCAViewByID(result.ca_id) }}
|
||||
sx={{ font: 'inherit', textAlign: 'left', justifySelf: 'start' }}
|
||||
>
|
||||
{selectedCA?.name || result.ca_id}
|
||||
</Link>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Typography variant="body2">-</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<TextField label="Key ID" value={result?.key_id || ''} InputProps={{ readOnly: true }} fullWidth />
|
||||
<TextField label="Serial" value={result ? String(result.serial) : ''} InputProps={{ readOnly: true }} fullWidth />
|
||||
<TextField label="Valid After" value={result ? fmt(result.valid_after) : ''} InputProps={{ readOnly: true }} fullWidth />
|
||||
|
||||
@@ -6,6 +6,7 @@ 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 Link from '@mui/material/Link'
|
||||
import Paper from '@mui/material/Paper'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
@@ -74,6 +75,7 @@ export default function SSHServersPage() {
|
||||
const [serverSaving, setServerSaving] = useState(false)
|
||||
const [deleteServerItem, setDeleteServerItem] = useState<SSHServer | null>(null)
|
||||
const [viewServerItem, setViewServerItem] = useState<SSHServer | null>(null)
|
||||
const [viewSharedServerItem, setViewSharedServerItem] = useState<SSHServer | null>(null)
|
||||
const [hostKeyServer, setHostKeyServer] = useState<SSHServer | null>(null)
|
||||
const [hostKeys, setHostKeys] = useState<SSHServerHostKey[]>([])
|
||||
const [hostKeysLoading, setHostKeysLoading] = useState(false)
|
||||
@@ -139,6 +141,18 @@ export default function SSHServersPage() {
|
||||
)
|
||||
)
|
||||
|
||||
const openSharedServerView = async (item: SSHServer) => {
|
||||
let detail: SSHServer
|
||||
|
||||
setError(null)
|
||||
try {
|
||||
detail = await api.getSSHServerForSelf(item.server_id)
|
||||
setViewSharedServerItem(detail)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load SSH Server')
|
||||
}
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingID(null)
|
||||
setForm({
|
||||
@@ -477,7 +491,15 @@ export default function SSHServersPage() {
|
||||
</ListRowActions>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
||||
{item.server?.name} · {item.server?.host}:{item.server?.port} · {item.remote_username} · {item.auth_method} · 2FA: {item.second_factor_mode || 'none'}
|
||||
<Link
|
||||
component="button"
|
||||
type="button"
|
||||
underline="hover"
|
||||
onClick={() => openSharedServerView(item).catch(() => {})}>
|
||||
{item.server?.name}
|
||||
</Link>
|
||||
{' '}
|
||||
· {item.server?.host}:{item.server?.port} · {item.remote_username} · {item.auth_method} · 2FA: {item.second_factor_mode || 'none'}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
||||
Key fingerprint: {item.auth_public_key_fingerprint || '-'}
|
||||
@@ -649,6 +671,11 @@ export default function SSHServersPage() {
|
||||
onClose={() => setViewServerItem(null)}
|
||||
/>
|
||||
|
||||
<SSHServerDetailsDialog
|
||||
item={viewSharedServerItem}
|
||||
onClose={() => setViewSharedServerItem(null)}
|
||||
/>
|
||||
|
||||
<Dialog open={Boolean(deleteServerItem)} onClose={() => setDeleteServerItem(null)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Delete SSH Server</DialogTitle>
|
||||
<FormDialogContent>
|
||||
|
||||
Reference in New Issue
Block a user