Compare commits

...

3 Commits

14 changed files with 565 additions and 120 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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" /> })

View 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>
)
}

View File

@@ -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 }}

View File

@@ -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>
)
}

View File

@@ -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>

View File

@@ -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'}

View File

@@ -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 }}>

View File

@@ -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}

View File

@@ -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 }} />

View File

@@ -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 />

View File

@@ -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>