Compare commits
4 Commits
001f0487e2
...
16295f34bb
| Author | SHA1 | Date | |
|---|---|---|---|
| 16295f34bb | |||
| 0a04381a28 | |||
| 1317afb6ea | |||
| f4395ce5d2 |
@@ -367,6 +367,7 @@ CREATE TABLE IF NOT EXISTS ssh_servers (
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
tags_json TEXT NOT NULL DEFAULT '[]',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
host_key_policy TEXT NOT NULL DEFAULT 'strict',
|
||||
created_by_kind TEXT NOT NULL DEFAULT 'user',
|
||||
created_by_subject_id TEXT NOT NULL DEFAULT '',
|
||||
created_by_subject_name TEXT NOT NULL DEFAULT '',
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE ssh_servers ADD COLUMN host_key_policy TEXT NOT NULL DEFAULT 'strict';
|
||||
@@ -0,0 +1,11 @@
|
||||
import ListItemText from '@mui/material/ListItemText'
|
||||
import type { ListItemTextProps } from '@mui/material/ListItemText'
|
||||
import type { SxProps, Theme } from '@mui/material/styles'
|
||||
|
||||
const compactSx: SxProps<Theme> = { minWidth: 0, m: 0 }
|
||||
|
||||
export default function CompactListItemText(props: ListItemTextProps) {
|
||||
const sx: SxProps<Theme> = (props.sx ? [compactSx, props.sx] : compactSx) as SxProps<Theme>
|
||||
|
||||
return <ListItemText {...props} sx={sx} />
|
||||
}
|
||||
@@ -8,10 +8,10 @@ import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
import List from '@mui/material/List'
|
||||
import ListItem from '@mui/material/ListItem'
|
||||
import ListItemIcon from '@mui/material/ListItemIcon'
|
||||
import ListItemText from '@mui/material/ListItemText'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import FormDialogContent from './FormDialogContent'
|
||||
import { SSHAccessProfile, SSHServer } from '../api'
|
||||
import CompactListItemText from './CompactListItemText'
|
||||
|
||||
type SSHServerGroupConnectDialogProps = {
|
||||
open: boolean
|
||||
@@ -120,11 +120,10 @@ export default function SSHServerGroupConnectDialog(props: SSHServerGroupConnect
|
||||
sx={{ p: 0.5 }}
|
||||
/>
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
<CompactListItemText
|
||||
primary={item.name}
|
||||
secondary={`${item.host}:${item.port}`}
|
||||
secondaryTypographyProps={{ variant: 'caption' }}
|
||||
sx={{ minWidth: 0, m: 0 }}
|
||||
/>
|
||||
</ListItem>
|
||||
)
|
||||
|
||||
@@ -6,11 +6,11 @@ import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import List from '@mui/material/List'
|
||||
import ListItem from '@mui/material/ListItem'
|
||||
import ListItemText from '@mui/material/ListItemText'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import FormDialogContent from './FormDialogContent'
|
||||
import { SSHServer, SSHServerGroup } from '../api'
|
||||
import CompactListItemText from './CompactListItemText'
|
||||
|
||||
type SSHServerGroupDetailsDialogProps = {
|
||||
item: SSHServerGroup | null
|
||||
@@ -61,7 +61,7 @@ export default function SSHServerGroupDetailsDialog(props: SSHServerGroupDetails
|
||||
<List disablePadding>
|
||||
{memberServers.map((server: SSHServer) => (
|
||||
<ListItem key={server.id} divider sx={{ alignItems: 'flex-start' }}>
|
||||
<ListItemText
|
||||
<CompactListItemText
|
||||
primary={
|
||||
<Typography>
|
||||
{server.name}
|
||||
@@ -69,7 +69,6 @@ export default function SSHServerGroupDetailsDialog(props: SSHServerGroupDetails
|
||||
</Typography>
|
||||
}
|
||||
secondary={`${server.host}:${server.port}${server.description ? ` · ${server.description}` : ''}`}
|
||||
sx={{ minWidth: 0, m: 0 }}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
List,
|
||||
ListItemIcon,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
MenuItem,
|
||||
TextField,
|
||||
Typography
|
||||
@@ -19,6 +18,7 @@ import { ListRowActionButton, ListRowActions } from '../components/ListRowAction
|
||||
import SectionCard from '../components/SectionCard'
|
||||
import { AdminAPIKey, api } from '../api'
|
||||
import SelectField from '../components/SelectField'
|
||||
import CompactListItemText from '../components/CompactListItemText'
|
||||
|
||||
function formatUnix(value: number) {
|
||||
if (!value || value <= 0) {
|
||||
@@ -203,7 +203,7 @@ export default function AdminApiKeysPage() {
|
||||
indeterminate={selected.length > 0 && selected.length < keys.length}
|
||||
onChange={(event) => handleSelectAll(event.target.checked)}
|
||||
/>
|
||||
<ListItemText primary="Select all" />
|
||||
<CompactListItemText primary="Select all" />
|
||||
</ListItem>
|
||||
) : null}
|
||||
{keys.map((key) => (
|
||||
@@ -222,15 +222,23 @@ export default function AdminApiKeysPage() {
|
||||
/>
|
||||
</ListItemIcon>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
|
||||
<ListItemText
|
||||
<CompactListItemText
|
||||
primary={
|
||||
<Typography>
|
||||
{key.name}({key.prefix})
|
||||
{key.disabled? (<> <Chip size="small" color='error' label='Disabled'/></>): null}
|
||||
</Typography>
|
||||
}
|
||||
secondary={`${renderSubject(key)}${key.email ? ` | ${key.email}` : ''} | Created: ${formatUnix(key.created_at)} | Last used: ${formatUnix(key.last_used_at)} | Expires: ${formatUnix(key.expires_at)}`}
|
||||
sx={{ minWidth: 0, m: 0 }}
|
||||
secondary={
|
||||
<Box sx={{ display: "grid" }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{renderSubject(key)}{key.email ? ` · ${key.email}` : ''}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Created: {formatUnix(key.created_at)} · Last used: {formatUnix(key.last_used_at)} · Expires: {formatUnix(key.expires_at)}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<ListRowActions>
|
||||
<ListRowActionButton
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
Link,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Typography
|
||||
} from '@mui/material'
|
||||
import { useEffect, useState } from 'react'
|
||||
@@ -21,6 +20,7 @@ import PKICADetailsDialog from '../components/PKICADetailsDialog'
|
||||
import SectionCard from '../components/SectionCard'
|
||||
import { api, PKICA, PKICADetail, PKIClientProfile, PKIClientProfileUpsertPayload, User, UserGroup } from '../api'
|
||||
import { AppBrand, useBrand } from '../branding'
|
||||
import CompactListItemText from '../components/CompactListItemText'
|
||||
|
||||
function fmt(value: number): string {
|
||||
if (!value || value <= 0) {
|
||||
@@ -289,7 +289,7 @@ export default function AdminPKIClientProfilesPage() {
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
|
||||
<ListItemText
|
||||
<CompactListItemText
|
||||
primary={
|
||||
<Typography>
|
||||
{item.name}({item.id})
|
||||
@@ -297,7 +297,7 @@ export default function AdminPKIClientProfilesPage() {
|
||||
</Typography>
|
||||
}
|
||||
secondary={
|
||||
<>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
CA:{' '}
|
||||
<Link
|
||||
underline="hover"
|
||||
@@ -308,9 +308,8 @@ export default function AdminPKIClientProfilesPage() {
|
||||
</Link>
|
||||
{' '}
|
||||
· server auth: {item.allow_server_auth ? 'yes' : 'no'} · perms: {(item.authz_permissions || []).join(', ') || '-'} · scope: {item.authz_scope || '-'} · validity: {item.default_valid_seconds}s / max {item.max_valid_seconds}s · targets: {item.targets.length} · updated: {fmt(item.updated_at)}
|
||||
</>
|
||||
</Typography>
|
||||
}
|
||||
sx={{ minWidth: 0, m: 0 }}
|
||||
/>
|
||||
<ListRowActions>
|
||||
<ListRowActionButton onClick={() => openView(item)}>
|
||||
@@ -335,7 +334,7 @@ export default function AdminPKIClientProfilesPage() {
|
||||
))}
|
||||
{!loading && items.length === 0 ? (
|
||||
<ListItem>
|
||||
<ListItemText primary="No client certificate profiles." />
|
||||
<CompactListItemText primary="No client certificate profiles." />
|
||||
</ListItem>
|
||||
) : null}
|
||||
</List>
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
Link,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
MenuItem,
|
||||
TextField,
|
||||
Tooltip,
|
||||
@@ -28,6 +27,7 @@ import { PKICertImportDialog, PKICertIssueDialog, PKICertRevokeDialog } from '..
|
||||
import SectionCard from '../components/SectionCard'
|
||||
import { ACMEOrder, ACMEOrderCreatePayload, ACMEProfile, ACMEProfileUpsertPayload, api, PKICA, PKICADetail, PKICert, PKICertDetail, PKICertImportPayload, PKICertIssuePayload, PKIIntermediateCARequest, PKIRootCARequest } from '../api'
|
||||
import SelectField from '../components/SelectField'
|
||||
import CompactListItemText from '../components/CompactListItemText'
|
||||
|
||||
function fmt(ts: number): string {
|
||||
if (!ts || ts <= 0) {
|
||||
@@ -723,9 +723,9 @@ export default function AdminPKIPage() {
|
||||
{filteredCAs.map((ca) => (
|
||||
<ListItem key={ca.id} divider sx={{ alignItems: 'flex-start' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
|
||||
<ListItemText
|
||||
<CompactListItemText
|
||||
primary={(
|
||||
<Box component="span">
|
||||
<Typography>
|
||||
<Tooltip title={ca.id} arrow>
|
||||
<Link
|
||||
underline="hover"
|
||||
@@ -736,10 +736,13 @@ export default function AdminPKIPage() {
|
||||
</Link>
|
||||
</Tooltip>
|
||||
{` (${ca.id})`}
|
||||
</Box>
|
||||
</Typography>
|
||||
)}
|
||||
secondary={`${ca.is_root ? 'root' : 'intermediate'} · status: ${ca.status} · parent: ${ca.parent_ca_id || '-'} · updated: ${fmt(ca.updated_at)}`}
|
||||
sx={{ minWidth: 0, m: 0 }}
|
||||
secondary={
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{ca.is_root ? 'root' : 'intermediate'} · status: {ca.status} · parent: {ca.parent_ca_id || '-'} · updated: {fmt(ca.updated_at)}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
<ListRowActions>
|
||||
<ListRowActionButton
|
||||
@@ -776,7 +779,7 @@ export default function AdminPKIPage() {
|
||||
))}
|
||||
{filteredCAs.length === 0 ? (
|
||||
<ListItem>
|
||||
<ListItemText primary="No certificate authorities found." />
|
||||
<CompactListItemText primary="No certificate authorities found." />
|
||||
</ListItem>
|
||||
) : null}
|
||||
</List>
|
||||
@@ -861,10 +864,10 @@ export default function AdminPKIPage() {
|
||||
{filteredCerts.map((cert) => (
|
||||
<ListItem key={cert.id} divider sx={{ alignItems: 'flex-start' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
|
||||
<ListItemText
|
||||
primary={`${cert.common_name} (${cert.id})`}
|
||||
<CompactListItemText
|
||||
primary={<Typography>{cert.common_name} ({cert.id})</Typography>}
|
||||
secondary={(
|
||||
<Box component="span">
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{`serial: ${cert.serial_hex} · ca: `}
|
||||
{cert.ca_id ? (
|
||||
<Tooltip title={cert.ca_id} arrow>
|
||||
@@ -880,9 +883,8 @@ export default function AdminPKIPage() {
|
||||
'standalone'
|
||||
)}
|
||||
{` · source: ${fmtPKICertSource(cert)} · actor: ${fmtPKICertActor(cert)} · status: ${cert.status} · valid: ${fmt(cert.not_before)} ~ ${fmt(cert.not_after)}${cert.revoked_at ? ` · revoked: ${fmt(cert.revoked_at)}` : ''}`}
|
||||
</Box>
|
||||
</Typography>
|
||||
)}
|
||||
sx={{ minWidth: 0, m: 0 }}
|
||||
/>
|
||||
<ListRowActions>
|
||||
<ListRowActionButton onClick={() => openCertView(cert.id)}>
|
||||
@@ -911,7 +913,7 @@ export default function AdminPKIPage() {
|
||||
))}
|
||||
{filteredCerts.length === 0 ? (
|
||||
<ListItem>
|
||||
<ListItemText primary="No issued certificates found." />
|
||||
<CompactListItemText primary="No issued certificates found." />
|
||||
</ListItem>
|
||||
) : null}
|
||||
</List>
|
||||
@@ -931,10 +933,24 @@ export default function AdminPKIPage() {
|
||||
{acmeProfiles.map((item) => (
|
||||
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
|
||||
<ListItemText
|
||||
primary={`${item.name} (${item.id})`}
|
||||
secondary={`${item.directory_url} · solver: ${item.solver_type === 'acme_dns' ? `acme-dns (${item.acme_dns_full_domain || '-'})` : 'manual'} · email: ${item.email || '-'} · account: ${item.account_url || '-'} · enabled: ${item.enabled ? 'yes' : 'no'}${item.last_error ? ` · last error: ${item.last_error}` : ''}`}
|
||||
sx={{ minWidth: 0, m: 0 }}
|
||||
<CompactListItemText
|
||||
primary={<Typography>{item.name} ({item.id})</Typography>}
|
||||
secondary={
|
||||
<Box sx={{ display: "grid" }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{item.directory_url} · solver: {item.solver_type === 'acme_dns' ? `acme-dns (${item.acme_dns_full_domain || '-'})` : 'manual'} ·
|
||||
email: {item.email || '-'}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
account: {item.account_url || '-'} · enabled: {item.enabled ? 'yes' : 'no'}
|
||||
</Typography>
|
||||
{item.last_error? (
|
||||
<Typography variant="caption" color="text.secondary">last error: {
|
||||
<Typography variant="caption" color="error.main">{item.last_error}</Typography>
|
||||
}</Typography>
|
||||
) : ''}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<ListRowActions>
|
||||
<ListRowActionButton onClick={() => openEditACME(item)}>
|
||||
@@ -1021,10 +1037,22 @@ export default function AdminPKIPage() {
|
||||
{acmeOrders.map((item) => (
|
||||
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
|
||||
<ListItemText
|
||||
primary={`${item.common_name} (${item.id})`}
|
||||
secondary={`profile: ${item.profile_id} · status: ${item.status} · cert: ${item.cert_id || '-'} · created: ${fmt(item.created_at)}${item.last_error ? ` · error: ${item.last_error}` : ''}`}
|
||||
sx={{ minWidth: 0, m: 0 }}
|
||||
<CompactListItemText
|
||||
primary={
|
||||
<Typography>{item.common_name} ({item.id})</Typography>
|
||||
}
|
||||
secondary={
|
||||
<Box sx={{ display: "grid" }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
profile: {item.profile_id} · status: {item.status} · cert: {item.cert_id || '-'} · created: {fmt(item.created_at)}
|
||||
</Typography>
|
||||
{item.last_error? (
|
||||
<Typography variant="caption" color="text.secondary">last error: {
|
||||
<Typography variant="caption" color="error.main">{item.last_error}</Typography>
|
||||
}</Typography>
|
||||
) : ''}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<ListRowActions>
|
||||
<ListRowActionButton onClick={() => setACMEOrderView(item)}>
|
||||
|
||||
@@ -5,11 +5,13 @@ import Dialog from '@mui/material/Dialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import Link from '@mui/material/Link'
|
||||
import Paper from '@mui/material/Paper'
|
||||
import List from '@mui/material/List'
|
||||
import ListItem from '@mui/material/ListItem'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import CompactListItemText from '../components/CompactListItemText'
|
||||
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
|
||||
import FormDialogContent from '../components/FormDialogContent'
|
||||
import SSHAccessProfileDetailsDialog from '../components/SSHAccessProfileDetailsDialog'
|
||||
@@ -386,42 +388,20 @@ export default function AdminSSHAccessProfilesPage() {
|
||||
Loading...
|
||||
</Typography>
|
||||
) : null}
|
||||
<Box sx={{ display: 'grid', gap: 0 }}>
|
||||
{filteredItems.length === 0 && !loading ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No SSH access profiles found.
|
||||
</Typography>
|
||||
) : null}
|
||||
<List>
|
||||
{filteredItems.map((item) => (
|
||||
<Paper
|
||||
key={item.id}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 1,
|
||||
display: 'grid',
|
||||
gap: 0.5,
|
||||
backgroundColor: 'transparent',
|
||||
backgroundImage: 'none',
|
||||
borderLeft: 'none',
|
||||
borderRight: 'none',
|
||||
borderTop: 'none',
|
||||
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: 0,
|
||||
boxShadow: 'none'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Typography variant="subtitle2" sx={{ wordBreak: 'break-word' }}>
|
||||
{item.name}
|
||||
</Typography>
|
||||
<ListRowActions>
|
||||
<ListRowActionButton onClick={() => setViewItem(item)}>View</ListRowActionButton>
|
||||
<ListRowActionButton onClick={() => openEdit(item)}>Edit</ListRowActionButton>
|
||||
<ListRowActionButton color="error" onClick={() => { setDeleteItem(item); setDeleteConfirm('') }}>
|
||||
Delete
|
||||
</ListRowActionButton>
|
||||
</ListRowActions>
|
||||
</Box>
|
||||
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
|
||||
<CompactListItemText
|
||||
primary={
|
||||
<Typography>{item.name}</Typography>
|
||||
}
|
||||
secondary={
|
||||
<Box sx={{ display: 'grid' }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
||||
{item.server_target_type === 'group' ? (
|
||||
<Tooltip title={item.server_group_id || '-'} arrow>
|
||||
@@ -480,9 +460,20 @@ export default function AdminSSHAccessProfilesPage() {
|
||||
) : item.auth_public_key_fingerprint || '-'}
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
}
|
||||
disableTypography
|
||||
/>
|
||||
<ListRowActions>
|
||||
<ListRowActionButton onClick={() => setViewItem(item)}>View</ListRowActionButton>
|
||||
<ListRowActionButton onClick={() => openEdit(item)}>Edit</ListRowActionButton>
|
||||
<ListRowActionButton color="error" onClick={() => { setDeleteItem(item); setDeleteConfirm('') }}>
|
||||
Delete
|
||||
</ListRowActionButton>
|
||||
</ListRowActions>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</SectionCard>
|
||||
|
||||
<SSHAccessProfileFormDialog
|
||||
|
||||
@@ -10,7 +10,6 @@ import DialogTitle from '@mui/material/DialogTitle'
|
||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
import List from '@mui/material/List'
|
||||
import ListItem from '@mui/material/ListItem'
|
||||
import ListItemText from '@mui/material/ListItemText'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useEffect, useState } from 'react'
|
||||
@@ -22,6 +21,7 @@ import MonospaceTextField from '../components/MonospaceTextField'
|
||||
import SectionCard from '../components/SectionCard'
|
||||
import SSHCredentialDetailsDialog from '../components/SSHCredentialDetailsDialog'
|
||||
import { api, SSHCredential, SSHCredentialUpsertPayload } from '../api'
|
||||
import CompactListItemText from '../components/CompactListItemText'
|
||||
|
||||
type FormState = {
|
||||
name: string
|
||||
@@ -145,15 +145,18 @@ export default function AdminSSHCredentialsPage() {
|
||||
<List>
|
||||
{items.map((item) => (
|
||||
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
|
||||
<ListItemText
|
||||
<CompactListItemText
|
||||
primary={
|
||||
<Typography>
|
||||
{item.name}({item.id})
|
||||
{item.enabled? null: (<> <Chip size="small" color='error' label='Disabled'/></>)}
|
||||
</Typography>
|
||||
}
|
||||
secondary={`${item.fingerprint || item.id} · ${item.type} · Updated: ${fmt(item.updated_at)}`}
|
||||
sx={{ minWidth: 0, m: 0 }}
|
||||
secondary={
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{item.fingerprint || item.id} · {item.type} · Updated: {fmt(item.updated_at)}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
<ListRowActions>
|
||||
<ListRowActionButton onClick={() => setViewItem(item)}>View</ListRowActionButton>
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
DialogTitle,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
MenuItem,
|
||||
TextField,
|
||||
Typography
|
||||
@@ -24,6 +23,7 @@ import SSHPrincipalGrantDetailsDialog from '../components/SSHPrincipalGrantDetai
|
||||
import SSHPrincipalGrantFormDialog, { SSHPrincipalGrantFormState } from '../components/SSHPrincipalGrantFormDialog'
|
||||
import { api, SSHPrincipalGrant, SSHPrincipalGrantUpsertPayload, User, UserGroup } from '../api'
|
||||
import SelectField from '../components/SelectField'
|
||||
import CompactListItemText from '../components/CompactListItemText'
|
||||
|
||||
function fmt(value: number): string {
|
||||
if (!value || value <= 0) {
|
||||
@@ -309,10 +309,21 @@ export default function AdminSSHPrincipalGrantsPage() {
|
||||
{items.map((item) => (
|
||||
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
|
||||
<ListItemText
|
||||
primary={`${item.name || item.principal}${item.disabled ? ' (disabled)' : ''}`}
|
||||
secondary={`principals: ${(item.principals || []).join(', ') || '-'} · targets: ${(item.targets || []).map((target) => `${target.target_type}:${target.target_name || target.target_id}`).join(', ') || '-'} · max-valid: ${item.max_cert_valid_seconds || 0}s · uses: ${item.used_count}/${item.max_uses || '∞'} · valid: ${fmt(item.valid_after)} ~ ${fmt(item.valid_before)}`}
|
||||
sx={{ minWidth: 0, m: 0 }}
|
||||
<CompactListItemText
|
||||
primary={
|
||||
<Typography>
|
||||
{item.name || item.principal}{item.disabled ? ' (disabled)' : ''}
|
||||
</Typography>
|
||||
}
|
||||
secondary={
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
principals: {(item.principals || []).join(', ') || '-'} ·
|
||||
targets: {(item.targets || []).map((target) => `${target.target_type}:${target.target_name || target.target_id}`).join(', ') || '-'} ·
|
||||
max-valid: {item.max_cert_valid_seconds || 0}s ·
|
||||
uses: {item.used_count}/{item.max_uses || '∞'} ·
|
||||
valid: {fmt(item.valid_after)} ~ {fmt(item.valid_before)}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
<ListRowActions>
|
||||
<ListRowActionButton onClick={() => openView(item)}>
|
||||
|
||||
@@ -4,7 +4,6 @@ import Button from '@mui/material/Button'
|
||||
import Chip from '@mui/material/Chip'
|
||||
import List from '@mui/material/List'
|
||||
import ListItem from '@mui/material/ListItem'
|
||||
import ListItemText from '@mui/material/ListItemText'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
@@ -20,6 +19,7 @@ import { SSHServerGroupFormState } from '../components/SSHServerGroupFormDialog'
|
||||
import SSHServerGroupMembersDialog from '../components/SSHServerGroupMembersDialog'
|
||||
import SSHHostKeysDialog from '../components/SSHHostKeysDialog'
|
||||
import { api, SSHServer, SSHServerGroup, SSHServerGroupUpsertPayload, SSHServerHostKey, SSHServerUpsertPayload } from '../api'
|
||||
import CompactListItemText from '../components/CompactListItemText'
|
||||
|
||||
const emptyForm = (): SSHServerFormState => ({
|
||||
name: '',
|
||||
@@ -444,7 +444,7 @@ export default function AdminSSHServersPage() {
|
||||
<List>
|
||||
{filteredItems.map((item) => (
|
||||
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
|
||||
<ListItemText
|
||||
<CompactListItemText
|
||||
primary={
|
||||
<Typography>
|
||||
{item.name}({item.id})
|
||||
@@ -452,12 +452,11 @@ export default function AdminSSHServersPage() {
|
||||
</Typography>
|
||||
}
|
||||
secondary={
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{item.host}:{item.port}· Tags: {(item.tags || []).join(', ') || '-'}· Updated: {fmt(item.updated_at)}
|
||||
{item.description? (<><br />{item.description}</>): null}
|
||||
</Typography>
|
||||
}
|
||||
sx={{ minWidth: 0, m: 0 }}
|
||||
/>
|
||||
<ListRowActions>
|
||||
<ListRowActionButton onClick={() => setViewItem(item)}>View</ListRowActionButton>
|
||||
@@ -490,15 +489,18 @@ export default function AdminSSHServersPage() {
|
||||
<List>
|
||||
{groups.map((item) => (
|
||||
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
|
||||
<ListItemText
|
||||
<CompactListItemText
|
||||
primary={
|
||||
<Typography>
|
||||
{item.name}({item.id})
|
||||
{item.enabled ? null : (<> <Chip size="small" color="error" label="Disabled" /></>)}
|
||||
</Typography>
|
||||
}
|
||||
secondary={`${(item.server_ids || []).length} servers · ${item.description || '-'}`}
|
||||
sx={{ minWidth: 0, m: 0 }}
|
||||
secondary={
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{(item.server_ids || []).length} servers · {item.description || '-'}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
<ListRowActions>
|
||||
<ListRowActionButton onClick={() => setViewGroupItem(item)}>View</ListRowActionButton>
|
||||
|
||||
@@ -3,11 +3,15 @@ import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Chip from '@mui/material/Chip'
|
||||
import Link from '@mui/material/Link'
|
||||
import List from '@mui/material/List'
|
||||
import ListItem from '@mui/material/ListItem'
|
||||
import MenuItem from '@mui/material/MenuItem'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import CompactListItemText from '../components/CompactListItemText'
|
||||
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
|
||||
import SectionCard from '../components/SectionCard'
|
||||
import SSHAccessProfileDetailsDialog from '../components/SSHAccessProfileDetailsDialog'
|
||||
import SSHServerDetailsDialog from '../components/SSHServerDetailsDialog'
|
||||
@@ -222,22 +226,22 @@ export default function AdminSSHSessionsPage() {
|
||||
</Typography>
|
||||
) : null}
|
||||
{items.length ? (
|
||||
<Box sx={{ display: 'grid', gap: 0 }}>
|
||||
<List>
|
||||
{items.map((item) => (
|
||||
<Box
|
||||
key={item.id}
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gap: 0.5,
|
||||
py: 1,
|
||||
borderBottom: (theme) => `1px solid ${theme.palette.divider}`
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Box sx={{ minWidth: 0, display: 'grid', gap: 0.25, flex: '1 1 320px' }}>
|
||||
<Typography variant="subtitle2" sx={{ wordBreak: 'break-word' }}>
|
||||
{sessionTitle(item)}
|
||||
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
|
||||
<CompactListItemText
|
||||
primary={
|
||||
<Typography>
|
||||
{sessionTitle(item)}{' '}
|
||||
<Chip
|
||||
label={item.status || 'unknown'}
|
||||
size="small"
|
||||
color={item.status === 'connected'? 'success': (item.status === 'error'? 'error' : 'default')}
|
||||
/>
|
||||
</Typography>
|
||||
}
|
||||
secondary={
|
||||
<Box sx={{ display: 'grid' }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
||||
User: {item.username} · Auth: {item.auth_method} · Started: {fmt(item.started_at)} · Ended: {fmt(item.ended_at)}
|
||||
</Typography>
|
||||
@@ -272,26 +276,20 @@ export default function AdminSSHSessionsPage() {
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Chip
|
||||
label={item.status || 'unknown'}
|
||||
size="small"
|
||||
color={item.status === 'connected' ? 'success' : item.status === 'error' ? 'error' : 'default'}
|
||||
variant={item.status === 'connected' ? 'filled' : 'outlined'}
|
||||
}
|
||||
disableTypography
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
<ListRowActions>
|
||||
<ListRowActionButton
|
||||
onClick={() => void openTranscript(item)}
|
||||
disabled={!item.transcript_available}
|
||||
>
|
||||
Transcript
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</ListRowActionButton>
|
||||
</ListRowActions>
|
||||
</ListItem>
|
||||
))}
|
||||
</Box>
|
||||
</List>
|
||||
) : null}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Link,
|
||||
List,
|
||||
ListItem,
|
||||
MenuItem,
|
||||
TextField,
|
||||
Tooltip,
|
||||
@@ -15,6 +17,7 @@ import {
|
||||
} from '@mui/material'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
|
||||
import CompactListItemText from '../components/CompactListItemText'
|
||||
import SectionCard from '../components/SectionCard'
|
||||
import SSHUserCADetailsDialog from '../components/SSHUserCADetailsDialog'
|
||||
import SSHPrincipalGrantDetailsDialog from '../components/SSHPrincipalGrantDetailsDialog'
|
||||
@@ -190,18 +193,12 @@ export default function AdminSSHSignHistoryPage() {
|
||||
</Button>
|
||||
</Box>
|
||||
{!loading && items.length === 0 ? <Typography variant="body2" color="text.secondary">No signing history found.</Typography> : null}
|
||||
{items.map((item) => (
|
||||
<Box
|
||||
key={item.id}
|
||||
sx={{
|
||||
p: 1,
|
||||
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
minWidth: 0
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, minWidth: 0 }}>
|
||||
<Box sx={{ display: 'grid', gap: 0.5, minWidth: 0, flexGrow: 1 }}>
|
||||
<Typography variant="body2">
|
||||
<List>
|
||||
{items.map((item: SSHUserCAIssuance) => (
|
||||
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
|
||||
<CompactListItemText
|
||||
primary={
|
||||
<Typography>
|
||||
<Tooltip title={item.ca_id} arrow>
|
||||
<Link
|
||||
underline="hover"
|
||||
@@ -213,7 +210,10 @@ export default function AdminSSHSignHistoryPage() {
|
||||
</Tooltip>
|
||||
{' '}· serial: {item.serial} · key_id: {item.key_id}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" component="div">
|
||||
}
|
||||
secondary={
|
||||
<Box sx={{ display: 'grid' }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
by: {item.issuer_username || '-'} ({item.issuer_kind}) · {'principals:'}{' '}
|
||||
{item.grant_ids && item.grant_ids.length > 0 ? item.grant_ids.map((grantID: string, index: number) => (
|
||||
<Box key={grantID} component="span">
|
||||
@@ -242,13 +242,16 @@ export default function AdminSSHSignHistoryPage() {
|
||||
source fp: {item.source_public_key_fingerprint} · remote: {item.remote_addr || '-'}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
disableTypography
|
||||
/>
|
||||
<ListRowActions>
|
||||
<ListRowActionButton onClick={() => copyCert(item)}>Copy Cert</ListRowActionButton>
|
||||
<ListRowActionButton onClick={() => inspectCert(item)} disabled={inspectBusy}>{inspectBusy ? 'Inspecting...' : 'Inspect'}</ListRowActionButton>
|
||||
</ListRowActions>
|
||||
</Box>
|
||||
</Box>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</SectionCard>
|
||||
|
||||
<SSHUserCADetailsDialog
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
DialogTitle,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
TextField,
|
||||
Typography
|
||||
} from '@mui/material'
|
||||
@@ -25,6 +24,7 @@ import SSHUserCADetailsDialog from '../components/SSHUserCADetailsDialog'
|
||||
import SSHUserCAFormDialog, { SSHUserCAFormState } from '../components/SSHUserCAFormDialog'
|
||||
import SSHCertificateInspectDialog from '../components/SSHCertificateInspectDialog'
|
||||
import { api, SSHSignUserKeyResponse, SSHUserCA, SSHUserCACreatePayload, SSHUserCASignPayload, SSHUserCAUpdatePayload } from '../api'
|
||||
import CompactListItemText from '../components/CompactListItemText'
|
||||
|
||||
function fmt(value: number): string {
|
||||
if (!value || value <= 0) {
|
||||
@@ -324,15 +324,25 @@ export default function AdminSSHUserCAPage() {
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
|
||||
<ListItemText
|
||||
<CompactListItemText
|
||||
primary={
|
||||
<Typography>
|
||||
{item.name}({item.id})
|
||||
{item.enabled? null: (<> <Chip size="small" color='error' label='Disabled'/></>)}
|
||||
</Typography>
|
||||
}
|
||||
secondary={`${item.algorithm} · ${item.fingerprint} · serial: ${item.serial_counter} · self-sign: ${item.allow_user_sign ? 'on' : 'off'} (max ${item.max_user_valid_seconds}s) · updated: ${fmt(item.updated_at)}`}
|
||||
sx={{ minWidth: 0, m: 0 }}
|
||||
secondary={
|
||||
<Box sx={{ display: 'grid' }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{item.algorithm} · {item.fingerprint}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
serial: {item.serial_counter} ·
|
||||
self-sign: {item.allow_user_sign ? 'on' : 'off'} (max {item.max_user_valid_seconds}s) ·
|
||||
updated: {fmt(item.updated_at)}`
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<ListRowActions>
|
||||
<ListRowActionButton onClick={() => openView(item)}>
|
||||
|
||||
@@ -12,8 +12,9 @@ import {
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogTitle,
|
||||
List,
|
||||
ListItem,
|
||||
MenuItem,
|
||||
Paper,
|
||||
TextField,
|
||||
Typography
|
||||
} from '@mui/material'
|
||||
@@ -22,6 +23,7 @@ import { ListRowActionButton, ListRowActions } from '../components/ListRowAction
|
||||
import SectionCard from '../components/SectionCard'
|
||||
import { api, CertPrincipalBinding, PKICert, PKICertDetail, PrincipalAPIKey, PrincipalProjectRole, Project, ServicePrincipal } from '../api'
|
||||
import SelectField from '../components/SelectField'
|
||||
import CompactListItemText from '../components/CompactListItemText'
|
||||
|
||||
function fmt(ts: number): string {
|
||||
if (!ts || ts <= 0) return '-'
|
||||
@@ -563,29 +565,19 @@ export default function AdminServicePrincipalsPage() {
|
||||
}
|
||||
>
|
||||
{loading && principals.length === 0 ? <Typography variant="body2" color="text.secondary">Loading...</Typography> : null}
|
||||
<Box sx={{ display: 'grid', gap: 0 }}>
|
||||
<List>
|
||||
{principals.map((item) => (
|
||||
<Paper
|
||||
key={item.id}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 1,
|
||||
backgroundColor: 'transparent',
|
||||
backgroundImage: 'none',
|
||||
borderLeft: 'none',
|
||||
borderRight: 'none',
|
||||
borderTop: 'none',
|
||||
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: 0,
|
||||
boxShadow: 'none'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
|
||||
<CompactListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Typography variant="body2">{item.name} ({item.id})</Typography>
|
||||
<Chip size="small" color={item.disabled ? 'default' : 'success'} label={item.disabled ? 'Disabled' : 'Active'} />
|
||||
{item.is_admin ? <Chip size="small" color="warning" label="Principal Admin" /> : null}
|
||||
</Box>
|
||||
}
|
||||
secondary={`${item.description || '(no description)'} · updated: ${fmt(item.updated_at)}`}
|
||||
/>
|
||||
<ListRowActions>
|
||||
<ListRowActionButton onClick={() => openKeys(item)}>
|
||||
API Keys
|
||||
@@ -607,13 +599,9 @@ export default function AdminServicePrincipalsPage() {
|
||||
Delete
|
||||
</ListRowActionButton>
|
||||
</ListRowActions>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{item.description || '(no description)'} · updated: {fmt(item.updated_at)}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</ListItem>
|
||||
))}
|
||||
</Box>
|
||||
</List>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
|
||||
Global permissions are managed on each subject page. No service-principal global permissions are available yet.
|
||||
</Typography>
|
||||
@@ -688,31 +676,21 @@ export default function AdminServicePrincipalsPage() {
|
||||
Reset
|
||||
</Button>
|
||||
</Box>
|
||||
<Box sx={{ display: 'grid', gap: 0 }}>
|
||||
<List>
|
||||
{filteredPrincipals.map((principal) => (
|
||||
<Paper
|
||||
key={principal.id}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 1,
|
||||
backgroundColor: 'transparent',
|
||||
backgroundImage: 'none',
|
||||
borderLeft: 'none',
|
||||
borderRight: 'none',
|
||||
borderTop: 'none',
|
||||
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: 0,
|
||||
boxShadow: 'none'
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2">{principal.name}</Typography>
|
||||
{(principalRoles[principal.id] || []).length === 0 ? (
|
||||
<ListItem key={principal.id} divider sx={{ alignItems: 'flex-start' }}>
|
||||
<CompactListItemText
|
||||
primary={<Typography variant="subtitle2">{principal.name}</Typography>}
|
||||
secondary={
|
||||
(principalRoles[principal.id] || []).length === 0 ? (
|
||||
<Typography variant="caption" color="text.secondary">No project roles</Typography>
|
||||
) : (
|
||||
<Box sx={{ display: 'grid', gap: 0.75, mt: 0.75 }}>
|
||||
<List disablePadding>
|
||||
{(principalRoles[principal.id] || []).map((role) => (
|
||||
<Box key={`${role.principal_id}:${role.project_id}`} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary">{projectName(role.project_id)} · {role.role}</Typography>
|
||||
<ListItem
|
||||
key={`${role.principal_id}:${role.project_id}`}
|
||||
disableGutters
|
||||
secondaryAction={
|
||||
<ListRowActionButton
|
||||
color="error"
|
||||
onClick={() => {
|
||||
@@ -723,13 +701,20 @@ export default function AdminServicePrincipalsPage() {
|
||||
>
|
||||
Delete
|
||||
</ListRowActionButton>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<CompactListItemText
|
||||
primary={<Typography variant="body2" color="text.secondary">{projectName(role.project_id)} · {role.role}</Typography>}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</List>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</Box>
|
||||
</List>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
@@ -742,27 +727,12 @@ export default function AdminServicePrincipalsPage() {
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Box sx={{ display: 'grid', gap: 0 }}>
|
||||
<List>
|
||||
{bindings.map((item) => (
|
||||
<Paper
|
||||
key={item.fingerprint}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 1,
|
||||
backgroundColor: 'transparent',
|
||||
backgroundImage: 'none',
|
||||
borderLeft: 'none',
|
||||
borderRight: 'none',
|
||||
borderTop: 'none',
|
||||
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: 0,
|
||||
boxShadow: 'none'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
|
||||
<Box sx={{ display: 'grid' }}>
|
||||
{pkiCertByFingerprint(item.fingerprint) ? (
|
||||
<Typography variant="body2">
|
||||
<ListItem key={item.fingerprint} divider sx={{ alignItems: 'flex-start' }}>
|
||||
<CompactListItemText
|
||||
primary={
|
||||
pkiCertByFingerprint(item.fingerprint) ? (
|
||||
<Link
|
||||
underline="hover"
|
||||
onClick={() => void openCertView(pkiCertByFingerprint(item.fingerprint)?.id || '')}
|
||||
@@ -770,10 +740,12 @@ export default function AdminServicePrincipalsPage() {
|
||||
>
|
||||
{pkiCertByFingerprint(item.fingerprint)?.common_name || pkiCertByFingerprint(item.fingerprint)?.serial_hex} ({pkiCertByFingerprint(item.fingerprint)?.id})
|
||||
</Link>
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="body2">{item.fingerprint}</Typography>
|
||||
)}
|
||||
<Typography>{item.fingerprint}</Typography>
|
||||
)
|
||||
}
|
||||
secondary={
|
||||
<Box sx={{ display: 'grid' }}>
|
||||
<Typography variant="caption" color="text.secondary">principal: {principalName(item.principal_id)} ({item.principal_id})</Typography>
|
||||
{pkiCertByFingerprint(item.fingerprint) ? (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
@@ -783,6 +755,8 @@ export default function AdminServicePrincipalsPage() {
|
||||
<Typography variant="caption" color="text.secondary">source: manual fingerprint</Typography>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<ListRowActions>
|
||||
<ListRowActionButton color={item.enabled ? 'warning' : 'success'} onClick={() => toggleBinding(item)}>
|
||||
{item.enabled ? 'Disable' : 'Enable'}
|
||||
@@ -798,10 +772,9 @@ export default function AdminServicePrincipalsPage() {
|
||||
Delete
|
||||
</ListRowActionButton>
|
||||
</ListRowActions>
|
||||
</Box>
|
||||
</Paper>
|
||||
</ListItem>
|
||||
))}
|
||||
</Box>
|
||||
</List>
|
||||
</SectionCard>
|
||||
</Box>
|
||||
|
||||
@@ -925,30 +898,13 @@ export default function AdminServicePrincipalsPage() {
|
||||
/>
|
||||
{keysLoading ? <Typography variant="body2" color="text.secondary">Loading API keys...</Typography> : null}
|
||||
{!keysLoading ? (
|
||||
<Box sx={{ display: 'grid', gap: 0 }}>
|
||||
<List>
|
||||
{principalKeys.map((item) => (
|
||||
<Paper
|
||||
key={item.id}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 1,
|
||||
backgroundColor: 'transparent',
|
||||
backgroundImage: 'none',
|
||||
borderLeft: 'none',
|
||||
borderRight: 'none',
|
||||
borderTop: 'none',
|
||||
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: 0,
|
||||
boxShadow: 'none'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 1 }}>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="body2">{item.disabled ? `${item.name} (disabled)` : item.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Prefix: {item.prefix} · Created: {formatUnix(item.created_at)} · Last used: {formatUnix(item.last_used_at)} · Expires: {formatUnix(item.expires_at)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
|
||||
<CompactListItemText
|
||||
primary={item.disabled ? `${item.name} (disabled)` : item.name}
|
||||
secondary={`Prefix: ${item.prefix} · Created: ${formatUnix(item.created_at)} · Last used: ${formatUnix(item.last_used_at)} · Expires: ${formatUnix(item.expires_at)}`}
|
||||
/>
|
||||
<ListRowActions>
|
||||
<ListRowActionButton color={item.disabled ? 'success' : 'warning'} onClick={() => togglePrincipalKey(item)} disabled={keysBusy}>
|
||||
{item.disabled ? 'Enable' : 'Disable'}
|
||||
@@ -965,13 +921,12 @@ export default function AdminServicePrincipalsPage() {
|
||||
Delete
|
||||
</ListRowActionButton>
|
||||
</ListRowActions>
|
||||
</Box>
|
||||
</Paper>
|
||||
</ListItem>
|
||||
))}
|
||||
{!principalKeys.length ? (
|
||||
<Typography variant="body2" color="text.secondary">No API keys yet.</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
</List>
|
||||
) : null}
|
||||
</FormDialogContent>
|
||||
<DialogActions>
|
||||
|
||||
@@ -6,11 +6,13 @@ import Dialog from '@mui/material/Dialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
import List from '@mui/material/List'
|
||||
import ListItem from '@mui/material/ListItem'
|
||||
import MenuItem from '@mui/material/MenuItem'
|
||||
import Paper from '@mui/material/Paper'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import CompactListItemText from '../components/CompactListItemText'
|
||||
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
|
||||
import FormDialogContent from '../components/FormDialogContent'
|
||||
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
|
||||
@@ -206,34 +208,35 @@ export default function AdminTLSAuthPoliciesPage() {
|
||||
Loading...
|
||||
</Typography>
|
||||
) : null}
|
||||
<Box sx={{ display: 'grid', gap: 0 }}>
|
||||
{filteredItems.length === 0 && !loading ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No auth policies found.
|
||||
</Typography>
|
||||
) : null}
|
||||
<List>
|
||||
{filteredItems.map((item) => (
|
||||
<Paper
|
||||
key={item.id}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 1,
|
||||
display: 'grid',
|
||||
gap: 0.5,
|
||||
backgroundColor: 'transparent',
|
||||
backgroundImage: 'none',
|
||||
borderLeft: 'none',
|
||||
borderRight: 'none',
|
||||
borderTop: 'none',
|
||||
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: 0,
|
||||
boxShadow: 'none'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Typography variant="subtitle2" sx={{ wordBreak: 'break-word' }}>
|
||||
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
|
||||
<CompactListItemText
|
||||
primary={
|
||||
<Typography sx={{ wordBreak: 'break-word' }}>
|
||||
{item.name}
|
||||
</Typography>
|
||||
}
|
||||
secondary={
|
||||
<Box sx={{ display: 'grid', gap: 0.5 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
||||
Read: {item.read_mode} · Write: {item.write_mode} · Principal for write: {item.require_principal_for_write ? 'yes' : 'no'}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
||||
Required perms ({item.permission_match}): {(item.required_permissions || []).join(', ') || '-'} · Scope: {item.required_scope || '-'}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
||||
Allowed PKI certs: {(item.allowed_pki_client_cert_ids || []).length} · Allowed fingerprints: {(item.allowed_cert_fingerprints || []).length} · {item.id}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
disableTypography
|
||||
/>
|
||||
<ListRowActions>
|
||||
<ListRowActionButton onClick={() => openEdit(item)}>Edit</ListRowActionButton>
|
||||
<ListRowActionButton color="error" onClick={() => {
|
||||
@@ -244,19 +247,9 @@ export default function AdminTLSAuthPoliciesPage() {
|
||||
Delete
|
||||
</ListRowActionButton>
|
||||
</ListRowActions>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
||||
Read: {item.read_mode} · Write: {item.write_mode} · Principal for write: {item.require_principal_for_write ? 'yes' : 'no'}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
||||
Required perms ({item.permission_match}): {(item.required_permissions || []).join(', ') || '-'} · Scope: {item.required_scope || '-'}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
||||
Allowed PKI certs: {(item.allowed_pki_client_cert_ids || []).length} · Allowed fingerprints: {(item.allowed_cert_fingerprints || []).length} · {item.id}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</ListItem>
|
||||
))}
|
||||
</Box>
|
||||
</List>
|
||||
</SectionCard>
|
||||
|
||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="md" fullWidth>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Alert from '@mui/material/Alert'
|
||||
import CompactListItemText from '../components/CompactListItemText'
|
||||
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
|
||||
import FormDialogContent from '../components/FormDialogContent'
|
||||
import {
|
||||
@@ -8,8 +9,9 @@ import {
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogTitle,
|
||||
List,
|
||||
ListItem,
|
||||
MenuItem,
|
||||
Paper,
|
||||
TextField,
|
||||
Typography
|
||||
} from '@mui/material'
|
||||
@@ -399,32 +401,19 @@ export default function AdminTLSSettingsPage() {
|
||||
Loading...
|
||||
</Typography>
|
||||
) : null}
|
||||
<Box sx={{ display: 'grid', gap: 0 }}>
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 1,
|
||||
display: 'grid',
|
||||
gap: 0.5,
|
||||
backgroundColor: 'transparent',
|
||||
backgroundImage: 'none',
|
||||
borderLeft: 'none',
|
||||
borderRight: 'none',
|
||||
borderTop: 'none',
|
||||
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: 0,
|
||||
boxShadow: 'none'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, minWidth: 0, flexWrap: 'wrap' }}>
|
||||
<List>
|
||||
<ListItem divider sx={{ alignItems: 'flex-start' }}>
|
||||
<CompactListItemText
|
||||
primary={
|
||||
<Typography component="div">
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0, flexWrap: 'wrap' }}>
|
||||
<Typography variant="subtitle2">Main Listener</Typography>
|
||||
<Box component="span">Main Listener</Box>
|
||||
<Chip size="small" color="info" label="Main" />
|
||||
</Box>
|
||||
<ListRowActions>
|
||||
<ListRowActionButton onClick={openMainEditDialog}>Edit</ListRowActionButton>
|
||||
</ListRowActions>
|
||||
</Box>
|
||||
</Typography>
|
||||
}
|
||||
secondary={
|
||||
<Box sx={{ display: 'grid', gap: 0.5 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
||||
HTTP: {(settings.http_addrs || []).join(', ') || '(none)'}
|
||||
</Typography>
|
||||
@@ -437,33 +426,32 @@ export default function AdminTLSSettingsPage() {
|
||||
<Typography variant="caption" color="warning.main" sx={{ wordBreak: 'break-word' }}>
|
||||
Main listener transport changes require backend restart to take effect.
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
}
|
||||
disableTypography
|
||||
/>
|
||||
<ListRowActions>
|
||||
<ListRowActionButton onClick={openMainEditDialog}>Edit</ListRowActionButton>
|
||||
</ListRowActions>
|
||||
</ListItem>
|
||||
{!listenersLoading && (listeners || []).length === 0 ? (
|
||||
<ListItem>
|
||||
<CompactListItemText
|
||||
primary={
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No additional listeners configured.
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
) : null}
|
||||
{(listeners || []).map((item) => (
|
||||
<Paper
|
||||
key={item.id}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 1,
|
||||
display: 'grid',
|
||||
gap: 0.5,
|
||||
backgroundColor: 'transparent',
|
||||
backgroundImage: 'none',
|
||||
borderLeft: 'none',
|
||||
borderRight: 'none',
|
||||
borderTop: 'none',
|
||||
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: 0,
|
||||
boxShadow: 'none'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, minWidth: 0, flexWrap: 'wrap' }}>
|
||||
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
|
||||
<CompactListItemText
|
||||
primary={
|
||||
<Typography component="div" sx={{ wordBreak: 'break-word' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0, flexWrap: 'wrap' }}>
|
||||
<Typography variant="subtitle2" sx={{ wordBreak: 'break-word' }}>{item.name}</Typography>
|
||||
<Box component="span">{item.name}</Box>
|
||||
{item.enabled ? (
|
||||
(runtimeCounts[item.id] || 0) > 0 ? (
|
||||
<Chip size="small" color="success" label={`Running (${runtimeCounts[item.id]} endpoint${runtimeCounts[item.id] > 1 ? 's' : ''})`} />
|
||||
@@ -474,6 +462,23 @@ export default function AdminTLSSettingsPage() {
|
||||
<Chip size="small" label="Disabled" />
|
||||
)}
|
||||
</Box>
|
||||
</Typography>
|
||||
}
|
||||
secondary={
|
||||
<Box sx={{ display: 'grid', gap: 0.5 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
||||
HTTP: {(item.http_addrs || []).join(', ') || '(none)'}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
||||
HTTPS: {(item.https_addrs || []).join(', ') || '(none)'}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
||||
Policies: {summarizeEndpointPolicies(item.endpoint_policies || [])}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
disableTypography
|
||||
/>
|
||||
<ListRowActions>
|
||||
<ListRowActionButton
|
||||
color={item.enabled ? 'warning' : 'success'}
|
||||
@@ -488,19 +493,9 @@ export default function AdminTLSSettingsPage() {
|
||||
Delete
|
||||
</ListRowActionButton>
|
||||
</ListRowActions>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
||||
HTTP: {(item.http_addrs || []).join(', ') || '(none)'}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
||||
HTTPS: {(item.https_addrs || []).join(', ') || '(none)'}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
||||
Policies: {summarizeEndpointPolicies(item.endpoint_policies || [])}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</ListItem>
|
||||
))}
|
||||
</Box>
|
||||
</List>
|
||||
</SectionCard>
|
||||
|
||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="md" fullWidth>
|
||||
|
||||
@@ -2,6 +2,7 @@ import AddIcon from '@mui/icons-material/Add'
|
||||
import DeleteIcon from '@mui/icons-material/Delete'
|
||||
import EditIcon from '@mui/icons-material/Edit'
|
||||
import Alert from '@mui/material/Alert'
|
||||
import CompactListItemText from '../components/CompactListItemText'
|
||||
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
|
||||
import FormDialogContent from '../components/FormDialogContent'
|
||||
import {
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
MenuItem,
|
||||
Paper,
|
||||
TextField,
|
||||
@@ -299,7 +299,7 @@ export default function AdminUserGroupsPage() {
|
||||
<Button variant="outlined" startIcon={<AddIcon />} onClick={openCreate}>New Group</Button>
|
||||
</Box>
|
||||
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '360px minmax(0, 1fr)' }, gap: 1, alignItems: 'start' }}>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '40% minmax(0, 1fr)' }, gap: 1, alignItems: 'start' }}>
|
||||
<TintedPanel>
|
||||
<TextField
|
||||
size="small"
|
||||
@@ -324,19 +324,19 @@ export default function AdminUserGroupsPage() {
|
||||
onClick={() => setSelectedGroupID(group.id)}
|
||||
sx={{ alignItems: 'flex-start', gap: 1, cursor: 'pointer' }}
|
||||
>
|
||||
<ListItemText
|
||||
sx={{ minWidth: 0, flex: 1 }}
|
||||
primary={group.name}
|
||||
secondary={`${group.id} · ${group.scope === 'all_users' ? 'all users' : 'explicit'} · updated: ${fmt(group.updated_at)}${group.totp_required ? ' · TOTP required' : ''}${hasDirectProjectCreate(group.id) ? ' · project.create' : ''}`}
|
||||
primaryTypographyProps={{
|
||||
noWrap: true,
|
||||
title: group.name + (group.disabled ? ' (disabled)' : '')
|
||||
}}
|
||||
secondaryTypographyProps={{
|
||||
sx: { whiteSpace: 'normal', wordBreak: 'break-all' }
|
||||
}}
|
||||
<CompactListItemText
|
||||
primary={
|
||||
<Typography noWrap>
|
||||
{group.name}
|
||||
{group.disabled? (<> <Chip size="small" label="Disabled" color="error" sx={{ mt: 0.25, flexShrink: 0 }} /></>): null}
|
||||
</Typography>
|
||||
}
|
||||
secondary={
|
||||
<Typography variant="caption" color="text.secondary" sx={{ whiteSpace: 'normal', wordBreak: 'break-all' }}>
|
||||
{group.id} · {group.scope === 'all_users' ? 'all users' : 'explicit'} · updated: {fmt(group.updated_at)}{group.totp_required ? ' · TOTP required' : ''}{hasDirectProjectCreate(group.id) ? ' · project.create' : ''}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
{group.disabled ? <Chip size="small" label="Disabled" sx={{ mt: 0.25, flexShrink: 0 }} /> : null}
|
||||
{group.scope === 'all_users' ? <Chip size="small" label="All Users" sx={{ mt: 0.25, flexShrink: 0 }} /> : null}
|
||||
<Box sx={{ display: 'flex', gap: 0.5, mt: 0.25, flexShrink: 0 }}>
|
||||
<IconButton
|
||||
@@ -374,10 +374,10 @@ export default function AdminUserGroupsPage() {
|
||||
<Typography variant="h6" sx={{ minWidth: 0 }}>
|
||||
{selectedGroup.name}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap' }}>
|
||||
<Typography>
|
||||
<Chip
|
||||
size="small"
|
||||
color={selectedGroup.disabled ? 'default' : 'success'}
|
||||
color={selectedGroup.disabled ? 'error' : 'success'}
|
||||
label={selectedGroup.disabled ? 'Disabled' : 'Active'}
|
||||
/>
|
||||
{hasDirectProjectCreate(selectedGroup.id) ? (
|
||||
@@ -389,20 +389,16 @@ export default function AdminUserGroupsPage() {
|
||||
{selectedGroup.scope === 'all_users' ? (
|
||||
<Chip size="small" variant="outlined" label="All users" />
|
||||
) : null}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{selectedGroup.id} · scope: {selectedGroup.scope === 'all_users' ? 'all users' : 'explicit'} · updated: {fmt(selectedGroup.updated_at)}
|
||||
</Typography>
|
||||
{selectedGroup.description ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{selectedGroup.description}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No description.
|
||||
</Typography>
|
||||
)}
|
||||
) : null}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Chip,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Typography
|
||||
} from '@mui/material'
|
||||
import { useEffect, useState } from 'react'
|
||||
@@ -15,6 +14,7 @@ import { ListRowActionButton, ListRowActions } from '../components/ListRowAction
|
||||
import SectionCard from '../components/SectionCard'
|
||||
import UserFormDialog, { UserFormState } from '../components/UserFormDialog'
|
||||
import { api, SubjectPermission, User, UserCreatePayload, UserUpdatePayload } from '../api'
|
||||
import CompactListItemText from '../components/CompactListItemText'
|
||||
|
||||
const projectCreatePermission = 'project.create'
|
||||
|
||||
@@ -267,16 +267,18 @@ export default function AdminUsersPage() {
|
||||
borderBottom: (theme) => `1px solid ${theme.palette.divider}`
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
|
||||
<ListItemText
|
||||
<CompactListItemText
|
||||
primary={
|
||||
<Typography>
|
||||
{u.display_name || u.username}({u.username})
|
||||
{u.disabled? (<> <Chip size="small" color='error' label='Disabled'/></>): null}
|
||||
</Typography>
|
||||
}
|
||||
secondary={`${u.is_admin ? 'admin' : 'user'} · source: ${u.auth_source || 'db'}${u.totp_required ? ' · TOTP required' : ''}${u.totp_enabled ? ' · TOTP enabled' : ''}${hasDirectProjectCreate(u.id) ? ' · direct project.create' : ''}`}
|
||||
sx={{ minWidth: 0, m: 0 }}
|
||||
secondary={
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{u.is_admin ? 'admin' : 'user'} · source: {u.auth_source || 'db'}{u.totp_required ? ' · TOTP required' : ''}{u.totp_enabled ? ' · TOTP enabled' : ''}{hasDirectProjectCreate(u.id) ? ' · direct project.create' : ''}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
<ListRowActions>
|
||||
<ListRowActionButton onClick={() => openEditUser(u)}>
|
||||
@@ -300,7 +302,6 @@ export default function AdminUsersPage() {
|
||||
Delete
|
||||
</ListRowActionButton>
|
||||
</ListRowActions>
|
||||
</Box>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
@@ -10,18 +10,19 @@ import FormDialogContent from '../components/FormDialogContent'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogTitle,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Typography
|
||||
} from '@mui/material'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
|
||||
import SectionCard from '../components/SectionCard'
|
||||
import { api, APIKey } from '../api'
|
||||
import CompactListItemText from '../components/CompactListItemText'
|
||||
|
||||
function formatUnix(value: number) {
|
||||
if (!value || value <= 0) {
|
||||
@@ -194,10 +195,19 @@ export default function ApiKeysPage() {
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
|
||||
<ListItemText
|
||||
primary={key.disabled ? `${key.name} (disabled)` : key.name}
|
||||
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'}`}
|
||||
sx={{ minWidth: 0, m: 0 }}
|
||||
<CompactListItemText
|
||||
primary={
|
||||
<Typography>
|
||||
{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
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
Link,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography
|
||||
@@ -23,6 +22,7 @@ import PKICADetailsDialog from '../components/PKICADetailsDialog'
|
||||
import PKICertDetailsDialog from '../components/PKICertDetailsDialog'
|
||||
import SectionCard from '../components/SectionCard'
|
||||
import { api, PKICADetail, PKIClientIssueResponse, PKIClientIssuance, PKIClientProfile, PKICertDetail } from '../api'
|
||||
import CompactListItemText from '../components/CompactListItemText'
|
||||
|
||||
function fmt(value: number): string {
|
||||
if (!value || value <= 0) {
|
||||
@@ -304,7 +304,7 @@ export default function ClientCertificatesPage() {
|
||||
})
|
||||
|
||||
const issuanceStatusColor = (status: string): 'default' | 'success' | 'warning' | 'error' => {
|
||||
if (status === 'issued' || status === 'valid') {
|
||||
if (status === 'issued' || status === 'valid' || status === 'active') {
|
||||
return 'success'
|
||||
}
|
||||
if (status === 'revoked') {
|
||||
@@ -347,9 +347,9 @@ export default function ClientCertificatesPage() {
|
||||
borderBottom: (theme) => `1px solid ${theme.palette.divider}`
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
|
||||
<ListItemText
|
||||
primary={(
|
||||
<CompactListItemText
|
||||
primary={
|
||||
<Typography>
|
||||
<Tooltip title={profile.id} arrow>
|
||||
<Link
|
||||
underline="hover"
|
||||
@@ -359,9 +359,10 @@ export default function ClientCertificatesPage() {
|
||||
{`${profile.name} (${profile.id})`}
|
||||
</Link>
|
||||
</Tooltip>
|
||||
)}
|
||||
secondary={(
|
||||
<Typography component="span" variant="body2" color="text.secondary">
|
||||
</Typography>
|
||||
}
|
||||
secondary={
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
CA:{' '}
|
||||
<Tooltip title={profile.ca_id} arrow>
|
||||
<Link
|
||||
@@ -374,25 +375,23 @@ export default function ClientCertificatesPage() {
|
||||
</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>
|
||||
)}
|
||||
sx={{ minWidth: 0, m: 0 }}
|
||||
}
|
||||
/>
|
||||
<ListRowActions>
|
||||
<ListRowActionButton startIcon={<KeyIcon />} onClick={() => openIssue(profile)}>
|
||||
Issue
|
||||
</ListRowActionButton>
|
||||
</ListRowActions>
|
||||
</Box>
|
||||
</ListItem>
|
||||
))}
|
||||
{!loading && profiles.length === 0 ? (
|
||||
<ListItem>
|
||||
<ListItemText primary="No client certificate profiles are available to you." />
|
||||
<CompactListItemText primary="No client certificate profiles are available to you." />
|
||||
</ListItem>
|
||||
) : null}
|
||||
{!loading && profiles.length > 0 && filteredProfiles.length === 0 ? (
|
||||
<ListItem>
|
||||
<ListItemText primary="No matching client certificate profiles found." />
|
||||
<CompactListItemText primary="No matching client certificate profiles found." />
|
||||
</ListItem>
|
||||
) : null}
|
||||
</List>
|
||||
@@ -424,21 +423,21 @@ export default function ClientCertificatesPage() {
|
||||
borderBottom: (theme) => `1px solid ${theme.palette.divider}`
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
|
||||
<ListItemText
|
||||
primary={(
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Box component="span">{`${item.common_name} (${item.cert_id})`}</Box>
|
||||
<CompactListItemText
|
||||
primary={
|
||||
<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 === 'issued' || item.status === 'valid' ? 'filled' : 'outlined'}
|
||||
variant={item.status === 'deleted' ? 'outlined' : 'filled'}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
secondary={(
|
||||
<Typography component="span" variant="body2" color="text.secondary">
|
||||
</Typography>
|
||||
}
|
||||
secondary={
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
profile:{' '}
|
||||
{profiles.some((profile) => profile.id === item.profile_id) ? (
|
||||
<Tooltip title={item.profile_id} arrow>
|
||||
@@ -453,10 +452,9 @@ export default function ClientCertificatesPage() {
|
||||
) : (
|
||||
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}` : ''}{item.status === 'deleted' ? ' · deleted from Admin PKI' : ''}
|
||||
{' '}· 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>
|
||||
)}
|
||||
sx={{ minWidth: 0, m: 0 }}
|
||||
}
|
||||
/>
|
||||
<ListRowActions>
|
||||
<ListRowActionButton startIcon={<VisibilityIcon />} onClick={() => openView(item)} disabled={busy || item.status === 'deleted'}>
|
||||
@@ -474,17 +472,16 @@ export default function ClientCertificatesPage() {
|
||||
Revoke
|
||||
</ListRowActionButton>
|
||||
</ListRowActions>
|
||||
</Box>
|
||||
</ListItem>
|
||||
))}
|
||||
{!loading && issuances.length === 0 ? (
|
||||
<ListItem>
|
||||
<ListItemText primary="No client certificates issued yet." />
|
||||
<CompactListItemText primary="No client certificates issued yet." />
|
||||
</ListItem>
|
||||
) : null}
|
||||
{!loading && issuances.length > 0 && filteredIssuances.length === 0 ? (
|
||||
<ListItem>
|
||||
<ListItemText primary="No matching client certificates found." />
|
||||
<CompactListItemText primary="No matching client certificates found." />
|
||||
</ListItem>
|
||||
) : null}
|
||||
</List>
|
||||
|
||||
@@ -3,6 +3,8 @@ import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
||||
import {
|
||||
Box,
|
||||
Link,
|
||||
List,
|
||||
ListItem,
|
||||
MenuItem,
|
||||
TextField,
|
||||
Tooltip,
|
||||
@@ -13,6 +15,7 @@ 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 MonospaceTextField from '../components/MonospaceTextField'
|
||||
import SectionCard from '../components/SectionCard'
|
||||
import SSHCertificateInspectDialog from '../components/SSHCertificateInspectDialog'
|
||||
@@ -378,17 +381,12 @@ 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}
|
||||
{issuances.map((item) => (
|
||||
<Box
|
||||
key={item.id}
|
||||
sx={{
|
||||
p: 1,
|
||||
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, minWidth: 0 }}>
|
||||
<Box sx={{ display: 'grid', gap: 0.5, minWidth: 0, flexGrow: 1 }}>
|
||||
<Typography variant="body2">
|
||||
<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"
|
||||
@@ -400,6 +398,9 @@ export default function SSHCertificatesPage() {
|
||||
</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>
|
||||
@@ -407,13 +408,16 @@ export default function SSHCertificatesPage() {
|
||||
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>
|
||||
</Box>
|
||||
</Box>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</SectionCard>
|
||||
|
||||
<SSHCertificateInspectDialog
|
||||
|
||||
@@ -10,7 +10,6 @@ import DialogTitle from '@mui/material/DialogTitle'
|
||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
import List from '@mui/material/List'
|
||||
import ListItem from '@mui/material/ListItem'
|
||||
import ListItemText from '@mui/material/ListItemText'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useEffect, useState } from 'react'
|
||||
@@ -22,6 +21,7 @@ import MonospaceTextField from '../components/MonospaceTextField'
|
||||
import SectionCard from '../components/SectionCard'
|
||||
import SSHCredentialDetailsDialog from '../components/SSHCredentialDetailsDialog'
|
||||
import { api, SSHCredential, SSHCredentialUpsertPayload } from '../api'
|
||||
import CompactListItemText from '../components/CompactListItemText'
|
||||
|
||||
type FormState = {
|
||||
name: string
|
||||
@@ -145,15 +145,18 @@ export default function SSHCredentialsPage() {
|
||||
<List>
|
||||
{items.map((item) => (
|
||||
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
|
||||
<ListItemText
|
||||
<CompactListItemText
|
||||
primary={
|
||||
<Typography>
|
||||
{item.name}({item.id})
|
||||
{item.enabled? null: (<> <Chip size="small" color='error' label='Disabled'/></>)}
|
||||
</Typography>
|
||||
}
|
||||
secondary={`${item.fingerprint || item.id} · ${item.type} · Updated: ${fmt(item.updated_at)}`}
|
||||
sx={{ minWidth: 0, m: 0 }}
|
||||
secondary={
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{item.fingerprint || item.id} · {item.type} · Updated: {fmt(item.updated_at)}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
<ListRowActions>
|
||||
<ListRowActionButton onClick={() => setViewItem(item)}>View</ListRowActionButton>
|
||||
|
||||
@@ -2,11 +2,13 @@ import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Link from '@mui/material/Link'
|
||||
import Paper from '@mui/material/Paper'
|
||||
import List from '@mui/material/List'
|
||||
import ListItem from '@mui/material/ListItem'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import CompactListItemText from '../components/CompactListItemText'
|
||||
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
|
||||
import SSHCredentialsPromptDialog from '../components/SSHCredentialsPromptDialog'
|
||||
import SSHAccessProfileDetailsDialog from '../components/SSHAccessProfileDetailsDialog'
|
||||
@@ -614,49 +616,23 @@ export default function SSHServersPage() {
|
||||
}
|
||||
|
||||
const renderProfileList = (list: SSHAccessProfile[], emptyText: string) => (
|
||||
<Box sx={{ display: 'grid', gap: 0 }}>
|
||||
<Box>
|
||||
{!list.length ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{emptyText}
|
||||
</Typography>
|
||||
) : null}
|
||||
{list.map((item) => (
|
||||
<Paper
|
||||
key={item.id}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 1,
|
||||
display: 'grid',
|
||||
gap: 0.5,
|
||||
backgroundColor: 'transparent',
|
||||
backgroundImage: 'none',
|
||||
borderLeft: 'none',
|
||||
borderRight: 'none',
|
||||
borderTop: 'none',
|
||||
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: 0,
|
||||
boxShadow: 'none'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Typography variant="subtitle2" sx={{ wordBreak: 'break-word' }}>
|
||||
<List>
|
||||
{list.map((item: SSHAccessProfile) => (
|
||||
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
|
||||
<CompactListItemText
|
||||
primary={
|
||||
<Typography sx={{ wordBreak: 'break-word' }}>
|
||||
{item.name}
|
||||
</Typography>
|
||||
<ListRowActions>
|
||||
<ListRowActionButton onClick={() => setViewItem(item)}>View</ListRowActionButton>
|
||||
{item.owner_scope === 'user' ? (
|
||||
<ListRowActionButton onClick={() => openEdit(item)}>Edit</ListRowActionButton>
|
||||
) : null}
|
||||
{item.owner_scope === 'user' ? (
|
||||
<ListRowActionButton color="error" onClick={() => { setDeleteItem(item); setDeleteConfirm('') }}>
|
||||
Delete
|
||||
</ListRowActionButton>
|
||||
) : null}
|
||||
<ListRowActionButton onClick={() => void handleConnect(item)} disabled={connectingID === item.id}>
|
||||
{connectingID === item.id ? 'Connecting...' : 'Connect'}
|
||||
</ListRowActionButton>
|
||||
</ListRowActions>
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
<Box sx={{ display: 'grid', gap: 0.5 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
||||
{item.server_target_type === 'group' ? (
|
||||
<Link
|
||||
@@ -689,49 +665,47 @@ export default function SSHServersPage() {
|
||||
</Link>
|
||||
) : item.auth_public_key_fingerprint || '-'}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<ListRowActions>
|
||||
<ListRowActionButton onClick={() => setViewItem(item)}>View</ListRowActionButton>
|
||||
{item.owner_scope === 'user' ? (
|
||||
<ListRowActionButton onClick={() => openEdit(item)}>Edit</ListRowActionButton>
|
||||
) : null}
|
||||
{item.owner_scope === 'user' ? (
|
||||
<ListRowActionButton color="error" onClick={() => { setDeleteItem(item); setDeleteConfirm('') }}>
|
||||
Delete
|
||||
</ListRowActionButton>
|
||||
) : null}
|
||||
<ListRowActionButton onClick={() => void handleConnect(item)} disabled={connectingID === item.id}>
|
||||
{connectingID === item.id ? 'Connecting...' : 'Connect'}
|
||||
</ListRowActionButton>
|
||||
</ListRowActions>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)
|
||||
|
||||
const renderServerList = (list: SSHServer[], emptyText: string) => (
|
||||
<Box sx={{ display: 'grid', gap: 0 }}>
|
||||
<Box>
|
||||
{!list.length ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{emptyText}
|
||||
</Typography>
|
||||
) : null}
|
||||
{list.map((item) => (
|
||||
<Paper
|
||||
key={item.id}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 1,
|
||||
display: 'grid',
|
||||
gap: 0.5,
|
||||
backgroundColor: 'transparent',
|
||||
backgroundImage: 'none',
|
||||
borderLeft: 'none',
|
||||
borderRight: 'none',
|
||||
borderTop: 'none',
|
||||
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: 0,
|
||||
boxShadow: 'none'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Typography variant="subtitle2" sx={{ wordBreak: 'break-word' }}>
|
||||
<List>
|
||||
{list.map((item: SSHServer) => (
|
||||
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
|
||||
<CompactListItemText
|
||||
primary={
|
||||
<Typography sx={{ wordBreak: 'break-word' }}>
|
||||
{item.name}
|
||||
</Typography>
|
||||
<ListRowActions>
|
||||
<ListRowActionButton onClick={() => setViewServerItem(item)}>View</ListRowActionButton>
|
||||
<ListRowActionButton onClick={() => openServerEdit(item)}>Edit</ListRowActionButton>
|
||||
<ListRowActionButton color="error" onClick={() => { setDeleteServerItem(item); setDeleteServerConfirm('') }}>
|
||||
Delete
|
||||
</ListRowActionButton>
|
||||
<ListRowActionButton onClick={() => void openHostKeys(item)}>Host Keys</ListRowActionButton>
|
||||
</ListRowActions>
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
<Box sx={{ display: 'grid', gap: 0.5 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
||||
{item.host}:{item.port} · {item.enabled ? 'enabled' : 'disabled'}
|
||||
</Typography>
|
||||
@@ -743,8 +717,20 @@ export default function SSHServersPage() {
|
||||
{item.description}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Paper>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<ListRowActions>
|
||||
<ListRowActionButton onClick={() => setViewServerItem(item)}>View</ListRowActionButton>
|
||||
<ListRowActionButton onClick={() => openServerEdit(item)}>Edit</ListRowActionButton>
|
||||
<ListRowActionButton color="error" onClick={() => { setDeleteServerItem(item); setDeleteServerConfirm('') }}>
|
||||
Delete
|
||||
</ListRowActionButton>
|
||||
<ListRowActionButton onClick={() => void openHostKeys(item)}>Host Keys</ListRowActionButton>
|
||||
</ListRowActions>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user