Compare commits
2 Commits
6f16b57984
...
35da73b3fd
| Author | SHA1 | Date | |
|---|---|---|---|
| 35da73b3fd | |||
| bd3dc4c188 |
@@ -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,
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
<List>
|
<ResizableSortableTable
|
||||||
{filteredKeys.map((key: APIKey) => (
|
columns={columns}
|
||||||
<ListItem
|
data={filteredKeys}
|
||||||
key={key.id}
|
getRowKey={(key: APIKey) => key.id}
|
||||||
sx={{
|
defaultSortKey="name"
|
||||||
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>
|
<Typography variant="body2" color="text.secondary">{keys.length ? 'No matching API keys.' : 'No API keys yet.'}</Typography>
|
||||||
) : null}
|
|
||||||
</List>
|
|
||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
|
||||||
<Typography variant="h5">Client Certificates</Typography>
|
<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}`
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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)}>
|
<Typography variant="body2" color="text.secondary">
|
||||||
Issue
|
{profiles.length ? 'No matching client certificate profiles found.' : 'No client certificate profiles are available to you.'}
|
||||||
</ListRowActionButton>
|
</Typography>
|
||||||
</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
|
|
||||||
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
|
<Typography variant="body2" color="text.secondary">
|
||||||
)}
|
{issuances.length ? 'No matching client certificates found.' : 'No client certificates issued yet.'}
|
||||||
{' '}· 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>
|
</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
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
|
||||||
<Typography variant="h5">SSH Certificates</Typography>
|
<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"
|
|
||||||
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>
|
) : null}
|
||||||
<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
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user