Compare commits

..

4 Commits

25 changed files with 850 additions and 848 deletions
+1
View File
@@ -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>
))}
+13 -5
View File
@@ -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>
+49 -21
View File
@@ -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)}>
+9 -7
View File
@@ -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>
+26 -28
View File
@@ -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">
+18 -15
View File
@@ -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
+14 -4
View File
@@ -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)}>
+58 -103
View File
@@ -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>
+26 -33
View File
@@ -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>
+51 -56
View File
@@ -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>
+20 -24
View File
@@ -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">
+7 -6
View File
@@ -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>
+15 -5
View File
@@ -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
+26 -29
View File
@@ -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>
+17 -13
View File
@@ -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
+7 -4
View File
@@ -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>
+55 -69
View File
@@ -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>
)