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 '', description TEXT NOT NULL DEFAULT '',
tags_json TEXT NOT NULL DEFAULT '[]', tags_json TEXT NOT NULL DEFAULT '[]',
enabled INTEGER NOT NULL DEFAULT 1, enabled INTEGER NOT NULL DEFAULT 1,
host_key_policy TEXT NOT NULL DEFAULT 'strict',
created_by_kind TEXT NOT NULL DEFAULT 'user', created_by_kind TEXT NOT NULL DEFAULT 'user',
created_by_subject_id TEXT NOT NULL DEFAULT '', created_by_subject_id TEXT NOT NULL DEFAULT '',
created_by_subject_name 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 List from '@mui/material/List'
import ListItem from '@mui/material/ListItem' import ListItem from '@mui/material/ListItem'
import ListItemIcon from '@mui/material/ListItemIcon' import ListItemIcon from '@mui/material/ListItemIcon'
import ListItemText from '@mui/material/ListItemText'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import FormDialogContent from './FormDialogContent' import FormDialogContent from './FormDialogContent'
import { SSHAccessProfile, SSHServer } from '../api' import { SSHAccessProfile, SSHServer } from '../api'
import CompactListItemText from './CompactListItemText'
type SSHServerGroupConnectDialogProps = { type SSHServerGroupConnectDialogProps = {
open: boolean open: boolean
@@ -120,11 +120,10 @@ export default function SSHServerGroupConnectDialog(props: SSHServerGroupConnect
sx={{ p: 0.5 }} sx={{ p: 0.5 }}
/> />
</ListItemIcon> </ListItemIcon>
<ListItemText <CompactListItemText
primary={item.name} primary={item.name}
secondary={`${item.host}:${item.port}`} secondary={`${item.host}:${item.port}`}
secondaryTypographyProps={{ variant: 'caption' }} secondaryTypographyProps={{ variant: 'caption' }}
sx={{ minWidth: 0, m: 0 }}
/> />
</ListItem> </ListItem>
) )
@@ -6,11 +6,11 @@ import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import List from '@mui/material/List' import List from '@mui/material/List'
import ListItem from '@mui/material/ListItem' import ListItem from '@mui/material/ListItem'
import ListItemText from '@mui/material/ListItemText'
import TextField from '@mui/material/TextField' import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import FormDialogContent from './FormDialogContent' import FormDialogContent from './FormDialogContent'
import { SSHServer, SSHServerGroup } from '../api' import { SSHServer, SSHServerGroup } from '../api'
import CompactListItemText from './CompactListItemText'
type SSHServerGroupDetailsDialogProps = { type SSHServerGroupDetailsDialogProps = {
item: SSHServerGroup | null item: SSHServerGroup | null
@@ -61,7 +61,7 @@ export default function SSHServerGroupDetailsDialog(props: SSHServerGroupDetails
<List disablePadding> <List disablePadding>
{memberServers.map((server: SSHServer) => ( {memberServers.map((server: SSHServer) => (
<ListItem key={server.id} divider sx={{ alignItems: 'flex-start' }}> <ListItem key={server.id} divider sx={{ alignItems: 'flex-start' }}>
<ListItemText <CompactListItemText
primary={ primary={
<Typography> <Typography>
{server.name} {server.name}
@@ -69,7 +69,6 @@ export default function SSHServerGroupDetailsDialog(props: SSHServerGroupDetails
</Typography> </Typography>
} }
secondary={`${server.host}:${server.port}${server.description ? ` · ${server.description}` : ''}`} secondary={`${server.host}:${server.port}${server.description ? ` · ${server.description}` : ''}`}
sx={{ minWidth: 0, m: 0 }}
/> />
</ListItem> </ListItem>
))} ))}
+13 -5
View File
@@ -8,7 +8,6 @@ import {
List, List,
ListItemIcon, ListItemIcon,
ListItem, ListItem,
ListItemText,
MenuItem, MenuItem,
TextField, TextField,
Typography Typography
@@ -19,6 +18,7 @@ import { ListRowActionButton, ListRowActions } from '../components/ListRowAction
import SectionCard from '../components/SectionCard' import SectionCard from '../components/SectionCard'
import { AdminAPIKey, api } from '../api' import { AdminAPIKey, api } from '../api'
import SelectField from '../components/SelectField' import SelectField from '../components/SelectField'
import CompactListItemText from '../components/CompactListItemText'
function formatUnix(value: number) { function formatUnix(value: number) {
if (!value || value <= 0) { if (!value || value <= 0) {
@@ -203,7 +203,7 @@ export default function AdminApiKeysPage() {
indeterminate={selected.length > 0 && selected.length < keys.length} indeterminate={selected.length > 0 && selected.length < keys.length}
onChange={(event) => handleSelectAll(event.target.checked)} onChange={(event) => handleSelectAll(event.target.checked)}
/> />
<ListItemText primary="Select all" /> <CompactListItemText primary="Select all" />
</ListItem> </ListItem>
) : null} ) : null}
{keys.map((key) => ( {keys.map((key) => (
@@ -222,15 +222,23 @@ export default function AdminApiKeysPage() {
/> />
</ListItemIcon> </ListItemIcon>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}> <Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
<ListItemText <CompactListItemText
primary={ primary={
<Typography> <Typography>
{key.name}({key.prefix}) {key.name}({key.prefix})
{key.disabled? (<> <Chip size="small" color='error' label='Disabled'/></>): null} {key.disabled? (<> <Chip size="small" color='error' label='Disabled'/></>): null}
</Typography> </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)}`} secondary={
sx={{ minWidth: 0, m: 0 }} <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> <ListRowActions>
<ListRowActionButton <ListRowActionButton
@@ -8,7 +8,6 @@ import {
Link, Link,
List, List,
ListItem, ListItem,
ListItemText,
Typography Typography
} from '@mui/material' } from '@mui/material'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
@@ -21,6 +20,7 @@ import PKICADetailsDialog from '../components/PKICADetailsDialog'
import SectionCard from '../components/SectionCard' import SectionCard from '../components/SectionCard'
import { api, PKICA, PKICADetail, PKIClientProfile, PKIClientProfileUpsertPayload, User, UserGroup } from '../api' import { api, PKICA, PKICADetail, PKIClientProfile, PKIClientProfileUpsertPayload, User, UserGroup } from '../api'
import { AppBrand, useBrand } from '../branding' import { AppBrand, useBrand } from '../branding'
import CompactListItemText from '../components/CompactListItemText'
function fmt(value: number): string { function fmt(value: number): string {
if (!value || value <= 0) { 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 }}> <Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
<ListItemText <CompactListItemText
primary={ primary={
<Typography> <Typography>
{item.name}({item.id}) {item.name}({item.id})
@@ -297,7 +297,7 @@ export default function AdminPKIClientProfilesPage() {
</Typography> </Typography>
} }
secondary={ secondary={
<> <Typography variant="caption" color="text.secondary">
CA:{' '} CA:{' '}
<Link <Link
underline="hover" underline="hover"
@@ -308,9 +308,8 @@ export default function AdminPKIClientProfilesPage() {
</Link> </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)} · 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> <ListRowActions>
<ListRowActionButton onClick={() => openView(item)}> <ListRowActionButton onClick={() => openView(item)}>
@@ -335,7 +334,7 @@ export default function AdminPKIClientProfilesPage() {
))} ))}
{!loading && items.length === 0 ? ( {!loading && items.length === 0 ? (
<ListItem> <ListItem>
<ListItemText primary="No client certificate profiles." /> <CompactListItemText primary="No client certificate profiles." />
</ListItem> </ListItem>
) : null} ) : null}
</List> </List>
+49 -21
View File
@@ -10,7 +10,6 @@ import {
Link, Link,
List, List,
ListItem, ListItem,
ListItemText,
MenuItem, MenuItem,
TextField, TextField,
Tooltip, Tooltip,
@@ -28,6 +27,7 @@ import { PKICertImportDialog, PKICertIssueDialog, PKICertRevokeDialog } from '..
import SectionCard from '../components/SectionCard' import SectionCard from '../components/SectionCard'
import { ACMEOrder, ACMEOrderCreatePayload, ACMEProfile, ACMEProfileUpsertPayload, api, PKICA, PKICADetail, PKICert, PKICertDetail, PKICertImportPayload, PKICertIssuePayload, PKIIntermediateCARequest, PKIRootCARequest } from '../api' import { ACMEOrder, ACMEOrderCreatePayload, ACMEProfile, ACMEProfileUpsertPayload, api, PKICA, PKICADetail, PKICert, PKICertDetail, PKICertImportPayload, PKICertIssuePayload, PKIIntermediateCARequest, PKIRootCARequest } from '../api'
import SelectField from '../components/SelectField' import SelectField from '../components/SelectField'
import CompactListItemText from '../components/CompactListItemText'
function fmt(ts: number): string { function fmt(ts: number): string {
if (!ts || ts <= 0) { if (!ts || ts <= 0) {
@@ -723,9 +723,9 @@ export default function AdminPKIPage() {
{filteredCAs.map((ca) => ( {filteredCAs.map((ca) => (
<ListItem key={ca.id} divider sx={{ alignItems: 'flex-start' }}> <ListItem key={ca.id} divider sx={{ alignItems: 'flex-start' }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}> <Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
<ListItemText <CompactListItemText
primary={( primary={(
<Box component="span"> <Typography>
<Tooltip title={ca.id} arrow> <Tooltip title={ca.id} arrow>
<Link <Link
underline="hover" underline="hover"
@@ -736,10 +736,13 @@ export default function AdminPKIPage() {
</Link> </Link>
</Tooltip> </Tooltip>
{` (${ca.id})`} {` (${ca.id})`}
</Box> </Typography>
)} )}
secondary={`${ca.is_root ? 'root' : 'intermediate'} · status: ${ca.status} · parent: ${ca.parent_ca_id || '-'} · updated: ${fmt(ca.updated_at)}`} secondary={
sx={{ minWidth: 0, m: 0 }} <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> <ListRowActions>
<ListRowActionButton <ListRowActionButton
@@ -776,7 +779,7 @@ export default function AdminPKIPage() {
))} ))}
{filteredCAs.length === 0 ? ( {filteredCAs.length === 0 ? (
<ListItem> <ListItem>
<ListItemText primary="No certificate authorities found." /> <CompactListItemText primary="No certificate authorities found." />
</ListItem> </ListItem>
) : null} ) : null}
</List> </List>
@@ -861,10 +864,10 @@ export default function AdminPKIPage() {
{filteredCerts.map((cert) => ( {filteredCerts.map((cert) => (
<ListItem key={cert.id} divider sx={{ alignItems: 'flex-start' }}> <ListItem key={cert.id} divider sx={{ alignItems: 'flex-start' }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}> <Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
<ListItemText <CompactListItemText
primary={`${cert.common_name} (${cert.id})`} primary={<Typography>{cert.common_name} ({cert.id})</Typography>}
secondary={( secondary={(
<Box component="span"> <Typography variant="caption" color="text.secondary">
{`serial: ${cert.serial_hex} · ca: `} {`serial: ${cert.serial_hex} · ca: `}
{cert.ca_id ? ( {cert.ca_id ? (
<Tooltip title={cert.ca_id} arrow> <Tooltip title={cert.ca_id} arrow>
@@ -880,9 +883,8 @@ export default function AdminPKIPage() {
'standalone' '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)}` : ''}`} {` · 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> <ListRowActions>
<ListRowActionButton onClick={() => openCertView(cert.id)}> <ListRowActionButton onClick={() => openCertView(cert.id)}>
@@ -911,7 +913,7 @@ export default function AdminPKIPage() {
))} ))}
{filteredCerts.length === 0 ? ( {filteredCerts.length === 0 ? (
<ListItem> <ListItem>
<ListItemText primary="No issued certificates found." /> <CompactListItemText primary="No issued certificates found." />
</ListItem> </ListItem>
) : null} ) : null}
</List> </List>
@@ -931,10 +933,24 @@ export default function AdminPKIPage() {
{acmeProfiles.map((item) => ( {acmeProfiles.map((item) => (
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}> <ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}> <Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
<ListItemText <CompactListItemText
primary={`${item.name} (${item.id})`} primary={<Typography>{item.name} ({item.id})</Typography>}
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}` : ''}`} secondary={
sx={{ minWidth: 0, m: 0 }} <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> <ListRowActions>
<ListRowActionButton onClick={() => openEditACME(item)}> <ListRowActionButton onClick={() => openEditACME(item)}>
@@ -1021,10 +1037,22 @@ export default function AdminPKIPage() {
{acmeOrders.map((item) => ( {acmeOrders.map((item) => (
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}> <ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}> <Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
<ListItemText <CompactListItemText
primary={`${item.common_name} (${item.id})`} primary={
secondary={`profile: ${item.profile_id} · status: ${item.status} · cert: ${item.cert_id || '-'} · created: ${fmt(item.created_at)}${item.last_error ? ` · error: ${item.last_error}` : ''}`} <Typography>{item.common_name} ({item.id})</Typography>
sx={{ minWidth: 0, m: 0 }} }
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> <ListRowActions>
<ListRowActionButton onClick={() => setACMEOrderView(item)}> <ListRowActionButton onClick={() => setACMEOrderView(item)}>
@@ -5,11 +5,13 @@ import Dialog from '@mui/material/Dialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import Link from '@mui/material/Link' 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 TextField from '@mui/material/TextField'
import Tooltip from '@mui/material/Tooltip' import Tooltip from '@mui/material/Tooltip'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import CompactListItemText from '../components/CompactListItemText'
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog' import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
import FormDialogContent from '../components/FormDialogContent' import FormDialogContent from '../components/FormDialogContent'
import SSHAccessProfileDetailsDialog from '../components/SSHAccessProfileDetailsDialog' import SSHAccessProfileDetailsDialog from '../components/SSHAccessProfileDetailsDialog'
@@ -386,103 +388,92 @@ export default function AdminSSHAccessProfilesPage() {
Loading... Loading...
</Typography> </Typography>
) : null} ) : null}
<Box sx={{ display: 'grid', gap: 0 }}> {filteredItems.length === 0 && !loading ? (
{filteredItems.length === 0 && !loading ? ( <Typography variant="body2" color="text.secondary">
<Typography variant="body2" color="text.secondary"> No SSH access profiles found.
No SSH access profiles found. </Typography>
</Typography> ) : null}
) : null} <List>
{filteredItems.map((item) => ( {filteredItems.map((item) => (
<Paper <ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
key={item.id} <CompactListItemText
variant="outlined" primary={
sx={{ <Typography>{item.name}</Typography>
p: 1, }
display: 'grid', secondary={
gap: 0.5, <Box sx={{ display: 'grid' }}>
backgroundColor: 'transparent', <Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
backgroundImage: 'none', {item.server_target_type === 'group' ? (
borderLeft: 'none', <Tooltip title={item.server_group_id || '-'} arrow>
borderRight: 'none', <Link
borderTop: 'none', underline="hover"
borderBottom: (theme) => `1px solid ${theme.palette.divider}`, onClick={() => openServerGroupView(item)}
borderRadius: 0, sx={{ cursor: 'default' }}
boxShadow: 'none' >
}} Group: {item.server_group?.name || item.server_group_id || '-'}
> </Link>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}> </Tooltip>
<Typography variant="subtitle2" sx={{ wordBreak: 'break-word' }}> ) : (
{item.name} <Tooltip title={item.server_id} arrow>
</Typography> <Link
<ListRowActions> underline="hover"
<ListRowActionButton onClick={() => setViewItem(item)}>View</ListRowActionButton> onClick={() => void openServerView(item.server_id)}
<ListRowActionButton onClick={() => openEdit(item)}>Edit</ListRowActionButton> sx={{ cursor: 'default' }}
<ListRowActionButton color="error" onClick={() => { setDeleteItem(item); setDeleteConfirm('') }}> >
Delete Server: {item.server?.name || item.server_id}
</ListRowActionButton> </Link>
</ListRowActions> </Tooltip>
</Box> )}
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}> {item.server_target_type === 'group' ? '' : ` · ${item.server?.host}:${item.server?.port}`} · {item.remote_username} · {item.auth_method} · 2FA: {item.second_factor_mode || 'none'} · {item.enabled ? 'enabled' : 'disabled'}
{item.server_target_type === 'group' ? ( </Typography>
<Tooltip title={item.server_group_id || '-'} arrow> <Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
<Link Targets: {(item.targets || []).map((target) => `${target.target_type}:${target.target_name}`).join(', ') || '-'}
underline="hover" </Typography>
onClick={() => openServerGroupView(item)} {item.auth_method === 'managed_ssh_cert' ? (
sx={{ cursor: 'default' }} <Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
> CA:{' '}
Group: {item.server_group?.name || item.server_group_id || '-'} {item.ssh_user_ca_id ? (
</Link> <Tooltip title={item.ssh_user_ca_id} arrow>
</Tooltip> <Link
) : ( underline="hover"
<Tooltip title={item.server_id} arrow> onClick={() => void openCAView(item.ssh_user_ca_id)}
<Link sx={{ cursor: 'default' }}
underline="hover" >
onClick={() => void openServerView(item.server_id)} {caNameByID[item.ssh_user_ca_id] || item.ssh_user_ca_id}
sx={{ cursor: 'default' }} </Link>
> </Tooltip>
Server: {item.server?.name || item.server_id} ) : '-'} · Grants: {(item.ssh_principal_grant_ids || []).join(', ') || '-'}
</Link> </Typography>
</Tooltip> ) : (
)} <Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
{item.server_target_type === 'group' ? '' : ` · ${item.server?.host}:${item.server?.port}`} · {item.remote_username} · {item.auth_method} · 2FA: {item.second_factor_mode || 'none'} · {item.enabled ? 'enabled' : 'disabled'} Key fingerprint:{' '}
</Typography> {item.ssh_credential_id && credentialByID[item.ssh_credential_id] ? (
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}> <Tooltip title={item.ssh_credential_id} arrow>
Targets: {(item.targets || []).map((target) => `${target.target_type}:${target.target_name}`).join(', ') || '-'} <Link
</Typography> underline="hover"
{item.auth_method === 'managed_ssh_cert' ? ( onClick={() => setViewCredentialItem(credentialByID[item.ssh_credential_id])}
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}> sx={{ cursor: 'default' }}
CA:{' '} >
{item.ssh_user_ca_id ? ( {item.auth_public_key_fingerprint || credentialByID[item.ssh_credential_id].fingerprint || item.ssh_credential_id}
<Tooltip title={item.ssh_user_ca_id} arrow> </Link>
<Link </Tooltip>
underline="hover" ) : item.auth_public_key_fingerprint || '-'}
onClick={() => void openCAView(item.ssh_user_ca_id)} </Typography>
sx={{ cursor: 'default' }} )}
> </Box>
{caNameByID[item.ssh_user_ca_id] || item.ssh_user_ca_id} }
</Link> disableTypography
</Tooltip> />
) : '-'} · Grants: {(item.ssh_principal_grant_ids || []).join(', ') || '-'} <ListRowActions>
</Typography> <ListRowActionButton onClick={() => setViewItem(item)}>View</ListRowActionButton>
) : ( <ListRowActionButton onClick={() => openEdit(item)}>Edit</ListRowActionButton>
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}> <ListRowActionButton color="error" onClick={() => { setDeleteItem(item); setDeleteConfirm('') }}>
Key fingerprint:{' '} Delete
{item.ssh_credential_id && credentialByID[item.ssh_credential_id] ? ( </ListRowActionButton>
<Tooltip title={item.ssh_credential_id} arrow> </ListRowActions>
<Link </ListItem>
underline="hover"
onClick={() => setViewCredentialItem(credentialByID[item.ssh_credential_id])}
sx={{ cursor: 'default' }}
>
{item.auth_public_key_fingerprint || credentialByID[item.ssh_credential_id].fingerprint || item.ssh_credential_id}
</Link>
</Tooltip>
) : item.auth_public_key_fingerprint || '-'}
</Typography>
)}
</Paper>
))} ))}
</Box> </List>
</SectionCard> </SectionCard>
<SSHAccessProfileFormDialog <SSHAccessProfileFormDialog
@@ -10,7 +10,6 @@ import DialogTitle from '@mui/material/DialogTitle'
import FormControlLabel from '@mui/material/FormControlLabel' import FormControlLabel from '@mui/material/FormControlLabel'
import List from '@mui/material/List' import List from '@mui/material/List'
import ListItem from '@mui/material/ListItem' import ListItem from '@mui/material/ListItem'
import ListItemText from '@mui/material/ListItemText'
import TextField from '@mui/material/TextField' import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
@@ -22,6 +21,7 @@ import MonospaceTextField from '../components/MonospaceTextField'
import SectionCard from '../components/SectionCard' import SectionCard from '../components/SectionCard'
import SSHCredentialDetailsDialog from '../components/SSHCredentialDetailsDialog' import SSHCredentialDetailsDialog from '../components/SSHCredentialDetailsDialog'
import { api, SSHCredential, SSHCredentialUpsertPayload } from '../api' import { api, SSHCredential, SSHCredentialUpsertPayload } from '../api'
import CompactListItemText from '../components/CompactListItemText'
type FormState = { type FormState = {
name: string name: string
@@ -145,15 +145,18 @@ export default function AdminSSHCredentialsPage() {
<List> <List>
{items.map((item) => ( {items.map((item) => (
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}> <ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
<ListItemText <CompactListItemText
primary={ primary={
<Typography> <Typography>
{item.name}({item.id}) {item.name}({item.id})
{item.enabled? null: (<> <Chip size="small" color='error' label='Disabled'/></>)} {item.enabled? null: (<> <Chip size="small" color='error' label='Disabled'/></>)}
</Typography> </Typography>
} }
secondary={`${item.fingerprint || item.id} · ${item.type} · Updated: ${fmt(item.updated_at)}`} secondary={
sx={{ minWidth: 0, m: 0 }} <Typography variant="caption" color="text.secondary">
{item.fingerprint || item.id} · {item.type} · Updated: {fmt(item.updated_at)}
</Typography>
}
/> />
<ListRowActions> <ListRowActions>
<ListRowActionButton onClick={() => setViewItem(item)}>View</ListRowActionButton> <ListRowActionButton onClick={() => setViewItem(item)}>View</ListRowActionButton>
@@ -10,7 +10,6 @@ import {
DialogTitle, DialogTitle,
List, List,
ListItem, ListItem,
ListItemText,
MenuItem, MenuItem,
TextField, TextField,
Typography Typography
@@ -24,6 +23,7 @@ import SSHPrincipalGrantDetailsDialog from '../components/SSHPrincipalGrantDetai
import SSHPrincipalGrantFormDialog, { SSHPrincipalGrantFormState } from '../components/SSHPrincipalGrantFormDialog' import SSHPrincipalGrantFormDialog, { SSHPrincipalGrantFormState } from '../components/SSHPrincipalGrantFormDialog'
import { api, SSHPrincipalGrant, SSHPrincipalGrantUpsertPayload, User, UserGroup } from '../api' import { api, SSHPrincipalGrant, SSHPrincipalGrantUpsertPayload, User, UserGroup } from '../api'
import SelectField from '../components/SelectField' import SelectField from '../components/SelectField'
import CompactListItemText from '../components/CompactListItemText'
function fmt(value: number): string { function fmt(value: number): string {
if (!value || value <= 0) { if (!value || value <= 0) {
@@ -309,10 +309,21 @@ export default function AdminSSHPrincipalGrantsPage() {
{items.map((item) => ( {items.map((item) => (
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}> <ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}> <Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
<ListItemText <CompactListItemText
primary={`${item.name || item.principal}${item.disabled ? ' (disabled)' : ''}`} primary={
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>
sx={{ minWidth: 0, m: 0 }} {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> <ListRowActions>
<ListRowActionButton onClick={() => openView(item)}> <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 Chip from '@mui/material/Chip'
import List from '@mui/material/List' import List from '@mui/material/List'
import ListItem from '@mui/material/ListItem' import ListItem from '@mui/material/ListItem'
import ListItemText from '@mui/material/ListItemText'
import TextField from '@mui/material/TextField' import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
@@ -20,6 +19,7 @@ import { SSHServerGroupFormState } from '../components/SSHServerGroupFormDialog'
import SSHServerGroupMembersDialog from '../components/SSHServerGroupMembersDialog' import SSHServerGroupMembersDialog from '../components/SSHServerGroupMembersDialog'
import SSHHostKeysDialog from '../components/SSHHostKeysDialog' import SSHHostKeysDialog from '../components/SSHHostKeysDialog'
import { api, SSHServer, SSHServerGroup, SSHServerGroupUpsertPayload, SSHServerHostKey, SSHServerUpsertPayload } from '../api' import { api, SSHServer, SSHServerGroup, SSHServerGroupUpsertPayload, SSHServerHostKey, SSHServerUpsertPayload } from '../api'
import CompactListItemText from '../components/CompactListItemText'
const emptyForm = (): SSHServerFormState => ({ const emptyForm = (): SSHServerFormState => ({
name: '', name: '',
@@ -444,7 +444,7 @@ export default function AdminSSHServersPage() {
<List> <List>
{filteredItems.map((item) => ( {filteredItems.map((item) => (
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}> <ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
<ListItemText <CompactListItemText
primary={ primary={
<Typography> <Typography>
{item.name}({item.id}) {item.name}({item.id})
@@ -452,12 +452,11 @@ export default function AdminSSHServersPage() {
</Typography> </Typography>
} }
secondary={ 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.host}:{item.port}· Tags: {(item.tags || []).join(', ') || '-'}· Updated: {fmt(item.updated_at)}
{item.description? (<><br />{item.description}</>): null} {item.description? (<><br />{item.description}</>): null}
</Typography> </Typography>
} }
sx={{ minWidth: 0, m: 0 }}
/> />
<ListRowActions> <ListRowActions>
<ListRowActionButton onClick={() => setViewItem(item)}>View</ListRowActionButton> <ListRowActionButton onClick={() => setViewItem(item)}>View</ListRowActionButton>
@@ -490,15 +489,18 @@ export default function AdminSSHServersPage() {
<List> <List>
{groups.map((item) => ( {groups.map((item) => (
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}> <ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
<ListItemText <CompactListItemText
primary={ primary={
<Typography> <Typography>
{item.name}({item.id}) {item.name}({item.id})
{item.enabled ? null : (<> <Chip size="small" color="error" label="Disabled" /></>)} {item.enabled ? null : (<> <Chip size="small" color="error" label="Disabled" /></>)}
</Typography> </Typography>
} }
secondary={`${(item.server_ids || []).length} servers · ${item.description || '-'}`} secondary={
sx={{ minWidth: 0, m: 0 }} <Typography variant="caption" color="text.secondary">
{(item.server_ids || []).length} servers · {item.description || '-'}
</Typography>
}
/> />
<ListRowActions> <ListRowActions>
<ListRowActionButton onClick={() => setViewGroupItem(item)}>View</ListRowActionButton> <ListRowActionButton onClick={() => setViewGroupItem(item)}>View</ListRowActionButton>
+31 -33
View File
@@ -3,11 +3,15 @@ import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Chip from '@mui/material/Chip' import Chip from '@mui/material/Chip'
import Link from '@mui/material/Link' 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 MenuItem from '@mui/material/MenuItem'
import TextField from '@mui/material/TextField' import TextField from '@mui/material/TextField'
import Tooltip from '@mui/material/Tooltip' import Tooltip from '@mui/material/Tooltip'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import CompactListItemText from '../components/CompactListItemText'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard' import SectionCard from '../components/SectionCard'
import SSHAccessProfileDetailsDialog from '../components/SSHAccessProfileDetailsDialog' import SSHAccessProfileDetailsDialog from '../components/SSHAccessProfileDetailsDialog'
import SSHServerDetailsDialog from '../components/SSHServerDetailsDialog' import SSHServerDetailsDialog from '../components/SSHServerDetailsDialog'
@@ -222,22 +226,22 @@ export default function AdminSSHSessionsPage() {
</Typography> </Typography>
) : null} ) : null}
{items.length ? ( {items.length ? (
<Box sx={{ display: 'grid', gap: 0 }}> <List>
{items.map((item) => ( {items.map((item) => (
<Box <ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
key={item.id} <CompactListItemText
sx={{ primary={
display: 'grid', <Typography>
gap: 0.5, {sessionTitle(item)}{' '}
py: 1, <Chip
borderBottom: (theme) => `1px solid ${theme.palette.divider}` label={item.status || 'unknown'}
}} size="small"
> color={item.status === 'connected'? 'success': (item.status === 'error'? 'error' : 'default')}
<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)}
</Typography> </Typography>
}
secondary={
<Box sx={{ display: 'grid' }}>
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}> <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)} User: {item.username} · Auth: {item.auth_method} · Started: {fmt(item.started_at)} · Ended: {fmt(item.ended_at)}
</Typography> </Typography>
@@ -272,26 +276,20 @@ export default function AdminSSHSessionsPage() {
</Typography> </Typography>
) : null} ) : null}
</Box> </Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}> }
<Chip disableTypography
label={item.status || 'unknown'} />
size="small" <ListRowActions>
color={item.status === 'connected' ? 'success' : item.status === 'error' ? 'error' : 'default'} <ListRowActionButton
variant={item.status === 'connected' ? 'filled' : 'outlined'} onClick={() => void openTranscript(item)}
/> disabled={!item.transcript_available}
<Button >
variant="outlined" Transcript
size="small" </ListRowActionButton>
onClick={() => void openTranscript(item)} </ListRowActions>
disabled={!item.transcript_available} </ListItem>
>
Transcript
</Button>
</Box>
</Box>
</Box>
))} ))}
</Box> </List>
) : null} ) : null}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}> <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
+58 -55
View File
@@ -8,6 +8,8 @@ import {
DialogContent, DialogContent,
DialogTitle, DialogTitle,
Link, Link,
List,
ListItem,
MenuItem, MenuItem,
TextField, TextField,
Tooltip, Tooltip,
@@ -15,6 +17,7 @@ import {
} from '@mui/material' } from '@mui/material'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions' import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import CompactListItemText from '../components/CompactListItemText'
import SectionCard from '../components/SectionCard' import SectionCard from '../components/SectionCard'
import SSHUserCADetailsDialog from '../components/SSHUserCADetailsDialog' import SSHUserCADetailsDialog from '../components/SSHUserCADetailsDialog'
import SSHPrincipalGrantDetailsDialog from '../components/SSHPrincipalGrantDetailsDialog' import SSHPrincipalGrantDetailsDialog from '../components/SSHPrincipalGrantDetailsDialog'
@@ -190,65 +193,65 @@ export default function AdminSSHSignHistoryPage() {
</Button> </Button>
</Box> </Box>
{!loading && items.length === 0 ? <Typography variant="body2" color="text.secondary">No signing history found.</Typography> : null} {!loading && items.length === 0 ? <Typography variant="body2" color="text.secondary">No signing history found.</Typography> : null}
{items.map((item) => ( <List>
<Box {items.map((item: SSHUserCAIssuance) => (
key={item.id} <ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
sx={{ <CompactListItemText
p: 1, primary={
borderBottom: (theme) => `1px solid ${theme.palette.divider}`, <Typography>
minWidth: 0 <Tooltip title={item.ca_id} arrow>
}} <Link
> underline="hover"
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, minWidth: 0 }}> onClick={() => openCAView(item).catch(() => {})}
<Box sx={{ display: 'grid', gap: 0.5, minWidth: 0, flexGrow: 1 }}> sx={{ cursor: 'default' }}
<Typography variant="body2"> >
<Tooltip title={item.ca_id} arrow> {item.ca_name || item.ca_id}
<Link </Link>
underline="hover" </Tooltip>
onClick={() => openCAView(item).catch(() => {})} {' '}· serial: {item.serial} · key_id: {item.key_id}
sx={{ cursor: 'default' }} </Typography>
> }
{item.ca_name || item.ca_id} secondary={
</Link> <Box sx={{ display: 'grid' }}>
</Tooltip> <Typography variant="caption" color="text.secondary">
{' '}· serial: {item.serial} · key_id: {item.key_id} by: {item.issuer_username || '-'} ({item.issuer_kind}) · {'principals:'}{' '}
</Typography> {item.grant_ids && item.grant_ids.length > 0 ? item.grant_ids.map((grantID: string, index: number) => (
<Typography variant="caption" color="text.secondary" component="div"> <Box key={grantID} component="span">
by: {item.issuer_username || '-'} ({item.issuer_kind}) · {'principals:'}{' '} {index > 0 ? ', ' : ''}
{item.grant_ids && item.grant_ids.length > 0 ? item.grant_ids.map((grantID: string, index: number) => ( <Tooltip title={item.grant_names[index] || grantID} arrow>
<Box key={grantID} component="span"> <Link
{index > 0 ? ', ' : ''} underline="hover"
<Tooltip title={item.grant_names[index] || grantID} arrow> onClick={() => openPrincipalGrantView(grantID).catch(() => {})}
<Link sx={{ cursor: 'default' }}
underline="hover" >
onClick={() => openPrincipalGrantView(grantID).catch(() => {})} {
sx={{ cursor: 'default' }} //(item.grant_names || [])[index] || grantID
> // show stored principals rather than the grant info
{ // but upon clocking on it, it shows the grant info dialog.
//(item.grant_names || [])[index] || grantID ((item.principals || []).join(', ') || '-')
// show stored principals rather than the grant info }
// but upon clocking on it, it shows the grant info dialog. </Link>
((item.principals || []).join(', ') || '-') </Tooltip>
} </Box>
</Link> )) : ((item.principals || []).join(', ') || '-')}
</Tooltip> </Typography>
</Box> <Typography variant="caption" color="text.secondary">
)) : ((item.principals || []).join(', ') || '-')} issued: {fmt(item.created_at)} · valid: {fmt(item.valid_after)} ~ {fmt(item.valid_before)}
</Typography> </Typography>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
issued: {fmt(item.created_at)} · valid: {fmt(item.valid_after)} ~ {fmt(item.valid_before)} source fp: {item.source_public_key_fingerprint} · remote: {item.remote_addr || '-'}
</Typography> </Typography>
<Typography variant="caption" color="text.secondary"> </Box>
source fp: {item.source_public_key_fingerprint} · remote: {item.remote_addr || '-'} }
</Typography> disableTypography
</Box> />
<ListRowActions> <ListRowActions>
<ListRowActionButton onClick={() => copyCert(item)}>Copy Cert</ListRowActionButton> <ListRowActionButton onClick={() => copyCert(item)}>Copy Cert</ListRowActionButton>
<ListRowActionButton onClick={() => inspectCert(item)} disabled={inspectBusy}>{inspectBusy ? 'Inspecting...' : 'Inspect'}</ListRowActionButton> <ListRowActionButton onClick={() => inspectCert(item)} disabled={inspectBusy}>{inspectBusy ? 'Inspecting...' : 'Inspect'}</ListRowActionButton>
</ListRowActions> </ListRowActions>
</Box> </ListItem>
</Box> ))}
))} </List>
</SectionCard> </SectionCard>
<SSHUserCADetailsDialog <SSHUserCADetailsDialog
+14 -4
View File
@@ -11,7 +11,6 @@ import {
DialogTitle, DialogTitle,
List, List,
ListItem, ListItem,
ListItemText,
TextField, TextField,
Typography Typography
} from '@mui/material' } from '@mui/material'
@@ -25,6 +24,7 @@ import SSHUserCADetailsDialog from '../components/SSHUserCADetailsDialog'
import SSHUserCAFormDialog, { SSHUserCAFormState } from '../components/SSHUserCAFormDialog' import SSHUserCAFormDialog, { SSHUserCAFormState } from '../components/SSHUserCAFormDialog'
import SSHCertificateInspectDialog from '../components/SSHCertificateInspectDialog' import SSHCertificateInspectDialog from '../components/SSHCertificateInspectDialog'
import { api, SSHSignUserKeyResponse, SSHUserCA, SSHUserCACreatePayload, SSHUserCASignPayload, SSHUserCAUpdatePayload } from '../api' import { api, SSHSignUserKeyResponse, SSHUserCA, SSHUserCACreatePayload, SSHUserCASignPayload, SSHUserCAUpdatePayload } from '../api'
import CompactListItemText from '../components/CompactListItemText'
function fmt(value: number): string { function fmt(value: number): string {
if (!value || value <= 0) { 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 }}> <Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
<ListItemText <CompactListItemText
primary={ primary={
<Typography> <Typography>
{item.name}({item.id}) {item.name}({item.id})
{item.enabled? null: (<> <Chip size="small" color='error' label='Disabled'/></>)} {item.enabled? null: (<> <Chip size="small" color='error' label='Disabled'/></>)}
</Typography> </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)}`} secondary={
sx={{ minWidth: 0, m: 0 }} <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> <ListRowActions>
<ListRowActionButton onClick={() => openView(item)}> <ListRowActionButton onClick={() => openView(item)}>
+144 -189
View File
@@ -12,8 +12,9 @@ import {
Dialog, Dialog,
DialogActions, DialogActions,
DialogTitle, DialogTitle,
List,
ListItem,
MenuItem, MenuItem,
Paper,
TextField, TextField,
Typography Typography
} from '@mui/material' } from '@mui/material'
@@ -22,6 +23,7 @@ import { ListRowActionButton, ListRowActions } from '../components/ListRowAction
import SectionCard from '../components/SectionCard' import SectionCard from '../components/SectionCard'
import { api, CertPrincipalBinding, PKICert, PKICertDetail, PrincipalAPIKey, PrincipalProjectRole, Project, ServicePrincipal } from '../api' import { api, CertPrincipalBinding, PKICert, PKICertDetail, PrincipalAPIKey, PrincipalProjectRole, Project, ServicePrincipal } from '../api'
import SelectField from '../components/SelectField' import SelectField from '../components/SelectField'
import CompactListItemText from '../components/CompactListItemText'
function fmt(ts: number): string { function fmt(ts: number): string {
if (!ts || ts <= 0) return '-' if (!ts || ts <= 0) return '-'
@@ -563,57 +565,43 @@ export default function AdminServicePrincipalsPage() {
} }
> >
{loading && principals.length === 0 ? <Typography variant="body2" color="text.secondary">Loading...</Typography> : null} {loading && principals.length === 0 ? <Typography variant="body2" color="text.secondary">Loading...</Typography> : null}
<Box sx={{ display: 'grid', gap: 0 }}> <List>
{principals.map((item) => ( {principals.map((item) => (
<Paper <ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
key={item.id} <CompactListItemText
variant="outlined" primary={
sx={{ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
p: 1, <Typography variant="body2">{item.name} ({item.id})</Typography>
backgroundColor: 'transparent', <Chip size="small" color={item.disabled ? 'default' : 'success'} label={item.disabled ? 'Disabled' : 'Active'} />
backgroundImage: 'none', {item.is_admin ? <Chip size="small" color="warning" label="Principal Admin" /> : null}
borderLeft: 'none', </Box>
borderRight: 'none', }
borderTop: 'none', secondary={`${item.description || '(no description)'} · updated: ${fmt(item.updated_at)}`}
borderBottom: (theme) => `1px solid ${theme.palette.divider}`, />
borderRadius: 0, <ListRowActions>
boxShadow: 'none' <ListRowActionButton onClick={() => openKeys(item)}>
}} API Keys
> </ListRowActionButton>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}> <ListRowActionButton color={item.is_admin ? 'warning' : 'primary'} onClick={() => togglePrincipalAdmin(item)}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> {item.is_admin ? 'Unset Admin' : 'Set Admin'}
<Typography variant="body2">{item.name} ({item.id})</Typography> </ListRowActionButton>
<Chip size="small" color={item.disabled ? 'default' : 'success'} label={item.disabled ? 'Disabled' : 'Active'} /> <ListRowActionButton color={item.disabled ? 'success' : 'warning'} onClick={() => togglePrincipal(item)}>
{item.is_admin ? <Chip size="small" color="warning" label="Principal Admin" /> : null} {item.disabled ? 'Enable' : 'Disable'}
</Box> </ListRowActionButton>
<ListRowActions> <ListRowActionButton
<ListRowActionButton onClick={() => openKeys(item)}> color="error"
API Keys onClick={() => {
</ListRowActionButton> setDeleteError(null)
<ListRowActionButton color={item.is_admin ? 'warning' : 'primary'} onClick={() => togglePrincipalAdmin(item)}> setDeletePrincipalTarget(item)
{item.is_admin ? 'Unset Admin' : 'Set Admin'} setDeletePrincipalConfirm('')
</ListRowActionButton> }}
<ListRowActionButton color={item.disabled ? 'success' : 'warning'} onClick={() => togglePrincipal(item)}> >
{item.disabled ? 'Enable' : 'Disable'} Delete
</ListRowActionButton> </ListRowActionButton>
<ListRowActionButton </ListRowActions>
color="error" </ListItem>
onClick={() => {
setDeleteError(null)
setDeletePrincipalTarget(item)
setDeletePrincipalConfirm('')
}}
>
Delete
</ListRowActionButton>
</ListRowActions>
</Box>
<Typography variant="caption" color="text.secondary">
{item.description || '(no description)'} · updated: {fmt(item.updated_at)}
</Typography>
</Paper>
))} ))}
</Box> </List>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}> <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. Global permissions are managed on each subject page. No service-principal global permissions are available yet.
</Typography> </Typography>
@@ -688,48 +676,45 @@ export default function AdminServicePrincipalsPage() {
Reset Reset
</Button> </Button>
</Box> </Box>
<Box sx={{ display: 'grid', gap: 0 }}> <List>
{filteredPrincipals.map((principal) => ( {filteredPrincipals.map((principal) => (
<Paper <ListItem key={principal.id} divider sx={{ alignItems: 'flex-start' }}>
key={principal.id} <CompactListItemText
variant="outlined" primary={<Typography variant="subtitle2">{principal.name}</Typography>}
sx={{ secondary={
p: 1, (principalRoles[principal.id] || []).length === 0 ? (
backgroundColor: 'transparent', <Typography variant="caption" color="text.secondary">No project roles</Typography>
backgroundImage: 'none', ) : (
borderLeft: 'none', <List disablePadding>
borderRight: 'none', {(principalRoles[principal.id] || []).map((role) => (
borderTop: 'none', <ListItem
borderBottom: (theme) => `1px solid ${theme.palette.divider}`, key={`${role.principal_id}:${role.project_id}`}
borderRadius: 0, disableGutters
boxShadow: 'none' secondaryAction={
}} <ListRowActionButton
> color="error"
<Typography variant="subtitle2">{principal.name}</Typography> onClick={() => {
{(principalRoles[principal.id] || []).length === 0 ? ( setDeleteError(null)
<Typography variant="caption" color="text.secondary">No project roles</Typography> setDeleteRoleTarget({ principalID: role.principal_id, projectID: role.project_id })
) : ( setDeleteRoleConfirm('')
<Box sx={{ display: 'grid', gap: 0.75, mt: 0.75 }}> }}
{(principalRoles[principal.id] || []).map((role) => ( >
<Box key={`${role.principal_id}:${role.project_id}`} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}> Delete
<Typography variant="body2" color="text.secondary">{projectName(role.project_id)} · {role.role}</Typography> </ListRowActionButton>
<ListRowActionButton }
color="error" >
onClick={() => { <CompactListItemText
setDeleteError(null) primary={<Typography variant="body2" color="text.secondary">{projectName(role.project_id)} · {role.role}</Typography>}
setDeleteRoleTarget({ principalID: role.principal_id, projectID: role.project_id }) />
setDeleteRoleConfirm('') </ListItem>
}} ))}
> </List>
Delete )
</ListRowActionButton> }
</Box> />
))} </ListItem>
</Box>
)}
</Paper>
))} ))}
</Box> </List>
</SectionCard> </SectionCard>
<SectionCard <SectionCard
@@ -742,66 +727,54 @@ export default function AdminServicePrincipalsPage() {
</Button> </Button>
} }
> >
<Box sx={{ display: 'grid', gap: 0 }}> <List>
{bindings.map((item) => ( {bindings.map((item) => (
<Paper <ListItem key={item.fingerprint} divider sx={{ alignItems: 'flex-start' }}>
key={item.fingerprint} <CompactListItemText
variant="outlined" primary={
sx={{ pkiCertByFingerprint(item.fingerprint) ? (
p: 1, <Link
backgroundColor: 'transparent', underline="hover"
backgroundImage: 'none', onClick={() => void openCertView(pkiCertByFingerprint(item.fingerprint)?.id || '')}
borderLeft: 'none', sx={{ cursor: 'default' }}
borderRight: 'none', >
borderTop: 'none', {pkiCertByFingerprint(item.fingerprint)?.common_name || pkiCertByFingerprint(item.fingerprint)?.serial_hex} ({pkiCertByFingerprint(item.fingerprint)?.id})
borderBottom: (theme) => `1px solid ${theme.palette.divider}`, </Link>
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">
<Link
underline="hover"
onClick={() => void openCertView(pkiCertByFingerprint(item.fingerprint)?.id || '')}
sx={{ cursor: 'default' }}
>
{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>
)} )
<Typography variant="caption" color="text.secondary">principal: {principalName(item.principal_id)} ({item.principal_id})</Typography> }
{pkiCertByFingerprint(item.fingerprint) ? ( secondary={
<Typography variant="caption" color="text.secondary"> <Box sx={{ display: 'grid' }}>
source: pki cert · fingerprint: {item.fingerprint} <Typography variant="caption" color="text.secondary">principal: {principalName(item.principal_id)} ({item.principal_id})</Typography>
</Typography> {pkiCertByFingerprint(item.fingerprint) ? (
) : ( <Typography variant="caption" color="text.secondary">
<Typography variant="caption" color="text.secondary">source: manual fingerprint</Typography> source: pki cert · fingerprint: {item.fingerprint}
)} </Typography>
</Box> ) : (
<ListRowActions> <Typography variant="caption" color="text.secondary">source: manual fingerprint</Typography>
<ListRowActionButton color={item.enabled ? 'warning' : 'success'} onClick={() => toggleBinding(item)}> )}
{item.enabled ? 'Disable' : 'Enable'} </Box>
</ListRowActionButton> }
<ListRowActionButton />
color="error" <ListRowActions>
onClick={() => { <ListRowActionButton color={item.enabled ? 'warning' : 'success'} onClick={() => toggleBinding(item)}>
setDeleteError(null) {item.enabled ? 'Disable' : 'Enable'}
setDeleteBindingTarget(item) </ListRowActionButton>
setDeleteBindingConfirm('') <ListRowActionButton
}} color="error"
> onClick={() => {
Delete setDeleteError(null)
</ListRowActionButton> setDeleteBindingTarget(item)
</ListRowActions> setDeleteBindingConfirm('')
</Box> }}
</Paper> >
Delete
</ListRowActionButton>
</ListRowActions>
</ListItem>
))} ))}
</Box> </List>
</SectionCard> </SectionCard>
</Box> </Box>
@@ -925,53 +898,35 @@ export default function AdminServicePrincipalsPage() {
/> />
{keysLoading ? <Typography variant="body2" color="text.secondary">Loading API keys...</Typography> : null} {keysLoading ? <Typography variant="body2" color="text.secondary">Loading API keys...</Typography> : null}
{!keysLoading ? ( {!keysLoading ? (
<Box sx={{ display: 'grid', gap: 0 }}> <List>
{principalKeys.map((item) => ( {principalKeys.map((item) => (
<Paper <ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
key={item.id} <CompactListItemText
variant="outlined" primary={item.disabled ? `${item.name} (disabled)` : item.name}
sx={{ secondary={`Prefix: ${item.prefix} · Created: ${formatUnix(item.created_at)} · Last used: ${formatUnix(item.last_used_at)} · Expires: ${formatUnix(item.expires_at)}`}
p: 1, />
backgroundColor: 'transparent', <ListRowActions>
backgroundImage: 'none', <ListRowActionButton color={item.disabled ? 'success' : 'warning'} onClick={() => togglePrincipalKey(item)} disabled={keysBusy}>
borderLeft: 'none', {item.disabled ? 'Enable' : 'Disable'}
borderRight: 'none', </ListRowActionButton>
borderTop: 'none', <ListRowActionButton
borderBottom: (theme) => `1px solid ${theme.palette.divider}`, color="error"
borderRadius: 0, onClick={() => {
boxShadow: 'none' setDeleteError(null)
}} setDeletePrincipalKeyTarget(item)
> setDeletePrincipalKeyConfirm('')
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 1 }}> }}
<Box sx={{ minWidth: 0 }}> disabled={keysBusy}
<Typography variant="body2">{item.disabled ? `${item.name} (disabled)` : item.name}</Typography> >
<Typography variant="caption" color="text.secondary"> Delete
Prefix: {item.prefix} · Created: {formatUnix(item.created_at)} · Last used: {formatUnix(item.last_used_at)} · Expires: {formatUnix(item.expires_at)} </ListRowActionButton>
</Typography> </ListRowActions>
</Box> </ListItem>
<ListRowActions>
<ListRowActionButton color={item.disabled ? 'success' : 'warning'} onClick={() => togglePrincipalKey(item)} disabled={keysBusy}>
{item.disabled ? 'Enable' : 'Disable'}
</ListRowActionButton>
<ListRowActionButton
color="error"
onClick={() => {
setDeleteError(null)
setDeletePrincipalKeyTarget(item)
setDeletePrincipalKeyConfirm('')
}}
disabled={keysBusy}
>
Delete
</ListRowActionButton>
</ListRowActions>
</Box>
</Paper>
))} ))}
{!principalKeys.length ? ( {!principalKeys.length ? (
<Typography variant="body2" color="text.secondary">No API keys yet.</Typography> <Typography variant="body2" color="text.secondary">No API keys yet.</Typography>
) : null} ) : null}
</Box> </List>
) : null} ) : null}
</FormDialogContent> </FormDialogContent>
<DialogActions> <DialogActions>
+43 -50
View File
@@ -6,11 +6,13 @@ import Dialog from '@mui/material/Dialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import FormControlLabel from '@mui/material/FormControlLabel' 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 MenuItem from '@mui/material/MenuItem'
import Paper from '@mui/material/Paper'
import TextField from '@mui/material/TextField' import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import CompactListItemText from '../components/CompactListItemText'
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog' import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
import FormDialogContent from '../components/FormDialogContent' import FormDialogContent from '../components/FormDialogContent'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions' import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
@@ -206,57 +208,48 @@ export default function AdminTLSAuthPoliciesPage() {
Loading... Loading...
</Typography> </Typography>
) : null} ) : null}
<Box sx={{ display: 'grid', gap: 0 }}> {filteredItems.length === 0 && !loading ? (
{filteredItems.length === 0 && !loading ? ( <Typography variant="body2" color="text.secondary">
<Typography variant="body2" color="text.secondary"> No auth policies found.
No auth policies found. </Typography>
</Typography> ) : null}
) : null} <List>
{filteredItems.map((item) => ( {filteredItems.map((item) => (
<Paper <ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
key={item.id} <CompactListItemText
variant="outlined" primary={
sx={{ <Typography sx={{ wordBreak: 'break-word' }}>
p: 1, {item.name}
display: 'grid', </Typography>
gap: 0.5, }
backgroundColor: 'transparent', secondary={
backgroundImage: 'none', <Box sx={{ display: 'grid', gap: 0.5 }}>
borderLeft: 'none', <Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
borderRight: 'none', Read: {item.read_mode} · Write: {item.write_mode} · Principal for write: {item.require_principal_for_write ? 'yes' : 'no'}
borderTop: 'none', </Typography>
borderBottom: (theme) => `1px solid ${theme.palette.divider}`, <Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
borderRadius: 0, Required perms ({item.permission_match}): {(item.required_permissions || []).join(', ') || '-'} · Scope: {item.required_scope || '-'}
boxShadow: 'none' </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}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}> </Typography>
<Typography variant="subtitle2" sx={{ wordBreak: 'break-word' }}> </Box>
{item.name} }
</Typography> disableTypography
<ListRowActions> />
<ListRowActionButton onClick={() => openEdit(item)}>Edit</ListRowActionButton> <ListRowActions>
<ListRowActionButton color="error" onClick={() => { <ListRowActionButton onClick={() => openEdit(item)}>Edit</ListRowActionButton>
setDeleteError(null) <ListRowActionButton color="error" onClick={() => {
setConfirmPolicy(item) setDeleteError(null)
setConfirmText('') setConfirmPolicy(item)
}}> setConfirmText('')
Delete }}>
</ListRowActionButton> Delete
</ListRowActions> </ListRowActionButton>
</Box> </ListRowActions>
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}> </ListItem>
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>
))} ))}
</Box> </List>
</SectionCard> </SectionCard>
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="md" fullWidth> <Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="md" fullWidth>
+99 -104
View File
@@ -1,4 +1,5 @@
import Alert from '@mui/material/Alert' import Alert from '@mui/material/Alert'
import CompactListItemText from '../components/CompactListItemText'
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog' import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
import FormDialogContent from '../components/FormDialogContent' import FormDialogContent from '../components/FormDialogContent'
import { import {
@@ -8,8 +9,9 @@ import {
Dialog, Dialog,
DialogActions, DialogActions,
DialogTitle, DialogTitle,
List,
ListItem,
MenuItem, MenuItem,
Paper,
TextField, TextField,
Typography Typography
} from '@mui/material' } from '@mui/material'
@@ -399,108 +401,101 @@ export default function AdminTLSSettingsPage() {
Loading... Loading...
</Typography> </Typography>
) : null} ) : null}
<Box sx={{ display: 'grid', gap: 0 }}> <List>
<Paper <ListItem divider sx={{ alignItems: 'flex-start' }}>
variant="outlined" <CompactListItemText
sx={{ primary={
p: 1, <Typography component="div">
display: 'grid', <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0, flexWrap: 'wrap' }}>
gap: 0.5, <Box component="span">Main Listener</Box>
backgroundColor: 'transparent', <Chip size="small" color="info" label="Main" />
backgroundImage: 'none', </Box>
borderLeft: 'none', </Typography>
borderRight: 'none', }
borderTop: 'none', secondary={
borderBottom: (theme) => `1px solid ${theme.palette.divider}`, <Box sx={{ display: 'grid', gap: 0.5 }}>
borderRadius: 0, <Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
boxShadow: 'none' HTTP: {(settings.http_addrs || []).join(', ') || '(none)'}
}} </Typography>
> <Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, minWidth: 0, flexWrap: 'wrap' }}> HTTPS: {(settings.https_addrs || []).join(', ') || '(none)'}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0, flexWrap: 'wrap' }}> </Typography>
<Typography variant="subtitle2">Main Listener</Typography> <Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
<Chip size="small" color="info" label="Main" /> Policies: {summarizeEndpointPolicies(settings.endpoint_policies || [])}
</Box> </Typography>
<ListRowActions> <Typography variant="caption" color="warning.main" sx={{ wordBreak: 'break-word' }}>
<ListRowActionButton onClick={openMainEditDialog}>Edit</ListRowActionButton> Main listener transport changes require backend restart to take effect.
</ListRowActions> </Typography>
</Box> </Box>
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}> }
HTTP: {(settings.http_addrs || []).join(', ') || '(none)'} disableTypography
</Typography> />
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}> <ListRowActions>
HTTPS: {(settings.https_addrs || []).join(', ') || '(none)'} <ListRowActionButton onClick={openMainEditDialog}>Edit</ListRowActionButton>
</Typography> </ListRowActions>
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}> </ListItem>
Policies: {summarizeEndpointPolicies(settings.endpoint_policies || [])}
</Typography>
<Typography variant="caption" color="warning.main" sx={{ wordBreak: 'break-word' }}>
Main listener transport changes require backend restart to take effect.
</Typography>
</Paper>
{!listenersLoading && (listeners || []).length === 0 ? ( {!listenersLoading && (listeners || []).length === 0 ? (
<Typography variant="body2" color="text.secondary"> <ListItem>
No additional listeners configured. <CompactListItemText
</Typography> primary={
<Typography variant="body2" color="text.secondary">
No additional listeners configured.
</Typography>
}
/>
</ListItem>
) : null} ) : null}
{(listeners || []).map((item) => ( {(listeners || []).map((item) => (
<Paper <ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
key={item.id} <CompactListItemText
variant="outlined" primary={
sx={{ <Typography component="div" sx={{ wordBreak: 'break-word' }}>
p: 1, <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0, flexWrap: 'wrap' }}>
display: 'grid', <Box component="span">{item.name}</Box>
gap: 0.5, {item.enabled ? (
backgroundColor: 'transparent', (runtimeCounts[item.id] || 0) > 0 ? (
backgroundImage: 'none', <Chip size="small" color="success" label={`Running (${runtimeCounts[item.id]} endpoint${runtimeCounts[item.id] > 1 ? 's' : ''})`} />
borderLeft: 'none', ) : (
borderRight: 'none', <Chip size="small" color="warning" label="Enabled (starting...)" />
borderTop: 'none', )
borderBottom: (theme) => `1px solid ${theme.palette.divider}`, ) : (
borderRadius: 0, <Chip size="small" label="Disabled" />
boxShadow: 'none' )}
}} </Box>
> </Typography>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, minWidth: 0, flexWrap: 'wrap' }}> }
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0, flexWrap: 'wrap' }}> secondary={
<Typography variant="subtitle2" sx={{ wordBreak: 'break-word' }}>{item.name}</Typography> <Box sx={{ display: 'grid', gap: 0.5 }}>
{item.enabled ? ( <Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
(runtimeCounts[item.id] || 0) > 0 ? ( HTTP: {(item.http_addrs || []).join(', ') || '(none)'}
<Chip size="small" color="success" label={`Running (${runtimeCounts[item.id]} endpoint${runtimeCounts[item.id] > 1 ? 's' : ''})`} /> </Typography>
) : ( <Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
<Chip size="small" color="warning" label="Enabled (starting...)" /> HTTPS: {(item.https_addrs || []).join(', ') || '(none)'}
) </Typography>
) : ( <Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
<Chip size="small" label="Disabled" /> Policies: {summarizeEndpointPolicies(item.endpoint_policies || [])}
)} </Typography>
</Box> </Box>
<ListRowActions> }
<ListRowActionButton disableTypography
color={item.enabled ? 'warning' : 'success'} />
onClick={() => handleToggleListener(item)} <ListRowActions>
> <ListRowActionButton
{item.enabled ? 'Disable' : 'Enable'} color={item.enabled ? 'warning' : 'success'}
</ListRowActionButton> onClick={() => handleToggleListener(item)}
<ListRowActionButton onClick={() => openEditDialog(item)}> >
Edit {item.enabled ? 'Disable' : 'Enable'}
</ListRowActionButton> </ListRowActionButton>
<ListRowActionButton color="error" onClick={() => handleDeleteListener(item)}> <ListRowActionButton onClick={() => openEditDialog(item)}>
Delete Edit
</ListRowActionButton> </ListRowActionButton>
</ListRowActions> <ListRowActionButton color="error" onClick={() => handleDeleteListener(item)}>
</Box> Delete
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}> </ListRowActionButton>
HTTP: {(item.http_addrs || []).join(', ') || '(none)'} </ListRowActions>
</Typography> </ListItem>
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}> ))}
HTTPS: {(item.https_addrs || []).join(', ') || '(none)'} </List>
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
Policies: {summarizeEndpointPolicies(item.endpoint_policies || [])}
</Typography>
</Paper>
))}
</Box>
</SectionCard> </SectionCard>
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="md" fullWidth> <Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="md" fullWidth>
@@ -552,7 +547,7 @@ export default function AdminTLSSettingsPage() {
{cert.common_name || cert.serial_hex} ({cert.id.slice(0, 8)}) {cert.common_name || cert.serial_hex} ({cert.id.slice(0, 8)})
{cert.status !== 'active' || cert.is_ca || cert.has_server_auth === false ? ' - current selection not server-usable' : ''} {cert.status !== 'active' || cert.is_ca || cert.has_server_auth === false ? ' - current selection not server-usable' : ''}
</MenuItem> </MenuItem>
))} ))}
</SelectField> </SelectField>
<SelectField <SelectField
select select
@@ -582,7 +577,7 @@ export default function AdminTLSSettingsPage() {
<MenuItem key={ca.id} value={ca.id}> <MenuItem key={ca.id} value={ca.id}>
{ca.name} ({ca.id.slice(0, 8)}) {ca.name} ({ca.id.slice(0, 8)})
</MenuItem> </MenuItem>
))} ))}
</SelectField> </SelectField>
<SelectField <SelectField
select select
@@ -610,9 +605,9 @@ export default function AdminTLSSettingsPage() {
<MenuItem key={item.id} value={item.id}> <MenuItem key={item.id} value={item.id}>
{item.name} {item.name}
</MenuItem> </MenuItem>
))} ))}
</SelectField> </SelectField>
))} ))}
</Box> </Box>
</FormDialogContent> </FormDialogContent>
<DialogActions> <DialogActions>
+20 -24
View File
@@ -2,6 +2,7 @@ import AddIcon from '@mui/icons-material/Add'
import DeleteIcon from '@mui/icons-material/Delete' import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit' import EditIcon from '@mui/icons-material/Edit'
import Alert from '@mui/material/Alert' import Alert from '@mui/material/Alert'
import CompactListItemText from '../components/CompactListItemText'
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog' import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
import FormDialogContent from '../components/FormDialogContent' import FormDialogContent from '../components/FormDialogContent'
import { import {
@@ -17,7 +18,6 @@ import {
IconButton, IconButton,
List, List,
ListItem, ListItem,
ListItemText,
MenuItem, MenuItem,
Paper, Paper,
TextField, TextField,
@@ -299,7 +299,7 @@ export default function AdminUserGroupsPage() {
<Button variant="outlined" startIcon={<AddIcon />} onClick={openCreate}>New Group</Button> <Button variant="outlined" startIcon={<AddIcon />} onClick={openCreate}>New Group</Button>
</Box> </Box>
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null} {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> <TintedPanel>
<TextField <TextField
size="small" size="small"
@@ -324,19 +324,19 @@ export default function AdminUserGroupsPage() {
onClick={() => setSelectedGroupID(group.id)} onClick={() => setSelectedGroupID(group.id)}
sx={{ alignItems: 'flex-start', gap: 1, cursor: 'pointer' }} sx={{ alignItems: 'flex-start', gap: 1, cursor: 'pointer' }}
> >
<ListItemText <CompactListItemText
sx={{ minWidth: 0, flex: 1 }} primary={
primary={group.name} <Typography noWrap>
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' : ''}`} {group.name}
primaryTypographyProps={{ {group.disabled? (<> <Chip size="small" label="Disabled" color="error" sx={{ mt: 0.25, flexShrink: 0 }} /></>): null}
noWrap: true, </Typography>
title: group.name + (group.disabled ? ' (disabled)' : '') }
}} secondary={
secondaryTypographyProps={{ <Typography variant="caption" color="text.secondary" sx={{ whiteSpace: 'normal', wordBreak: 'break-all' }}>
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} {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 }}> <Box sx={{ display: 'flex', gap: 0.5, mt: 0.25, flexShrink: 0 }}>
<IconButton <IconButton
@@ -374,10 +374,10 @@ export default function AdminUserGroupsPage() {
<Typography variant="h6" sx={{ minWidth: 0 }}> <Typography variant="h6" sx={{ minWidth: 0 }}>
{selectedGroup.name} {selectedGroup.name}
</Typography> </Typography>
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap' }}> <Typography>
<Chip <Chip
size="small" size="small"
color={selectedGroup.disabled ? 'default' : 'success'} color={selectedGroup.disabled ? 'error' : 'success'}
label={selectedGroup.disabled ? 'Disabled' : 'Active'} label={selectedGroup.disabled ? 'Disabled' : 'Active'}
/> />
{hasDirectProjectCreate(selectedGroup.id) ? ( {hasDirectProjectCreate(selectedGroup.id) ? (
@@ -389,20 +389,16 @@ export default function AdminUserGroupsPage() {
{selectedGroup.scope === 'all_users' ? ( {selectedGroup.scope === 'all_users' ? (
<Chip size="small" variant="outlined" label="All users" /> <Chip size="small" variant="outlined" label="All users" />
) : null} ) : null}
</Box> </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)} {selectedGroup.id} · scope: {selectedGroup.scope === 'all_users' ? 'all users' : 'explicit'} · updated: {fmt(selectedGroup.updated_at)}
</Typography> </Typography>
{selectedGroup.description ? ( {selectedGroup.description ? (
<Typography variant="body2" color="text.secondary"> <Typography variant="caption" color="text.secondary">
{selectedGroup.description} {selectedGroup.description}
</Typography> </Typography>
) : ( ) : null}
<Typography variant="body2" color="text.secondary">
No description.
</Typography>
)}
</Box> </Box>
) : ( ) : (
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
+36 -35
View File
@@ -5,7 +5,6 @@ import {
Chip, Chip,
List, List,
ListItem, ListItem,
ListItemText,
Typography Typography
} from '@mui/material' } from '@mui/material'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
@@ -15,6 +14,7 @@ import { ListRowActionButton, ListRowActions } from '../components/ListRowAction
import SectionCard from '../components/SectionCard' import SectionCard from '../components/SectionCard'
import UserFormDialog, { UserFormState } from '../components/UserFormDialog' import UserFormDialog, { UserFormState } from '../components/UserFormDialog'
import { api, SubjectPermission, User, UserCreatePayload, UserUpdatePayload } from '../api' import { api, SubjectPermission, User, UserCreatePayload, UserUpdatePayload } from '../api'
import CompactListItemText from '../components/CompactListItemText'
const projectCreatePermission = 'project.create' const projectCreatePermission = 'project.create'
@@ -267,40 +267,41 @@ export default function AdminUsersPage() {
borderBottom: (theme) => `1px solid ${theme.palette.divider}` borderBottom: (theme) => `1px solid ${theme.palette.divider}`
}} }}
> >
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}> <CompactListItemText
<ListItemText primary={
primary={ <Typography>
<Typography> {u.display_name || u.username}({u.username})
{u.display_name || u.username}({u.username}) {u.disabled? (<> <Chip size="small" color='error' label='Disabled'/></>): null}
{u.disabled? (<> <Chip size="small" color='error' label='Disabled'/></>): null} </Typography>
</Typography> }
} secondary={
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 variant="caption" color="text.secondary">
sx={{ minWidth: 0, m: 0 }} {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)}> />
Edit <ListRowActions>
</ListRowActionButton> <ListRowActionButton onClick={() => openEditUser(u)}>
<ListRowActionButton Edit
color={u.disabled ? 'success' : 'warning'} </ListRowActionButton>
onClick={() => toggleUserState(u)} <ListRowActionButton
disabled={togglingID === u.id} color={u.disabled ? 'success' : 'warning'}
> onClick={() => toggleUserState(u)}
{u.disabled ? 'Enable' : 'Disable'} disabled={togglingID === u.id}
</ListRowActionButton> >
<ListRowActionButton {u.disabled ? 'Enable' : 'Disable'}
color="warning" </ListRowActionButton>
onClick={() => setResetTOTPUser(u)} <ListRowActionButton
disabled={!u.totp_enabled} color="warning"
> onClick={() => setResetTOTPUser(u)}
Reset TOTP disabled={!u.totp_enabled}
</ListRowActionButton> >
<ListRowActionButton color="error" onClick={() => setDeletingUser(u)}> Reset TOTP
Delete </ListRowActionButton>
</ListRowActionButton> <ListRowActionButton color="error" onClick={() => setDeletingUser(u)}>
</ListRowActions> Delete
</Box> </ListRowActionButton>
</ListRowActions>
</ListItem> </ListItem>
))} ))}
</List> </List>
+15 -5
View File
@@ -10,18 +10,19 @@ import FormDialogContent from '../components/FormDialogContent'
import { import {
Box, Box,
Button, Button,
Chip,
Dialog, Dialog,
DialogActions, DialogActions,
DialogTitle, DialogTitle,
List, List,
ListItem, ListItem,
ListItemText,
Typography Typography
} from '@mui/material' } from '@mui/material'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions' import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard' import SectionCard from '../components/SectionCard'
import { api, APIKey } from '../api' import { api, APIKey } from '../api'
import CompactListItemText from '../components/CompactListItemText'
function formatUnix(value: number) { function formatUnix(value: number) {
if (!value || value <= 0) { 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 }}> <Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
<ListItemText <CompactListItemText
primary={key.disabled ? `${key.name} (disabled)` : key.name} primary={
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>
sx={{ minWidth: 0, m: 0 }} {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> <ListRowActions>
<ListRowActionButton <ListRowActionButton
+63 -66
View File
@@ -10,7 +10,6 @@ import {
Link, Link,
List, List,
ListItem, ListItem,
ListItemText,
TextField, TextField,
Tooltip, Tooltip,
Typography Typography
@@ -23,6 +22,7 @@ import PKICADetailsDialog from '../components/PKICADetailsDialog'
import PKICertDetailsDialog from '../components/PKICertDetailsDialog' import PKICertDetailsDialog from '../components/PKICertDetailsDialog'
import SectionCard from '../components/SectionCard' import SectionCard from '../components/SectionCard'
import { api, PKICADetail, PKIClientIssueResponse, PKIClientIssuance, PKIClientProfile, PKICertDetail } from '../api' import { api, PKICADetail, PKIClientIssueResponse, PKIClientIssuance, PKIClientProfile, PKICertDetail } from '../api'
import CompactListItemText from '../components/CompactListItemText'
function fmt(value: number): string { function fmt(value: number): string {
if (!value || value <= 0) { if (!value || value <= 0) {
@@ -304,7 +304,7 @@ export default function ClientCertificatesPage() {
}) })
const issuanceStatusColor = (status: string): 'default' | 'success' | 'warning' | 'error' => { const issuanceStatusColor = (status: string): 'default' | 'success' | 'warning' | 'error' => {
if (status === 'issued' || status === 'valid') { if (status === 'issued' || status === 'valid' || status === 'active') {
return 'success' return 'success'
} }
if (status === 'revoked') { if (status === 'revoked') {
@@ -347,9 +347,9 @@ export default function ClientCertificatesPage() {
borderBottom: (theme) => `1px solid ${theme.palette.divider}` borderBottom: (theme) => `1px solid ${theme.palette.divider}`
}} }}
> >
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}> <CompactListItemText
<ListItemText primary={
primary={( <Typography>
<Tooltip title={profile.id} arrow> <Tooltip title={profile.id} arrow>
<Link <Link
underline="hover" underline="hover"
@@ -359,40 +359,39 @@ export default function ClientCertificatesPage() {
{`${profile.name} (${profile.id})`} {`${profile.name} (${profile.id})`}
</Link> </Link>
</Tooltip> </Tooltip>
)} </Typography>
secondary={( }
<Typography component="span" variant="body2" color="text.secondary"> secondary={
CA:{' '} <Typography variant="caption" color="text.secondary">
<Tooltip title={profile.ca_id} arrow> CA:{' '}
<Link <Tooltip title={profile.ca_id} arrow>
underline="hover" <Link
onClick={() => openCAView(profile).catch(() => {})} underline="hover"
sx={{ cursor: 'default' }} onClick={() => openCAView(profile).catch(() => {})}
> sx={{ cursor: 'default' }}
{profile.ca_name || profile.ca_id} >
</Link> {profile.ca_name || profile.ca_id}
</Tooltip> </Link>
{' '}· 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 </Tooltip>
</Typography> {' '}· 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> <ListRowActions>
<ListRowActionButton startIcon={<KeyIcon />} onClick={() => openIssue(profile)}> <ListRowActionButton startIcon={<KeyIcon />} onClick={() => openIssue(profile)}>
Issue Issue
</ListRowActionButton> </ListRowActionButton>
</ListRowActions> </ListRowActions>
</Box>
</ListItem> </ListItem>
))} ))}
{!loading && profiles.length === 0 ? ( {!loading && profiles.length === 0 ? (
<ListItem> <ListItem>
<ListItemText primary="No client certificate profiles are available to you." /> <CompactListItemText primary="No client certificate profiles are available to you." />
</ListItem> </ListItem>
) : null} ) : null}
{!loading && profiles.length > 0 && filteredProfiles.length === 0 ? ( {!loading && profiles.length > 0 && filteredProfiles.length === 0 ? (
<ListItem> <ListItem>
<ListItemText primary="No matching client certificate profiles found." /> <CompactListItemText primary="No matching client certificate profiles found." />
</ListItem> </ListItem>
) : null} ) : null}
</List> </List>
@@ -424,23 +423,23 @@ export default function ClientCertificatesPage() {
borderBottom: (theme) => `1px solid ${theme.palette.divider}` borderBottom: (theme) => `1px solid ${theme.palette.divider}`
}} }}
> >
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}> <CompactListItemText
<ListItemText primary={
primary={( <Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}> {item.common_name} ({item.cert_id})
<Box component="span">{`${item.common_name} (${item.cert_id})`}</Box> {' '}
<Chip <Chip
label={item.status === 'deleted' ? 'Deleted from Admin PKI' : item.status} label={item.status === 'deleted' ? 'Deleted from Admin PKI' : item.status}
color={issuanceStatusColor(item.status)} color={issuanceStatusColor(item.status)}
size="small" size="small"
variant={item.status === 'issued' || item.status === 'valid' ? 'filled' : 'outlined'} variant={item.status === 'deleted' ? 'outlined' : 'filled'}
/> />
</Box> </Typography>
)} }
secondary={( secondary={
<Typography component="span" variant="body2" color="text.secondary"> <Typography variant="caption" color="text.secondary">
profile:{' '} profile:{' '}
{profiles.some((profile) => profile.id === item.profile_id) ? ( {profiles.some((profile) => profile.id === item.profile_id) ? (
<Tooltip title={item.profile_id} arrow> <Tooltip title={item.profile_id} arrow>
<Link <Link
underline="hover" underline="hover"
@@ -453,38 +452,36 @@ export default function ClientCertificatesPage() {
) : ( ) : (
item.profile_name 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> </Typography>
)} }
sx={{ minWidth: 0, m: 0 }} />
/> <ListRowActions>
<ListRowActions> <ListRowActionButton startIcon={<VisibilityIcon />} onClick={() => openView(item)} disabled={busy || item.status === 'deleted'}>
<ListRowActionButton startIcon={<VisibilityIcon />} onClick={() => openView(item)} disabled={busy || item.status === 'deleted'}> View
View </ListRowActionButton>
</ListRowActionButton> <ListRowActionButton startIcon={<DownloadIcon />} onClick={() => downloadBundle(item)} disabled={busy || item.status === 'deleted'}>
<ListRowActionButton startIcon={<DownloadIcon />} onClick={() => downloadBundle(item)} disabled={busy || item.status === 'deleted'}> Bundle
Bundle </ListRowActionButton>
</ListRowActionButton> <ListRowActionButton
<ListRowActionButton color="error"
color="error" startIcon={<BlockIcon />}
startIcon={<BlockIcon />} onClick={() => setRevokeItem(item)}
onClick={() => setRevokeItem(item)} disabled={busy || item.status === 'revoked' || item.status === 'deleted'}
disabled={busy || item.status === 'revoked' || item.status === 'deleted'} >
> Revoke
Revoke </ListRowActionButton>
</ListRowActionButton> </ListRowActions>
</ListRowActions>
</Box>
</ListItem> </ListItem>
))} ))}
{!loading && issuances.length === 0 ? ( {!loading && issuances.length === 0 ? (
<ListItem> <ListItem>
<ListItemText primary="No client certificates issued yet." /> <CompactListItemText primary="No client certificates issued yet." />
</ListItem> </ListItem>
) : null} ) : null}
{!loading && issuances.length > 0 && filteredIssuances.length === 0 ? ( {!loading && issuances.length > 0 && filteredIssuances.length === 0 ? (
<ListItem> <ListItem>
<ListItemText primary="No matching client certificates found." /> <CompactListItemText primary="No matching client certificates found." />
</ListItem> </ListItem>
) : null} ) : null}
</List> </List>
+36 -32
View File
@@ -3,6 +3,8 @@ import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import { import {
Box, Box,
Link, Link,
List,
ListItem,
MenuItem, MenuItem,
TextField, TextField,
Tooltip, Tooltip,
@@ -13,6 +15,7 @@ import { useEffect, useMemo, useState } from 'react'
import Autocomplete from '../components/Autocomplete' import Autocomplete from '../components/Autocomplete'
import HeaderActionButton from '../components/HeaderActionButton' import HeaderActionButton from '../components/HeaderActionButton'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions' import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import CompactListItemText from '../components/CompactListItemText'
import MonospaceTextField from '../components/MonospaceTextField' import MonospaceTextField from '../components/MonospaceTextField'
import SectionCard from '../components/SectionCard' import SectionCard from '../components/SectionCard'
import SSHCertificateInspectDialog from '../components/SSHCertificateInspectDialog' import SSHCertificateInspectDialog from '../components/SSHCertificateInspectDialog'
@@ -378,42 +381,43 @@ export default function SSHCertificatesPage() {
> >
{issuancesLoading ? <Typography variant="body2" color="text.secondary">Loading...</Typography> : null} {issuancesLoading ? <Typography variant="body2" color="text.secondary">Loading...</Typography> : null}
{!issuancesLoading && issuances.length === 0 ? <Typography variant="body2" color="text.secondary">No issuance history found.</Typography> : null} {!issuancesLoading && issuances.length === 0 ? <Typography variant="body2" color="text.secondary">No issuance history found.</Typography> : null}
{issuances.map((item) => ( <List>
<Box {issuances.map((item: SSHUserCAIssuance) => (
key={item.id} <ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
sx={{ <CompactListItemText
p: 1, primary={
borderBottom: (theme) => `1px solid ${theme.palette.divider}`, <Typography>
}} <Tooltip title={item.ca_id} arrow>
> <Link
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, minWidth: 0 }}> underline="hover"
<Box sx={{ display: 'grid', gap: 0.5, minWidth: 0, flexGrow: 1 }}> onClick={() => openCAView(item).catch(() => {})}
<Typography variant="body2"> sx={{ cursor: 'default' }}
<Tooltip title={item.ca_id} arrow> >
<Link {item.ca_name || item.ca_id}
underline="hover" </Link>
onClick={() => openCAView(item).catch(() => {})} </Tooltip>
sx={{ cursor: 'default' }} {' '}· serial: {item.serial} · key_id: {item.key_id}
> </Typography>
{item.ca_name || item.ca_id} }
</Link> secondary={
</Tooltip> <Box sx={{ display: 'grid', gap: 0.5 }}>
{' '}· serial: {item.serial} · key_id: {item.key_id} <Typography variant="caption" color="text.secondary">
</Typography> {fmt(item.created_at)} · valid: {fmt(item.valid_after)} ~ {fmt(item.valid_before)}
<Typography variant="caption" color="text.secondary"> </Typography>
{fmt(item.created_at)} · valid: {fmt(item.valid_after)} ~ {fmt(item.valid_before)} <Typography variant="caption" color="text.secondary">
</Typography> fp: {item.source_public_key_fingerprint}
<Typography variant="caption" color="text.secondary"> </Typography>
fp: {item.source_public_key_fingerprint} </Box>
</Typography> }
</Box> disableTypography
/>
<ListRowActions> <ListRowActions>
<ListRowActionButton startIcon={<ContentCopyIcon />} onClick={() => copyIssuanceCert(item)}>Copy Cert</ListRowActionButton> <ListRowActionButton startIcon={<ContentCopyIcon />} onClick={() => copyIssuanceCert(item)}>Copy Cert</ListRowActionButton>
<ListRowActionButton startIcon={<VisibilityIcon />} onClick={() => viewIssuanceCert(item)} disabled={inspectBusy}>{inspectBusy ? 'Loading...' : 'View'}</ListRowActionButton> <ListRowActionButton startIcon={<VisibilityIcon />} onClick={() => viewIssuanceCert(item)} disabled={inspectBusy}>{inspectBusy ? 'Loading...' : 'View'}</ListRowActionButton>
</ListRowActions> </ListRowActions>
</Box> </ListItem>
</Box> ))}
))} </List>
</SectionCard> </SectionCard>
<SSHCertificateInspectDialog <SSHCertificateInspectDialog
+7 -4
View File
@@ -10,7 +10,6 @@ import DialogTitle from '@mui/material/DialogTitle'
import FormControlLabel from '@mui/material/FormControlLabel' import FormControlLabel from '@mui/material/FormControlLabel'
import List from '@mui/material/List' import List from '@mui/material/List'
import ListItem from '@mui/material/ListItem' import ListItem from '@mui/material/ListItem'
import ListItemText from '@mui/material/ListItemText'
import TextField from '@mui/material/TextField' import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
@@ -22,6 +21,7 @@ import MonospaceTextField from '../components/MonospaceTextField'
import SectionCard from '../components/SectionCard' import SectionCard from '../components/SectionCard'
import SSHCredentialDetailsDialog from '../components/SSHCredentialDetailsDialog' import SSHCredentialDetailsDialog from '../components/SSHCredentialDetailsDialog'
import { api, SSHCredential, SSHCredentialUpsertPayload } from '../api' import { api, SSHCredential, SSHCredentialUpsertPayload } from '../api'
import CompactListItemText from '../components/CompactListItemText'
type FormState = { type FormState = {
name: string name: string
@@ -145,15 +145,18 @@ export default function SSHCredentialsPage() {
<List> <List>
{items.map((item) => ( {items.map((item) => (
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}> <ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
<ListItemText <CompactListItemText
primary={ primary={
<Typography> <Typography>
{item.name}({item.id}) {item.name}({item.id})
{item.enabled? null: (<> <Chip size="small" color='error' label='Disabled'/></>)} {item.enabled? null: (<> <Chip size="small" color='error' label='Disabled'/></>)}
</Typography> </Typography>
} }
secondary={`${item.fingerprint || item.id} · ${item.type} · Updated: ${fmt(item.updated_at)}`} secondary={
sx={{ minWidth: 0, m: 0 }} <Typography variant="caption" color="text.secondary">
{item.fingerprint || item.id} · {item.type} · Updated: {fmt(item.updated_at)}
</Typography>
}
/> />
<ListRowActions> <ListRowActions>
<ListRowActionButton onClick={() => setViewItem(item)}>View</ListRowActionButton> <ListRowActionButton onClick={() => setViewItem(item)}>View</ListRowActionButton>
+82 -96
View File
@@ -2,11 +2,13 @@ import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Link from '@mui/material/Link' 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 TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import CompactListItemText from '../components/CompactListItemText'
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog' import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
import SSHCredentialsPromptDialog from '../components/SSHCredentialsPromptDialog' import SSHCredentialsPromptDialog from '../components/SSHCredentialsPromptDialog'
import SSHAccessProfileDetailsDialog from '../components/SSHAccessProfileDetailsDialog' import SSHAccessProfileDetailsDialog from '../components/SSHAccessProfileDetailsDialog'
@@ -614,34 +616,58 @@ export default function SSHServersPage() {
} }
const renderProfileList = (list: SSHAccessProfile[], emptyText: string) => ( const renderProfileList = (list: SSHAccessProfile[], emptyText: string) => (
<Box sx={{ display: 'grid', gap: 0 }}> <Box>
{!list.length ? ( {!list.length ? (
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
{emptyText} {emptyText}
</Typography> </Typography>
) : null} ) : null}
{list.map((item) => ( <List>
<Paper {list.map((item: SSHAccessProfile) => (
key={item.id} <ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
variant="outlined" <CompactListItemText
sx={{ primary={
p: 1, <Typography sx={{ wordBreak: 'break-word' }}>
display: 'grid', {item.name}
gap: 0.5, </Typography>
backgroundColor: 'transparent', }
backgroundImage: 'none', secondary={
borderLeft: 'none', <Box sx={{ display: 'grid', gap: 0.5 }}>
borderRight: 'none', <Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
borderTop: 'none', {item.server_target_type === 'group' ? (
borderBottom: (theme) => `1px solid ${theme.palette.divider}`, <Link
borderRadius: 0, underline="hover"
boxShadow: 'none' onClick={() => void openServerGroupView(item)}
}} sx={{ cursor: 'default' }}
> >
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}> Group: {item.server_group?.name || item.server_group_id || '-'}
<Typography variant="subtitle2" sx={{ wordBreak: 'break-word' }}> </Link>
{item.name} ) : (
</Typography> <Link
underline="hover"
onClick={() => openSharedServerView(item).catch(() => {})}
sx={{ cursor: 'default' }}
>
Server: {item.server?.name}
</Link>
)}
{item.server_target_type === 'group' ? '' : ` · ${item.server?.host}:${item.server?.port}`} · {item.remote_username} · {item.auth_method} · 2FA: {item.second_factor_mode || 'none'}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
Key fingerprint:{' '}
{item.ssh_credential_id ? (
<Link
underline="hover"
onClick={() => void openCredentialView(item)}
sx={{ cursor: 'default' }}
>
{item.auth_public_key_fingerprint || item.ssh_credential_id}
</Link>
) : item.auth_public_key_fingerprint || '-'}
</Typography>
</Box>
}
/>
<ListRowActions> <ListRowActions>
<ListRowActionButton onClick={() => setViewItem(item)}>View</ListRowActionButton> <ListRowActionButton onClick={() => setViewItem(item)}>View</ListRowActionButton>
{item.owner_scope === 'user' ? ( {item.owner_scope === 'user' ? (
@@ -656,73 +682,44 @@ export default function SSHServersPage() {
{connectingID === item.id ? 'Connecting...' : 'Connect'} {connectingID === item.id ? 'Connecting...' : 'Connect'}
</ListRowActionButton> </ListRowActionButton>
</ListRowActions> </ListRowActions>
</Box> </ListItem>
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}> ))}
{item.server_target_type === 'group' ? ( </List>
<Link
underline="hover"
onClick={() => void openServerGroupView(item)}
sx={{ cursor: 'default' }}
>
Group: {item.server_group?.name || item.server_group_id || '-'}
</Link>
) : (
<Link
underline="hover"
onClick={() => openSharedServerView(item).catch(() => {})}
sx={{ cursor: 'default' }}
>
Server: {item.server?.name}
</Link>
)}
{item.server_target_type === 'group' ? '' : ` · ${item.server?.host}:${item.server?.port}`} · {item.remote_username} · {item.auth_method} · 2FA: {item.second_factor_mode || 'none'}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
Key fingerprint:{' '}
{item.ssh_credential_id ? (
<Link
underline="hover"
onClick={() => void openCredentialView(item)}
sx={{ cursor: 'default' }}
>
{item.auth_public_key_fingerprint || item.ssh_credential_id}
</Link>
) : item.auth_public_key_fingerprint || '-'}
</Typography>
</Paper>
))}
</Box> </Box>
) )
const renderServerList = (list: SSHServer[], emptyText: string) => ( const renderServerList = (list: SSHServer[], emptyText: string) => (
<Box sx={{ display: 'grid', gap: 0 }}> <Box>
{!list.length ? ( {!list.length ? (
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
{emptyText} {emptyText}
</Typography> </Typography>
) : null} ) : null}
{list.map((item) => ( <List>
<Paper {list.map((item: SSHServer) => (
key={item.id} <ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
variant="outlined" <CompactListItemText
sx={{ primary={
p: 1, <Typography sx={{ wordBreak: 'break-word' }}>
display: 'grid', {item.name}
gap: 0.5, </Typography>
backgroundColor: 'transparent', }
backgroundImage: 'none', secondary={
borderLeft: 'none', <Box sx={{ display: 'grid', gap: 0.5 }}>
borderRight: 'none', <Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
borderTop: 'none', {item.host}:{item.port} · {item.enabled ? 'enabled' : 'disabled'}
borderBottom: (theme) => `1px solid ${theme.palette.divider}`, </Typography>
borderRadius: 0, <Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
boxShadow: 'none' Tags: {(item.tags || []).join(', ') || '-'} · {item.id}
}} </Typography>
> {item.description ? (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}> <Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
<Typography variant="subtitle2" sx={{ wordBreak: 'break-word' }}> {item.description}
{item.name} </Typography>
</Typography> ) : null}
</Box>
}
/>
<ListRowActions> <ListRowActions>
<ListRowActionButton onClick={() => setViewServerItem(item)}>View</ListRowActionButton> <ListRowActionButton onClick={() => setViewServerItem(item)}>View</ListRowActionButton>
<ListRowActionButton onClick={() => openServerEdit(item)}>Edit</ListRowActionButton> <ListRowActionButton onClick={() => openServerEdit(item)}>Edit</ListRowActionButton>
@@ -731,20 +728,9 @@ export default function SSHServersPage() {
</ListRowActionButton> </ListRowActionButton>
<ListRowActionButton onClick={() => void openHostKeys(item)}>Host Keys</ListRowActionButton> <ListRowActionButton onClick={() => void openHostKeys(item)}>Host Keys</ListRowActionButton>
</ListRowActions> </ListRowActions>
</Box> </ListItem>
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}> ))}
{item.host}:{item.port} · {item.enabled ? 'enabled' : 'disabled'} </List>
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
Tags: {(item.tags || []).join(', ') || '-'} · {item.id}
</Typography>
{item.description ? (
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
{item.description}
</Typography>
) : null}
</Paper>
))}
</Box> </Box>
) )