Compare commits

..

2 Commits

9 changed files with 518 additions and 231 deletions
@@ -519,6 +519,8 @@ func (s *Store) ListPKIClientIssuancesForUser(userID string) ([]models.PKIClient
i.serial_hex, i.serial_hex,
i.common_name, i.common_name,
i.san_uri, i.san_uri,
COALESCE(c.san_dns, ''),
COALESCE(c.san_ips, ''),
i.authz_permissions, i.authz_permissions,
i.authz_scope, i.authz_scope,
i.not_before, i.not_before,
@@ -549,6 +551,8 @@ func (s *Store) ListPKIClientIssuancesForUser(userID string) ([]models.PKIClient
&item.SerialHex, &item.SerialHex,
&item.CommonName, &item.CommonName,
&item.SANURI, &item.SANURI,
&item.SANDNS,
&item.SANIPs,
&item.AuthzPermissions, &item.AuthzPermissions,
&item.AuthzScope, &item.AuthzScope,
&item.NotBefore, &item.NotBefore,
@@ -93,6 +93,8 @@ type pkiClientIssuanceSummary struct {
SerialHex string `json:"serial_hex"` SerialHex string `json:"serial_hex"`
CommonName string `json:"common_name"` CommonName string `json:"common_name"`
SANURI string `json:"san_uri"` SANURI string `json:"san_uri"`
SANDNS []string `json:"san_dns"`
SANIPs []string `json:"san_ips"`
AuthzPermissions []string `json:"authz_permissions"` AuthzPermissions []string `json:"authz_permissions"`
AuthzScope string `json:"authz_scope"` AuthzScope string `json:"authz_scope"`
NotBefore int64 `json:"not_before"` NotBefore int64 `json:"not_before"`
@@ -728,6 +730,8 @@ func buildPKIClientIssuanceSummary(item models.PKIClientIssuance) pkiClientIssua
SerialHex: item.SerialHex, SerialHex: item.SerialHex,
CommonName: item.CommonName, CommonName: item.CommonName,
SANURI: item.SANURI, SANURI: item.SANURI,
SANDNS: splitPKIClientPermissionList(item.SANDNS),
SANIPs: splitPKIClientPermissionList(item.SANIPs),
AuthzPermissions: splitPKIClientPermissionList(item.AuthzPermissions), AuthzPermissions: splitPKIClientPermissionList(item.AuthzPermissions),
AuthzScope: item.AuthzScope, AuthzScope: item.AuthzScope,
NotBefore: item.NotBefore, NotBefore: item.NotBefore,
+2
View File
@@ -486,6 +486,8 @@ type PKIClientIssuance struct {
SerialHex string `json:"serial_hex"` SerialHex string `json:"serial_hex"`
CommonName string `json:"common_name"` CommonName string `json:"common_name"`
SANURI string `json:"san_uri"` SANURI string `json:"san_uri"`
SANDNS string `json:"san_dns"`
SANIPs string `json:"san_ips"`
AuthzPermissions string `json:"authz_permissions"` AuthzPermissions string `json:"authz_permissions"`
AuthzScope string `json:"authz_scope"` AuthzScope string `json:"authz_scope"`
NotBefore int64 `json:"not_before"` NotBefore int64 `json:"not_before"`
+2
View File
@@ -760,6 +760,8 @@ export interface PKIClientIssuance {
serial_hex: string serial_hex: string
common_name: string common_name: string
san_uri: string san_uri: string
san_dns?: string[]
san_ips?: string[]
authz_permissions: string[] authz_permissions: string[]
authz_scope: string authz_scope: string
not_before: number not_before: number
+100 -50
View File
@@ -2,7 +2,7 @@ import AddIcon from '@mui/icons-material/Add'
import BlockIcon from '@mui/icons-material/Block' import BlockIcon from '@mui/icons-material/Block'
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline' import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined' import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'
import Alert from '@mui/material/Alert' import RefreshIcon from '@mui/icons-material/Refresh'
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog' import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
import APIKeyCreateFields from '../components/APIKeyCreateFields' import APIKeyCreateFields from '../components/APIKeyCreateFields'
import type { APIKeyExpiryUnit } from '../components/APIKeyCreateFields' import type { APIKeyExpiryUnit } from '../components/APIKeyCreateFields'
@@ -11,21 +11,22 @@ import {
Box, Box,
Button, Button,
Chip, Chip,
CircularProgress,
Dialog, Dialog,
DialogActions, DialogActions,
DialogTitle, DialogTitle,
List,
ListItem,
TextField, TextField,
Tooltip,
Typography Typography
} from '@mui/material' } from '@mui/material'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useOutletContext } from 'react-router-dom' import { useOutletContext } from 'react-router-dom'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions' import HeaderActionButton from '../components/HeaderActionButton'
import ResizableSortableTable, { RSTColumn } from '../components/ResizableSortableTable'
import { ListRowActions, ListRowIconAction } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard' import SectionCard from '../components/SectionCard'
import { api, APIKey, User } from '../api' import { api, APIKey, User } from '../api'
import { formatEpochTime } from '../time' import { formatEpochTime } from '../time'
import CompactListItemText from '../components/CompactListItemText'
import PageAlert from '../components/PageAlert' import PageAlert from '../components/PageAlert'
function formatUnix(value: number) { function formatUnix(value: number) {
@@ -35,6 +36,9 @@ function formatUnix(value: number) {
return formatEpochTime(value) return formatEpochTime(value)
} }
// Single-line cell text: no wrap, hidden overflow, hard clip (no ellipsis).
const oneLineSx = { whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'clip' } as const
export default function ApiKeysPage() { export default function ApiKeysPage() {
const { user } = useOutletContext<{ user: User | null }>() const { user } = useOutletContext<{ user: User | null }>()
const canCreateApiKey = Boolean(user?.is_admin || user?.permissions?.includes('apikey.create')) const canCreateApiKey = Boolean(user?.is_admin || user?.permissions?.includes('apikey.create'))
@@ -190,9 +194,91 @@ export default function ApiKeysPage() {
setTokenCopied(false) setTokenCopied(false)
} }
const columns: RSTColumn<APIKey>[] = [
{
key: 'name',
label: 'Name',
defaultWidth: 220,
minWidth: 140,
getValue: (key: APIKey) => key.name,
renderCell: (key: APIKey) => (
<Typography component="div" variant="body2" sx={{ ...oneLineSx, minWidth: 0 }}>
<Tooltip title={`ID: ${key.id}`}>
<Box component="span">{key.name}</Box>
</Tooltip>
{key.disabled ? (<> <Chip size="small" color="error" label="Disabled" /></>) : null}
</Typography>
)
},
{
key: 'prefix',
label: 'Prefix',
defaultWidth: 160,
minWidth: 100,
getValue: (key: APIKey) => key.prefix,
renderCell: (key: APIKey) => (
<Typography variant="body2" color="text.secondary" sx={oneLineSx}>{key.prefix}</Typography>
)
},
{
key: 'created',
label: 'Created',
defaultWidth: 170,
minWidth: 120,
getValue: (key: APIKey) => key.created_at || 0,
renderCell: (key: APIKey) => (
<Typography variant="body2" color="text.secondary" sx={oneLineSx}>{formatUnix(key.created_at)}</Typography>
)
},
{
key: 'last_used',
label: 'Last used',
defaultWidth: 170,
minWidth: 120,
getValue: (key: APIKey) => key.last_used_at || 0,
renderCell: (key: APIKey) => (
<Typography variant="body2" color="text.secondary" sx={oneLineSx}>{formatUnix(key.last_used_at)}</Typography>
)
},
{
key: 'expires',
label: 'Expires',
defaultWidth: 170,
minWidth: 120,
getValue: (key: APIKey) => key.expires_at || 0,
renderCell: (key: APIKey) => (
<Typography variant="body2" color="text.secondary" sx={oneLineSx}>{formatUnix(key.expires_at)}</Typography>
)
},
{
key: 'actions',
label: 'Actions',
defaultWidth: 120,
minWidth: 110,
sortable: false,
resizable: false,
cellSx: { textAlign: 'right', whiteSpace: 'nowrap' },
renderCell: (key: APIKey) => (
<ListRowActions>
<ListRowIconAction title={key.disabled ? 'Enable' : 'Disable'} color={key.disabled ? 'success' : 'warning'} disabled={togglingID === key.id} onClick={() => void toggleKeyState(key)}>
{togglingID === key.id ? <CircularProgress size={16} /> : (key.disabled ? <CheckCircleOutlineIcon fontSize="small" /> : <BlockIcon fontSize="small" />)}
</ListRowIconAction>
<ListRowIconAction title="Delete" color="error" onClick={() => setDeleteTarget(key)}>
<DeleteOutlinedIcon fontSize="small" />
</ListRowIconAction>
</ListRowActions>
)
}
]
return ( return (
<Box> <Box>
<Typography variant="h5" sx={{ mb: 2 }}>API Keys</Typography> <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap', mb: 2 }}>
<Typography variant="h5">API Keys</Typography>
<HeaderActionButton variant="outlined" startIcon={<RefreshIcon />} onClick={() => void load()} disabled={loading}>
Refresh
</HeaderActionButton>
</Box>
<SectionCard <SectionCard
title={ title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0, flexWrap: 'wrap' }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0, flexWrap: 'wrap' }}>
@@ -218,51 +304,15 @@ export default function ApiKeysPage() {
<PageAlert message={error} onClose={() => setError(null)} sx={{ mb: 1 }} /> <PageAlert message={error} onClose={() => setError(null)} sx={{ mb: 1 }} />
{loading ? ( {loading ? (
<Typography variant="body2" color="text.secondary">Loading API keys...</Typography> <Typography variant="body2" color="text.secondary">Loading API keys...</Typography>
) : filteredKeys.length ? (
<ResizableSortableTable
columns={columns}
data={filteredKeys}
getRowKey={(key: APIKey) => key.id}
defaultSortKey="name"
/>
) : ( ) : (
<List> <Typography variant="body2" color="text.secondary">{keys.length ? 'No matching API keys.' : 'No API keys yet.'}</Typography>
{filteredKeys.map((key: APIKey) => (
<ListItem
key={key.id}
sx={{
alignItems: 'flex-start',
borderBottom: (theme) => `1px solid ${theme.palette.divider}`
}}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
<CompactListItemText
primary={
<Typography component="div">
{key.name} ({key.prefix})
{key.disabled? (<> <Chip size="small" color='error' label='Disabled'/></>): null}
</Typography>
}
secondary={
<Typography variant="caption" color="text.secondary">
prefix: {key.prefix} · created: {formatUnix(key.created_at)} · last used: {formatUnix(key.last_used_at)} ·
expires: {key.expires_at > 0 ? formatUnix(key.expires_at) : 'Never'}
</Typography>
}
/>
<ListRowActions>
<ListRowActionButton
startIcon={key.disabled ? <CheckCircleOutlineIcon /> : <BlockIcon />}
color={key.disabled ? 'success' : 'warning'}
onClick={() => toggleKeyState(key)}
disabled={togglingID === key.id}
>
{key.disabled ? 'Enable' : 'Disable'}
</ListRowActionButton>
<ListRowActionButton startIcon={<DeleteOutlinedIcon />} color="error" onClick={() => setDeleteTarget(key)}>
Delete
</ListRowActionButton>
</ListRowActions>
</Box>
</ListItem>
))}
{!filteredKeys.length ? (
<Typography variant="body2" color="text.secondary">{keys.length ? 'No matching API keys.' : 'No API keys yet.'}</Typography>
) : null}
</List>
)} )}
</SectionCard> </SectionCard>
+244 -134
View File
@@ -1,20 +1,22 @@
import DownloadIcon from '@mui/icons-material/Download' import DownloadIcon from '@mui/icons-material/Download'
import KeyIcon from '@mui/icons-material/Key' import KeyIcon from '@mui/icons-material/Key'
import BlockIcon from '@mui/icons-material/Block' import BlockIcon from '@mui/icons-material/Block'
import DnsOutlinedIcon from '@mui/icons-material/DnsOutlined'
import RefreshIcon from '@mui/icons-material/Refresh'
import VisibilityIcon from '@mui/icons-material/Visibility' import VisibilityIcon from '@mui/icons-material/Visibility'
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog' import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
import { import {
Box, Box,
Chip, Chip,
Link, Link,
List,
ListItem,
TextField, TextField,
Tooltip, Tooltip,
Typography Typography
} from '@mui/material' } from '@mui/material'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions' import HeaderActionButton from '../components/HeaderActionButton'
import { ListRowActions, ListRowIconAction } from '../components/ListRowActions'
import ResizableSortableTable, { RSTColumn } from '../components/ResizableSortableTable'
import { PKIClientCertificateIssueDialog, PKIClientCertificateIssuedDialog } from '../components/PKIClientCertificateIssueDialogs' import { PKIClientCertificateIssueDialog, PKIClientCertificateIssuedDialog } from '../components/PKIClientCertificateIssueDialogs'
import PKIClientProfileDetailsDialog from '../components/PKIClientProfileDetailsDialog' import PKIClientProfileDetailsDialog from '../components/PKIClientProfileDetailsDialog'
import PKICADetailsDialog from '../components/PKICADetailsDialog' import PKICADetailsDialog from '../components/PKICADetailsDialog'
@@ -22,10 +24,12 @@ import PKICertDetailsDialog from '../components/PKICertDetailsDialog'
import SectionCard from '../components/SectionCard' import SectionCard from '../components/SectionCard'
import PageAlert from '../components/PageAlert' import PageAlert from '../components/PageAlert'
import { api, PKICADetail, PKIClientIssueResponse, PKIClientIssuance, PKIClientProfile, PKICertDetail } from '../api' import { api, PKICADetail, PKIClientIssueResponse, PKIClientIssuance, PKIClientProfile, PKICertDetail } from '../api'
import { formatEpochTime } from '../time' import { formatEpochTime, formatRelative, formatDuration } from '../time'
import CompactListItemText from '../components/CompactListItemText'
import { storageKey } from '../branding' import { storageKey } from '../branding'
// Single-line cell text: no wrap, hidden overflow, hard clip (no ellipsis).
const oneLineSx = { whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'clip' } as const
function fmt(value: number): string { function fmt(value: number): string {
if (!value || value <= 0) { if (!value || value <= 0) {
return '-' return '-'
@@ -318,9 +322,218 @@ export default function ClientCertificatesPage() {
return 'default' return 'default'
} }
const profileColumns: RSTColumn<PKIClientProfile>[] = [
{
key: 'name',
label: 'Name',
defaultWidth: 220,
minWidth: 140,
getValue: (p: PKIClientProfile) => p.name,
renderCell: (p: PKIClientProfile) => (
<Typography variant="body2" sx={{ ...oneLineSx, minWidth: 0 }}>
<Tooltip title={`ID: ${p.id}`}>
<Link underline="hover" onClick={() => openProfileView(p.id)} sx={{ cursor: 'pointer' }}>{p.name}</Link>
</Tooltip>
</Typography>
)
},
{
key: 'ca',
label: 'CA',
defaultWidth: 200,
minWidth: 130,
getValue: (p: PKIClientProfile) => p.ca_name || p.ca_id,
renderCell: (p: PKIClientProfile) => (
<Typography variant="body2" sx={{ ...oneLineSx, minWidth: 0 }}>
<Tooltip title={`ID: ${p.ca_id}`} arrow>
<Link underline="hover" onClick={() => openCAView(p).catch(() => {})} sx={{ cursor: 'pointer' }}>{p.ca_name || p.ca_id}</Link>
</Tooltip>
</Typography>
)
},
{
key: 'server_auth',
label: 'Server Auth',
defaultWidth: 110,
minWidth: 90,
getValue: (p: PKIClientProfile) => p.allow_server_auth ? 'yes' : 'no',
renderCell: (p: PKIClientProfile) => (
<Typography variant="body2" color="text.secondary" sx={oneLineSx}>{p.allow_server_auth ? 'yes' : 'no'}</Typography>
)
},
{
key: 'validity',
label: 'Validity',
defaultWidth: 150,
minWidth: 110,
getValue: (p: PKIClientProfile) => p.default_valid_seconds || 0,
renderCell: (p: PKIClientProfile) => (
<Tooltip title={`default ${p.default_valid_seconds}s · max ${p.max_valid_seconds}s`}>
<Typography component="span" variant="body2" color="text.secondary" sx={oneLineSx}>{formatDuration(p.default_valid_seconds)} / max {formatDuration(p.max_valid_seconds)}</Typography>
</Tooltip>
)
},
{
key: 'actions',
label: 'Actions',
defaultWidth: 90,
minWidth: 80,
sortable: false,
resizable: false,
cellSx: { textAlign: 'right', whiteSpace: 'nowrap' },
renderCell: (p: PKIClientProfile) => (
<ListRowActions>
<ListRowIconAction title="Issue certificate" onClick={() => openIssue(p)}>
<KeyIcon fontSize="small" />
</ListRowIconAction>
</ListRowActions>
)
}
]
const issuanceColumns: RSTColumn<PKIClientIssuance>[] = [
{
key: 'common_name',
label: 'Common Name',
defaultWidth: 200,
minWidth: 140,
getValue: (i: PKIClientIssuance) => i.common_name,
renderCell: (i: PKIClientIssuance) => {
const dns: string[] = i.san_dns || []
const ips: string[] = i.san_ips || []
const hasSAN: boolean = dns.length > 0 || ips.length > 0
const sanTip: string = [
dns.length ? `DNS: ${dns.join(', ')}` : '',
ips.length ? `IP: ${ips.join(', ')}` : ''
].filter(Boolean).join(' · ')
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, minWidth: 0 }}>
<Typography component="span" variant="body2" sx={{ ...oneLineSx, minWidth: 0 }}>
<Tooltip title={`Cert ID: ${i.cert_id}`}>
<Box component="span">{i.common_name || i.cert_id}</Box>
</Tooltip>
</Typography>
{hasSAN ? (
<Tooltip title={sanTip}>
<Box component="span" sx={{ display: 'inline-flex', alignItems: 'center', color: 'text.disabled', flexShrink: 0 }}>
<DnsOutlinedIcon sx={{ fontSize: 16 }} />
</Box>
</Tooltip>
) : null}
</Box>
)
}
},
{
key: 'status',
label: 'Status',
defaultWidth: 120,
minWidth: 90,
getValue: (i: PKIClientIssuance) => i.status,
renderCell: (i: PKIClientIssuance) => {
const deleted: boolean = i.status === 'deleted'
const tip: string = deleted ? 'Deleted from Admin PKI' : (i.revocation_reason ? `reason: ${i.revocation_reason}` : '')
return (
<Tooltip title={tip} disableHoverListener={!tip}>
<Chip
label={i.status}
color={issuanceStatusColor(i.status)}
size="small"
variant={deleted ? 'outlined' : 'filled'}
/>
</Tooltip>
)
}
},
{
key: 'profile',
label: 'Profile',
defaultWidth: 160,
minWidth: 110,
getValue: (i: PKIClientIssuance) => i.profile_name,
renderCell: (i: PKIClientIssuance) => (
<Typography variant="body2" sx={{ ...oneLineSx, minWidth: 0 }}>
{profiles.some((p) => p.id === i.profile_id) ? (
<Tooltip title={`ID: ${i.profile_id}`} arrow>
<Link underline="hover" onClick={() => openProfileView(i.profile_id)} sx={{ cursor: 'pointer' }}>{i.profile_name}</Link>
</Tooltip>
) : i.profile_name}
</Typography>
)
},
{
key: 'serial',
label: 'Serial',
defaultWidth: 160,
minWidth: 100,
getValue: (i: PKIClientIssuance) => i.serial_hex,
renderCell: (i: PKIClientIssuance) => (
<Typography variant="body2" color="text.secondary" sx={oneLineSx}>{i.serial_hex}</Typography>
)
},
{
key: 'issued',
label: 'Issued',
defaultWidth: 110,
minWidth: 90,
getValue: (i: PKIClientIssuance) => i.not_before || 0,
renderCell: (i: PKIClientIssuance) => (
<Tooltip title={fmt(i.not_before)}>
<Typography component="span" variant="body2" color="text.secondary" sx={oneLineSx}>{formatRelative(i.not_before)}</Typography>
</Tooltip>
)
},
{
key: 'expires',
label: 'Expires',
defaultWidth: 130,
minWidth: 100,
getValue: (i: PKIClientIssuance) => i.not_after || 0,
renderCell: (i: PKIClientIssuance) => {
const nowSec: number = Math.floor(Date.now() / 1000)
const expired: boolean = Boolean(i.not_after) && i.not_after <= nowSec
const soon: boolean = !expired && Boolean(i.not_after) && i.not_after - nowSec < 3600
const color: string = expired ? 'error.main' : soon ? 'warning.main' : 'text.secondary'
const label: string = expired ? `expired ${formatRelative(i.not_after)}` : formatRelative(i.not_after)
return (
<Tooltip title={`${fmt(i.not_before)}${fmt(i.not_after)}`}>
<Typography component="span" variant="body2" sx={{ ...oneLineSx, color }}>{label}</Typography>
</Tooltip>
)
}
},
{
key: 'actions',
label: 'Actions',
defaultWidth: 130,
minWidth: 120,
sortable: false,
resizable: false,
cellSx: { textAlign: 'right', whiteSpace: 'nowrap' },
renderCell: (i: PKIClientIssuance) => (
<ListRowActions>
<ListRowIconAction title="View" onClick={() => openView(i)} disabled={busy || i.status === 'deleted'}>
<VisibilityIcon fontSize="small" />
</ListRowIconAction>
<ListRowIconAction title="Download bundle" onClick={() => downloadBundle(i)} disabled={busy || i.status === 'deleted'}>
<DownloadIcon fontSize="small" />
</ListRowIconAction>
<ListRowIconAction title="Revoke" color="error" onClick={() => setRevokeItem(i)} disabled={busy || i.status === 'revoked' || i.status === 'deleted'}>
<BlockIcon fontSize="small" />
</ListRowIconAction>
</ListRowActions>
)
}
]
return ( return (
<Box sx={{ display: 'grid', gap: 1 }}> <Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h5">Client Certificates</Typography> <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="h5">Client Certificates</Typography>
<HeaderActionButton variant="outlined" startIcon={<RefreshIcon />} onClick={() => void load()} disabled={loading}>
Refresh
</HeaderActionButton>
</Box>
<PageAlert message={error} onClose={() => setError(null)} /> <PageAlert message={error} onClose={() => setError(null)} />
<SectionCard <SectionCard
@@ -340,63 +553,18 @@ export default function ClientCertificatesPage() {
</Box> </Box>
)} )}
> >
<List dense> {filteredProfiles.length ? (
{filteredProfiles.map((profile) => ( <ResizableSortableTable
<ListItem columns={profileColumns}
key={profile.id} data={filteredProfiles}
sx={{ getRowKey={(profile: PKIClientProfile) => profile.id}
alignItems: 'flex-start', defaultSortKey="name"
borderBottom: (theme) => `1px solid ${theme.palette.divider}` />
}} ) : (
> <Typography variant="body2" color="text.secondary">
<CompactListItemText {profiles.length ? 'No matching client certificate profiles found.' : 'No client certificate profiles are available to you.'}
primary={ </Typography>
<Typography> )}
<Tooltip title={profile.id} arrow>
<Link
underline="hover"
onClick={() => openProfileView(profile.id)}
sx={{ cursor: 'default' }}
>
{`${profile.name} (${profile.id})`}
</Link>
</Tooltip>
</Typography>
}
secondary={
<Typography variant="caption" color="text.secondary">
CA:{' '}
<Tooltip title={profile.ca_id} arrow>
<Link
underline="hover"
onClick={() => openCAView(profile).catch(() => {})}
sx={{ cursor: 'default' }}
>
{profile.ca_name || profile.ca_id}
</Link>
</Tooltip>
{' '}· server auth: {profile.allow_server_auth ? 'yes' : 'no'} · perms: {(profile.authz_permissions || []).join(', ') || '-'} · scope: {profile.authz_scope || '-'} · validity: {profile.default_valid_seconds}s / max {profile.max_valid_seconds}s
</Typography>
}
/>
<ListRowActions>
<ListRowActionButton startIcon={<KeyIcon />} onClick={() => openIssue(profile)}>
Issue
</ListRowActionButton>
</ListRowActions>
</ListItem>
))}
{!loading && profiles.length === 0 ? (
<ListItem>
<CompactListItemText primary="No client certificate profiles are available to you." />
</ListItem>
) : null}
{!loading && profiles.length > 0 && filteredProfiles.length === 0 ? (
<ListItem>
<CompactListItemText primary="No matching client certificate profiles found." />
</ListItem>
) : null}
</List>
</SectionCard> </SectionCard>
<SectionCard <SectionCard
@@ -416,77 +584,19 @@ export default function ClientCertificatesPage() {
</Box> </Box>
)} )}
> >
<List dense> {filteredIssuances.length ? (
{filteredIssuances.map((item) => ( <ResizableSortableTable
<ListItem columns={issuanceColumns}
key={item.id} data={filteredIssuances}
sx={{ getRowKey={(item: PKIClientIssuance) => item.id}
alignItems: 'flex-start', defaultSortKey="issued"
borderBottom: (theme) => `1px solid ${theme.palette.divider}` defaultSortDir="desc"
}} />
> ) : (
<CompactListItemText <Typography variant="body2" color="text.secondary">
primary={ {issuances.length ? 'No matching client certificates found.' : 'No client certificates issued yet.'}
<Typography component="div"> </Typography>
{item.common_name} ({item.cert_id}) )}
{' '}
<Chip
label={item.status === 'deleted' ? 'Deleted from Admin PKI' : item.status}
color={issuanceStatusColor(item.status)}
size="small"
variant={item.status === 'deleted' ? 'outlined' : 'filled'}
/>
</Typography>
}
secondary={
<Typography variant="caption" color="text.secondary">
profile:{' '}
{profiles.some((profile) => profile.id === item.profile_id) ? (
<Tooltip title={item.profile_id} arrow>
<Link
underline="hover"
onClick={() => openProfileView(item.profile_id)}
sx={{ cursor: 'default' }}
>
{item.profile_name}
</Link>
</Tooltip>
) : (
item.profile_name
)}
{' '}· serial: {item.serial_hex} · perms: {(item.authz_permissions || []).join(', ') || '-'} · scope: {item.authz_scope || '-'} · uri: {item.san_uri} · valid: {fmt(item.not_before)} {fmt(item.not_after)}{item.revocation_reason ? ` · reason: ${item.revocation_reason}` : ''}
</Typography>
}
/>
<ListRowActions>
<ListRowActionButton startIcon={<VisibilityIcon />} onClick={() => openView(item)} disabled={busy || item.status === 'deleted'}>
View
</ListRowActionButton>
<ListRowActionButton startIcon={<DownloadIcon />} onClick={() => downloadBundle(item)} disabled={busy || item.status === 'deleted'}>
Bundle
</ListRowActionButton>
<ListRowActionButton
color="error"
startIcon={<BlockIcon />}
onClick={() => setRevokeItem(item)}
disabled={busy || item.status === 'revoked' || item.status === 'deleted'}
>
Revoke
</ListRowActionButton>
</ListRowActions>
</ListItem>
))}
{!loading && issuances.length === 0 ? (
<ListItem>
<CompactListItemText primary="No client certificates issued yet." />
</ListItem>
) : null}
{!loading && issuances.length > 0 && filteredIssuances.length === 0 ? (
<ListItem>
<CompactListItemText primary="No matching client certificates found." />
</ListItem>
) : null}
</List>
</SectionCard> </SectionCard>
<PKIClientCertificateIssueDialog <PKIClientCertificateIssueDialog
+122 -43
View File
@@ -1,31 +1,34 @@
import ContentCopyIcon from '@mui/icons-material/ContentCopy' import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import { import {
Box, Box,
CircularProgress,
Link, Link,
List,
ListItem,
MenuItem, MenuItem,
TextField, TextField,
Tooltip, Tooltip,
Typography Typography
} from '@mui/material' } from '@mui/material'
import VisibilityIcon from '@mui/icons-material/Visibility' import VisibilityIcon from '@mui/icons-material/Visibility'
import RefreshIcon from '@mui/icons-material/Refresh'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import Autocomplete from '../components/Autocomplete' import Autocomplete from '../components/Autocomplete'
import HeaderActionButton from '../components/HeaderActionButton' import HeaderActionButton from '../components/HeaderActionButton'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions' import { ListRowActions, ListRowIconAction } from '../components/ListRowActions'
import CompactListItemText from '../components/CompactListItemText'
import MonospaceTextField from '../components/MonospaceTextField' import MonospaceTextField from '../components/MonospaceTextField'
import ResizableSortableTable, { RSTColumn } from '../components/ResizableSortableTable'
import SectionCard from '../components/SectionCard' import SectionCard from '../components/SectionCard'
import SSHCertificateInspectDialog from '../components/SSHCertificateInspectDialog' import SSHCertificateInspectDialog from '../components/SSHCertificateInspectDialog'
import SSHCertificateIssuedDialog from '../components/SSHCertificateIssuedDialog' import SSHCertificateIssuedDialog from '../components/SSHCertificateIssuedDialog'
import SSHUserCADetailsDialog from '../components/SSHUserCADetailsDialog' import SSHUserCADetailsDialog from '../components/SSHUserCADetailsDialog'
import PageAlert from '../components/PageAlert' import PageAlert from '../components/PageAlert'
import { api, SSHPrincipalGrant, SSHSignUserKeyResponse, SSHUserCA, SSHUserCAIssuance, SSHUserCASelfSignPayload } from '../api' import { api, SSHPrincipalGrant, SSHSignUserKeyResponse, SSHUserCA, SSHUserCAIssuance, SSHUserCASelfSignPayload } from '../api'
import { formatEpochTime } from '../time' import { formatEpochTime, formatRelative } from '../time'
import SelectField from '../components/SelectField' import SelectField from '../components/SelectField'
import { storageKey } from '../branding' import { storageKey } from '../branding'
// Single-line cell text: no wrap, hidden overflow, hard clip (no ellipsis).
const oneLineSx = { whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'clip' } as const
function fmt(value: number): string { function fmt(value: number): string {
if (!value || value <= 0) { if (!value || value <= 0) {
return '-' return '-'
@@ -315,9 +318,113 @@ export default function SSHCertificatesPage() {
} }
} }
const issuanceColumns: RSTColumn<SSHUserCAIssuance>[] = [
{
key: 'ca',
label: 'CA',
defaultWidth: 200,
minWidth: 140,
getValue: (item: SSHUserCAIssuance) => item.ca_name || item.ca_id,
renderCell: (item: SSHUserCAIssuance) => (
<Typography variant="body2" sx={{ ...oneLineSx, minWidth: 0 }}>
<Tooltip title={item.ca_id} arrow>
<Link underline="hover" onClick={() => openCAView(item).catch(() => {})} sx={{ cursor: 'pointer' }}>
{item.ca_name || item.ca_id}
</Link>
</Tooltip>
</Typography>
)
},
{
key: 'serial',
label: 'Serial',
defaultWidth: 120,
minWidth: 80,
getValue: (item: SSHUserCAIssuance) => item.serial,
renderCell: (item: SSHUserCAIssuance) => (
<Typography variant="body2" color="text.secondary" sx={oneLineSx}>{item.serial}</Typography>
)
},
{
key: 'key_id',
label: 'Key ID',
defaultWidth: 180,
minWidth: 100,
getValue: (item: SSHUserCAIssuance) => item.key_id,
renderCell: (item: SSHUserCAIssuance) => (
<Typography variant="body2" color="text.secondary" sx={oneLineSx}>{item.key_id || '-'}</Typography>
)
},
{
key: 'fingerprint',
label: 'Fingerprint',
defaultWidth: 260,
minWidth: 140,
getValue: (item: SSHUserCAIssuance) => item.source_public_key_fingerprint,
renderCell: (item: SSHUserCAIssuance) => (
<Typography variant="body2" color="text.secondary" sx={oneLineSx}>{item.source_public_key_fingerprint || '-'}</Typography>
)
},
{
key: 'created',
label: 'Issued',
defaultWidth: 110,
minWidth: 90,
getValue: (item: SSHUserCAIssuance) => item.created_at || 0,
renderCell: (item: SSHUserCAIssuance) => (
<Tooltip title={fmt(item.created_at)}>
<Typography component="span" variant="body2" color="text.secondary" sx={oneLineSx}>{formatRelative(item.created_at)}</Typography>
</Tooltip>
)
},
{
key: 'expires',
label: 'Expires',
defaultWidth: 130,
minWidth: 100,
getValue: (item: SSHUserCAIssuance) => item.valid_before || 0,
renderCell: (item: SSHUserCAIssuance) => {
const nowSec: number = Math.floor(Date.now() / 1000)
const expired: boolean = Boolean(item.valid_before) && item.valid_before <= nowSec
const soon: boolean = !expired && Boolean(item.valid_before) && item.valid_before - nowSec < 3600
const color: string = expired ? 'error.main' : soon ? 'warning.main' : 'text.secondary'
const label: string = expired ? `expired ${formatRelative(item.valid_before)}` : formatRelative(item.valid_before)
return (
<Tooltip title={`${fmt(item.valid_after)} ~ ${fmt(item.valid_before)}`}>
<Typography component="span" variant="body2" sx={{ ...oneLineSx, color }}>{label}</Typography>
</Tooltip>
)
}
},
{
key: 'actions',
label: 'Actions',
defaultWidth: 120,
minWidth: 110,
sortable: false,
resizable: false,
cellSx: { textAlign: 'right', whiteSpace: 'nowrap' },
renderCell: (item: SSHUserCAIssuance) => (
<ListRowActions>
<ListRowIconAction title="Copy certificate" onClick={() => copyIssuanceCert(item)}>
<ContentCopyIcon fontSize="small" />
</ListRowIconAction>
<ListRowIconAction title={inspectBusy ? 'Loading…' : 'View'} disabled={inspectBusy} onClick={() => viewIssuanceCert(item)}>
{inspectBusy ? <CircularProgress size={16} /> : <VisibilityIcon fontSize="small" />}
</ListRowIconAction>
</ListRowActions>
)
}
]
return ( return (
<Box sx={{ display: 'grid', gap: 1 }}> <Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h5">SSH Certificates</Typography> <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="h5">SSH Certificates</Typography>
<HeaderActionButton variant="outlined" startIcon={<RefreshIcon />} onClick={() => { void load(); void loadIssuances() }} disabled={loading || issuancesLoading}>
Refresh
</HeaderActionButton>
</Box>
<PageAlert message={error} onClose={() => setError(null)} /> <PageAlert message={error} onClose={() => setError(null)} />
<SectionCard <SectionCard
collapsible collapsible
@@ -383,43 +490,15 @@ export default function SSHCertificatesPage() {
> >
{issuancesLoading ? <Typography variant="body2" color="text.secondary">Loading...</Typography> : null} {issuancesLoading ? <Typography variant="body2" color="text.secondary">Loading...</Typography> : null}
{!issuancesLoading && issuances.length === 0 ? <Typography variant="body2" color="text.secondary">No issuance history found.</Typography> : null} {!issuancesLoading && issuances.length === 0 ? <Typography variant="body2" color="text.secondary">No issuance history found.</Typography> : null}
<List> {issuances.length ? (
{issuances.map((item: SSHUserCAIssuance) => ( <ResizableSortableTable
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}> columns={issuanceColumns}
<CompactListItemText data={issuances}
primary={ getRowKey={(item: SSHUserCAIssuance) => item.id}
<Typography> defaultSortKey="created"
<Tooltip title={item.ca_id} arrow> defaultSortDir="desc"
<Link />
underline="hover" ) : null}
onClick={() => openCAView(item).catch(() => {})}
sx={{ cursor: 'default' }}
>
{item.ca_name || item.ca_id}
</Link>
</Tooltip>
{' '}· serial: {item.serial} · key_id: {item.key_id}
</Typography>
}
secondary={
<Box sx={{ display: 'grid', gap: 0.5 }}>
<Typography variant="caption" color="text.secondary">
{fmt(item.created_at)} · valid: {fmt(item.valid_after)} ~ {fmt(item.valid_before)}
</Typography>
<Typography variant="caption" color="text.secondary">
fp: {item.source_public_key_fingerprint}
</Typography>
</Box>
}
disableTypography
/>
<ListRowActions>
<ListRowActionButton startIcon={<ContentCopyIcon />} onClick={() => copyIssuanceCert(item)}>Copy Cert</ListRowActionButton>
<ListRowActionButton startIcon={<VisibilityIcon />} onClick={() => viewIssuanceCert(item)} disabled={inspectBusy}>{inspectBusy ? 'Loading...' : 'View'}</ListRowActionButton>
</ListRowActions>
</ListItem>
))}
</List>
</SectionCard> </SectionCard>
<SSHCertificateInspectDialog <SSHCertificateInspectDialog
+6 -4
View File
@@ -752,7 +752,6 @@ export default function SSHServersPage() {
getValue: (item: SSHAccessProfile) => item.name, getValue: (item: SSHAccessProfile) => item.name,
renderCell: (item: SSHAccessProfile) => ( renderCell: (item: SSHAccessProfile) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, minWidth: 0 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, minWidth: 0 }}>
{renderAuthIcon(item)}
{connectingID === item.id ? ( {connectingID === item.id ? (
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, minWidth: 0 }}> <Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, minWidth: 0 }}>
<CircularProgress size={14} /> <CircularProgress size={14} />
@@ -792,11 +791,14 @@ export default function SSHServersPage() {
{ {
key: 'remote_username', key: 'remote_username',
label: 'User', label: 'User',
defaultWidth: 130, defaultWidth: 160,
minWidth: 80, minWidth: 100,
getValue: (item: SSHAccessProfile) => item.remote_username, getValue: (item: SSHAccessProfile) => item.remote_username,
renderCell: (item: SSHAccessProfile) => ( renderCell: (item: SSHAccessProfile) => (
<Typography variant="body2" sx={oneLineSx}>{item.remote_username}</Typography> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, minWidth: 0 }}>
{renderAuthIcon(item)}
<Typography component="span" variant="body2" sx={oneLineSx}>{item.remote_username}</Typography>
</Box>
) )
}, },
{ {
+34
View File
@@ -22,6 +22,40 @@ export function formatEpochTime(seconds: number | null | undefined): string {
return formatLocalWithOffset(date) return formatLocalWithOffset(date)
} }
// formatRelative renders a Unix timestamp (seconds) as a compact relative label
// relative to now — "2h ago" for the past, "in 22h" for the future — using the
// largest sensible unit (m/h/d/mo/y). Returns '-' for missing values. Pair it
// with a tooltip carrying the absolute time (formatEpochTime) for precision.
export function formatRelative(seconds: number | null | undefined): string {
if (!seconds || seconds <= 0) return '-'
const deltaSec = seconds - Math.floor(Date.now() / 1000)
const future = deltaSec >= 0
const abs = Math.abs(deltaSec)
let value: number
let unit: string
if (abs < 45) return future ? 'in moments' : 'just now'
if (abs < 5400) { value = Math.round(abs / 60); unit = 'm' }
else if (abs < 129600) { value = Math.round(abs / 3600); unit = 'h' }
else if (abs < 7776000) { value = Math.round(abs / 86400); unit = 'd' }
else if (abs < 31104000) { value = Math.round(abs / 2592000); unit = 'mo' }
else { value = Math.round(abs / 31536000); unit = 'y' }
return future ? `in ${value}${unit}` : `${value}${unit} ago`
}
// formatDuration renders a length of time in seconds compactly using the largest
// sensible unit (s/m/h/d/mo/y), e.g. 3600 → "1h", 86400 → "1d". Returns '-' for
// missing values. For a wall-clock instant use formatEpochTime/formatRelative.
export function formatDuration(seconds: number | null | undefined): string {
if (!seconds || seconds <= 0) return '-'
const s = Math.floor(seconds)
if (s < 60) return `${s}s`
if (s < 3600) return `${Math.round(s / 60)}m`
if (s < 86400) return `${Math.round(s / 3600)}h`
if (s < 2592000) return `${Math.round(s / 86400)}d`
if (s < 31536000) return `${Math.round(s / 2592000)}mo`
return `${Math.round(s / 31536000)}y`
}
function formatLocalWithOffset(date: Date): string { function formatLocalWithOffset(date: Date): string {
const pad = (n: number) => String(n).padStart(2, '0') const pad = (n: number) => String(n).padStart(2, '0')
const year = date.getFullYear() const year = date.getFullYear()