Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 35da73b3fd | |||
| bd3dc4c188 |
@@ -519,6 +519,8 @@ func (s *Store) ListPKIClientIssuancesForUser(userID string) ([]models.PKIClient
|
||||
i.serial_hex,
|
||||
i.common_name,
|
||||
i.san_uri,
|
||||
COALESCE(c.san_dns, ''),
|
||||
COALESCE(c.san_ips, ''),
|
||||
i.authz_permissions,
|
||||
i.authz_scope,
|
||||
i.not_before,
|
||||
@@ -549,6 +551,8 @@ func (s *Store) ListPKIClientIssuancesForUser(userID string) ([]models.PKIClient
|
||||
&item.SerialHex,
|
||||
&item.CommonName,
|
||||
&item.SANURI,
|
||||
&item.SANDNS,
|
||||
&item.SANIPs,
|
||||
&item.AuthzPermissions,
|
||||
&item.AuthzScope,
|
||||
&item.NotBefore,
|
||||
|
||||
@@ -93,6 +93,8 @@ type pkiClientIssuanceSummary struct {
|
||||
SerialHex string `json:"serial_hex"`
|
||||
CommonName string `json:"common_name"`
|
||||
SANURI string `json:"san_uri"`
|
||||
SANDNS []string `json:"san_dns"`
|
||||
SANIPs []string `json:"san_ips"`
|
||||
AuthzPermissions []string `json:"authz_permissions"`
|
||||
AuthzScope string `json:"authz_scope"`
|
||||
NotBefore int64 `json:"not_before"`
|
||||
@@ -728,6 +730,8 @@ func buildPKIClientIssuanceSummary(item models.PKIClientIssuance) pkiClientIssua
|
||||
SerialHex: item.SerialHex,
|
||||
CommonName: item.CommonName,
|
||||
SANURI: item.SANURI,
|
||||
SANDNS: splitPKIClientPermissionList(item.SANDNS),
|
||||
SANIPs: splitPKIClientPermissionList(item.SANIPs),
|
||||
AuthzPermissions: splitPKIClientPermissionList(item.AuthzPermissions),
|
||||
AuthzScope: item.AuthzScope,
|
||||
NotBefore: item.NotBefore,
|
||||
|
||||
@@ -486,6 +486,8 @@ type PKIClientIssuance struct {
|
||||
SerialHex string `json:"serial_hex"`
|
||||
CommonName string `json:"common_name"`
|
||||
SANURI string `json:"san_uri"`
|
||||
SANDNS string `json:"san_dns"`
|
||||
SANIPs string `json:"san_ips"`
|
||||
AuthzPermissions string `json:"authz_permissions"`
|
||||
AuthzScope string `json:"authz_scope"`
|
||||
NotBefore int64 `json:"not_before"`
|
||||
|
||||
@@ -760,6 +760,8 @@ export interface PKIClientIssuance {
|
||||
serial_hex: string
|
||||
common_name: string
|
||||
san_uri: string
|
||||
san_dns?: string[]
|
||||
san_ips?: string[]
|
||||
authz_permissions: string[]
|
||||
authz_scope: string
|
||||
not_before: number
|
||||
|
||||
@@ -2,7 +2,7 @@ import AddIcon from '@mui/icons-material/Add'
|
||||
import BlockIcon from '@mui/icons-material/Block'
|
||||
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'
|
||||
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 APIKeyCreateFields from '../components/APIKeyCreateFields'
|
||||
import type { APIKeyExpiryUnit } from '../components/APIKeyCreateFields'
|
||||
@@ -11,21 +11,22 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogTitle,
|
||||
List,
|
||||
ListItem,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography
|
||||
} from '@mui/material'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
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 { api, APIKey, User } from '../api'
|
||||
import { formatEpochTime } from '../time'
|
||||
import CompactListItemText from '../components/CompactListItemText'
|
||||
import PageAlert from '../components/PageAlert'
|
||||
|
||||
function formatUnix(value: number) {
|
||||
@@ -35,6 +36,9 @@ function formatUnix(value: number) {
|
||||
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() {
|
||||
const { user } = useOutletContext<{ user: User | null }>()
|
||||
const canCreateApiKey = Boolean(user?.is_admin || user?.permissions?.includes('apikey.create'))
|
||||
@@ -190,9 +194,91 @@ export default function ApiKeysPage() {
|
||||
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 (
|
||||
<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
|
||||
title={
|
||||
<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 }} />
|
||||
{loading ? (
|
||||
<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>
|
||||
{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>
|
||||
<Typography variant="body2" color="text.secondary">{keys.length ? 'No matching API keys.' : 'No API keys yet.'}</Typography>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import DownloadIcon from '@mui/icons-material/Download'
|
||||
import KeyIcon from '@mui/icons-material/Key'
|
||||
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 ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
|
||||
import {
|
||||
Box,
|
||||
Chip,
|
||||
Link,
|
||||
List,
|
||||
ListItem,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography
|
||||
} from '@mui/material'
|
||||
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 PKIClientProfileDetailsDialog from '../components/PKIClientProfileDetailsDialog'
|
||||
import PKICADetailsDialog from '../components/PKICADetailsDialog'
|
||||
@@ -22,10 +24,12 @@ import PKICertDetailsDialog from '../components/PKICertDetailsDialog'
|
||||
import SectionCard from '../components/SectionCard'
|
||||
import PageAlert from '../components/PageAlert'
|
||||
import { api, PKICADetail, PKIClientIssueResponse, PKIClientIssuance, PKIClientProfile, PKICertDetail } from '../api'
|
||||
import { formatEpochTime } from '../time'
|
||||
import CompactListItemText from '../components/CompactListItemText'
|
||||
import { formatEpochTime, formatRelative, formatDuration } from '../time'
|
||||
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 {
|
||||
if (!value || value <= 0) {
|
||||
return '-'
|
||||
@@ -318,9 +322,218 @@ export default function ClientCertificatesPage() {
|
||||
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 (
|
||||
<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)} />
|
||||
|
||||
<SectionCard
|
||||
@@ -340,63 +553,18 @@ export default function ClientCertificatesPage() {
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
<List dense>
|
||||
{filteredProfiles.map((profile) => (
|
||||
<ListItem
|
||||
key={profile.id}
|
||||
sx={{
|
||||
alignItems: 'flex-start',
|
||||
borderBottom: (theme) => `1px solid ${theme.palette.divider}`
|
||||
}}
|
||||
>
|
||||
<CompactListItemText
|
||||
primary={
|
||||
<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>
|
||||
{filteredProfiles.length ? (
|
||||
<ResizableSortableTable
|
||||
columns={profileColumns}
|
||||
data={filteredProfiles}
|
||||
getRowKey={(profile: PKIClientProfile) => profile.id}
|
||||
defaultSortKey="name"
|
||||
/>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{profiles.length ? 'No matching client certificate profiles found.' : 'No client certificate profiles are available to you.'}
|
||||
</Typography>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
@@ -416,77 +584,19 @@ export default function ClientCertificatesPage() {
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
<List dense>
|
||||
{filteredIssuances.map((item) => (
|
||||
<ListItem
|
||||
key={item.id}
|
||||
sx={{
|
||||
alignItems: 'flex-start',
|
||||
borderBottom: (theme) => `1px solid ${theme.palette.divider}`
|
||||
}}
|
||||
>
|
||||
<CompactListItemText
|
||||
primary={
|
||||
<Typography component="div">
|
||||
{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>
|
||||
{filteredIssuances.length ? (
|
||||
<ResizableSortableTable
|
||||
columns={issuanceColumns}
|
||||
data={filteredIssuances}
|
||||
getRowKey={(item: PKIClientIssuance) => item.id}
|
||||
defaultSortKey="issued"
|
||||
defaultSortDir="desc"
|
||||
/>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{issuances.length ? 'No matching client certificates found.' : 'No client certificates issued yet.'}
|
||||
</Typography>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<PKIClientCertificateIssueDialog
|
||||
|
||||
@@ -1,31 +1,34 @@
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
||||
import {
|
||||
Box,
|
||||
CircularProgress,
|
||||
Link,
|
||||
List,
|
||||
ListItem,
|
||||
MenuItem,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography
|
||||
} from '@mui/material'
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility'
|
||||
import RefreshIcon from '@mui/icons-material/Refresh'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Autocomplete from '../components/Autocomplete'
|
||||
import HeaderActionButton from '../components/HeaderActionButton'
|
||||
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
|
||||
import CompactListItemText from '../components/CompactListItemText'
|
||||
import { ListRowActions, ListRowIconAction } from '../components/ListRowActions'
|
||||
import MonospaceTextField from '../components/MonospaceTextField'
|
||||
import ResizableSortableTable, { RSTColumn } from '../components/ResizableSortableTable'
|
||||
import SectionCard from '../components/SectionCard'
|
||||
import SSHCertificateInspectDialog from '../components/SSHCertificateInspectDialog'
|
||||
import SSHCertificateIssuedDialog from '../components/SSHCertificateIssuedDialog'
|
||||
import SSHUserCADetailsDialog from '../components/SSHUserCADetailsDialog'
|
||||
import PageAlert from '../components/PageAlert'
|
||||
import { api, SSHPrincipalGrant, SSHSignUserKeyResponse, SSHUserCA, SSHUserCAIssuance, SSHUserCASelfSignPayload } from '../api'
|
||||
import { formatEpochTime } from '../time'
|
||||
import { formatEpochTime, formatRelative } from '../time'
|
||||
import SelectField from '../components/SelectField'
|
||||
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 {
|
||||
if (!value || value <= 0) {
|
||||
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 (
|
||||
<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)} />
|
||||
<SectionCard
|
||||
collapsible
|
||||
@@ -383,43 +490,15 @@ export default function SSHCertificatesPage() {
|
||||
>
|
||||
{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}
|
||||
<List>
|
||||
{issuances.map((item: SSHUserCAIssuance) => (
|
||||
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
|
||||
<CompactListItemText
|
||||
primary={
|
||||
<Typography>
|
||||
<Tooltip title={item.ca_id} arrow>
|
||||
<Link
|
||||
underline="hover"
|
||||
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>
|
||||
{issuances.length ? (
|
||||
<ResizableSortableTable
|
||||
columns={issuanceColumns}
|
||||
data={issuances}
|
||||
getRowKey={(item: SSHUserCAIssuance) => item.id}
|
||||
defaultSortKey="created"
|
||||
defaultSortDir="desc"
|
||||
/>
|
||||
) : null}
|
||||
</SectionCard>
|
||||
|
||||
<SSHCertificateInspectDialog
|
||||
|
||||
@@ -752,7 +752,6 @@ export default function SSHServersPage() {
|
||||
getValue: (item: SSHAccessProfile) => item.name,
|
||||
renderCell: (item: SSHAccessProfile) => (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, minWidth: 0 }}>
|
||||
{renderAuthIcon(item)}
|
||||
{connectingID === item.id ? (
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, minWidth: 0 }}>
|
||||
<CircularProgress size={14} />
|
||||
@@ -792,11 +791,14 @@ export default function SSHServersPage() {
|
||||
{
|
||||
key: 'remote_username',
|
||||
label: 'User',
|
||||
defaultWidth: 130,
|
||||
minWidth: 80,
|
||||
defaultWidth: 160,
|
||||
minWidth: 100,
|
||||
getValue: (item: SSHAccessProfile) => item.remote_username,
|
||||
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>
|
||||
)
|
||||
},
|
||||
{
|
||||
|
||||
@@ -22,6 +22,40 @@ export function formatEpochTime(seconds: number | null | undefined): string {
|
||||
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 {
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
const year = date.getFullYear()
|
||||
|
||||
Reference in New Issue
Block a user