Let operators manage ordered action groups on each host, run them manually or on a schedule over SSH, and inspect disk-backed JSON run logs from a node detail UI.
4206 lines
123 KiB
TypeScript
4206 lines
123 KiB
TypeScript
import { useEffect, useState, type FormEvent } from 'react'
|
||
import {
|
||
beginMyTOTP,
|
||
changeMyPassword,
|
||
confirmMyTOTP,
|
||
createNode,
|
||
createUser,
|
||
createAction,
|
||
deleteAction,
|
||
deleteGroup,
|
||
deleteNode,
|
||
deleteUser,
|
||
DEFAULT_NETWORK_SETTINGS,
|
||
DEFAULT_SECURITY_SETTINGS,
|
||
disableMyTOTP,
|
||
fetchActions,
|
||
fetchAuthLogs,
|
||
fetchGroups,
|
||
fetchMe,
|
||
fetchNetwork,
|
||
logout,
|
||
fetchNodeLogs,
|
||
fetchNodes,
|
||
fetchSecurity,
|
||
fetchSetupStatus,
|
||
fetchStatus,
|
||
fetchUsers,
|
||
isReauthRequiredError,
|
||
patchAction,
|
||
patchUser,
|
||
reauth,
|
||
testNodeSSH,
|
||
type AccessMode,
|
||
type Action,
|
||
type ActionEnvVar,
|
||
type ActionKind,
|
||
type AuthAuditEvent,
|
||
type Group,
|
||
type GroupScopeKind,
|
||
type MeResponse,
|
||
type NetworkSettings,
|
||
type Node,
|
||
type NodeAuditAction,
|
||
type NodeAuditEvent,
|
||
type NodeKind,
|
||
type ReauthCredentials,
|
||
type SecuritySettings,
|
||
type User,
|
||
saveNetwork,
|
||
saveSecurity,
|
||
upsertGroup,
|
||
} from './api/client'
|
||
import {
|
||
filterAuthEvents,
|
||
filterNodeEvents,
|
||
pageRangeLabel,
|
||
paginateItems,
|
||
resolveDateRangeBounds,
|
||
sortAuthEvents,
|
||
sortNodeEvents,
|
||
type AuthActionQuickFilter,
|
||
type AuthCategoryFilter,
|
||
type AuthLogsSortKey,
|
||
type AuthOutcomeFilter,
|
||
type DateRangePreset,
|
||
type NodeLogsSortKey,
|
||
type SortDirection,
|
||
} from './logsFiltering'
|
||
import {
|
||
LOGS_PAGE_SIZE_OPTIONS,
|
||
readLogsPageSize,
|
||
writeLogsPageSize,
|
||
type LogsPageSize,
|
||
} from './logsPreferences'
|
||
import { LoginPage } from './LoginPage'
|
||
import { NodeDetailPanel } from './NodeDetailPanel'
|
||
import {
|
||
countAuthFailuresToday,
|
||
countNodesBySection,
|
||
type NodeSectionCounts,
|
||
} from './sidebarCounts'
|
||
import { documentTitleFor, SECTION_TITLES } from './documentTitle'
|
||
import {
|
||
isResourceSectionId,
|
||
navigateTo,
|
||
parseLocation,
|
||
pathFor,
|
||
subscribeToLocation,
|
||
type ConfigTabId,
|
||
type NodeDetailTabId,
|
||
type ResourceSectionId,
|
||
type ResourceTabId,
|
||
type SectionId,
|
||
} from './navigation'
|
||
import {
|
||
validateHostname,
|
||
validateListenAddress,
|
||
validatePassword,
|
||
} from './passwordPolicy'
|
||
import { ReauthModal } from './ReauthModal'
|
||
import { SetupWizard } from './SetupWizard'
|
||
import {
|
||
applyThemeFromPreference,
|
||
getSystemPrefersDark,
|
||
readThemePreference,
|
||
writeThemePreference,
|
||
type ThemePreference,
|
||
} from './theme'
|
||
import './App.css'
|
||
|
||
const NAV_ITEMS: ReadonlyArray<{ id: SectionId; label: string }> = [
|
||
{ id: 'overview', label: 'Overview' },
|
||
{ id: 'containers', label: 'Containers' },
|
||
{ id: 'docker', label: 'Docker' },
|
||
{ id: 'vms', label: 'Virtual Machines' },
|
||
]
|
||
|
||
const CONFIG_TABS: ReadonlyArray<{ id: ConfigTabId; label: string }> = [
|
||
{ id: 'overview', label: 'Overview' },
|
||
{ id: 'users', label: 'Users' },
|
||
{ id: 'roles', label: 'Roles' },
|
||
{ id: 'groups', label: 'Groups' },
|
||
{ id: 'actions', label: 'Actions' },
|
||
{ id: 'security', label: 'Security' },
|
||
{ id: 'integrations', label: 'Integrations' },
|
||
{ id: 'network', label: 'Network' },
|
||
{ id: 'advanced', label: 'Advanced' },
|
||
]
|
||
|
||
const RESOURCE_SECTION_KIND: Record<ResourceSectionId, NodeKind> = {
|
||
containers: 'container',
|
||
vms: 'vm',
|
||
docker: 'docker',
|
||
}
|
||
|
||
const RESOURCE_ADD_LABEL: Record<ResourceSectionId, string> = {
|
||
containers: 'Add Containers',
|
||
vms: 'Add Virtual Machines',
|
||
docker: 'Add Docker',
|
||
}
|
||
|
||
const DEFAULT_PERMISSIONS = [
|
||
'nodes.read',
|
||
'nodes.exec',
|
||
'nodes.update',
|
||
'nodes.delete',
|
||
'jobs.read',
|
||
'jobs.run',
|
||
'actions.create',
|
||
'actions.update',
|
||
'actions.delete',
|
||
'users.manage',
|
||
'secrets.manage',
|
||
'roles.manage',
|
||
'logs.read',
|
||
] as const
|
||
|
||
const ADMINISTRATORS_GROUP_NAME = 'Administrators'
|
||
|
||
function formatUserTimestamp(value: string): string {
|
||
const parsed = Date.parse(value)
|
||
if (Number.isNaN(parsed)) {
|
||
return value || '—'
|
||
}
|
||
return new Date(parsed).toLocaleString()
|
||
}
|
||
|
||
function CogIcon() {
|
||
return (
|
||
<svg
|
||
className="cog-icon"
|
||
viewBox="0 0 24 24"
|
||
width="18"
|
||
height="18"
|
||
aria-hidden="true"
|
||
focusable="false"
|
||
>
|
||
<path
|
||
fill="currentColor"
|
||
d="M19.14 12.94c.04-.31.06-.63.06-.94s-.02-.63-.06-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.49.49 0 0 0-.59-.22l-2.39.96a7.2 7.2 0 0 0-1.62-.94l-.36-2.54A.48.48 0 0 0 13.9 2h-3.8a.48.48 0 0 0-.48.42l-.36 2.54c-.59.24-1.13.55-1.62.94l-2.39-.96a.49.49 0 0 0-.59.22L2.74 8.48a.49.49 0 0 0 .12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94L2.86 14.52a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.3.59.22l2.39-.96c.49.39 1.03.7 1.62.94l.36 2.54c.05.24.24.42.48.42h3.8c.24 0 .43-.18.48-.42l.36-2.54c.59-.24 1.13-.55 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32a.49.49 0 0 0-.12-.61l-2.03-1.58ZM12 15.5A3.5 3.5 0 1 1 12 8.5a3.5 3.5 0 0 1 0 7Z"
|
||
/>
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
function UserIcon() {
|
||
return (
|
||
<svg
|
||
className="user-icon"
|
||
viewBox="0 0 24 24"
|
||
width="18"
|
||
height="18"
|
||
aria-hidden="true"
|
||
focusable="false"
|
||
>
|
||
<path
|
||
fill="currentColor"
|
||
d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12Zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v1.2c0 .7.5 1.2 1.2 1.2h16.8c.7 0 1.2-.5 1.2-1.2v-1.2c0-3.2-6.4-4.8-9.6-4.8Z"
|
||
/>
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
function LogoutIcon() {
|
||
return (
|
||
<svg
|
||
className="logout-icon"
|
||
viewBox="0 0 24 24"
|
||
width="18"
|
||
height="18"
|
||
aria-hidden="true"
|
||
focusable="false"
|
||
>
|
||
<path
|
||
fill="currentColor"
|
||
d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5-5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"
|
||
/>
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
function FiltersChevronIcon({ expanded }: { expanded: boolean }) {
|
||
return (
|
||
<svg
|
||
className={
|
||
expanded
|
||
? 'logs-filters-chevron logs-filters-chevron-expanded'
|
||
: 'logs-filters-chevron'
|
||
}
|
||
viewBox="0 0 24 24"
|
||
width="16"
|
||
height="16"
|
||
aria-hidden="true"
|
||
focusable="false"
|
||
>
|
||
<path
|
||
fill="currentColor"
|
||
d="M7.41 8.59 12 13.17l4.59-4.58L18 10l-6 6-6-6z"
|
||
/>
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
function ProfilePanel({
|
||
onSignedOut,
|
||
}: {
|
||
onSignedOut: () => void
|
||
}) {
|
||
const [me, setMe] = useState<MeResponse | null>(null)
|
||
const [loadError, setLoadError] = useState<string | null>(null)
|
||
const [currentPassword, setCurrentPassword] = useState('')
|
||
const [newPassword, setNewPassword] = useState('')
|
||
const [confirmPassword, setConfirmPassword] = useState('')
|
||
const [passwordTotp, setPasswordTotp] = useState('')
|
||
const [passwordError, setPasswordError] = useState<string | null>(null)
|
||
const [passwordSuccess, setPasswordSuccess] = useState<string | null>(null)
|
||
|
||
const [totpPassword, setTotpPassword] = useState('')
|
||
const [totpReauthCode, setTotpReauthCode] = useState('')
|
||
const [pendingSecret, setPendingSecret] = useState<string | null>(null)
|
||
const [pendingUrl, setPendingUrl] = useState<string | null>(null)
|
||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null)
|
||
const [confirmCode, setConfirmCode] = useState('')
|
||
const [disableCode, setDisableCode] = useState('')
|
||
const [totpError, setTotpError] = useState<string | null>(null)
|
||
const [totpMessage, setTotpMessage] = useState<string | null>(null)
|
||
|
||
useEffect(() => {
|
||
let isCancelled = false
|
||
async function load() {
|
||
try {
|
||
const response = await fetchMe()
|
||
if (!isCancelled) {
|
||
if (!response) {
|
||
onSignedOut()
|
||
return
|
||
}
|
||
setMe(response)
|
||
}
|
||
} catch (err) {
|
||
if (!isCancelled) {
|
||
setLoadError(err instanceof Error ? err.message : 'Failed to load profile')
|
||
}
|
||
}
|
||
}
|
||
void load()
|
||
return () => {
|
||
isCancelled = true
|
||
}
|
||
}, [onSignedOut])
|
||
|
||
useEffect(() => {
|
||
if (!pendingUrl) {
|
||
setQrDataUrl(null)
|
||
return
|
||
}
|
||
let isCancelled = false
|
||
void import('qrcode').then(async (qrcode) => {
|
||
const dataUrl = await qrcode.toDataURL(pendingUrl, { width: 220, margin: 1 })
|
||
if (!isCancelled) {
|
||
setQrDataUrl(dataUrl)
|
||
}
|
||
})
|
||
return () => {
|
||
isCancelled = true
|
||
}
|
||
}, [pendingUrl])
|
||
|
||
async function handleChangePassword(event: FormEvent) {
|
||
event.preventDefault()
|
||
setPasswordError(null)
|
||
setPasswordSuccess(null)
|
||
if (!me) {
|
||
return
|
||
}
|
||
if (newPassword !== confirmPassword) {
|
||
setPasswordError('New passwords do not match')
|
||
return
|
||
}
|
||
const policyError = validatePassword(newPassword, me.username)
|
||
if (policyError) {
|
||
setPasswordError(policyError)
|
||
return
|
||
}
|
||
try {
|
||
await changeMyPassword(currentPassword, newPassword, passwordTotp)
|
||
setPasswordSuccess('Password updated. Sign in again.')
|
||
setCurrentPassword('')
|
||
setNewPassword('')
|
||
setConfirmPassword('')
|
||
setPasswordTotp('')
|
||
onSignedOut()
|
||
} catch (err) {
|
||
setPasswordError(err instanceof Error ? err.message : 'Password change failed')
|
||
}
|
||
}
|
||
|
||
async function handleBeginTotp(event: FormEvent) {
|
||
event.preventDefault()
|
||
setTotpError(null)
|
||
setTotpMessage(null)
|
||
try {
|
||
const began = await beginMyTOTP({
|
||
current_password: totpPassword,
|
||
totp_code: totpReauthCode,
|
||
})
|
||
setPendingSecret(began.secret)
|
||
setPendingUrl(began.otpauth_url)
|
||
setTotpMessage('Scan the QR code, then enter a code to confirm.')
|
||
} catch (err) {
|
||
setTotpError(err instanceof Error ? err.message : 'Failed to start TOTP enrollment')
|
||
}
|
||
}
|
||
|
||
async function handleConfirmTotp(event: FormEvent) {
|
||
event.preventDefault()
|
||
if (!pendingSecret) {
|
||
return
|
||
}
|
||
setTotpError(null)
|
||
try {
|
||
await confirmMyTOTP(pendingSecret, confirmCode, {
|
||
current_password: totpPassword,
|
||
totp_code: totpReauthCode,
|
||
})
|
||
setPendingSecret(null)
|
||
setPendingUrl(null)
|
||
setConfirmCode('')
|
||
setTotpPassword('')
|
||
setTotpReauthCode('')
|
||
setTotpMessage('Authenticator enabled.')
|
||
const refreshed = await fetchMe()
|
||
if (refreshed) {
|
||
setMe(refreshed)
|
||
}
|
||
} catch (err) {
|
||
setTotpError(err instanceof Error ? err.message : 'Failed to confirm TOTP')
|
||
}
|
||
}
|
||
|
||
async function handleDisableTotp(event: FormEvent) {
|
||
event.preventDefault()
|
||
setTotpError(null)
|
||
try {
|
||
await disableMyTOTP({
|
||
current_password: totpPassword,
|
||
totp_code: disableCode,
|
||
})
|
||
setDisableCode('')
|
||
setTotpPassword('')
|
||
setTotpMessage('Authenticator disabled.')
|
||
const refreshed = await fetchMe()
|
||
if (refreshed) {
|
||
setMe(refreshed)
|
||
}
|
||
} catch (err) {
|
||
setTotpError(err instanceof Error ? err.message : 'Failed to disable TOTP')
|
||
}
|
||
}
|
||
|
||
if (loadError) {
|
||
return (
|
||
<div className="profile-panel">
|
||
<p className="config-empty" role="alert">
|
||
{loadError}
|
||
</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (!me) {
|
||
return (
|
||
<div className="profile-panel">
|
||
<p className="config-empty">Loading…</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="profile-panel">
|
||
<section className="profile-section">
|
||
<h2 className="profile-section-title">Account</h2>
|
||
<p className="config-hint">Signed in as {me.username}</p>
|
||
</section>
|
||
|
||
<section className="profile-section">
|
||
<h2 className="profile-section-title">Change password</h2>
|
||
<form className="profile-form" onSubmit={(event) => void handleChangePassword(event)}>
|
||
<label htmlFor="profile-current-password">Current password</label>
|
||
<input
|
||
id="profile-current-password"
|
||
type="password"
|
||
autoComplete="current-password"
|
||
value={currentPassword}
|
||
onChange={(event) => setCurrentPassword(event.target.value)}
|
||
required
|
||
/>
|
||
<label htmlFor="profile-new-password">New password</label>
|
||
<input
|
||
id="profile-new-password"
|
||
type="password"
|
||
autoComplete="new-password"
|
||
value={newPassword}
|
||
onChange={(event) => setNewPassword(event.target.value)}
|
||
required
|
||
/>
|
||
<label htmlFor="profile-confirm-password">Confirm new password</label>
|
||
<input
|
||
id="profile-confirm-password"
|
||
type="password"
|
||
autoComplete="new-password"
|
||
value={confirmPassword}
|
||
onChange={(event) => setConfirmPassword(event.target.value)}
|
||
required
|
||
/>
|
||
{me.totp_confirmed ? (
|
||
<>
|
||
<label htmlFor="profile-password-totp">Authenticator code</label>
|
||
<input
|
||
id="profile-password-totp"
|
||
type="text"
|
||
inputMode="numeric"
|
||
autoComplete="one-time-code"
|
||
value={passwordTotp}
|
||
onChange={(event) => setPasswordTotp(event.target.value)}
|
||
required
|
||
/>
|
||
</>
|
||
) : null}
|
||
{passwordError ? (
|
||
<p className="groups-error" role="alert">
|
||
{passwordError}
|
||
</p>
|
||
) : null}
|
||
{passwordSuccess ? (
|
||
<p className="config-hint" role="status">
|
||
{passwordSuccess}
|
||
</p>
|
||
) : null}
|
||
<button type="submit" className="config-action-save">
|
||
Update password
|
||
</button>
|
||
</form>
|
||
</section>
|
||
|
||
<section className="profile-section">
|
||
<h2 className="profile-section-title">Authenticator (TOTP)</h2>
|
||
{!me.totp_enabled ? (
|
||
<p className="config-hint">
|
||
An administrator must enable TOTP on the server before you can enroll.
|
||
</p>
|
||
) : me.totp_confirmed ? (
|
||
<form className="profile-form" onSubmit={(event) => void handleDisableTotp(event)}>
|
||
<p className="config-hint">Authenticator is enabled on your account.</p>
|
||
<label htmlFor="profile-disable-password">Password</label>
|
||
<input
|
||
id="profile-disable-password"
|
||
type="password"
|
||
autoComplete="current-password"
|
||
value={totpPassword}
|
||
onChange={(event) => setTotpPassword(event.target.value)}
|
||
required
|
||
/>
|
||
<label htmlFor="profile-disable-code">Current authenticator code</label>
|
||
<input
|
||
id="profile-disable-code"
|
||
type="text"
|
||
inputMode="numeric"
|
||
autoComplete="one-time-code"
|
||
value={disableCode}
|
||
onChange={(event) => setDisableCode(event.target.value)}
|
||
required
|
||
/>
|
||
{totpError ? (
|
||
<p className="groups-error" role="alert">
|
||
{totpError}
|
||
</p>
|
||
) : null}
|
||
{totpMessage ? (
|
||
<p className="config-hint" role="status">
|
||
{totpMessage}
|
||
</p>
|
||
) : null}
|
||
<button type="submit" className="groups-delete">
|
||
Disable authenticator
|
||
</button>
|
||
</form>
|
||
) : pendingSecret ? (
|
||
<form className="profile-form" onSubmit={(event) => void handleConfirmTotp(event)}>
|
||
{qrDataUrl ? (
|
||
<img className="totp-qr" src={qrDataUrl} alt="TOTP QR code" />
|
||
) : null}
|
||
<p className="config-hint">Secret: {pendingSecret}</p>
|
||
<label htmlFor="profile-confirm-code">Authenticator code</label>
|
||
<input
|
||
id="profile-confirm-code"
|
||
type="text"
|
||
inputMode="numeric"
|
||
autoComplete="one-time-code"
|
||
value={confirmCode}
|
||
onChange={(event) => setConfirmCode(event.target.value)}
|
||
required
|
||
/>
|
||
{totpError ? (
|
||
<p className="groups-error" role="alert">
|
||
{totpError}
|
||
</p>
|
||
) : null}
|
||
<button type="submit" className="config-action-save">
|
||
Confirm authenticator
|
||
</button>
|
||
</form>
|
||
) : (
|
||
<form className="profile-form" onSubmit={(event) => void handleBeginTotp(event)}>
|
||
<p className="config-hint">
|
||
Re-enter your password to begin authenticator enrollment.
|
||
</p>
|
||
<label htmlFor="profile-totp-password">Password</label>
|
||
<input
|
||
id="profile-totp-password"
|
||
type="password"
|
||
autoComplete="current-password"
|
||
value={totpPassword}
|
||
onChange={(event) => setTotpPassword(event.target.value)}
|
||
required
|
||
/>
|
||
{totpError ? (
|
||
<p className="groups-error" role="alert">
|
||
{totpError}
|
||
</p>
|
||
) : null}
|
||
{totpMessage ? (
|
||
<p className="config-hint" role="status">
|
||
{totpMessage}
|
||
</p>
|
||
) : null}
|
||
<button type="submit" className="config-action-save">
|
||
Begin enrollment
|
||
</button>
|
||
</form>
|
||
)}
|
||
</section>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function UsersConfigTab() {
|
||
const [users, setUsers] = useState<ReadonlyArray<User>>([])
|
||
const [groups, setGroups] = useState<ReadonlyArray<Group>>([])
|
||
const [me, setMe] = useState<MeResponse | null>(null)
|
||
const [isLoadingUsers, setIsLoadingUsers] = useState(true)
|
||
const [usersLoadError, setUsersLoadError] = useState<string | null>(null)
|
||
const [actionError, setActionError] = useState<string | null>(null)
|
||
|
||
const [createUsername, setCreateUsername] = useState('')
|
||
const [createPassword, setCreatePassword] = useState('')
|
||
const [createGroupNames, setCreateGroupNames] = useState<ReadonlyArray<string>>([])
|
||
|
||
const [editingUserId, setEditingUserId] = useState<string | null>(null)
|
||
const [editGroupNames, setEditGroupNames] = useState<ReadonlyArray<string>>([])
|
||
const [setPasswordUserId, setSetPasswordUserId] = useState<string | null>(null)
|
||
const [setPasswordValue, setSetPasswordValue] = useState('')
|
||
|
||
type PendingAction =
|
||
| { kind: 'delete'; userId: string }
|
||
| {
|
||
kind: 'grace'
|
||
run: (credentials?: ReauthCredentials) => Promise<void>
|
||
}
|
||
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null)
|
||
|
||
const canManageUsers = Boolean(me?.permissions?.includes('users.manage'))
|
||
const requireTotp = Boolean(me?.totp_confirmed && me?.totp_enabled)
|
||
|
||
useEffect(() => {
|
||
let isCancelled = false
|
||
|
||
async function loadUsers() {
|
||
setIsLoadingUsers(true)
|
||
setUsersLoadError(null)
|
||
try {
|
||
const [usersResponse, groupsResponse, meResponse] = await Promise.all([
|
||
fetchUsers(),
|
||
fetchGroups(),
|
||
fetchMe(),
|
||
])
|
||
if (!isCancelled) {
|
||
setUsers(usersResponse.users)
|
||
setGroups(groupsResponse.groups)
|
||
setMe(meResponse)
|
||
}
|
||
} catch (err) {
|
||
if (!isCancelled) {
|
||
setUsersLoadError(
|
||
err instanceof Error ? err.message : 'Failed to load users',
|
||
)
|
||
}
|
||
} finally {
|
||
if (!isCancelled) {
|
||
setIsLoadingUsers(false)
|
||
}
|
||
}
|
||
}
|
||
|
||
void loadUsers()
|
||
|
||
return () => {
|
||
isCancelled = true
|
||
}
|
||
}, [])
|
||
|
||
function toggleGroupSelection(
|
||
current: ReadonlyArray<string>,
|
||
groupName: string,
|
||
): ReadonlyArray<string> {
|
||
if (current.includes(groupName)) {
|
||
return current.filter((name) => name !== groupName)
|
||
}
|
||
return [...current, groupName]
|
||
}
|
||
|
||
async function runGraceMutation(mutation: () => Promise<void>) {
|
||
setActionError(null)
|
||
try {
|
||
await mutation()
|
||
} catch (err) {
|
||
if (isReauthRequiredError(err)) {
|
||
setPendingAction({
|
||
kind: 'grace',
|
||
run: async () => {
|
||
// caller will reauth first then call mutation again
|
||
await mutation()
|
||
},
|
||
})
|
||
return
|
||
}
|
||
setActionError(err instanceof Error ? err.message : 'Action failed')
|
||
}
|
||
}
|
||
|
||
async function handleCreateUser(event: FormEvent) {
|
||
event.preventDefault()
|
||
setActionError(null)
|
||
const policyError = validatePassword(createPassword, createUsername)
|
||
if (policyError) {
|
||
setActionError(policyError)
|
||
return
|
||
}
|
||
try {
|
||
const response = await createUser({
|
||
username: createUsername,
|
||
password: createPassword,
|
||
group_names: createGroupNames,
|
||
})
|
||
setUsers(response.users)
|
||
setCreateUsername('')
|
||
setCreatePassword('')
|
||
setCreateGroupNames([])
|
||
} catch (err) {
|
||
setActionError(err instanceof Error ? err.message : 'Failed to create user')
|
||
}
|
||
}
|
||
|
||
async function handleSaveGroups(userId: string) {
|
||
await runGraceMutation(async () => {
|
||
const response = await patchUser(userId, { group_names: editGroupNames })
|
||
setUsers(response.users)
|
||
setEditingUserId(null)
|
||
})
|
||
}
|
||
|
||
async function handleSetPassword(userId: string) {
|
||
const target = users.find((user) => user.id === userId)
|
||
const policyError = validatePassword(setPasswordValue, target?.username ?? '')
|
||
if (policyError) {
|
||
setActionError(policyError)
|
||
return
|
||
}
|
||
await runGraceMutation(async () => {
|
||
const response = await patchUser(userId, { password: setPasswordValue })
|
||
setUsers(response.users)
|
||
setSetPasswordUserId(null)
|
||
setSetPasswordValue('')
|
||
})
|
||
}
|
||
|
||
async function handleDisableTotp(userId: string) {
|
||
await runGraceMutation(async () => {
|
||
const response = await patchUser(userId, { disable_totp: true })
|
||
setUsers(response.users)
|
||
})
|
||
}
|
||
|
||
async function handleDeleteConfirm(credentials: ReauthCredentials) {
|
||
if (!pendingAction || pendingAction.kind !== 'delete') {
|
||
return
|
||
}
|
||
const response = await deleteUser(pendingAction.userId, credentials)
|
||
setUsers(response.users)
|
||
setPendingAction(null)
|
||
}
|
||
|
||
async function handleGraceConfirm(credentials: ReauthCredentials) {
|
||
if (!pendingAction || pendingAction.kind !== 'grace') {
|
||
return
|
||
}
|
||
await reauth(credentials.current_password, credentials.totp_code ?? '')
|
||
await pendingAction.run()
|
||
setPendingAction(null)
|
||
}
|
||
|
||
return (
|
||
<div className="groups-panel users-panel">
|
||
{pendingAction?.kind === 'delete' ? (
|
||
<ReauthModal
|
||
title="Confirm delete"
|
||
hint="Re-enter your password to delete this user."
|
||
requireTotp={requireTotp}
|
||
confirmLabel="Delete user"
|
||
onCancel={() => setPendingAction(null)}
|
||
onConfirm={handleDeleteConfirm}
|
||
/>
|
||
) : null}
|
||
{pendingAction?.kind === 'grace' ? (
|
||
<ReauthModal
|
||
title="Confirm identity"
|
||
hint="Re-authentication is required for this change."
|
||
requireTotp={requireTotp}
|
||
confirmLabel="Continue"
|
||
onCancel={() => setPendingAction(null)}
|
||
onConfirm={handleGraceConfirm}
|
||
/>
|
||
) : null}
|
||
|
||
{canManageUsers ? (
|
||
<form
|
||
className="users-create-form"
|
||
onSubmit={(event) => void handleCreateUser(event)}
|
||
>
|
||
<p className="config-hint">Create user</p>
|
||
<label htmlFor="create-username">Username</label>
|
||
<input
|
||
id="create-username"
|
||
value={createUsername}
|
||
onChange={(event) => setCreateUsername(event.target.value)}
|
||
required
|
||
/>
|
||
<label htmlFor="create-password">Password</label>
|
||
<input
|
||
id="create-password"
|
||
type="password"
|
||
autoComplete="new-password"
|
||
value={createPassword}
|
||
onChange={(event) => setCreatePassword(event.target.value)}
|
||
required
|
||
/>
|
||
<fieldset className="users-group-fieldset">
|
||
<legend>Groups (optional)</legend>
|
||
{groups.map((group) => (
|
||
<label key={group.name} className="permission-option">
|
||
<input
|
||
type="checkbox"
|
||
checked={createGroupNames.includes(group.name)}
|
||
onChange={() =>
|
||
setCreateGroupNames(
|
||
toggleGroupSelection(createGroupNames, group.name),
|
||
)
|
||
}
|
||
/>
|
||
<span>{group.name}</span>
|
||
</label>
|
||
))}
|
||
</fieldset>
|
||
<button type="submit" className="config-action-save">
|
||
Create user
|
||
</button>
|
||
</form>
|
||
) : null}
|
||
|
||
<div className="groups-list">
|
||
<p className="config-hint">Configured accounts</p>
|
||
{actionError ? (
|
||
<p className="config-empty groups-error" role="alert">
|
||
{actionError}
|
||
</p>
|
||
) : null}
|
||
{isLoadingUsers ? (
|
||
<p className="config-empty">Loading…</p>
|
||
) : usersLoadError ? (
|
||
<p className="config-empty" role="alert">
|
||
{usersLoadError}
|
||
</p>
|
||
) : users.length === 0 ? (
|
||
<p className="config-empty">No users configured yet.</p>
|
||
) : (
|
||
<ul className="groups-ul">
|
||
{users.map((user) => (
|
||
<li key={user.id} className="groups-li">
|
||
<div className="groups-li-content">
|
||
<div className="groups-li-title">
|
||
{user.username}
|
||
{user.is_admin ? (
|
||
<span className="user-badge">Admin</span>
|
||
) : null}
|
||
</div>
|
||
<div className="groups-li-meta">
|
||
Groups:{' '}
|
||
{user.group_names.length > 0
|
||
? user.group_names.join(', ')
|
||
: '—'}
|
||
</div>
|
||
<div className="groups-li-meta">
|
||
Enabled: {user.enabled ? 'yes' : 'no'} · TOTP:{' '}
|
||
{user.totp_confirmed ? 'confirmed' : 'not confirmed'}
|
||
</div>
|
||
<div className="groups-li-meta">
|
||
Created: {formatUserTimestamp(user.created_at)} · Password
|
||
changed: {formatUserTimestamp(user.password_changed_at)}
|
||
</div>
|
||
<div className="groups-li-meta">ID: {user.id}</div>
|
||
|
||
{canManageUsers && editingUserId === user.id ? (
|
||
<div className="users-inline-edit">
|
||
<fieldset className="users-group-fieldset">
|
||
<legend>Groups</legend>
|
||
{groups.map((group) => (
|
||
<label key={group.name} className="permission-option">
|
||
<input
|
||
type="checkbox"
|
||
checked={editGroupNames.includes(group.name)}
|
||
onChange={() =>
|
||
setEditGroupNames(
|
||
toggleGroupSelection(editGroupNames, group.name),
|
||
)
|
||
}
|
||
/>
|
||
<span>{group.name}</span>
|
||
</label>
|
||
))}
|
||
</fieldset>
|
||
<div className="users-inline-actions">
|
||
<button
|
||
type="button"
|
||
className="config-action-save"
|
||
onClick={() => void handleSaveGroups(user.id)}
|
||
>
|
||
Save groups
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="config-action-revert"
|
||
onClick={() => setEditingUserId(null)}
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{canManageUsers && setPasswordUserId === user.id ? (
|
||
<div className="users-inline-edit">
|
||
<label htmlFor={`set-password-${user.id}`}>New password</label>
|
||
<input
|
||
id={`set-password-${user.id}`}
|
||
type="password"
|
||
autoComplete="new-password"
|
||
value={setPasswordValue}
|
||
onChange={(event) => setSetPasswordValue(event.target.value)}
|
||
/>
|
||
<div className="users-inline-actions">
|
||
<button
|
||
type="button"
|
||
className="config-action-save"
|
||
onClick={() => void handleSetPassword(user.id)}
|
||
>
|
||
Set password
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="config-action-revert"
|
||
onClick={() => {
|
||
setSetPasswordUserId(null)
|
||
setSetPasswordValue('')
|
||
}}
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
{canManageUsers ? (
|
||
<div className="users-row-actions">
|
||
<button
|
||
type="button"
|
||
className="config-action-revert"
|
||
onClick={() => {
|
||
setEditingUserId(user.id)
|
||
setEditGroupNames(user.group_names)
|
||
setSetPasswordUserId(null)
|
||
}}
|
||
>
|
||
Groups
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="config-action-revert"
|
||
onClick={() => {
|
||
setSetPasswordUserId(user.id)
|
||
setSetPasswordValue('')
|
||
setEditingUserId(null)
|
||
}}
|
||
>
|
||
Password
|
||
</button>
|
||
{user.totp_confirmed ? (
|
||
<button
|
||
type="button"
|
||
className="config-action-revert"
|
||
onClick={() => void handleDisableTotp(user.id)}
|
||
>
|
||
Disable TOTP
|
||
</button>
|
||
) : null}
|
||
<button
|
||
type="button"
|
||
className="groups-delete"
|
||
disabled={!user.can_delete}
|
||
title={
|
||
user.can_delete
|
||
? undefined
|
||
: 'Cannot delete the last administrator'
|
||
}
|
||
onClick={() =>
|
||
setPendingAction({ kind: 'delete', userId: user.id })
|
||
}
|
||
>
|
||
Delete
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
className="groups-delete"
|
||
disabled
|
||
title="Requires users.manage"
|
||
>
|
||
Delete
|
||
</button>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function useThemePreference() {
|
||
const [themePreference, setThemePreference] =
|
||
useState<ThemePreference>(readThemePreference)
|
||
|
||
useEffect(() => {
|
||
applyThemeFromPreference(themePreference)
|
||
|
||
if (themePreference !== 'auto') {
|
||
return
|
||
}
|
||
|
||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||
|
||
function handleSystemThemeChange() {
|
||
applyThemeFromPreference('auto', getSystemPrefersDark(mediaQuery))
|
||
}
|
||
|
||
mediaQuery.addEventListener('change', handleSystemThemeChange)
|
||
return () => {
|
||
mediaQuery.removeEventListener('change', handleSystemThemeChange)
|
||
}
|
||
}, [themePreference])
|
||
|
||
function updateThemePreference(nextPreference: ThemePreference) {
|
||
writeThemePreference(nextPreference)
|
||
setThemePreference(nextPreference)
|
||
}
|
||
|
||
return { themePreference, updateThemePreference }
|
||
}
|
||
|
||
function OverviewConfigTab({
|
||
draftThemePreference,
|
||
onDraftThemePreferenceChange,
|
||
}: {
|
||
draftThemePreference: ThemePreference
|
||
onDraftThemePreferenceChange: (preference: ThemePreference) => void
|
||
}) {
|
||
return (
|
||
<div className="config-field">
|
||
<label htmlFor="theme-preference">Theme</label>
|
||
<select
|
||
id="theme-preference"
|
||
className="theme-select"
|
||
value={draftThemePreference}
|
||
onChange={(event) => {
|
||
onDraftThemePreferenceChange(event.target.value as ThemePreference)
|
||
}}
|
||
>
|
||
<option value="auto">Auto</option>
|
||
<option value="light">Light</option>
|
||
<option value="dark">Dark</option>
|
||
</select>
|
||
<p className="config-hint">
|
||
Auto follows the browser preference. Light and Dark always use
|
||
Catppuccin Latte or Mocha.
|
||
</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const ACTION_PLACEHOLDERS: ReadonlyArray<{
|
||
token: string
|
||
description: string
|
||
example: string
|
||
}> = [
|
||
{
|
||
token: '{{cc.host}}',
|
||
description: 'ClusterCanvas public hostname',
|
||
example: 'canvas.example.com',
|
||
},
|
||
{
|
||
token: '{{cc.ip}}',
|
||
description: 'ClusterCanvas listen / resolved IP',
|
||
example: '192.168.1.10',
|
||
},
|
||
{
|
||
token: '{{node.host}}',
|
||
description: 'Target node hostname / address as stored',
|
||
example: 'vm-web-01',
|
||
},
|
||
{
|
||
token: '{{node.ip}}',
|
||
description: 'Target node host IP',
|
||
example: '10.0.0.5',
|
||
},
|
||
{
|
||
token: '{{node.username}}',
|
||
description: 'SSH username on the target node',
|
||
example: 'root',
|
||
},
|
||
{
|
||
token: '{{env.NAME}}',
|
||
description: 'Value of an ENV var defined on this action',
|
||
example: '{{env.APP_HOME}} → /opt/app',
|
||
},
|
||
{
|
||
token: '{{secret.NAME}}',
|
||
description:
|
||
'Reserved for the future Secrets page (not resolved yet)',
|
||
example: '{{secret.db_password}}',
|
||
},
|
||
]
|
||
|
||
function emptyEnvRow(): ActionEnvVar {
|
||
return { name: '', value: '' }
|
||
}
|
||
|
||
function ActionsConfigTab() {
|
||
const [actions, setActions] = useState<Action[]>([])
|
||
const [me, setMe] = useState<MeResponse | null>(null)
|
||
const [isLoading, setIsLoading] = useState(true)
|
||
const [loadError, setLoadError] = useState<string | null>(null)
|
||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||
const [showTypePicker, setShowTypePicker] = useState(false)
|
||
const [editorKind, setEditorKind] = useState<ActionKind | null>(null)
|
||
const [editingActionId, setEditingActionId] = useState<string | null>(null)
|
||
const [draftName, setDraftName] = useState('')
|
||
const [draftDescription, setDraftDescription] = useState('')
|
||
const [draftBody, setDraftBody] = useState('')
|
||
const [draftEnv, setDraftEnv] = useState<ActionEnvVar[]>([emptyEnvRow()])
|
||
const [isSaving, setIsSaving] = useState(false)
|
||
|
||
const isEditing = editingActionId !== null
|
||
const showEditor = editorKind !== null
|
||
const canCreateActions = Boolean(me?.permissions?.includes('actions.create'))
|
||
const canUpdateActions = Boolean(me?.permissions?.includes('actions.update'))
|
||
const canDeleteActions = Boolean(me?.permissions?.includes('actions.delete'))
|
||
|
||
useEffect(() => {
|
||
let isCancelled = false
|
||
|
||
async function loadActions() {
|
||
setIsLoading(true)
|
||
setLoadError(null)
|
||
try {
|
||
const [actionsResponse, meResponse] = await Promise.all([
|
||
fetchActions(),
|
||
fetchMe(),
|
||
])
|
||
if (!isCancelled) {
|
||
setActions([...actionsResponse.actions])
|
||
setMe(meResponse)
|
||
}
|
||
} catch (err) {
|
||
if (!isCancelled) {
|
||
setLoadError(
|
||
err instanceof Error ? err.message : 'Failed to load actions',
|
||
)
|
||
}
|
||
} finally {
|
||
if (!isCancelled) {
|
||
setIsLoading(false)
|
||
}
|
||
}
|
||
}
|
||
|
||
void loadActions()
|
||
|
||
return () => {
|
||
isCancelled = true
|
||
}
|
||
}, [])
|
||
|
||
function clearEditor() {
|
||
setShowTypePicker(false)
|
||
setEditorKind(null)
|
||
setEditingActionId(null)
|
||
setDraftName('')
|
||
setDraftDescription('')
|
||
setDraftBody('')
|
||
setDraftEnv([emptyEnvRow()])
|
||
setSubmitError(null)
|
||
}
|
||
|
||
function openTypePicker() {
|
||
clearEditor()
|
||
setShowTypePicker(true)
|
||
}
|
||
|
||
function startCreate(kind: ActionKind) {
|
||
setShowTypePicker(false)
|
||
setEditorKind(kind)
|
||
setEditingActionId(null)
|
||
setDraftName('')
|
||
setDraftDescription('')
|
||
setDraftBody('')
|
||
setDraftEnv([emptyEnvRow()])
|
||
setSubmitError(null)
|
||
}
|
||
|
||
function startEdit(action: Action) {
|
||
if (action.builtin || !canUpdateActions) {
|
||
return
|
||
}
|
||
setShowTypePicker(false)
|
||
setEditorKind(action.kind)
|
||
setEditingActionId(action.id)
|
||
setDraftName(action.name)
|
||
setDraftDescription(action.description)
|
||
setDraftBody(action.body)
|
||
setDraftEnv(
|
||
action.env.length > 0
|
||
? action.env.map((envVar) => ({ ...envVar }))
|
||
: [emptyEnvRow()],
|
||
)
|
||
setSubmitError(null)
|
||
}
|
||
|
||
function updateEnvRow(index: number, field: 'name' | 'value', value: string) {
|
||
setDraftEnv((previous) =>
|
||
previous.map((row, rowIndex) =>
|
||
rowIndex === index ? { ...row, [field]: value } : row,
|
||
),
|
||
)
|
||
}
|
||
|
||
function addEnvRow() {
|
||
setDraftEnv((previous) => [...previous, emptyEnvRow()])
|
||
}
|
||
|
||
function removeEnvRow(index: number) {
|
||
setDraftEnv((previous) => {
|
||
if (previous.length <= 1) {
|
||
return [emptyEnvRow()]
|
||
}
|
||
return previous.filter((_, rowIndex) => rowIndex !== index)
|
||
})
|
||
}
|
||
|
||
function normalizedEnv(): ActionEnvVar[] {
|
||
return draftEnv
|
||
.map((row) => ({
|
||
name: row.name.trim(),
|
||
value: row.value,
|
||
}))
|
||
.filter((row) => row.name !== '' || row.value.trim() !== '')
|
||
}
|
||
|
||
async function handleSaveAction() {
|
||
if (!editorKind) {
|
||
return
|
||
}
|
||
setSubmitError(null)
|
||
setIsSaving(true)
|
||
try {
|
||
const env = normalizedEnv()
|
||
const response = isEditing
|
||
? await patchAction(editingActionId!, {
|
||
name: draftName,
|
||
description: draftDescription,
|
||
body: draftBody,
|
||
env,
|
||
})
|
||
: await createAction({
|
||
name: draftName,
|
||
description: draftDescription,
|
||
kind: editorKind,
|
||
body: draftBody,
|
||
env,
|
||
})
|
||
setActions([...response.actions])
|
||
clearEditor()
|
||
} catch (err) {
|
||
setSubmitError(
|
||
err instanceof Error ? err.message : 'Failed to save action',
|
||
)
|
||
} finally {
|
||
setIsSaving(false)
|
||
}
|
||
}
|
||
|
||
async function handleDeleteAction(action: Action) {
|
||
if (action.builtin || !canDeleteActions) {
|
||
return
|
||
}
|
||
setSubmitError(null)
|
||
try {
|
||
const response = await deleteAction(action.id)
|
||
setActions([...response.actions])
|
||
if (editingActionId === action.id) {
|
||
clearEditor()
|
||
}
|
||
} catch (err) {
|
||
setSubmitError(
|
||
err instanceof Error ? err.message : 'Failed to delete action',
|
||
)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="actions-panel">
|
||
<p className="config-hint">
|
||
Pre-defined and custom actions for managed nodes. Placeholders and ENV
|
||
vars are expanded when an action runs.
|
||
</p>
|
||
|
||
{submitError && !showEditor ? (
|
||
<p className="config-empty groups-error" role="alert">
|
||
{submitError}
|
||
</p>
|
||
) : null}
|
||
|
||
<div className="actions-layout">
|
||
<div className="actions-list">
|
||
<div className="actions-list-header">
|
||
<p className="config-hint">Configured actions</p>
|
||
<button
|
||
type="button"
|
||
className="groups-save"
|
||
disabled={!canCreateActions}
|
||
title={
|
||
canCreateActions ? undefined : 'Requires actions.create'
|
||
}
|
||
onClick={() => openTypePicker()}
|
||
>
|
||
Add Action…
|
||
</button>
|
||
</div>
|
||
{isLoading ? (
|
||
<p className="config-empty">Loading…</p>
|
||
) : loadError ? (
|
||
<p className="config-empty" role="alert">
|
||
{loadError}
|
||
</p>
|
||
) : actions.length === 0 ? (
|
||
<p className="config-empty">No actions configured yet.</p>
|
||
) : (
|
||
<ul className="groups-ul">
|
||
{actions.map((action) => {
|
||
const isSelected = editingActionId === action.id
|
||
return (
|
||
<li
|
||
key={action.id}
|
||
className={
|
||
isSelected ? 'groups-li groups-li-selected' : 'groups-li'
|
||
}
|
||
>
|
||
<div className="groups-li-content">
|
||
<div className="groups-li-title">
|
||
{action.name}
|
||
<span className="user-badge">{action.kind}</span>
|
||
{action.builtin ? (
|
||
<span className="user-badge">built-in</span>
|
||
) : null}
|
||
</div>
|
||
<div className="groups-li-meta">
|
||
{action.description || 'No description'}
|
||
</div>
|
||
<div className="groups-li-meta">
|
||
<code className="actions-body-preview">
|
||
{action.body.length > 80
|
||
? `${action.body.slice(0, 80)}…`
|
||
: action.body}
|
||
</code>
|
||
</div>
|
||
</div>
|
||
<div className="groups-li-actions">
|
||
<button
|
||
type="button"
|
||
className="groups-edit"
|
||
disabled={action.builtin || !canUpdateActions}
|
||
title={
|
||
action.builtin
|
||
? 'Built-in actions cannot be edited'
|
||
: canUpdateActions
|
||
? undefined
|
||
: 'Requires actions.update'
|
||
}
|
||
onClick={() => startEdit(action)}
|
||
>
|
||
Edit
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="groups-delete"
|
||
disabled={action.builtin || !canDeleteActions}
|
||
title={
|
||
action.builtin
|
||
? 'Built-in actions cannot be deleted'
|
||
: canDeleteActions
|
||
? undefined
|
||
: 'Requires actions.delete'
|
||
}
|
||
onClick={() => void handleDeleteAction(action)}
|
||
>
|
||
Delete
|
||
</button>
|
||
</div>
|
||
</li>
|
||
)
|
||
})}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
|
||
<aside className="actions-placeholders">
|
||
<p className="config-hint">Placeholders</p>
|
||
<p className="config-hint actions-placeholders-intro">
|
||
Use these tokens in the command/script body or ENV values. ENV vars
|
||
are applied before the action runs.
|
||
</p>
|
||
<ul className="actions-placeholder-list">
|
||
{ACTION_PLACEHOLDERS.map((entry) => (
|
||
<li key={entry.token}>
|
||
<code>{entry.token}</code>
|
||
<span>{entry.description}</span>
|
||
<span className="actions-placeholder-example">
|
||
Example: {entry.example}
|
||
</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</aside>
|
||
</div>
|
||
|
||
{showTypePicker ? (
|
||
<div className="reauth-backdrop" role="presentation">
|
||
<div
|
||
className="reauth-modal actions-type-modal"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="action-type-title"
|
||
>
|
||
<h2 id="action-type-title">Add Action</h2>
|
||
<p className="config-hint">Choose the type of action to create.</p>
|
||
<div className="actions-type-choices">
|
||
<button
|
||
type="button"
|
||
className="actions-type-choice"
|
||
onClick={() => startCreate('shell')}
|
||
>
|
||
<strong>Shell Command</strong>
|
||
<span>
|
||
A single command run over SSH on the target node (e.g.{' '}
|
||
<code>uptime</code>, <code>df -h</code>).
|
||
</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="actions-type-choice"
|
||
onClick={() => startCreate('script')}
|
||
>
|
||
<strong>Script</strong>
|
||
<span>
|
||
A multi-line shell script uploaded and run on the target node.
|
||
</span>
|
||
</button>
|
||
</div>
|
||
<div className="reauth-actions">
|
||
<button
|
||
type="button"
|
||
className="config-action-revert"
|
||
onClick={() => clearEditor()}
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{showEditor && editorKind ? (
|
||
<div className="reauth-backdrop" role="presentation">
|
||
<div
|
||
className="reauth-modal actions-editor-modal"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="action-editor-title"
|
||
>
|
||
<h2 id="action-editor-title">
|
||
{isEditing
|
||
? `Edit ${editorKind === 'shell' ? 'shell command' : 'script'}`
|
||
: `New ${editorKind === 'shell' ? 'shell command' : 'script'}`}
|
||
</h2>
|
||
|
||
{submitError ? (
|
||
<p className="config-empty groups-error" role="alert">
|
||
{submitError}
|
||
</p>
|
||
) : null}
|
||
|
||
<div className="config-field">
|
||
<label htmlFor="action-name">Name</label>
|
||
<input
|
||
id="action-name"
|
||
className="config-input"
|
||
value={draftName}
|
||
onChange={(event) => setDraftName(event.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="config-field">
|
||
<label htmlFor="action-description">Description</label>
|
||
<input
|
||
id="action-description"
|
||
className="config-input"
|
||
value={draftDescription}
|
||
onChange={(event) => setDraftDescription(event.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="config-field">
|
||
<label htmlFor="action-body">
|
||
{editorKind === 'shell' ? 'Command' : 'Script'}
|
||
</label>
|
||
{editorKind === 'shell' ? (
|
||
<input
|
||
id="action-body"
|
||
className="config-input"
|
||
value={draftBody}
|
||
placeholder="echo {{node.ip}}"
|
||
onChange={(event) => setDraftBody(event.target.value)}
|
||
/>
|
||
) : (
|
||
<textarea
|
||
id="action-body"
|
||
className="config-input actions-script-input"
|
||
value={draftBody}
|
||
rows={8}
|
||
placeholder={"#!/bin/sh\necho {{node.host}}"}
|
||
onChange={(event) => setDraftBody(event.target.value)}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
<div className="config-field">
|
||
<label>Environment variables</label>
|
||
<p className="config-hint">
|
||
Applied before the command/script runs. Values may use
|
||
placeholders.
|
||
</p>
|
||
<div className="actions-env-rows">
|
||
{draftEnv.map((row, index) => (
|
||
<div key={`env-${index}`} className="actions-env-row">
|
||
<input
|
||
className="config-input"
|
||
aria-label={`ENV name ${index + 1}`}
|
||
placeholder="NAME"
|
||
value={row.name}
|
||
onChange={(event) =>
|
||
updateEnvRow(index, 'name', event.target.value)
|
||
}
|
||
/>
|
||
<input
|
||
className="config-input"
|
||
aria-label={`ENV value ${index + 1}`}
|
||
placeholder="value or {{node.ip}}"
|
||
value={row.value}
|
||
onChange={(event) =>
|
||
updateEnvRow(index, 'value', event.target.value)
|
||
}
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="groups-delete"
|
||
aria-label={`Remove ENV row ${index + 1}`}
|
||
onClick={() => removeEnvRow(index)}
|
||
>
|
||
Remove
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="groups-new"
|
||
onClick={() => addEnvRow()}
|
||
>
|
||
Add ENV variable
|
||
</button>
|
||
</div>
|
||
|
||
<div className="reauth-actions">
|
||
<button
|
||
type="button"
|
||
className="config-action-revert"
|
||
onClick={() => clearEditor()}
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="config-action-save"
|
||
disabled={isSaving}
|
||
onClick={() => void handleSaveAction()}
|
||
>
|
||
{isSaving ? 'Saving…' : isEditing ? 'Save changes' : 'Save action'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function GroupsConfigTab() {
|
||
const [groups, setGroups] = useState<ReadonlyArray<Group>>([])
|
||
const [isLoadingGroups, setIsLoadingGroups] = useState(true)
|
||
const [groupsLoadError, setGroupsLoadError] = useState<string | null>(null)
|
||
|
||
const [editingGroupName, setEditingGroupName] = useState<string | null>(null)
|
||
const [draftGroupName, setDraftGroupName] = useState('')
|
||
const [draftScopeKind, setDraftScopeKind] = useState<GroupScopeKind>('node')
|
||
const [draftScopeName, setDraftScopeName] = useState('')
|
||
const [draftPermissions, setDraftPermissions] = useState<string[]>([])
|
||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||
|
||
const isEditing = editingGroupName !== null
|
||
|
||
useEffect(() => {
|
||
let isCancelled = false
|
||
|
||
async function loadGroups() {
|
||
setIsLoadingGroups(true)
|
||
setGroupsLoadError(null)
|
||
try {
|
||
const response = await fetchGroups()
|
||
if (!isCancelled) {
|
||
setGroups(response.groups)
|
||
}
|
||
} catch (err) {
|
||
if (!isCancelled) {
|
||
setGroupsLoadError(
|
||
err instanceof Error ? err.message : 'Failed to load groups',
|
||
)
|
||
}
|
||
} finally {
|
||
if (!isCancelled) {
|
||
setIsLoadingGroups(false)
|
||
}
|
||
}
|
||
}
|
||
|
||
void loadGroups()
|
||
|
||
return () => {
|
||
isCancelled = true
|
||
}
|
||
}, [])
|
||
|
||
function clearGroupForm() {
|
||
setEditingGroupName(null)
|
||
setDraftGroupName('')
|
||
setDraftScopeKind('node')
|
||
setDraftScopeName('')
|
||
setDraftPermissions([])
|
||
setSubmitError(null)
|
||
}
|
||
|
||
function handleEditGroup(group: Group) {
|
||
setEditingGroupName(group.name)
|
||
setDraftGroupName(group.name)
|
||
setDraftScopeKind(group.scope_kind)
|
||
setDraftScopeName(group.scope_name)
|
||
setDraftPermissions([...group.permissions])
|
||
setSubmitError(null)
|
||
}
|
||
|
||
function togglePermission(permission: string, checked: boolean) {
|
||
setDraftPermissions((prev) => {
|
||
if (checked) {
|
||
if (prev.includes(permission)) {
|
||
return prev
|
||
}
|
||
return [...prev, permission]
|
||
}
|
||
|
||
return prev.filter((item) => item !== permission)
|
||
})
|
||
}
|
||
|
||
async function handleSaveGroup() {
|
||
setSubmitError(null)
|
||
|
||
if (!isEditing && draftGroupName.trim() === ADMINISTRATORS_GROUP_NAME) {
|
||
setSubmitError('Administrators group cannot be created here')
|
||
return
|
||
}
|
||
|
||
try {
|
||
const response = await upsertGroup({
|
||
name: draftGroupName,
|
||
scope_kind: draftScopeKind,
|
||
scope_name: draftScopeName,
|
||
permissions: draftPermissions,
|
||
})
|
||
|
||
setGroups(response.groups)
|
||
clearGroupForm()
|
||
} catch (err) {
|
||
setSubmitError(
|
||
err instanceof Error ? err.message : 'Failed to save group',
|
||
)
|
||
}
|
||
}
|
||
|
||
async function handleDeleteGroup(groupName: string) {
|
||
setSubmitError(null)
|
||
|
||
try {
|
||
const response = await deleteGroup(groupName)
|
||
setGroups(response.groups)
|
||
if (editingGroupName === groupName) {
|
||
clearGroupForm()
|
||
}
|
||
} catch (err) {
|
||
setSubmitError(
|
||
err instanceof Error ? err.message : 'Failed to delete group',
|
||
)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="groups-panel">
|
||
<div className="groups-list">
|
||
<p className="config-hint">Configured groups</p>
|
||
{isLoadingGroups ? (
|
||
<p className="config-empty">Loading…</p>
|
||
) : groupsLoadError ? (
|
||
<p className="config-empty" role="alert">
|
||
{groupsLoadError}
|
||
</p>
|
||
) : groups.length === 0 ? (
|
||
<p className="config-empty">No groups configured yet.</p>
|
||
) : (
|
||
<ul className="groups-ul">
|
||
{groups.map((group) => {
|
||
const isAdministrators = group.name === ADMINISTRATORS_GROUP_NAME
|
||
const isSelected = editingGroupName === group.name
|
||
return (
|
||
<li
|
||
key={group.name}
|
||
className={
|
||
isSelected ? 'groups-li groups-li-selected' : 'groups-li'
|
||
}
|
||
>
|
||
<div className="groups-li-content">
|
||
<div className="groups-li-title">{group.name}</div>
|
||
<div className="groups-li-meta">
|
||
Scope: {group.scope_kind}: {group.scope_name}
|
||
</div>
|
||
<div className="groups-li-meta">
|
||
Permissions: {group.permissions.join(', ')}
|
||
</div>
|
||
</div>
|
||
<div className="groups-li-actions">
|
||
<button
|
||
type="button"
|
||
className="groups-edit"
|
||
onClick={() => handleEditGroup(group)}
|
||
>
|
||
Edit
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="groups-delete"
|
||
disabled={isAdministrators}
|
||
title={
|
||
isAdministrators
|
||
? 'Administrators group cannot be deleted'
|
||
: undefined
|
||
}
|
||
onClick={() => void handleDeleteGroup(group.name)}
|
||
>
|
||
Delete
|
||
</button>
|
||
</div>
|
||
</li>
|
||
)
|
||
})}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
|
||
<div className="groups-form">
|
||
<p className="config-hint">
|
||
{isEditing ? 'Edit group' : 'Create group'}
|
||
</p>
|
||
|
||
{submitError ? (
|
||
<p className="config-empty groups-error" role="alert">
|
||
{submitError}
|
||
</p>
|
||
) : null}
|
||
|
||
<div className="config-field">
|
||
<label htmlFor="group-name">Group name</label>
|
||
<input
|
||
id="group-name"
|
||
className="config-input"
|
||
value={draftGroupName}
|
||
readOnly={isEditing}
|
||
onChange={(event) => {
|
||
setDraftGroupName(event.target.value)
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
<div className="config-field">
|
||
<label htmlFor="scope-kind">Scope kind</label>
|
||
<select
|
||
id="scope-kind"
|
||
className="theme-select"
|
||
value={draftScopeKind}
|
||
onChange={(event) => {
|
||
setDraftScopeKind(event.target.value as GroupScopeKind)
|
||
}}
|
||
>
|
||
<option value="node">Node</option>
|
||
<option value="group">Group</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div className="config-field">
|
||
<label htmlFor="scope-name">Scope name</label>
|
||
<input
|
||
id="scope-name"
|
||
className="config-input"
|
||
value={draftScopeName}
|
||
onChange={(event) => {
|
||
setDraftScopeName(event.target.value)
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
<div className="config-field">
|
||
<label>Permissions</label>
|
||
<div className="permission-checkboxes">
|
||
{DEFAULT_PERMISSIONS.map((permission) => {
|
||
const checked = draftPermissions.includes(permission)
|
||
return (
|
||
<label key={permission} className="permission-option">
|
||
<input
|
||
type="checkbox"
|
||
aria-label={permission}
|
||
checked={checked}
|
||
onChange={(event) => {
|
||
togglePermission(permission, event.target.checked)
|
||
}}
|
||
/>
|
||
<code>{permission}</code>
|
||
</label>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="groups-form-actions">
|
||
{isEditing ? (
|
||
<button
|
||
type="button"
|
||
className="groups-new"
|
||
onClick={() => clearGroupForm()}
|
||
>
|
||
New group
|
||
</button>
|
||
) : null}
|
||
<button
|
||
type="button"
|
||
className="groups-save"
|
||
onClick={() => void handleSaveGroup()}
|
||
>
|
||
{isEditing ? 'Save changes' : 'Save group'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function NetworkConfigTab() {
|
||
const [draft, setDraft] = useState<NetworkSettings>(DEFAULT_NETWORK_SETTINGS)
|
||
const [rulesText, setRulesText] = useState('')
|
||
const [isLoading, setIsLoading] = useState(true)
|
||
const [loadError, setLoadError] = useState<string | null>(null)
|
||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||
const [saveSucceeded, setSaveSucceeded] = useState(false)
|
||
|
||
useEffect(() => {
|
||
let isCancelled = false
|
||
|
||
async function loadNetwork() {
|
||
setIsLoading(true)
|
||
setLoadError(null)
|
||
try {
|
||
const response = await fetchNetwork()
|
||
if (!isCancelled) {
|
||
setDraft(response.network)
|
||
setRulesText(response.network.rules.join('\n'))
|
||
}
|
||
} catch (err) {
|
||
if (!isCancelled) {
|
||
setLoadError(
|
||
err instanceof Error
|
||
? err.message
|
||
: 'Failed to load network settings',
|
||
)
|
||
}
|
||
} finally {
|
||
if (!isCancelled) {
|
||
setIsLoading(false)
|
||
}
|
||
}
|
||
}
|
||
|
||
void loadNetwork()
|
||
|
||
return () => {
|
||
isCancelled = true
|
||
}
|
||
}, [])
|
||
|
||
async function handleSave() {
|
||
setSubmitError(null)
|
||
setSaveSucceeded(false)
|
||
|
||
const listenError = validateListenAddress(draft.listen_address)
|
||
if (listenError) {
|
||
setSubmitError(listenError)
|
||
return
|
||
}
|
||
const hostnameError = validateHostname(draft.public_hostname)
|
||
if (hostnameError) {
|
||
setSubmitError(hostnameError)
|
||
return
|
||
}
|
||
|
||
const rules = rulesText
|
||
.split('\n')
|
||
.map((line) => line.trim())
|
||
.filter((line) => line.length > 0)
|
||
|
||
try {
|
||
const response = await saveNetwork({ ...draft, rules })
|
||
setDraft(response.network)
|
||
setRulesText(response.network.rules.join('\n'))
|
||
setSaveSucceeded(true)
|
||
} catch (err) {
|
||
setSubmitError(
|
||
err instanceof Error ? err.message : 'Failed to save network settings',
|
||
)
|
||
}
|
||
}
|
||
|
||
if (isLoading) {
|
||
return <p className="config-empty">Loading…</p>
|
||
}
|
||
|
||
if (loadError) {
|
||
return (
|
||
<p className="config-empty" role="alert">
|
||
{loadError}
|
||
</p>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="security-panel network-panel">
|
||
<p className="config-hint">
|
||
Listen bind address and IP access control for the admin UI.
|
||
</p>
|
||
|
||
{submitError ? (
|
||
<p className="config-empty groups-error" role="alert">
|
||
{submitError}
|
||
</p>
|
||
) : null}
|
||
{saveSucceeded ? (
|
||
<p className="config-hint" role="status">
|
||
Network settings saved.
|
||
</p>
|
||
) : null}
|
||
|
||
<div className="config-field">
|
||
<label htmlFor="network-listen">Listen address</label>
|
||
<input
|
||
id="network-listen"
|
||
className="config-input"
|
||
value={draft.listen_address}
|
||
onChange={(event) => {
|
||
setDraft((previous) => ({
|
||
...previous,
|
||
listen_address: event.target.value,
|
||
}))
|
||
setSaveSucceeded(false)
|
||
}}
|
||
/>
|
||
<p className="config-hint">
|
||
Default 0.0.0.0 (all interfaces). Applied on next process restart.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="config-field">
|
||
<label htmlFor="network-hostname">Public hostname</label>
|
||
<input
|
||
id="network-hostname"
|
||
className="config-input"
|
||
placeholder="admin.mydomain.ext"
|
||
value={draft.public_hostname}
|
||
onChange={(event) => {
|
||
setDraft((previous) => ({
|
||
...previous,
|
||
public_hostname: event.target.value,
|
||
}))
|
||
setSaveSucceeded(false)
|
||
}}
|
||
/>
|
||
<p className="config-hint">
|
||
Leave empty for local access by IP. When set, the UI must be visited
|
||
via that exact hostname.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="config-field">
|
||
<label htmlFor="network-access-mode">Access mode</label>
|
||
<select
|
||
id="network-access-mode"
|
||
className="theme-select"
|
||
value={draft.access_mode}
|
||
onChange={(event) => {
|
||
setDraft((previous) => ({
|
||
...previous,
|
||
access_mode: event.target.value as AccessMode,
|
||
}))
|
||
setSaveSucceeded(false)
|
||
}}
|
||
>
|
||
<option value="open">Open (no IP filter)</option>
|
||
<option value="whitelist">Whitelist (deny non-matching)</option>
|
||
<option value="blacklist">Blacklist (deny matching)</option>
|
||
</select>
|
||
</div>
|
||
|
||
{draft.access_mode !== 'open' ? (
|
||
<div className="config-field">
|
||
<label htmlFor="network-rules">IP / CIDR rules (one per line)</label>
|
||
<textarea
|
||
id="network-rules"
|
||
className="config-input wizard-rules"
|
||
rows={5}
|
||
value={rulesText}
|
||
onChange={(event) => {
|
||
setRulesText(event.target.value)
|
||
setSaveSucceeded(false)
|
||
}}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
|
||
<button
|
||
type="button"
|
||
className="groups-save"
|
||
onClick={() => void handleSave()}
|
||
>
|
||
Save network settings
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function validateSecurityDraft(security: SecuritySettings): string | null {
|
||
if (
|
||
!Number.isInteger(security.idle_timeout_minutes) ||
|
||
security.idle_timeout_minutes < 1 ||
|
||
security.idle_timeout_minutes > 240
|
||
) {
|
||
return 'Idle timeout must be an integer between 1 and 240 minutes.'
|
||
}
|
||
if (
|
||
!Number.isInteger(security.session_lifetime_hours) ||
|
||
security.session_lifetime_hours < 1
|
||
) {
|
||
return 'Session lifetime must be an integer of at least 1 hour.'
|
||
}
|
||
if (
|
||
!Number.isInteger(security.reauth_grace_minutes) ||
|
||
security.reauth_grace_minutes < 0
|
||
) {
|
||
return 'Re-auth grace period must be an integer of at least 0 minutes.'
|
||
}
|
||
return null
|
||
}
|
||
|
||
function parsePositiveIntegerInput(raw: string): number | null {
|
||
if (raw.trim() === '') {
|
||
return null
|
||
}
|
||
const parsed = Number(raw)
|
||
if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
|
||
return null
|
||
}
|
||
return parsed
|
||
}
|
||
|
||
function SecurityConfigTab() {
|
||
const [draft, setDraft] = useState<SecuritySettings>(DEFAULT_SECURITY_SETTINGS)
|
||
const [isLoading, setIsLoading] = useState(true)
|
||
const [loadError, setLoadError] = useState<string | null>(null)
|
||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||
const [saveSucceeded, setSaveSucceeded] = useState(false)
|
||
|
||
useEffect(() => {
|
||
let isCancelled = false
|
||
|
||
async function loadSecurity() {
|
||
setIsLoading(true)
|
||
setLoadError(null)
|
||
try {
|
||
const response = await fetchSecurity()
|
||
if (!isCancelled) {
|
||
setDraft(response.security)
|
||
}
|
||
} catch (err) {
|
||
if (!isCancelled) {
|
||
setLoadError(
|
||
err instanceof Error ? err.message : 'Failed to load security settings',
|
||
)
|
||
}
|
||
} finally {
|
||
if (!isCancelled) {
|
||
setIsLoading(false)
|
||
}
|
||
}
|
||
}
|
||
|
||
void loadSecurity()
|
||
|
||
return () => {
|
||
isCancelled = true
|
||
}
|
||
}, [])
|
||
|
||
async function handleSave() {
|
||
setSubmitError(null)
|
||
setSaveSucceeded(false)
|
||
|
||
const validationError = validateSecurityDraft(draft)
|
||
if (validationError) {
|
||
setSubmitError(validationError)
|
||
return
|
||
}
|
||
|
||
try {
|
||
const response = await saveSecurity(draft)
|
||
setDraft(response.security)
|
||
setSaveSucceeded(true)
|
||
} catch (err) {
|
||
setSubmitError(
|
||
err instanceof Error ? err.message : 'Failed to save security settings',
|
||
)
|
||
}
|
||
}
|
||
|
||
if (isLoading) {
|
||
return <p className="config-empty">Loading…</p>
|
||
}
|
||
|
||
if (loadError) {
|
||
return (
|
||
<p className="config-empty" role="alert">
|
||
{loadError}
|
||
</p>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="security-panel">
|
||
<p className="config-hint">
|
||
Session and re-authentication policy for administrators.
|
||
</p>
|
||
|
||
{submitError ? (
|
||
<p className="config-empty groups-error" role="alert">
|
||
{submitError}
|
||
</p>
|
||
) : null}
|
||
{saveSucceeded ? (
|
||
<p className="config-hint" role="status">
|
||
Security settings saved.
|
||
</p>
|
||
) : null}
|
||
|
||
<div className="config-field">
|
||
<label htmlFor="idle-timeout">Idle timeout (minutes)</label>
|
||
<input
|
||
id="idle-timeout"
|
||
className="config-input"
|
||
type="number"
|
||
min={1}
|
||
max={240}
|
||
step={1}
|
||
value={draft.idle_timeout_minutes}
|
||
onChange={(event) => {
|
||
const parsed = parsePositiveIntegerInput(event.target.value)
|
||
if (parsed === null) {
|
||
setDraft((prev) => ({ ...prev, idle_timeout_minutes: 0 }))
|
||
return
|
||
}
|
||
setDraft((prev) => ({ ...prev, idle_timeout_minutes: parsed }))
|
||
setSaveSucceeded(false)
|
||
}}
|
||
/>
|
||
<p className="config-hint">1–240 minutes (max 4 hours). Default 30.</p>
|
||
</div>
|
||
|
||
<div className="config-field">
|
||
<label htmlFor="session-lifetime">Session lifetime (hours)</label>
|
||
<input
|
||
id="session-lifetime"
|
||
className="config-input"
|
||
type="number"
|
||
min={1}
|
||
step={1}
|
||
value={draft.session_lifetime_hours}
|
||
onChange={(event) => {
|
||
const parsed = parsePositiveIntegerInput(event.target.value)
|
||
if (parsed === null) {
|
||
setDraft((prev) => ({ ...prev, session_lifetime_hours: 0 }))
|
||
return
|
||
}
|
||
setDraft((prev) => ({ ...prev, session_lifetime_hours: parsed }))
|
||
setSaveSucceeded(false)
|
||
}}
|
||
/>
|
||
<p className="config-hint">At least 1 hour. Default 24.</p>
|
||
</div>
|
||
|
||
<div className="config-field">
|
||
<label className="permission-option" htmlFor="totp-enabled">
|
||
<input
|
||
id="totp-enabled"
|
||
type="checkbox"
|
||
checked={draft.totp_enabled}
|
||
onChange={(event) => {
|
||
setDraft((prev) => ({
|
||
...prev,
|
||
totp_enabled: event.target.checked,
|
||
}))
|
||
setSaveSucceeded(false)
|
||
}}
|
||
/>
|
||
Enable TOTP
|
||
</label>
|
||
<p className="config-hint">
|
||
When enabled, users can enroll authenticator apps from their profile.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="config-field">
|
||
<label className="permission-option" htmlFor="reauth-sensitive">
|
||
<input
|
||
id="reauth-sensitive"
|
||
type="checkbox"
|
||
checked={draft.reauth_sensitive_actions}
|
||
onChange={(event) => {
|
||
setDraft((prev) => ({
|
||
...prev,
|
||
reauth_sensitive_actions: event.target.checked,
|
||
}))
|
||
setSaveSucceeded(false)
|
||
}}
|
||
/>
|
||
Re-auth for sensitive actions
|
||
</label>
|
||
</div>
|
||
|
||
<div className="config-field">
|
||
<label htmlFor="reauth-grace">Re-auth grace period (minutes)</label>
|
||
<input
|
||
id="reauth-grace"
|
||
className="config-input"
|
||
type="number"
|
||
min={0}
|
||
step={1}
|
||
disabled={!draft.reauth_sensitive_actions}
|
||
value={draft.reauth_grace_minutes}
|
||
onChange={(event) => {
|
||
const raw = event.target.value
|
||
if (raw.trim() === '') {
|
||
setDraft((prev) => ({ ...prev, reauth_grace_minutes: 0 }))
|
||
return
|
||
}
|
||
const parsed = Number(raw)
|
||
if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
|
||
return
|
||
}
|
||
setDraft((prev) => ({ ...prev, reauth_grace_minutes: parsed }))
|
||
setSaveSucceeded(false)
|
||
}}
|
||
/>
|
||
<p className="config-hint">
|
||
Skip re-auth if done within this many minutes. 0 means re-auth for
|
||
every sensitive action. Default 15.
|
||
</p>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
className="groups-save"
|
||
onClick={() => void handleSave()}
|
||
>
|
||
Save security settings
|
||
</button>
|
||
|
||
<p className="config-hint">
|
||
Service SSH keys and related credentials will also live here later —
|
||
add keys with a description and an expiry time limit or non-expiry.
|
||
</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ConfigTabPanelContent({
|
||
activeConfigTab,
|
||
draftThemePreference,
|
||
onDraftThemePreferenceChange,
|
||
}: {
|
||
activeConfigTab: ConfigTabId
|
||
draftThemePreference: ThemePreference
|
||
onDraftThemePreferenceChange: (preference: ThemePreference) => void
|
||
}) {
|
||
if (activeConfigTab === 'overview') {
|
||
return (
|
||
<OverviewConfigTab
|
||
draftThemePreference={draftThemePreference}
|
||
onDraftThemePreferenceChange={onDraftThemePreferenceChange}
|
||
/>
|
||
)
|
||
}
|
||
|
||
if (activeConfigTab === 'roles') {
|
||
return (
|
||
<div className="roles-panel">
|
||
<p className="config-hint">
|
||
Built-in permissions that roles can grant.
|
||
</p>
|
||
<ul className="permission-catalog">
|
||
{DEFAULT_PERMISSIONS.map((permission) => (
|
||
<li key={permission}>
|
||
<code>{permission}</code>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (activeConfigTab === 'groups') {
|
||
return <GroupsConfigTab />
|
||
}
|
||
|
||
if (activeConfigTab === 'actions') {
|
||
return <ActionsConfigTab />
|
||
}
|
||
|
||
if (activeConfigTab === 'users') {
|
||
return <UsersConfigTab />
|
||
}
|
||
|
||
if (activeConfigTab === 'security') {
|
||
return <SecurityConfigTab />
|
||
}
|
||
|
||
if (activeConfigTab === 'network') {
|
||
return <NetworkConfigTab />
|
||
}
|
||
|
||
return <p className="config-empty">Coming soon</p>
|
||
}
|
||
|
||
function resourceTabsFor(section: ResourceSectionId): ReadonlyArray<{
|
||
id: ResourceTabId
|
||
label: string
|
||
}> {
|
||
return [
|
||
{ id: 'overview', label: 'Overview' },
|
||
{ id: 'security', label: 'Security' },
|
||
{ id: 'add', label: RESOURCE_ADD_LABEL[section] },
|
||
]
|
||
}
|
||
|
||
function ResourceOverviewTab({
|
||
section,
|
||
canRead,
|
||
canExec,
|
||
canDelete,
|
||
}: {
|
||
section: ResourceSectionId
|
||
canRead: boolean
|
||
canExec: boolean
|
||
canDelete: boolean
|
||
}) {
|
||
const [nodes, setNodes] = useState<ReadonlyArray<Node>>([])
|
||
const [isLoading, setIsLoading] = useState(true)
|
||
const [loadError, setLoadError] = useState<string | null>(null)
|
||
const [testingNodeId, setTestingNodeId] = useState<string | null>(null)
|
||
const [testResults, setTestResults] = useState(
|
||
{} as Record<string, { ok: boolean; message: string }>,
|
||
)
|
||
const [me, setMe] = useState<MeResponse | null>(null)
|
||
const [pendingDeleteNodeId, setPendingDeleteNodeId] = useState<string | null>(
|
||
null,
|
||
)
|
||
const requireTotp = Boolean(me?.totp_confirmed && me?.totp_enabled)
|
||
|
||
useEffect(() => {
|
||
let isCancelled = false
|
||
|
||
async function loadMe() {
|
||
try {
|
||
const response = await fetchMe()
|
||
if (!isCancelled) {
|
||
setMe(response)
|
||
}
|
||
} catch {
|
||
if (!isCancelled) {
|
||
setMe(null)
|
||
}
|
||
}
|
||
}
|
||
|
||
void loadMe()
|
||
|
||
return () => {
|
||
isCancelled = true
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (!canRead) {
|
||
setIsLoading(false)
|
||
setNodes([])
|
||
return
|
||
}
|
||
|
||
let isCancelled = false
|
||
|
||
async function loadNodes() {
|
||
setIsLoading(true)
|
||
setLoadError(null)
|
||
try {
|
||
const response = await fetchNodes(RESOURCE_SECTION_KIND[section])
|
||
if (!isCancelled) {
|
||
setNodes(response.nodes)
|
||
}
|
||
} catch (err) {
|
||
if (!isCancelled) {
|
||
setLoadError(
|
||
err instanceof Error ? err.message : 'Failed to load nodes',
|
||
)
|
||
}
|
||
} finally {
|
||
if (!isCancelled) {
|
||
setIsLoading(false)
|
||
}
|
||
}
|
||
}
|
||
|
||
void loadNodes()
|
||
|
||
return () => {
|
||
isCancelled = true
|
||
}
|
||
}, [section, canRead])
|
||
|
||
async function handleTestSSH(nodeId: string) {
|
||
setTestingNodeId(nodeId)
|
||
setTestResults((previous) => {
|
||
const next = { ...previous }
|
||
delete next[nodeId]
|
||
return next
|
||
})
|
||
try {
|
||
const result = await testNodeSSH(nodeId)
|
||
setTestResults((previous) => ({
|
||
...previous,
|
||
[nodeId]: { ok: result.ok, message: result.message },
|
||
}))
|
||
} catch (err) {
|
||
setTestResults((previous) => ({
|
||
...previous,
|
||
[nodeId]: {
|
||
ok: false,
|
||
message: err instanceof Error ? err.message : 'SSH test failed',
|
||
},
|
||
}))
|
||
} finally {
|
||
setTestingNodeId(null)
|
||
}
|
||
}
|
||
|
||
async function handleDeleteConfirm(credentials: ReauthCredentials) {
|
||
if (!pendingDeleteNodeId) {
|
||
return
|
||
}
|
||
const response = await deleteNode(pendingDeleteNodeId, credentials)
|
||
setNodes(
|
||
response.nodes.filter(
|
||
(node) => node.kind === RESOURCE_SECTION_KIND[section],
|
||
),
|
||
)
|
||
setPendingDeleteNodeId(null)
|
||
}
|
||
|
||
if (!canRead) {
|
||
return (
|
||
<p className="config-hint">
|
||
You need the <code>nodes.read</code> permission to view managed hosts.
|
||
</p>
|
||
)
|
||
}
|
||
|
||
if (isLoading) {
|
||
return <p className="config-hint">Loading…</p>
|
||
}
|
||
|
||
if (loadError) {
|
||
return <p className="config-error">{loadError}</p>
|
||
}
|
||
|
||
if (nodes.length === 0) {
|
||
return (
|
||
<p className="config-hint">
|
||
No hosts registered yet. Use the {RESOURCE_ADD_LABEL[section]} tab to
|
||
add one.
|
||
</p>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<>
|
||
{pendingDeleteNodeId ? (
|
||
<ReauthModal
|
||
title="Confirm delete"
|
||
hint="Re-enter your password to delete this host."
|
||
requireTotp={requireTotp}
|
||
confirmLabel="Delete host"
|
||
onCancel={() => {
|
||
setPendingDeleteNodeId(null)
|
||
}}
|
||
onConfirm={handleDeleteConfirm}
|
||
/>
|
||
) : null}
|
||
<ul className="node-list">
|
||
{nodes.map((node) => {
|
||
const testResult = testResults[node.id]
|
||
return (
|
||
<li key={node.id} className="node-list-item">
|
||
<div className="node-list-heading">
|
||
<div className="node-list-heading-main">
|
||
<strong>{node.name}</strong>
|
||
<code className="node-id">{node.id}</code>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="node-edit"
|
||
onClick={() => {
|
||
navigateTo(
|
||
pathFor(section, 'overview', 'overview', node.id, 'actions'),
|
||
)
|
||
}}
|
||
>
|
||
Edit
|
||
</button>
|
||
</div>
|
||
<p className="config-hint">
|
||
{node.host_ip} · {node.username} · group {node.group_name} ·{' '}
|
||
{node.key_algo}
|
||
</p>
|
||
{canExec || canDelete ? (
|
||
<div className="node-actions">
|
||
{canExec ? (
|
||
<button
|
||
type="button"
|
||
className="node-test-ssh"
|
||
disabled={testingNodeId === node.id}
|
||
onClick={() => {
|
||
void handleTestSSH(node.id)
|
||
}}
|
||
>
|
||
{testingNodeId === node.id
|
||
? 'Testing SSH…'
|
||
: 'Test SSH connection'}
|
||
</button>
|
||
) : null}
|
||
{canDelete ? (
|
||
<button
|
||
type="button"
|
||
className="node-delete"
|
||
onClick={() => {
|
||
setPendingDeleteNodeId(node.id)
|
||
}}
|
||
>
|
||
Delete
|
||
</button>
|
||
) : null}
|
||
{testResult ? (
|
||
<p
|
||
className={
|
||
testResult.ok
|
||
? 'node-test-result node-test-result-ok'
|
||
: 'node-test-result node-test-result-fail'
|
||
}
|
||
role="status"
|
||
>
|
||
{testResult.ok ? 'Succeeded: ' : 'Failed: '}
|
||
{testResult.message}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</li>
|
||
)
|
||
})}
|
||
</ul>
|
||
</>
|
||
)
|
||
}
|
||
|
||
function ResourceSecurityTab({ section }: { section: ResourceSectionId }) {
|
||
return (
|
||
<div className="resource-security-panel">
|
||
<p className="config-hint">
|
||
Security controls for {SECTION_TITLES[section].toLowerCase()} hosts
|
||
(key rotation, hardening, and access policy) will live here.
|
||
</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ResourceAddTab({
|
||
section,
|
||
canExec,
|
||
onCreated,
|
||
}: {
|
||
section: ResourceSectionId
|
||
canExec: boolean
|
||
onCreated: () => void
|
||
}) {
|
||
const [nodeId, setNodeId] = useState(() => crypto.randomUUID())
|
||
const [name, setName] = useState('')
|
||
const [hostIP, setHostIP] = useState('')
|
||
const [username, setUsername] = useState('clustercanvas')
|
||
const [keyMode, setKeyMode] = useState<'generate' | 'paste'>('generate')
|
||
const [algorithm, setAlgorithm] = useState<'ed25519' | 'rsa'>('ed25519')
|
||
const [rsaBits, setRsaBits] = useState(4096)
|
||
const [kdfRounds, setKdfRounds] = useState(100)
|
||
const [privateKey, setPrivateKey] = useState('')
|
||
const [groupMode, setGroupMode] = useState<'existing' | 'new'>('existing')
|
||
const [groupName, setGroupName] = useState('')
|
||
const [newGroupName, setNewGroupName] = useState('')
|
||
const [groups, setGroups] = useState<ReadonlyArray<Group>>([])
|
||
const [createdPublicKey, setCreatedPublicKey] = useState<string | null>(null)
|
||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||
|
||
useEffect(() => {
|
||
let isCancelled = false
|
||
|
||
async function loadGroups() {
|
||
try {
|
||
const response = await fetchGroups()
|
||
if (!isCancelled) {
|
||
setGroups(response.groups)
|
||
setGroupName((current) =>
|
||
current === '' && response.groups.length > 0
|
||
? response.groups[0].name
|
||
: current,
|
||
)
|
||
}
|
||
} catch {
|
||
// Group list is best-effort for the form.
|
||
}
|
||
}
|
||
|
||
void loadGroups()
|
||
|
||
return () => {
|
||
isCancelled = true
|
||
}
|
||
}, [])
|
||
|
||
if (!canExec) {
|
||
return (
|
||
<p className="config-hint">
|
||
You need the <code>nodes.exec</code> permission to register a host.
|
||
</p>
|
||
)
|
||
}
|
||
|
||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||
event.preventDefault()
|
||
setSubmitError(null)
|
||
setIsSubmitting(true)
|
||
try {
|
||
const response = await createNode({
|
||
id: nodeId,
|
||
kind: RESOURCE_SECTION_KIND[section],
|
||
name: name.trim(),
|
||
host_ip: hostIP.trim(),
|
||
username: username.trim() || 'clustercanvas',
|
||
...(groupMode === 'new'
|
||
? { new_group: { name: newGroupName.trim() } }
|
||
: { group_name: groupName }),
|
||
...(keyMode === 'generate'
|
||
? {
|
||
generate: {
|
||
algorithm,
|
||
...(algorithm === 'rsa' ? { rsa_bits: rsaBits } : {}),
|
||
...(algorithm === 'ed25519' ? { kdf_rounds: kdfRounds } : {}),
|
||
},
|
||
}
|
||
: { private_key: privateKey }),
|
||
})
|
||
setCreatedPublicKey(response.node.public_key)
|
||
setName('')
|
||
setHostIP('')
|
||
setPrivateKey('')
|
||
setNodeId(crypto.randomUUID())
|
||
onCreated()
|
||
} catch (err) {
|
||
setSubmitError(err instanceof Error ? err.message : 'Failed to create node')
|
||
} finally {
|
||
setIsSubmitting(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<form className="resource-add-form" onSubmit={handleSubmit}>
|
||
<label className="field">
|
||
<span>Node UUID</span>
|
||
<input
|
||
type="text"
|
||
className="readonly-field"
|
||
value={nodeId}
|
||
readOnly
|
||
aria-readonly="true"
|
||
/>
|
||
</label>
|
||
|
||
<label className="field">
|
||
<span>Name</span>
|
||
<input
|
||
type="text"
|
||
value={name}
|
||
onChange={(event) => setName(event.target.value)}
|
||
required
|
||
autoComplete="off"
|
||
/>
|
||
</label>
|
||
|
||
<label className="field">
|
||
<span>Host IP</span>
|
||
<input
|
||
type="text"
|
||
value={hostIP}
|
||
onChange={(event) => setHostIP(event.target.value)}
|
||
required
|
||
autoComplete="off"
|
||
placeholder="192.168.1.10"
|
||
/>
|
||
</label>
|
||
|
||
<label className="field">
|
||
<span>SSH username</span>
|
||
<input
|
||
type="text"
|
||
value={username}
|
||
onChange={(event) => setUsername(event.target.value)}
|
||
required
|
||
autoComplete="off"
|
||
/>
|
||
</label>
|
||
|
||
<fieldset className="field-group">
|
||
<legend>SSH key</legend>
|
||
<div className="radio-row">
|
||
<label>
|
||
<input
|
||
type="radio"
|
||
name="key-mode"
|
||
checked={keyMode === 'generate'}
|
||
onChange={() => setKeyMode('generate')}
|
||
/>
|
||
Generate
|
||
</label>
|
||
<label>
|
||
<input
|
||
type="radio"
|
||
name="key-mode"
|
||
checked={keyMode === 'paste'}
|
||
onChange={() => setKeyMode('paste')}
|
||
/>
|
||
Add existing
|
||
</label>
|
||
</div>
|
||
|
||
{keyMode === 'generate' ? (
|
||
<>
|
||
<div className="radio-row">
|
||
<label>
|
||
<input
|
||
type="radio"
|
||
name="key-algo"
|
||
checked={algorithm === 'ed25519'}
|
||
onChange={() => setAlgorithm('ed25519')}
|
||
/>
|
||
Ed25519 (Recommended)
|
||
</label>
|
||
<label>
|
||
<input
|
||
type="radio"
|
||
name="key-algo"
|
||
checked={algorithm === 'rsa'}
|
||
onChange={() => setAlgorithm('rsa')}
|
||
/>
|
||
RSA (Compatibility)
|
||
</label>
|
||
</div>
|
||
{algorithm === 'rsa' ? (
|
||
<label className="field">
|
||
<span>RSA bits</span>
|
||
<select
|
||
value={rsaBits}
|
||
onChange={(event) => setRsaBits(Number(event.target.value))}
|
||
>
|
||
<option value={2048}>2048</option>
|
||
<option value={3072}>3072</option>
|
||
<option value={4096}>4096</option>
|
||
</select>
|
||
</label>
|
||
) : (
|
||
<label className="field">
|
||
<span>KDF rounds</span>
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
value={kdfRounds}
|
||
onChange={(event) =>
|
||
setKdfRounds(Number(event.target.value) || 100)
|
||
}
|
||
/>
|
||
</label>
|
||
)}
|
||
</>
|
||
) : (
|
||
<label className="field">
|
||
<span>Private key (OpenSSH / PEM)</span>
|
||
<textarea
|
||
value={privateKey}
|
||
onChange={(event) => setPrivateKey(event.target.value)}
|
||
rows={8}
|
||
required={keyMode === 'paste'}
|
||
spellCheck={false}
|
||
/>
|
||
</label>
|
||
)}
|
||
</fieldset>
|
||
|
||
<fieldset className="field-group">
|
||
<legend>Scope / group</legend>
|
||
<div className="radio-row">
|
||
<label>
|
||
<input
|
||
type="radio"
|
||
name="group-mode"
|
||
checked={groupMode === 'existing'}
|
||
onChange={() => setGroupMode('existing')}
|
||
/>
|
||
Existing group
|
||
</label>
|
||
<label>
|
||
<input
|
||
type="radio"
|
||
name="group-mode"
|
||
checked={groupMode === 'new'}
|
||
onChange={() => setGroupMode('new')}
|
||
/>
|
||
Create a new group
|
||
</label>
|
||
</div>
|
||
{groupMode === 'existing' ? (
|
||
<label className="field">
|
||
<span>Group</span>
|
||
<select
|
||
value={groupName}
|
||
onChange={(event) => setGroupName(event.target.value)}
|
||
required
|
||
>
|
||
{groups.map((group) => (
|
||
<option key={group.name} value={group.name}>
|
||
{group.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
) : (
|
||
<label className="field">
|
||
<span>New group name</span>
|
||
<input
|
||
type="text"
|
||
value={newGroupName}
|
||
onChange={(event) => setNewGroupName(event.target.value)}
|
||
required
|
||
autoComplete="off"
|
||
/>
|
||
<span className="config-hint">
|
||
Scoped to this node UUID. Set permissions later under
|
||
Configuration → Groups.
|
||
</span>
|
||
</label>
|
||
)}
|
||
</fieldset>
|
||
|
||
{submitError ? <p className="config-error">{submitError}</p> : null}
|
||
{createdPublicKey ? (
|
||
<div className="created-public-key">
|
||
<p className="config-hint">
|
||
Host registered. Install this public key on the remote account:
|
||
</p>
|
||
<textarea readOnly value={createdPublicKey} rows={3} />
|
||
</div>
|
||
) : null}
|
||
|
||
<button type="submit" disabled={isSubmitting}>
|
||
{isSubmitting ? 'Saving…' : 'Register host'}
|
||
</button>
|
||
</form>
|
||
)
|
||
}
|
||
|
||
function ResourceSectionPanel({
|
||
section,
|
||
activeResourceTab,
|
||
onResourceTabChange,
|
||
nodeId,
|
||
nodeDetailTab,
|
||
}: {
|
||
section: ResourceSectionId
|
||
activeResourceTab: ResourceTabId
|
||
onResourceTabChange: (tab: ResourceTabId) => void
|
||
nodeId: string | null
|
||
nodeDetailTab: NodeDetailTabId
|
||
}) {
|
||
const [me, setMe] = useState<MeResponse | null>(null)
|
||
const [overviewKey, setOverviewKey] = useState(0)
|
||
|
||
useEffect(() => {
|
||
let isCancelled = false
|
||
|
||
async function loadMe() {
|
||
try {
|
||
const response = await fetchMe()
|
||
if (!isCancelled) {
|
||
setMe(response)
|
||
}
|
||
} catch {
|
||
if (!isCancelled) {
|
||
setMe(null)
|
||
}
|
||
}
|
||
}
|
||
|
||
void loadMe()
|
||
|
||
return () => {
|
||
isCancelled = true
|
||
}
|
||
}, [])
|
||
|
||
const canRead = Boolean(me?.permissions?.includes('nodes.read'))
|
||
const canExec = Boolean(me?.permissions?.includes('nodes.exec'))
|
||
const canDelete = Boolean(me?.permissions?.includes('nodes.delete'))
|
||
const tabs = resourceTabsFor(section)
|
||
|
||
if (nodeId) {
|
||
return (
|
||
<NodeDetailPanel
|
||
section={section}
|
||
nodeId={nodeId}
|
||
activeTab={nodeDetailTab}
|
||
/>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="config-panel">
|
||
<div
|
||
className="config-tabs"
|
||
role="tablist"
|
||
aria-label={SECTION_TITLES[section]}
|
||
>
|
||
{tabs.map((tab) => {
|
||
const isSelected = activeResourceTab === tab.id
|
||
return (
|
||
<button
|
||
key={tab.id}
|
||
type="button"
|
||
role="tab"
|
||
id={`resource-tab-${section}-${tab.id}`}
|
||
className={
|
||
isSelected ? 'config-tab config-tab-active' : 'config-tab'
|
||
}
|
||
aria-selected={isSelected}
|
||
aria-controls={`resource-tab-panel-${section}`}
|
||
tabIndex={isSelected ? 0 : -1}
|
||
onClick={() => onResourceTabChange(tab.id)}
|
||
>
|
||
{tab.label}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
<div
|
||
className="config-tab-panel"
|
||
role="tabpanel"
|
||
id={`resource-tab-panel-${section}`}
|
||
aria-labelledby={`resource-tab-${section}-${activeResourceTab}`}
|
||
>
|
||
{activeResourceTab === 'overview' ? (
|
||
<ResourceOverviewTab
|
||
key={overviewKey}
|
||
section={section}
|
||
canRead={canRead}
|
||
canExec={canExec}
|
||
canDelete={canDelete}
|
||
/>
|
||
) : null}
|
||
{activeResourceTab === 'security' ? (
|
||
<ResourceSecurityTab section={section} />
|
||
) : null}
|
||
{activeResourceTab === 'add' ? (
|
||
<ResourceAddTab
|
||
section={section}
|
||
canExec={canExec}
|
||
onCreated={() => setOverviewKey((value) => value + 1)}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function LogsPanel() {
|
||
type LogsSection = 'nodes' | 'auth'
|
||
type KindFilter = 'all' | NodeKind
|
||
type NodeActionFilter = 'all' | NodeAuditAction
|
||
|
||
const [logsSection, setLogsSection] = useState<LogsSection>('nodes')
|
||
const [kindFilter, setKindFilter] = useState<KindFilter>('all')
|
||
const [nodeActionFilter, setNodeActionFilter] =
|
||
useState<NodeActionFilter>('all')
|
||
const [authOutcomeFilter, setAuthOutcomeFilter] =
|
||
useState<AuthOutcomeFilter>('all')
|
||
const [authCategoryFilter, setAuthCategoryFilter] =
|
||
useState<AuthCategoryFilter>('all')
|
||
const [authActionFilter, setAuthActionFilter] =
|
||
useState<AuthActionQuickFilter>('all')
|
||
const [searchQuery, setSearchQuery] = useState('')
|
||
const [datePreset, setDatePreset] = useState<DateRangePreset>('all')
|
||
const [customDateFrom, setCustomDateFrom] = useState('')
|
||
const [customDateTo, setCustomDateTo] = useState('')
|
||
const [pageSize, setPageSize] = useState<LogsPageSize>(() =>
|
||
readLogsPageSize(),
|
||
)
|
||
const [filtersExpanded, setFiltersExpanded] = useState(false)
|
||
const [page, setPage] = useState(1)
|
||
const [nodeSortKey, setNodeSortKey] = useState<NodeLogsSortKey>('at')
|
||
const [authSortKey, setAuthSortKey] = useState<AuthLogsSortKey>('at')
|
||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc')
|
||
const [nodeEvents, setNodeEvents] = useState<ReadonlyArray<NodeAuditEvent>>(
|
||
[],
|
||
)
|
||
const [authEvents, setAuthEvents] = useState<ReadonlyArray<AuthAuditEvent>>(
|
||
[],
|
||
)
|
||
const [isLoading, setIsLoading] = useState(true)
|
||
const [loadError, setLoadError] = useState<string | null>(null)
|
||
const [canReadLogs, setCanReadLogs] = useState(false)
|
||
const [permissionsReady, setPermissionsReady] = useState(false)
|
||
|
||
useEffect(() => {
|
||
let isCancelled = false
|
||
|
||
async function loadPermissions() {
|
||
try {
|
||
const me = await fetchMe()
|
||
if (isCancelled) {
|
||
return
|
||
}
|
||
setCanReadLogs(Boolean(me?.permissions?.includes('logs.read')))
|
||
} catch {
|
||
if (!isCancelled) {
|
||
setCanReadLogs(false)
|
||
}
|
||
} finally {
|
||
if (!isCancelled) {
|
||
setPermissionsReady(true)
|
||
}
|
||
}
|
||
}
|
||
|
||
void loadPermissions()
|
||
|
||
return () => {
|
||
isCancelled = true
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (!permissionsReady || !canReadLogs) {
|
||
return
|
||
}
|
||
|
||
let isCancelled = false
|
||
|
||
async function load() {
|
||
setIsLoading(true)
|
||
setLoadError(null)
|
||
try {
|
||
if (logsSection === 'nodes') {
|
||
const response = await fetchNodeLogs()
|
||
if (!isCancelled) {
|
||
setNodeEvents(response.events)
|
||
}
|
||
return
|
||
}
|
||
|
||
const response = await fetchAuthLogs()
|
||
if (!isCancelled) {
|
||
setAuthEvents(response.events)
|
||
}
|
||
} catch (err) {
|
||
if (!isCancelled) {
|
||
setLoadError(
|
||
err instanceof Error ? err.message : 'Failed to load logs',
|
||
)
|
||
}
|
||
} finally {
|
||
if (!isCancelled) {
|
||
setIsLoading(false)
|
||
}
|
||
}
|
||
}
|
||
|
||
void load()
|
||
|
||
return () => {
|
||
isCancelled = true
|
||
}
|
||
}, [logsSection, canReadLogs, permissionsReady])
|
||
|
||
useEffect(() => {
|
||
setPage(1)
|
||
}, [
|
||
logsSection,
|
||
kindFilter,
|
||
nodeActionFilter,
|
||
authOutcomeFilter,
|
||
authCategoryFilter,
|
||
authActionFilter,
|
||
searchQuery,
|
||
datePreset,
|
||
customDateFrom,
|
||
customDateTo,
|
||
pageSize,
|
||
])
|
||
|
||
const kindFilters: ReadonlyArray<{ id: KindFilter; label: string }> = [
|
||
{ id: 'all', label: 'All' },
|
||
{ id: 'container', label: 'Containers' },
|
||
{ id: 'docker', label: 'Docker' },
|
||
{ id: 'vm', label: 'Virtual Machines' },
|
||
]
|
||
|
||
const nodeActionFilters: ReadonlyArray<{
|
||
id: NodeActionFilter
|
||
label: string
|
||
}> = [
|
||
{ id: 'all', label: 'All actions' },
|
||
{ id: 'create', label: 'create' },
|
||
{ id: 'update', label: 'update' },
|
||
{ id: 'delete', label: 'delete' },
|
||
{ id: 'test_ssh', label: 'test_ssh' },
|
||
]
|
||
|
||
const authOutcomeFilters: ReadonlyArray<{
|
||
id: AuthOutcomeFilter
|
||
label: string
|
||
}> = [
|
||
{ id: 'all', label: 'All outcomes' },
|
||
{ id: 'failure', label: 'Failures' },
|
||
{ id: 'success', label: 'Success' },
|
||
]
|
||
|
||
const authCategoryFilters: ReadonlyArray<{
|
||
id: AuthCategoryFilter
|
||
label: string
|
||
}> = [
|
||
{ id: 'all', label: 'All categories' },
|
||
{ id: 'auth', label: 'auth' },
|
||
{ id: 'user', label: 'user' },
|
||
{ id: 'group', label: 'group' },
|
||
{ id: 'self', label: 'self' },
|
||
]
|
||
|
||
const authActionFilters: ReadonlyArray<{
|
||
id: AuthActionQuickFilter
|
||
label: string
|
||
}> = [
|
||
{ id: 'all', label: 'All events' },
|
||
{ id: 'login_failures', label: 'Login failures' },
|
||
{ id: 'login', label: 'login' },
|
||
{ id: 'logout', label: 'logout' },
|
||
{ id: 'user_create', label: 'user_create' },
|
||
{ id: 'user_update', label: 'user_update' },
|
||
{ id: 'user_delete', label: 'user_delete' },
|
||
{ id: 'group_create', label: 'group_create' },
|
||
{ id: 'group_update', label: 'group_update' },
|
||
{ id: 'group_delete', label: 'group_delete' },
|
||
{ id: 'password_change', label: 'password_change' },
|
||
{ id: 'totp_enable', label: 'totp_enable' },
|
||
{ id: 'totp_disable', label: 'totp_disable' },
|
||
]
|
||
|
||
const datePresets: ReadonlyArray<{ id: DateRangePreset; label: string }> = [
|
||
{ id: 'all', label: 'All time' },
|
||
{ id: 'today', label: 'Today' },
|
||
{ id: '7d', label: 'Last 7 days' },
|
||
{ id: '30d', label: 'Last 30 days' },
|
||
{ id: 'custom', label: 'Custom' },
|
||
]
|
||
|
||
const { from: dateFrom, to: dateTo } = resolveDateRangeBounds(
|
||
datePreset,
|
||
customDateFrom,
|
||
customDateTo,
|
||
)
|
||
|
||
const filteredNodeEvents = sortNodeEvents(
|
||
filterNodeEvents(nodeEvents, {
|
||
search: searchQuery,
|
||
kind: kindFilter,
|
||
action: nodeActionFilter,
|
||
dateFrom,
|
||
dateTo,
|
||
}),
|
||
nodeSortKey,
|
||
sortDirection,
|
||
)
|
||
|
||
const filteredAuthEvents = sortAuthEvents(
|
||
filterAuthEvents(authEvents, {
|
||
search: searchQuery,
|
||
outcome: authOutcomeFilter,
|
||
category: authCategoryFilter,
|
||
actionFilter: authActionFilter,
|
||
dateFrom,
|
||
dateTo,
|
||
}),
|
||
authSortKey,
|
||
sortDirection,
|
||
)
|
||
|
||
const activeEvents =
|
||
logsSection === 'nodes' ? filteredNodeEvents : filteredAuthEvents
|
||
const nodePagination = paginateItems(filteredNodeEvents, page, pageSize)
|
||
const authPagination = paginateItems(filteredAuthEvents, page, pageSize)
|
||
const pagination =
|
||
logsSection === 'nodes' ? nodePagination : authPagination
|
||
const displayNodeEvents =
|
||
logsSection === 'nodes' ? nodePagination.pageItems : []
|
||
const displayAuthEvents =
|
||
logsSection === 'auth' ? authPagination.pageItems : []
|
||
|
||
function handlePageSizeChange(nextSize: LogsPageSize) {
|
||
setPageSize(nextSize)
|
||
writeLogsPageSize(nextSize)
|
||
}
|
||
|
||
function toggleNodeSort(key: NodeLogsSortKey) {
|
||
if (nodeSortKey === key) {
|
||
setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc'))
|
||
return
|
||
}
|
||
setNodeSortKey(key)
|
||
setSortDirection(key === 'at' ? 'desc' : 'asc')
|
||
}
|
||
|
||
function toggleAuthSort(key: AuthLogsSortKey) {
|
||
if (authSortKey === key) {
|
||
setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc'))
|
||
return
|
||
}
|
||
setAuthSortKey(key)
|
||
setSortDirection(key === 'at' ? 'desc' : 'asc')
|
||
}
|
||
|
||
function sortIndicator(active: boolean): string {
|
||
if (!active) {
|
||
return ''
|
||
}
|
||
return sortDirection === 'asc' ? ' ▲' : ' ▼'
|
||
}
|
||
|
||
function authRowClassName(event: AuthAuditEvent): string {
|
||
if (event.outcome === 'failure') {
|
||
return 'logs-row-failure'
|
||
}
|
||
if (event.category === 'self') {
|
||
return 'logs-row-self'
|
||
}
|
||
if (event.outcome === 'success') {
|
||
return 'logs-row-success'
|
||
}
|
||
return ''
|
||
}
|
||
|
||
function renderFilterChip<T extends string>(
|
||
filters: ReadonlyArray<{ id: T; label: string }>,
|
||
selected: T,
|
||
onSelect: (id: T) => void,
|
||
ariaLabel: string,
|
||
) {
|
||
return (
|
||
<div className="logs-filters" role="tablist" aria-label={ariaLabel}>
|
||
{filters.map((filter) => {
|
||
const isSelected = selected === filter.id
|
||
return (
|
||
<button
|
||
key={filter.id}
|
||
type="button"
|
||
role="tab"
|
||
className={
|
||
isSelected ? 'config-tab config-tab-active' : 'config-tab'
|
||
}
|
||
aria-selected={isSelected}
|
||
onClick={() => onSelect(filter.id)}
|
||
>
|
||
{filter.label}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (!permissionsReady) {
|
||
return (
|
||
<div className="logs-panel">
|
||
<p className="config-hint">Loading…</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (!canReadLogs) {
|
||
return (
|
||
<p className="config-hint">
|
||
You need the <code>logs.read</code> permission to view activity logs.
|
||
</p>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="logs-panel">
|
||
<div className="logs-filters" role="tablist" aria-label="Log section">
|
||
{(
|
||
[
|
||
{ id: 'nodes' as const, label: 'Nodes' },
|
||
{ id: 'auth' as const, label: 'Users & Auth' },
|
||
] as const
|
||
).map((section) => {
|
||
const isSelected = logsSection === section.id
|
||
return (
|
||
<button
|
||
key={section.id}
|
||
type="button"
|
||
role="tab"
|
||
className={
|
||
isSelected ? 'config-tab config-tab-active' : 'config-tab'
|
||
}
|
||
aria-selected={isSelected}
|
||
onClick={() => {
|
||
setLogsSection(section.id)
|
||
setSortDirection('desc')
|
||
if (section.id === 'nodes') {
|
||
setNodeSortKey('at')
|
||
} else {
|
||
setAuthSortKey('at')
|
||
}
|
||
}}
|
||
>
|
||
{section.label}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
<div className="logs-toolbar">
|
||
<label className="logs-search-label" htmlFor="logs-search">
|
||
Search
|
||
<input
|
||
id="logs-search"
|
||
type="search"
|
||
className="logs-search-input"
|
||
placeholder="Node, user, group, event, detail…"
|
||
value={searchQuery}
|
||
onChange={(event) => setSearchQuery(event.target.value)}
|
||
/>
|
||
</label>
|
||
|
||
<label className="logs-page-size-label" htmlFor="logs-page-size">
|
||
Rows
|
||
<select
|
||
id="logs-page-size"
|
||
value={pageSize}
|
||
onChange={(event) =>
|
||
handlePageSizeChange(Number(event.target.value) as LogsPageSize)
|
||
}
|
||
>
|
||
{LOGS_PAGE_SIZE_OPTIONS.map((size) => (
|
||
<option key={size} value={size}>
|
||
{size}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
|
||
<button
|
||
type="button"
|
||
className={
|
||
filtersExpanded
|
||
? 'logs-filters-toggle logs-filters-toggle-open'
|
||
: 'logs-filters-toggle'
|
||
}
|
||
aria-expanded={filtersExpanded}
|
||
aria-controls="logs-filters-panel"
|
||
onClick={() => setFiltersExpanded((current) => !current)}
|
||
>
|
||
<FiltersChevronIcon expanded={filtersExpanded} />
|
||
{filtersExpanded ? 'Hide filters' : 'Filters…'}
|
||
</button>
|
||
</div>
|
||
|
||
{filtersExpanded ? (
|
||
<div
|
||
id="logs-filters-panel"
|
||
className="logs-filters-panel"
|
||
role="region"
|
||
aria-label="Log filters"
|
||
>
|
||
{renderFilterChip(
|
||
datePresets,
|
||
datePreset,
|
||
setDatePreset,
|
||
'Date range',
|
||
)}
|
||
|
||
{datePreset === 'custom' ? (
|
||
<div className="logs-date-custom">
|
||
<label htmlFor="logs-date-from">
|
||
From
|
||
<input
|
||
id="logs-date-from"
|
||
type="datetime-local"
|
||
value={customDateFrom}
|
||
onChange={(event) => setCustomDateFrom(event.target.value)}
|
||
/>
|
||
</label>
|
||
<label htmlFor="logs-date-to">
|
||
To
|
||
<input
|
||
id="logs-date-to"
|
||
type="datetime-local"
|
||
value={customDateTo}
|
||
onChange={(event) => setCustomDateTo(event.target.value)}
|
||
/>
|
||
</label>
|
||
</div>
|
||
) : null}
|
||
|
||
{logsSection === 'nodes'
|
||
? renderFilterChip(
|
||
kindFilters,
|
||
kindFilter,
|
||
setKindFilter,
|
||
'Log kind filter',
|
||
)
|
||
: null}
|
||
{logsSection === 'nodes'
|
||
? renderFilterChip(
|
||
nodeActionFilters,
|
||
nodeActionFilter,
|
||
setNodeActionFilter,
|
||
'Node action filter',
|
||
)
|
||
: null}
|
||
|
||
{logsSection === 'auth'
|
||
? renderFilterChip(
|
||
authOutcomeFilters,
|
||
authOutcomeFilter,
|
||
setAuthOutcomeFilter,
|
||
'Auth outcome filter',
|
||
)
|
||
: null}
|
||
{logsSection === 'auth'
|
||
? renderFilterChip(
|
||
authCategoryFilters,
|
||
authCategoryFilter,
|
||
setAuthCategoryFilter,
|
||
'Auth category filter',
|
||
)
|
||
: null}
|
||
{logsSection === 'auth'
|
||
? renderFilterChip(
|
||
authActionFilters,
|
||
authActionFilter,
|
||
setAuthActionFilter,
|
||
'Auth event filter',
|
||
)
|
||
: null}
|
||
</div>
|
||
) : null}
|
||
|
||
{isLoading ? <p className="config-hint">Loading…</p> : null}
|
||
{loadError ? <p className="config-error">{loadError}</p> : null}
|
||
|
||
{!isLoading && !loadError && activeEvents.length === 0 ? (
|
||
<p className="config-hint">
|
||
{logsSection === 'nodes'
|
||
? 'No node activity matches the current filters.'
|
||
: 'No user or auth activity matches the current filters.'}
|
||
</p>
|
||
) : null}
|
||
|
||
{!isLoading && displayNodeEvents.length > 0 ? (
|
||
<div className="logs-table-scroll">
|
||
<table className="logs-table">
|
||
<thead>
|
||
<tr>
|
||
{(
|
||
[
|
||
{ key: 'at' as const, label: 'When' },
|
||
{ key: 'action' as const, label: 'Action' },
|
||
{ key: 'actor' as const, label: 'Actor' },
|
||
{ key: 'node' as const, label: 'Node' },
|
||
{ key: 'kind' as const, label: 'Kind' },
|
||
{ key: 'detail' as const, label: 'Detail' },
|
||
] as const
|
||
).map((column) => (
|
||
<th key={column.key} scope="col">
|
||
<button
|
||
type="button"
|
||
className="logs-sort-button"
|
||
onClick={() => toggleNodeSort(column.key)}
|
||
>
|
||
{column.label}
|
||
{sortIndicator(nodeSortKey === column.key)}
|
||
</button>
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{displayNodeEvents.map((event) => (
|
||
<tr key={event.id}>
|
||
<td>{formatUserTimestamp(event.at)}</td>
|
||
<td>
|
||
<code>{event.action}</code>
|
||
</td>
|
||
<td>{event.actor}</td>
|
||
<td>
|
||
<strong>{event.node_name}</strong>
|
||
<div>
|
||
<code className="node-id">{event.node_id}</code>
|
||
</div>
|
||
</td>
|
||
<td>{event.node_kind}</td>
|
||
<td>{event.detail || '—'}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
) : null}
|
||
|
||
{!isLoading && displayAuthEvents.length > 0 ? (
|
||
<div className="logs-table-scroll">
|
||
<table className="logs-table">
|
||
<thead>
|
||
<tr>
|
||
{(
|
||
[
|
||
{ key: 'at' as const, label: 'When' },
|
||
{ key: 'action' as const, label: 'Action' },
|
||
{ key: 'outcome' as const, label: 'Outcome' },
|
||
{ key: 'category' as const, label: 'Category' },
|
||
{ key: 'actor' as const, label: 'Actor' },
|
||
{ key: 'target' as const, label: 'Target' },
|
||
{ key: 'detail' as const, label: 'Detail' },
|
||
] as const
|
||
).map((column) => (
|
||
<th key={column.key} scope="col">
|
||
<button
|
||
type="button"
|
||
className="logs-sort-button"
|
||
onClick={() => toggleAuthSort(column.key)}
|
||
>
|
||
{column.label}
|
||
{sortIndicator(authSortKey === column.key)}
|
||
</button>
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{displayAuthEvents.map((event) => (
|
||
<tr key={event.id} className={authRowClassName(event)}>
|
||
<td>{formatUserTimestamp(event.at)}</td>
|
||
<td>
|
||
<code>{event.action}</code>
|
||
</td>
|
||
<td>{event.outcome}</td>
|
||
<td>{event.category}</td>
|
||
<td>{event.actor}</td>
|
||
<td>{event.target || '—'}</td>
|
||
<td>{event.detail || '—'}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
) : null}
|
||
|
||
{!isLoading && activeEvents.length > 0 ? (
|
||
<div className="logs-pagination">
|
||
<span className="logs-pagination-label">
|
||
{pageRangeLabel(
|
||
activeEvents.length,
|
||
pagination.safePage,
|
||
pageSize,
|
||
)}
|
||
</span>
|
||
<div className="logs-pagination-controls">
|
||
<button
|
||
type="button"
|
||
className="config-tab"
|
||
disabled={pagination.safePage <= 1}
|
||
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
||
>
|
||
Previous
|
||
</button>
|
||
<span className="logs-pagination-page">
|
||
Page {pagination.safePage} of {pagination.totalPages}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
className="config-tab"
|
||
disabled={pagination.safePage >= pagination.totalPages}
|
||
onClick={() =>
|
||
setPage((current) =>
|
||
Math.min(pagination.totalPages, current + 1),
|
||
)
|
||
}
|
||
>
|
||
Next
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ConfigurationPanel({
|
||
activeConfigTab,
|
||
onConfigTabChange,
|
||
themePreference,
|
||
onThemePreferenceChange,
|
||
}: {
|
||
activeConfigTab: ConfigTabId
|
||
onConfigTabChange: (tab: ConfigTabId) => void
|
||
themePreference: ThemePreference
|
||
onThemePreferenceChange: (preference: ThemePreference) => void
|
||
}) {
|
||
const [draftThemePreference, setDraftThemePreference] =
|
||
useState<ThemePreference>(themePreference)
|
||
|
||
useEffect(() => {
|
||
setDraftThemePreference(themePreference)
|
||
}, [themePreference])
|
||
|
||
const isDirty = draftThemePreference !== themePreference
|
||
|
||
return (
|
||
<div className="config-panel">
|
||
<div className="config-tabs" role="tablist" aria-label="Configuration">
|
||
{CONFIG_TABS.map((tab) => {
|
||
const isSelected = activeConfigTab === tab.id
|
||
return (
|
||
<button
|
||
key={tab.id}
|
||
type="button"
|
||
role="tab"
|
||
id={`config-tab-${tab.id}`}
|
||
className={
|
||
isSelected ? 'config-tab config-tab-active' : 'config-tab'
|
||
}
|
||
aria-selected={isSelected}
|
||
aria-controls="config-tab-panel"
|
||
tabIndex={isSelected ? 0 : -1}
|
||
onClick={() => onConfigTabChange(tab.id)}
|
||
>
|
||
{tab.label}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
<div
|
||
className="config-tab-panel"
|
||
role="tabpanel"
|
||
id="config-tab-panel"
|
||
aria-labelledby={`config-tab-${activeConfigTab}`}
|
||
>
|
||
<ConfigTabPanelContent
|
||
activeConfigTab={activeConfigTab}
|
||
draftThemePreference={draftThemePreference}
|
||
onDraftThemePreferenceChange={setDraftThemePreference}
|
||
/>
|
||
</div>
|
||
|
||
<div className="config-actions">
|
||
<button
|
||
type="button"
|
||
className="config-action-revert"
|
||
disabled={!isDirty}
|
||
onClick={() => setDraftThemePreference(themePreference)}
|
||
>
|
||
Revert
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="config-action-save"
|
||
disabled={!isDirty}
|
||
onClick={() => onThemePreferenceChange(draftThemePreference)}
|
||
>
|
||
Save
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
type BootState =
|
||
| { kind: 'loading' }
|
||
| { kind: 'setup'; clientIp: string }
|
||
| { kind: 'login' }
|
||
| { kind: 'ready'; username: string }
|
||
|
||
function App() {
|
||
const initialLocation = parseLocation(window.location.pathname)
|
||
const [activeSection, setActiveSection] = useState<SectionId>(
|
||
initialLocation.section,
|
||
)
|
||
const [activeConfigTab, setActiveConfigTab] = useState<ConfigTabId>(
|
||
initialLocation.configTab,
|
||
)
|
||
const [activeResourceTab, setActiveResourceTab] = useState<ResourceTabId>(
|
||
initialLocation.resourceTab,
|
||
)
|
||
const [activeNodeId, setActiveNodeId] = useState<string | null>(
|
||
initialLocation.nodeId,
|
||
)
|
||
const [activeNodeDetailTab, setActiveNodeDetailTab] =
|
||
useState<NodeDetailTabId>(initialLocation.nodeDetailTab)
|
||
const [profileDisplayName, setProfileDisplayName] = useState<string | null>(
|
||
null,
|
||
)
|
||
const [backendVersion, setBackendVersion] = useState('…')
|
||
const [bootState, setBootState] = useState<BootState>({ kind: 'loading' })
|
||
const [canReadLogs, setCanReadLogs] = useState(false)
|
||
const [nodeSectionCounts, setNodeSectionCounts] =
|
||
useState<NodeSectionCounts | null>(null)
|
||
const [authFailuresToday, setAuthFailuresToday] = useState<number | null>(
|
||
null,
|
||
)
|
||
const { themePreference, updateThemePreference } = useThemePreference()
|
||
|
||
const profileLabel = profileDisplayName ?? 'Profile'
|
||
|
||
useEffect(() => {
|
||
return subscribeToLocation((pathname) => {
|
||
const location = parseLocation(pathname)
|
||
setActiveSection(location.section)
|
||
setActiveConfigTab(location.configTab)
|
||
setActiveResourceTab(location.resourceTab)
|
||
setActiveNodeId(location.nodeId)
|
||
setActiveNodeDetailTab(location.nodeDetailTab)
|
||
})
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
let isCancelled = false
|
||
|
||
async function boot() {
|
||
try {
|
||
const setupStatus = await fetchSetupStatus()
|
||
if (isCancelled) {
|
||
return
|
||
}
|
||
if (!setupStatus.completed) {
|
||
setBootState({ kind: 'setup', clientIp: setupStatus.client_ip })
|
||
navigateTo('/setup')
|
||
return
|
||
}
|
||
|
||
const me = await fetchMe()
|
||
if (isCancelled) {
|
||
return
|
||
}
|
||
if (!me) {
|
||
setBootState({ kind: 'login' })
|
||
navigateTo('/login')
|
||
return
|
||
}
|
||
|
||
setProfileDisplayName(me.username)
|
||
setCanReadLogs(Boolean(me.permissions?.includes('logs.read')))
|
||
setBootState({ kind: 'ready', username: me.username })
|
||
if (
|
||
window.location.pathname === '/setup' ||
|
||
window.location.pathname === '/login'
|
||
) {
|
||
navigateTo('/')
|
||
}
|
||
} catch {
|
||
if (!isCancelled) {
|
||
setBootState({ kind: 'login' })
|
||
navigateTo('/login')
|
||
}
|
||
}
|
||
}
|
||
|
||
void boot()
|
||
|
||
return () => {
|
||
isCancelled = true
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (bootState.kind !== 'ready') {
|
||
return
|
||
}
|
||
|
||
let isCancelled = false
|
||
|
||
async function loadSidebarCounts(idleExempt: boolean) {
|
||
try {
|
||
const nodesResponse = await fetchNodes(undefined, fetch, {
|
||
idleExempt,
|
||
})
|
||
if (!isCancelled) {
|
||
setNodeSectionCounts(countNodesBySection(nodesResponse.nodes))
|
||
}
|
||
} catch {
|
||
if (!isCancelled) {
|
||
setNodeSectionCounts(null)
|
||
}
|
||
}
|
||
|
||
if (!canReadLogs) {
|
||
if (!isCancelled) {
|
||
setAuthFailuresToday(null)
|
||
}
|
||
return
|
||
}
|
||
|
||
try {
|
||
const authResponse = await fetchAuthLogs(fetch, { idleExempt })
|
||
if (!isCancelled) {
|
||
setAuthFailuresToday(countAuthFailuresToday(authResponse.events))
|
||
}
|
||
} catch {
|
||
if (!isCancelled) {
|
||
setAuthFailuresToday(null)
|
||
}
|
||
}
|
||
}
|
||
|
||
void loadSidebarCounts(false)
|
||
|
||
const pollIntervalId = window.setInterval(() => {
|
||
void loadSidebarCounts(true)
|
||
}, 30_000)
|
||
|
||
return () => {
|
||
isCancelled = true
|
||
window.clearInterval(pollIntervalId)
|
||
}
|
||
}, [bootState.kind, canReadLogs, activeSection])
|
||
|
||
useEffect(() => {
|
||
if (bootState.kind !== 'ready') {
|
||
return
|
||
}
|
||
|
||
let isCancelled = false
|
||
|
||
async function loadBackendVersion() {
|
||
try {
|
||
const status = await fetchStatus()
|
||
if (!isCancelled) {
|
||
setBackendVersion(status.version)
|
||
}
|
||
} catch {
|
||
if (!isCancelled) {
|
||
setBackendVersion('unavailable')
|
||
}
|
||
}
|
||
}
|
||
|
||
void loadBackendVersion()
|
||
|
||
return () => {
|
||
isCancelled = true
|
||
}
|
||
}, [bootState.kind])
|
||
|
||
useEffect(() => {
|
||
if (bootState.kind === 'loading') {
|
||
document.title = documentTitleFor('loading')
|
||
return
|
||
}
|
||
if (bootState.kind === 'setup') {
|
||
document.title = documentTitleFor('setup')
|
||
return
|
||
}
|
||
if (bootState.kind === 'login') {
|
||
document.title = documentTitleFor('login')
|
||
return
|
||
}
|
||
document.title = documentTitleFor(activeSection)
|
||
}, [bootState.kind, activeSection])
|
||
|
||
function goToSection(section: SectionId) {
|
||
if (section === 'setup' || section === 'login') {
|
||
return
|
||
}
|
||
setActiveSection(section)
|
||
setActiveNodeId(null)
|
||
setActiveNodeDetailTab('overview')
|
||
if (section === 'configuration') {
|
||
navigateTo(pathFor(section, activeConfigTab))
|
||
return
|
||
}
|
||
if (isResourceSectionId(section)) {
|
||
setActiveResourceTab('overview')
|
||
navigateTo(pathFor(section, 'overview', 'overview'))
|
||
return
|
||
}
|
||
setActiveResourceTab('overview')
|
||
navigateTo(pathFor(section))
|
||
}
|
||
|
||
function goToConfigTab(tab: ConfigTabId) {
|
||
setActiveSection('configuration')
|
||
setActiveConfigTab(tab)
|
||
setActiveNodeId(null)
|
||
navigateTo(pathFor('configuration', tab))
|
||
}
|
||
|
||
function goToResourceTab(tab: ResourceTabId) {
|
||
if (!isResourceSectionId(activeSection)) {
|
||
return
|
||
}
|
||
setActiveResourceTab(tab)
|
||
setActiveNodeId(null)
|
||
setActiveNodeDetailTab('overview')
|
||
navigateTo(pathFor(activeSection, 'overview', tab))
|
||
}
|
||
|
||
function clearSignedInState() {
|
||
setProfileDisplayName(null)
|
||
setCanReadLogs(false)
|
||
}
|
||
|
||
async function handleLogout() {
|
||
try {
|
||
await logout()
|
||
} catch {
|
||
// Still leave the signed-in UI even if the session is already gone.
|
||
}
|
||
clearSignedInState()
|
||
window.history.replaceState(null, '', '/login?reason=logout')
|
||
setBootState({ kind: 'login' })
|
||
}
|
||
|
||
if (bootState.kind === 'loading') {
|
||
return (
|
||
<div className="wizard-shell">
|
||
<p className="config-hint">Loading…</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (bootState.kind === 'setup') {
|
||
return (
|
||
<SetupWizard
|
||
clientIp={bootState.clientIp}
|
||
onCompleted={() => {
|
||
window.history.replaceState(null, '', '/login?from=setup')
|
||
setBootState({ kind: 'login' })
|
||
}}
|
||
/>
|
||
)
|
||
}
|
||
|
||
if (bootState.kind === 'login') {
|
||
return (
|
||
<LoginPage
|
||
onLoggedIn={(username) => {
|
||
setProfileDisplayName(username)
|
||
void (async () => {
|
||
try {
|
||
const me = await fetchMe()
|
||
setCanReadLogs(Boolean(me?.permissions?.includes('logs.read')))
|
||
} catch {
|
||
setCanReadLogs(false)
|
||
}
|
||
setBootState({ kind: 'ready', username })
|
||
navigateTo('/')
|
||
setActiveSection('overview')
|
||
})()
|
||
}}
|
||
/>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="app-shell">
|
||
<aside className="sidebar" aria-label="Main menu">
|
||
<div className="sidebar-header">
|
||
<h1 className="brand">Cluster Canvas</h1>
|
||
<p className="version-line">Frontend Version {__FRONTEND_VERSION__}</p>
|
||
<p className="version-line">Backend Version {backendVersion}</p>
|
||
</div>
|
||
|
||
<nav className="sidebar-nav" aria-label="Sections">
|
||
{NAV_ITEMS.slice(0, 1).map((item) => (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
className={
|
||
activeSection === item.id
|
||
? 'nav-item nav-item-active'
|
||
: 'nav-item'
|
||
}
|
||
aria-current={activeSection === item.id ? 'page' : undefined}
|
||
onClick={() => goToSection(item.id)}
|
||
>
|
||
{item.label}
|
||
</button>
|
||
))}
|
||
<div className="nav-section-divider" aria-hidden="true" />
|
||
{NAV_ITEMS.slice(1).map((item) => {
|
||
const sectionCount =
|
||
isResourceSectionId(item.id) && nodeSectionCounts !== null
|
||
? nodeSectionCounts[item.id]
|
||
: null
|
||
return (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
className={
|
||
activeSection === item.id
|
||
? 'nav-item nav-item-active'
|
||
: 'nav-item'
|
||
}
|
||
aria-current={activeSection === item.id ? 'page' : undefined}
|
||
onClick={() => goToSection(item.id)}
|
||
>
|
||
<span className="nav-item-label">{item.label}</span>
|
||
{sectionCount !== null ? (
|
||
<span className="nav-count-badge" aria-hidden="true">
|
||
{sectionCount}
|
||
</span>
|
||
) : null}
|
||
</button>
|
||
)
|
||
})}
|
||
</nav>
|
||
|
||
{canReadLogs ? (
|
||
<div className="sidebar-logs">
|
||
<p className="sidebar-category-label">Logs</p>
|
||
<button
|
||
type="button"
|
||
className={
|
||
activeSection === 'logs'
|
||
? 'nav-item nav-item-active'
|
||
: 'nav-item'
|
||
}
|
||
aria-current={activeSection === 'logs' ? 'page' : undefined}
|
||
onClick={() => goToSection('logs')}
|
||
>
|
||
<span className="nav-item-label">Activity</span>
|
||
{authFailuresToday !== null && authFailuresToday > 0 ? (
|
||
<span
|
||
className="nav-count-badge nav-count-badge-alert"
|
||
aria-hidden="true"
|
||
>
|
||
{authFailuresToday}
|
||
</span>
|
||
) : null}
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="sidebar-footer">
|
||
<div className="sidebar-footer-profile-row">
|
||
<button
|
||
type="button"
|
||
className={
|
||
activeSection === 'profile'
|
||
? 'nav-item nav-item-active profile-item'
|
||
: 'nav-item profile-item'
|
||
}
|
||
aria-current={activeSection === 'profile' ? 'page' : undefined}
|
||
onClick={() => goToSection('profile')}
|
||
>
|
||
<UserIcon />
|
||
{profileLabel}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="nav-item logout-item"
|
||
aria-label="Log out"
|
||
onClick={() => {
|
||
void handleLogout()
|
||
}}
|
||
>
|
||
<LogoutIcon />
|
||
</button>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className={
|
||
activeSection === 'configuration'
|
||
? 'nav-item nav-item-active config-item'
|
||
: 'nav-item config-item'
|
||
}
|
||
aria-current={
|
||
activeSection === 'configuration' ? 'page' : undefined
|
||
}
|
||
onClick={() => goToSection('configuration')}
|
||
>
|
||
<CogIcon />
|
||
Configuration
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
|
||
<main className="content" aria-label={SECTION_TITLES[activeSection]}>
|
||
<h2>
|
||
{activeSection === 'profile' ? profileLabel : SECTION_TITLES[activeSection]}
|
||
</h2>
|
||
{activeSection === 'configuration' ? (
|
||
<ConfigurationPanel
|
||
activeConfigTab={activeConfigTab}
|
||
onConfigTabChange={goToConfigTab}
|
||
themePreference={themePreference}
|
||
onThemePreferenceChange={updateThemePreference}
|
||
/>
|
||
) : activeSection === 'profile' ? (
|
||
<ProfilePanel
|
||
onSignedOut={() => {
|
||
clearSignedInState()
|
||
setBootState({ kind: 'login' })
|
||
navigateTo('/login')
|
||
}}
|
||
/>
|
||
) : activeSection === 'logs' ? (
|
||
<LogsPanel />
|
||
) : isResourceSectionId(activeSection) ? (
|
||
<ResourceSectionPanel
|
||
section={activeSection}
|
||
activeResourceTab={activeResourceTab}
|
||
onResourceTabChange={goToResourceTab}
|
||
nodeId={activeNodeId}
|
||
nodeDetailTab={activeNodeDetailTab}
|
||
/>
|
||
) : (
|
||
<p>Coming soon</p>
|
||
)}
|
||
</main>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default App
|