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/user-cas/:id/public-key", api.DownloadSSHUserCAPublicKeyForSelf)
|
||||||
router.Handle("GET", "/api/ssh/principal-grants", api.ListSSHPrincipalGrantsForSelf)
|
router.Handle("GET", "/api/ssh/principal-grants", api.ListSSHPrincipalGrantsForSelf)
|
||||||
router.Handle("GET", "/api/ssh/servers", api.ListSSHServersForSelf)
|
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("POST", "/api/ssh/servers", api.CreateSSHServerForSelf)
|
||||||
router.Handle("PATCH", "/api/ssh/servers/:id", api.UpdateSSHServerForSelf)
|
router.Handle("PATCH", "/api/ssh/servers/:id", api.UpdateSSHServerForSelf)
|
||||||
router.Handle("DELETE", "/api/ssh/servers/:id", api.DeleteSSHServerForSelf)
|
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)
|
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) {
|
func (api *API) ListSSHServersForSelf(w http.ResponseWriter, r *http.Request, _ map[string]string) {
|
||||||
var user models.User
|
var user models.User
|
||||||
var ok bool
|
var ok bool
|
||||||
|
|||||||
@@ -1139,6 +1139,7 @@ export const api = {
|
|||||||
getSSHUserCAForSelf: (id: string) => request<SSHUserCA>(`/api/ssh/user-cas/${id}`),
|
getSSHUserCAForSelf: (id: string) => request<SSHUserCA>(`/api/ssh/user-cas/${id}`),
|
||||||
downloadSSHUserCAPublicKeyForSelf: (id: string) => requestText(`/api/ssh/user-cas/${id}/public-key`),
|
downloadSSHUserCAPublicKeyForSelf: (id: string) => requestText(`/api/ssh/user-cas/${id}/public-key`),
|
||||||
listSSHPrincipalGrantsForSelf: () => request<SSHPrincipalGrant[]>('/api/ssh/principal-grants'),
|
listSSHPrincipalGrantsForSelf: () => request<SSHPrincipalGrant[]>('/api/ssh/principal-grants'),
|
||||||
|
getSSHServerForSelf: (id: string) => request<SSHServer[]>(`/api/ssh/servers/${id}`),
|
||||||
listSSHServersForSelf: () => request<SSHServer[]>('/api/ssh/servers'),
|
listSSHServersForSelf: () => request<SSHServer[]>('/api/ssh/servers'),
|
||||||
createSSHServerForSelf: (payload: {
|
createSSHServerForSelf: (payload: {
|
||||||
name: string
|
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: '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 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 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: '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 Servers', path: '/admin/ssh-servers', icon: <DnsIcon fontSize="small" /> })
|
||||||
adminItems.push({ label: 'SSH Access Profiles', path: '/admin/ssh-access-profiles', icon: <TerminalIcon 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 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 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: '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: '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: 'TLS Auth Policies', path: '/admin/tls/auth-policies', icon: <SecurityIcon fontSize="small" /> })
|
||||||
adminItems.push({ label: 'Site TLS', path: '/admin/tls', icon: <HttpsIcon fontSize="small" /> })
|
adminItems.push({ label: 'Site TLS', path: '/admin/tls', icon: <HttpsIcon fontSize="small" /> })
|
||||||
|
|||||||
@@ -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})`}
|
primary={`${item.name} (${item.id})`}
|
||||||
secondary={
|
secondary={
|
||||||
<>
|
<>
|
||||||
CA:{' '}
|
CA:{' '}
|
||||||
<Link
|
<Link
|
||||||
component="button"
|
component="button"
|
||||||
type="button"
|
type="button"
|
||||||
underline="hover"
|
underline="hover"
|
||||||
onClick={() => openCAView(item).catch(() => {})}
|
onClick={() => openCAView(item).catch(() => {})}
|
||||||
>
|
>
|
||||||
{item.ca_name}
|
{item.ca_name}
|
||||||
</Link>
|
</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)}
|
· 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 }}
|
sx={{ minWidth: 0, m: 0 }}
|
||||||
|
|||||||
@@ -11,16 +11,19 @@ import {
|
|||||||
DialogContent,
|
DialogContent,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
FormControlLabel,
|
FormControlLabel,
|
||||||
|
Link,
|
||||||
List,
|
List,
|
||||||
ListItem,
|
ListItem,
|
||||||
ListItemText,
|
ListItemText,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
TextField,
|
TextField,
|
||||||
|
Tooltip,
|
||||||
Typography
|
Typography
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import HeaderActionButton from '../components/HeaderActionButton'
|
import HeaderActionButton from '../components/HeaderActionButton'
|
||||||
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
|
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
|
||||||
|
import PKICertDetailsDialog from '../components/PKICertDetailsDialog'
|
||||||
import PKICADetailsDialog from '../components/PKICADetailsDialog'
|
import PKICADetailsDialog from '../components/PKICADetailsDialog'
|
||||||
import SectionCard from '../components/SectionCard'
|
import SectionCard from '../components/SectionCard'
|
||||||
import { ACMEOrder, ACMEProfile, api, PKICA, PKICADetail, PKICert, PKICertDetail } from '../api'
|
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' }}>
|
<ListItem key={ca.id} divider sx={{ alignItems: 'flex-start' }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
|
||||||
<ListItemText
|
<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)}`}
|
secondary={`${ca.is_root ? 'root' : 'intermediate'} · status: ${ca.status} · parent: ${ca.parent_ca_id || '-'} · updated: ${fmt(ca.updated_at)}`}
|
||||||
sx={{ minWidth: 0, m: 0 }}
|
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 }}>
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
primary={`${cert.common_name} (${cert.id})`}
|
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 }}
|
sx={{ minWidth: 0, m: 0 }}
|
||||||
/>
|
/>
|
||||||
<ListRowActions>
|
<ListRowActions>
|
||||||
@@ -1436,92 +1476,29 @@ export default function AdminPKIPage() {
|
|||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={Boolean(viewCert)} onClose={() => setViewCert(null)} maxWidth="md" fullWidth>
|
<PKICertDetailsDialog
|
||||||
<DialogTitle>Certificate Details</DialogTitle>
|
item={viewCert}
|
||||||
<FormDialogContent>
|
dialogError={dialogError}
|
||||||
{dialogError ? <Alert severity="error" sx={{ mb: 1 }}>{dialogError}</Alert> : null}
|
dump={viewCertDump}
|
||||||
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}>
|
dumpOpen={viewCertDumpOpen}
|
||||||
<TextField label="ID" value={viewCert?.id || ''} InputProps={{ readOnly: true }} />
|
dumpLoading={viewCertDumpLoading}
|
||||||
<TextField label="Issuer CA ID" value={viewCert?.ca_id || 'standalone'} InputProps={{ readOnly: true }} />
|
issuedBySubjectName={fmtPKICertActor(viewCert)}
|
||||||
<TextField label="Issuance Source" value={fmtPKICertSource(viewCert)} InputProps={{ readOnly: true }} />
|
issuanceSource={fmtPKICertSource(viewCert)}
|
||||||
<TextField label="Issued By Kind" value={viewCert?.created_by_kind || '-'} InputProps={{ readOnly: true }} />
|
onClose={() => setViewCert(null)}
|
||||||
<TextField label="Issued By Subject ID" value={viewCert?.created_by_subject_id || '-'} InputProps={{ readOnly: true }} />
|
onToggleDump={toggleCertDump}
|
||||||
<TextField label="Issued By Subject Name" value={fmtPKICertActor(viewCert)} InputProps={{ readOnly: true }} />
|
onRenewWithACME={renewCertWithACME}
|
||||||
<TextField label="Serial" value={viewCert?.serial_hex || ''} InputProps={{ readOnly: true }} />
|
renewBusy={busy}
|
||||||
<TextField label="Common Name" value={viewCert?.common_name || ''} InputProps={{ readOnly: true }} />
|
onDownloadBundle={async (item) => {
|
||||||
<TextField label="SAN DNS" value={viewCert?.san_dns || ''} InputProps={{ readOnly: true }} />
|
const data = await api.downloadPKICertBundle(item.id)
|
||||||
<TextField label="SAN IPs" value={viewCert?.san_ips || ''} InputProps={{ readOnly: true }} />
|
downloadBinary(`${item.common_name || item.id}.bundle.zip`, data, 'application/zip')
|
||||||
<TextField label="Status" value={viewCert?.status || ''} InputProps={{ readOnly: true }} />
|
}}
|
||||||
<TextField label="Not Before" value={fmt(viewCert?.not_before || 0)} InputProps={{ readOnly: true }} />
|
onDownloadCert={(item) => {
|
||||||
<TextField label="Not After" value={fmt(viewCert?.not_after || 0)} InputProps={{ readOnly: true }} />
|
downloadText(`${item.common_name || item.id}.crt.pem`, item.cert_pem || '')
|
||||||
<TextField
|
}}
|
||||||
label="Certificate PEM"
|
onDownloadKey={(item) => {
|
||||||
multiline
|
downloadText(`${item.common_name || item.id}.key.pem`, item.key_pem || '')
|
||||||
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>
|
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Button from '@mui/material/Button'
|
|||||||
import Dialog from '@mui/material/Dialog'
|
import Dialog from '@mui/material/Dialog'
|
||||||
import DialogActions from '@mui/material/DialogActions'
|
import DialogActions from '@mui/material/DialogActions'
|
||||||
import DialogTitle from '@mui/material/DialogTitle'
|
import DialogTitle from '@mui/material/DialogTitle'
|
||||||
|
import Link from '@mui/material/Link'
|
||||||
import Paper from '@mui/material/Paper'
|
import Paper from '@mui/material/Paper'
|
||||||
import TextField from '@mui/material/TextField'
|
import TextField from '@mui/material/TextField'
|
||||||
import Tooltip from '@mui/material/Tooltip'
|
import Tooltip from '@mui/material/Tooltip'
|
||||||
@@ -15,6 +16,8 @@ import { ListRowActionButton, ListRowActions } from '../components/ListRowAction
|
|||||||
import SectionCard from '../components/SectionCard'
|
import SectionCard from '../components/SectionCard'
|
||||||
import SSHAccessProfileFormDialog from '../components/SSHAccessProfileFormDialog'
|
import SSHAccessProfileFormDialog from '../components/SSHAccessProfileFormDialog'
|
||||||
import { SSHAccessProfileFormState } 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'
|
import { api, SSHAccessProfile, SSHPrincipalGrant, SSHServer, SSHUserCA, User, UserGroup } from '../api'
|
||||||
|
|
||||||
const emptyForm = (): SSHAccessProfileFormState => ({
|
const emptyForm = (): SSHAccessProfileFormState => ({
|
||||||
@@ -52,6 +55,8 @@ export default function AdminSSHAccessProfilesPage() {
|
|||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [deleteItem, setDeleteItem] = useState<SSHAccessProfile | null>(null)
|
const [deleteItem, setDeleteItem] = useState<SSHAccessProfile | null>(null)
|
||||||
const [viewItem, setViewItem] = 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 () => {
|
const load = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -150,6 +155,70 @@ export default function AdminSSHAccessProfilesPage() {
|
|||||||
setDialogOpen(true)
|
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 handleSave = async () => {
|
||||||
const payload = {
|
const payload = {
|
||||||
server_id: form.server_id,
|
server_id: form.server_id,
|
||||||
@@ -285,7 +354,19 @@ export default function AdminSSHAccessProfilesPage() {
|
|||||||
</ListRowActions>
|
</ListRowActions>
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
<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>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
||||||
Targets: {(item.targets || []).map((target) => `${target.target_type}:${target.target_name}`).join(', ') || '-'}
|
Targets: {(item.targets || []).map((target) => `${target.target_type}:${target.target_name}`).join(', ') || '-'}
|
||||||
@@ -295,9 +376,16 @@ export default function AdminSSHAccessProfilesPage() {
|
|||||||
CA:{' '}
|
CA:{' '}
|
||||||
{item.ssh_user_ca_id ? (
|
{item.ssh_user_ca_id ? (
|
||||||
<Tooltip title={item.ssh_user_ca_id} arrow>
|
<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}
|
{caNameByID[item.ssh_user_ca_id] || item.ssh_user_ca_id}
|
||||||
</Box>
|
</Link>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : '-'} · Grants: {(item.ssh_principal_grant_ids || []).join(', ') || '-'}
|
) : '-'} · Grants: {(item.ssh_principal_grant_ids || []).join(', ') || '-'}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -336,6 +424,19 @@ export default function AdminSSHAccessProfilesPage() {
|
|||||||
showAdminFields
|
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>
|
<Dialog open={Boolean(deleteItem)} onClose={() => setDeleteItem(null)} maxWidth="xs" fullWidth>
|
||||||
<DialogTitle>Delete SSH Access Profile</DialogTitle>
|
<DialogTitle>Delete SSH Access Profile</DialogTitle>
|
||||||
<FormDialogContent>
|
<FormDialogContent>
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ import TextField from '@mui/material/TextField'
|
|||||||
import Tooltip from '@mui/material/Tooltip'
|
import Tooltip from '@mui/material/Tooltip'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { Link as RouterLink } from 'react-router-dom'
|
|
||||||
import SectionCard from '../components/SectionCard'
|
import SectionCard from '../components/SectionCard'
|
||||||
|
import SSHAccessProfileDetailsDialog from '../components/SSHAccessProfileDetailsDialog'
|
||||||
|
import SSHServerDetailsDialog from '../components/SSHServerDetailsDialog'
|
||||||
import { api, SSHAccessProfile, SSHServer, SSHSession, SSHSessionListResponse } from '../api'
|
import { api, SSHAccessProfile, SSHServer, SSHSession, SSHSessionListResponse } from '../api'
|
||||||
|
|
||||||
function fmt(value: number): string {
|
function fmt(value: number): string {
|
||||||
@@ -48,6 +49,8 @@ export default function AdminSSHSessionsPage() {
|
|||||||
const [transcriptText, setTranscriptText] = useState('')
|
const [transcriptText, setTranscriptText] = useState('')
|
||||||
const [transcriptError, setTranscriptError] = useState<string | null>(null)
|
const [transcriptError, setTranscriptError] = useState<string | null>(null)
|
||||||
const [loadingTranscript, setLoadingTranscript] = useState(false)
|
const [loadingTranscript, setLoadingTranscript] = useState(false)
|
||||||
|
const [viewProfile, setViewProfile] = useState<SSHAccessProfile | null>(null)
|
||||||
|
const [viewServer, setViewServer] = useState<SSHServer | null>(null)
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [status, setStatus] = useState('')
|
const [status, setStatus] = useState('')
|
||||||
const [limit, setLimit] = useState(25)
|
const [limit, setLimit] = useState(25)
|
||||||
@@ -141,6 +144,14 @@ export default function AdminSSHSessionsPage() {
|
|||||||
downloadTextFile(`ssh-session-${transcriptItem.id}.txt`, transcriptText)
|
downloadTextFile(`ssh-session-${transcriptItem.id}.txt`, transcriptText)
|
||||||
}, [transcriptItem, transcriptText])
|
}, [transcriptItem, transcriptText])
|
||||||
|
|
||||||
|
const openProfileView = useCallback((profileID: string) => {
|
||||||
|
setViewProfile(profiles[profileID] || null)
|
||||||
|
}, [profiles])
|
||||||
|
|
||||||
|
const openServerView = useCallback((serverID: string) => {
|
||||||
|
setViewServer(servers[serverID] || null)
|
||||||
|
}, [servers])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
<Box sx={{ display: 'grid', gap: 1 }}>
|
||||||
<Typography variant="h5">Admin: SSH Sessions</Typography>
|
<Typography variant="h5">Admin: SSH Sessions</Typography>
|
||||||
@@ -234,10 +245,11 @@ export default function AdminSSHSessionsPage() {
|
|||||||
Session: {item.id} · Profile:{' '}
|
Session: {item.id} · Profile:{' '}
|
||||||
<Tooltip title={item.profile_id} arrow>
|
<Tooltip title={item.profile_id} arrow>
|
||||||
<Link
|
<Link
|
||||||
component={RouterLink}
|
component="button"
|
||||||
to="/admin/ssh-access-profiles"
|
|
||||||
underline="hover"
|
underline="hover"
|
||||||
color="inherit"
|
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}
|
{profiles[item.profile_id]?.name || item.profile_id}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -245,10 +257,11 @@ export default function AdminSSHSessionsPage() {
|
|||||||
· Server:{' '}
|
· Server:{' '}
|
||||||
<Tooltip title={item.server_id} arrow>
|
<Tooltip title={item.server_id} arrow>
|
||||||
<Link
|
<Link
|
||||||
component={RouterLink}
|
component="button"
|
||||||
to="/admin/ssh-servers"
|
|
||||||
underline="hover"
|
underline="hover"
|
||||||
color="inherit"
|
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}
|
{servers[item.server_id]?.name || item.server_id}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -298,6 +311,16 @@ export default function AdminSSHSessionsPage() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
<SSHAccessProfileDetailsDialog
|
||||||
|
item={viewProfile}
|
||||||
|
onClose={() => setViewProfile(null)}
|
||||||
|
showAdminFields
|
||||||
|
/>
|
||||||
|
<SSHServerDetailsDialog
|
||||||
|
item={viewServer}
|
||||||
|
onClose={() => setViewServer(null)}
|
||||||
|
showAdminFields
|
||||||
|
/>
|
||||||
<Dialog open={Boolean(transcriptItem)} onClose={closeTranscript} fullWidth maxWidth="md">
|
<Dialog open={Boolean(transcriptItem)} onClose={closeTranscript} fullWidth maxWidth="md">
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{transcriptItem ? `Transcript: ${sessionTitle(transcriptItem)}` : 'Transcript'}
|
{transcriptItem ? `Transcript: ${sessionTitle(transcriptItem)}` : 'Transcript'}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
||||||
import Alert from '@mui/material/Alert'
|
import Alert from '@mui/material/Alert'
|
||||||
import FormDialogContent from '../components/FormDialogContent'
|
import FormDialogContent from '../components/FormDialogContent'
|
||||||
|
import Link from '@mui/material/Link'
|
||||||
|
import PKICertDetailsDialog from '../components/PKICertDetailsDialog'
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
@@ -17,7 +19,7 @@ import {
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
|
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
|
||||||
import SectionCard from '../components/SectionCard'
|
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'
|
import SelectField from '../components/SelectField'
|
||||||
|
|
||||||
function fmt(ts: number): string {
|
function fmt(ts: number): string {
|
||||||
@@ -30,6 +32,43 @@ function formatUnix(ts: number): string {
|
|||||||
return new Date(ts * 1000).toLocaleString()
|
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() {
|
export default function AdminServicePrincipalsPage() {
|
||||||
const [principals, setPrincipals] = useState<ServicePrincipal[]>([])
|
const [principals, setPrincipals] = useState<ServicePrincipal[]>([])
|
||||||
const [bindings, setBindings] = useState<CertPrincipalBinding[]>([])
|
const [bindings, setBindings] = useState<CertPrincipalBinding[]>([])
|
||||||
@@ -66,6 +105,10 @@ export default function AdminServicePrincipalsPage() {
|
|||||||
const [keysBusy, setKeysBusy] = useState(false)
|
const [keysBusy, setKeysBusy] = useState(false)
|
||||||
const [newPrincipalToken, setNewPrincipalToken] = useState<string | null>(null)
|
const [newPrincipalToken, setNewPrincipalToken] = useState<string | null>(null)
|
||||||
const [principalTokenCopied, setPrincipalTokenCopied] = useState(false)
|
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 () => {
|
const load = async () => {
|
||||||
let allProjectsPage: { items: Project[]; total: number }
|
let allProjectsPage: { items: Project[]; total: number }
|
||||||
@@ -387,6 +430,52 @@ export default function AdminServicePrincipalsPage() {
|
|||||||
return pkiCerts.find((item) => (item.fingerprint || '').toLowerCase() === (fingerprint || '').toLowerCase())
|
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 bindablePKICerts = pkiCerts.filter((item) => !item.is_ca && item.status === 'active' && item.has_client_auth)
|
||||||
|
|
||||||
const filteredPrincipals = principals.filter((principal) => {
|
const filteredPrincipals = principals.filter((principal) => {
|
||||||
@@ -596,7 +685,16 @@ export default function AdminServicePrincipalsPage() {
|
|||||||
<Box sx={{ display: 'grid' }}>
|
<Box sx={{ display: 'grid' }}>
|
||||||
{pkiCertByFingerprint(item.fingerprint) ? (
|
{pkiCertByFingerprint(item.fingerprint) ? (
|
||||||
<Typography variant="body2">
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<Typography variant="body2">{item.fingerprint}</Typography>
|
<Typography variant="body2">{item.fingerprint}</Typography>
|
||||||
@@ -640,6 +738,28 @@ export default function AdminServicePrincipalsPage() {
|
|||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</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>
|
<Dialog open={bindingOpen} onClose={() => setBindingOpen(false)} maxWidth="sm" fullWidth>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
<Box sx={{ display: 'grid', gap: 1 }}>
|
||||||
|
|||||||
@@ -286,7 +286,7 @@ export default function AdminUserGroupsPage() {
|
|||||||
value={groupQuery}
|
value={groupQuery}
|
||||||
onChange={(event) => setGroupQuery(event.target.value)}
|
onChange={(event) => setGroupQuery(event.target.value)}
|
||||||
fullWidth
|
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 ? <Typography variant="body2" color="text.secondary">Loading groups...</Typography> : null}
|
||||||
{!loading && groups.length === 0 ? <Typography variant="body2" color="text.secondary">No 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
|
let detail: PKICADetail
|
||||||
|
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
setDialogError(null)
|
setDialogError(null)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
detail = await api.getPKICAForSelf(profile.ca_id)
|
detail = await api.getPKICAForSelf(caID)
|
||||||
setViewCA(detail)
|
setViewCA(detail)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load CA details')
|
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) => {
|
const openProfileView = (profileID: string) => {
|
||||||
let i: number
|
let i: number
|
||||||
let item: PKIClientProfile | null
|
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 }}>
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
|
||||||
<ListItemText
|
<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={(
|
secondary={(
|
||||||
<Typography component="span" variant="body2" color="text.secondary">
|
<Typography component="span" variant="body2" color="text.secondary">
|
||||||
CA:{' '}
|
CA:{' '}
|
||||||
@@ -537,7 +554,22 @@ export default function ClientCertificatesPage() {
|
|||||||
<DialogTitle>Client Certificate Issued</DialogTitle>
|
<DialogTitle>Client Certificate Issued</DialogTitle>
|
||||||
<FormDialogContent>
|
<FormDialogContent>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<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>
|
</Typography>
|
||||||
<TextField label="Certificate" value={issueResult?.cert_pem || ''} multiline minRows={6} InputProps={{ sx: { fontFamily: 'monospace' } }} />
|
<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' } }} />
|
<TextField label="Private Key" value={issueResult?.key_pem || ''} multiline minRows={6} InputProps={{ sx: { fontFamily: 'monospace' } }} />
|
||||||
@@ -564,8 +596,27 @@ export default function ClientCertificatesPage() {
|
|||||||
<FormDialogContent>
|
<FormDialogContent>
|
||||||
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
|
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
|
||||||
<TextField label="ID" value={viewCert?.id || ''} InputProps={{ readOnly: true }} />
|
<TextField label="ID" value={viewCert?.id || ''} InputProps={{ readOnly: true }} />
|
||||||
<TextField label="Issuer CA" value={viewCert?.ca_name || viewCert?.ca_id || 'standalone'} InputProps={{ readOnly: true }} />
|
<Box sx={{ display: 'grid', gap: 0.5 }}>
|
||||||
<TextField label="Issuer CA ID" value={viewCert?.ca_id || 'standalone'} InputProps={{ readOnly: true }} />
|
<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="Serial" value={viewCert?.serial_hex || ''} InputProps={{ readOnly: true }} />
|
||||||
<TextField label="Common Name" value={viewCert?.common_name || ''} 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 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 || '')
|
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
|
let detail: SSHUserCA
|
||||||
|
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
detail = await api.getSSHUserCAForSelf(item.ca_id)
|
detail = await api.getSSHUserCAForSelf(caID)
|
||||||
setViewCA(detail)
|
setViewCA(detail)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load SSH User CA')
|
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) => {
|
const downloadCAPublicKey = async (item: SSHUserCA) => {
|
||||||
let text: string
|
let text: string
|
||||||
|
|
||||||
@@ -477,7 +481,27 @@ export default function SSHCertificatesPage() {
|
|||||||
<DialogTitle>SSH Certificate Issued</DialogTitle>
|
<DialogTitle>SSH Certificate Issued</DialogTitle>
|
||||||
<FormDialogContent>
|
<FormDialogContent>
|
||||||
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}>
|
<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="Key ID" value={result?.key_id || ''} InputProps={{ readOnly: true }} fullWidth />
|
||||||
<TextField label="Serial" value={result ? String(result.serial) : ''} 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 />
|
<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 DialogActions from '@mui/material/DialogActions'
|
||||||
import DialogTitle from '@mui/material/DialogTitle'
|
import DialogTitle from '@mui/material/DialogTitle'
|
||||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||||
|
import Link from '@mui/material/Link'
|
||||||
import Paper from '@mui/material/Paper'
|
import Paper from '@mui/material/Paper'
|
||||||
import TextField from '@mui/material/TextField'
|
import TextField from '@mui/material/TextField'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
@@ -74,6 +75,7 @@ export default function SSHServersPage() {
|
|||||||
const [serverSaving, setServerSaving] = useState(false)
|
const [serverSaving, setServerSaving] = useState(false)
|
||||||
const [deleteServerItem, setDeleteServerItem] = useState<SSHServer | null>(null)
|
const [deleteServerItem, setDeleteServerItem] = useState<SSHServer | null>(null)
|
||||||
const [viewServerItem, setViewServerItem] = 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 [hostKeyServer, setHostKeyServer] = useState<SSHServer | null>(null)
|
||||||
const [hostKeys, setHostKeys] = useState<SSHServerHostKey[]>([])
|
const [hostKeys, setHostKeys] = useState<SSHServerHostKey[]>([])
|
||||||
const [hostKeysLoading, setHostKeysLoading] = useState(false)
|
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 = () => {
|
const openCreate = () => {
|
||||||
setEditingID(null)
|
setEditingID(null)
|
||||||
setForm({
|
setForm({
|
||||||
@@ -477,7 +491,15 @@ export default function SSHServersPage() {
|
|||||||
</ListRowActions>
|
</ListRowActions>
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
<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>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
||||||
Key fingerprint: {item.auth_public_key_fingerprint || '-'}
|
Key fingerprint: {item.auth_public_key_fingerprint || '-'}
|
||||||
@@ -649,6 +671,11 @@ export default function SSHServersPage() {
|
|||||||
onClose={() => setViewServerItem(null)}
|
onClose={() => setViewServerItem(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SSHServerDetailsDialog
|
||||||
|
item={viewSharedServerItem}
|
||||||
|
onClose={() => setViewSharedServerItem(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
<Dialog open={Boolean(deleteServerItem)} onClose={() => setDeleteServerItem(null)} maxWidth="xs" fullWidth>
|
<Dialog open={Boolean(deleteServerItem)} onClose={() => setDeleteServerItem(null)} maxWidth="xs" fullWidth>
|
||||||
<DialogTitle>Delete SSH Server</DialogTitle>
|
<DialogTitle>Delete SSH Server</DialogTitle>
|
||||||
<FormDialogContent>
|
<FormDialogContent>
|
||||||
|
|||||||
Reference in New Issue
Block a user