Add OTP functionality and related components for host management

- Introduced HostOTP component to handle one-time passcode generation and display.
- Added OTPIcon for visual representation of OTP functionality.
- Updated HostInfo component to integrate OTP modal and manage state for OTP actions.
- Refactored Host model to include a new 'connect' action for OTP access.
- Created new SVG and design assets for OTP icon representation.
- Enhanced user experience with loading states and progress indicators for OTP validity.
This commit is contained in:
Tom Butcher 2025-08-18 00:54:20 +01:00
parent ec2d656b6e
commit ed322436e6
7 changed files with 411 additions and 151 deletions

Binary file not shown.

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2" viewBox="0 0 64 64"><path d="M39.064 13.281a16 16 0 0 0-1.552 2.283 16 16 0 0 0-1.102 2.424H11.319c-2.038 0-3.189 1.143-3.189 3.185V42.78c0 2.014 1.151 3.185 3.189 3.185h17.908c-.335 1.012-.407 2.18-.02 3.274l.532 1.47H10.622C5.731 50.709 3 48.064 3 43.165V20.784c0-4.878 2.731-7.503 7.622-7.503zm21.781 21.78v8.104c0 4.899-2.731 7.544-7.644 7.544h-5.695a4.8 4.8 0 0 0-.008-2.542l-.362-1.277 3.085-.865q.1-.029.197-.06h2.108c2.037 0 3.189-1.171 3.189-3.185v-5.062c1.92-.572 3.643-1.468 5.13-2.657" style="fill-rule:nonzero" transform="translate(-1.5)"/><path d="M18.888 77.914c1.551 1.303 3.64 1.459 5.099.032l9.646-9.657c1.437-1.448 1.375-3.661-.031-5.099l-4.446-4.435 6.674-6.653c1.408-1.406 1.385-3.65-.021-5.099l-6.024-6.046c8.385-4.254 13.079-11.304 13.079-19.577C42.864 9.551 33.292 0 21.443 0 9.51 0 0 9.498 0 21.38c0 8.455 4.793 16.002 12.406 19.504v29.212c0 1.161.372 2.517 1.378 3.399zm2.555-5.961-3.36-3.328V36.464C11.107 34.977 5.994 28.773 5.994 21.38c0-8.491 6.895-15.355 15.449-15.355 8.533 0 15.387 6.864 15.387 15.355 0 7.34-5.135 13.607-12.9 15.254v7.047l5.867 5.918-6.228 6.134v5.982l4.05 3.978zm0-51.321c2.771 0 5.042-2.282 5.042-5.064s-2.271-5.022-5.042-5.022c-2.814 0-5.043 2.231-5.043 5.022 0 2.782 2.25 5.064 5.043 5.064" style="fill-rule:nonzero" transform="rotate(29.234 11.74 88.091)scale(.60562)"/><path d="M34.933 27.241a4.697 4.697 0 0 0 4.694-4.694 4.697 4.697 0 0 0-4.694-4.694 4.7 4.7 0 0 0-4.695 4.694 4.7 4.7 0 0 0 4.695 4.694m-14.642 0c2.565 0 4.669-2.104 4.669-4.694s-2.13-4.694-4.669-4.694a4.697 4.697 0 0 0-4.694 4.694 4.697 4.697 0 0 0 4.694 4.694" style="fill-rule:nonzero" transform="translate(1.5 13.281)scale(.8287)"/></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,-1.5,7.10543e-15)">
<path d="M39.064,13.281C38.491,13.982 37.971,14.744 37.512,15.564C37.071,16.351 36.704,17.161 36.41,17.988L11.319,17.988C9.281,17.988 8.13,19.131 8.13,21.173L8.13,42.78C8.13,44.794 9.281,45.965 11.319,45.965L29.227,45.965C28.892,46.977 28.82,48.145 29.207,49.239L29.739,50.709L10.622,50.709C5.731,50.709 3,48.064 3,43.165L3,20.784C3,15.906 5.731,13.281 10.622,13.281L39.064,13.281ZM60.845,35.061L60.845,43.165C60.845,48.064 58.114,50.709 53.201,50.709L47.506,50.709C47.725,49.911 47.734,49.038 47.498,48.167L47.136,46.89L50.221,46.025C50.287,46.006 50.353,45.986 50.418,45.965L52.526,45.965C54.563,45.965 55.715,44.794 55.715,42.78L55.715,37.718C57.635,37.146 59.358,36.25 60.845,35.061Z" style="fill-rule:nonzero;"/>
<g transform="matrix(0.528487,0.29577,-0.29577,0.528487,46.0168,5.48596)">
<path d="M18.888,77.914C20.439,79.217 22.528,79.373 23.987,77.946L33.633,68.289C35.07,66.841 35.008,64.628 33.602,63.19L29.156,58.755L35.83,52.102C37.238,50.696 37.215,48.452 35.809,47.003L29.785,40.957C38.17,36.703 42.864,29.653 42.864,21.38C42.864,9.551 33.292,0 21.443,0C9.51,0 0,9.498 0,21.38C0,29.835 4.793,37.382 12.406,40.884L12.406,70.096C12.406,71.257 12.778,72.613 13.784,73.495L18.888,77.914ZM21.443,71.953L18.083,68.625L18.083,36.464C11.107,34.977 5.994,28.773 5.994,21.38C5.994,12.889 12.889,6.025 21.443,6.025C29.976,6.025 36.83,12.889 36.83,21.38C36.83,28.72 31.695,34.987 23.93,36.634L23.93,43.681L29.797,49.599L23.569,55.733L23.569,61.715L27.619,65.693L21.443,71.953ZM21.443,20.632C24.214,20.632 26.485,18.35 26.485,15.568C26.485,12.787 24.214,10.546 21.443,10.546C18.629,10.546 16.4,12.777 16.4,15.568C16.4,18.35 18.65,20.632 21.443,20.632Z" style="fill-rule:nonzero;"/>
</g>
<g transform="matrix(0.828691,0,0,0.828691,3,13.2814)">
<path d="M34.933,27.241C37.523,27.241 39.627,25.137 39.627,22.547C39.627,19.957 37.523,17.853 34.933,17.853C32.343,17.853 30.238,19.957 30.238,22.547C30.238,25.137 32.343,27.241 34.933,27.241Z" style="fill-rule:nonzero;"/>
</g>
<g transform="matrix(0.828691,0,0,0.828691,3,13.2814)">
<path d="M20.291,27.241C22.856,27.241 24.96,25.137 24.96,22.547C24.96,19.957 22.83,17.853 20.291,17.853C17.701,17.853 15.597,19.957 15.597,22.547C15.597,25.137 17.701,27.241 20.291,27.241Z" style="fill-rule:nonzero;"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@ -1,6 +1,6 @@
import React from 'react' import React, { useRef, useState } from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { Space, Flex, Card } from 'antd' import { Space, Flex, Card, Modal } from 'antd'
import { LoadingOutlined } from '@ant-design/icons' import { LoadingOutlined } from '@ant-design/icons'
import loglevel from 'loglevel' import loglevel from 'loglevel'
import config from '../../../../config.js' import config from '../../../../config.js'
@ -19,12 +19,15 @@ import ActionHandler from '../../common/ActionHandler.jsx'
import ObjectActions from '../../common/ObjectActions.jsx' import ObjectActions from '../../common/ObjectActions.jsx'
import ObjectTable from '../../common/ObjectTable.jsx' import ObjectTable from '../../common/ObjectTable.jsx'
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx' import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
import HostOTP from './HostOtp.jsx'
const log = loglevel.getLogger('HostInfo') const log = loglevel.getLogger('HostInfo')
log.setLevel(config.logLevel) log.setLevel(config.logLevel)
const HostInfo = () => { const HostInfo = () => {
const location = useLocation() const location = useLocation()
const editFormRef = useRef(null)
const actionHandlerRef = useRef(null)
const hostId = new URLSearchParams(location.search).get('hostId') const hostId = new URLSearchParams(location.search).get('hostId')
const [collapseState, updateCollapseState] = useCollapseState('HostInfo', { const [collapseState, updateCollapseState] = useCollapseState('HostInfo', {
info: true, info: true,
@ -33,43 +36,40 @@ const HostInfo = () => {
auditLogs: true auditLogs: true
}) })
return ( const [hostOTPOpen, setHostOTPOpen] = useState(false)
<EditObjectForm id={hostId} type='host' style={{ height: '100%' }}> const [editFormState, setEditFormState] = useState({
{({ isEditing: false,
loading, editLoading: false,
isEditing, formValid: false,
startEditing, locked: false,
cancelEditing, loading: false
handleUpdate, })
formValid,
objectData,
editLoading,
lock,
fetchObject
}) => {
// Define actions for ActionHandler
const actions = { const actions = {
reload: () => { reload: () => {
fetchObject() editFormRef?.current.handleFetchObject()
return true
},
hostOTP: () => {
setHostOTPOpen(true)
return true return true
}, },
edit: () => { edit: () => {
startEditing() editFormRef?.current.startEditing()
return false return false
}, },
cancelEdit: () => { cancelEdit: () => {
cancelEditing() editFormRef?.current.cancelEditing()
return true return true
}, },
finishEdit: () => { finishEdit: () => {
handleUpdate() editFormRef?.current.handleUpdate()
return true return true
} }
} }
return ( return (
<ActionHandler actions={actions} loading={loading}> <>
{({ callAction }) => (
<Flex <Flex
gap='large' gap='large'
vertical='true' vertical='true'
@ -84,10 +84,10 @@ const HostInfo = () => {
<ObjectActions <ObjectActions
type='host' type='host'
id={hostId} id={hostId}
disabled={loading} disabled={editFormState.loading}
/> />
<ViewButton <ViewButton
disabled={loading} disabled={editFormState.loading}
items={[ items={[
{ key: 'info', label: 'Host Information' }, { key: 'info', label: 'Host Information' },
{ key: 'notes', label: 'Notes' }, { key: 'notes', label: 'Notes' },
@ -97,39 +97,54 @@ const HostInfo = () => {
updateVisibleState={updateCollapseState} updateVisibleState={updateCollapseState}
/> />
</Space> </Space>
<LockIndicator lock={lock} /> <LockIndicator lock={editFormState.lock} />
</Space> </Space>
<Space> <Space>
<EditButtons <EditButtons
isEditing={isEditing} isEditing={editFormState.isEditing}
handleUpdate={() => { handleUpdate={() => {
callAction('finishEdit') actionHandlerRef.current.callAction('finishEdit')
}} }}
cancelEditing={() => { cancelEditing={() => {
callAction('cancelEdit') actionHandlerRef.current.callAction('cancelEdit')
}} }}
startEditing={() => { startEditing={() => {
callAction('edit') actionHandlerRef.current.callAction('edit')
}} }}
editLoading={editLoading} editLoading={editFormState.editLoading}
formValid={formValid} formValid={editFormState.formValid}
disabled={lock?.locked || loading} disabled={editFormState.lock?.locked || editFormState.loading}
loading={editLoading} loading={editFormState.editLoading}
/> />
</Space> </Space>
</Flex> </Flex>
<div style={{ height: '100%', overflowY: 'scroll' }}> <div style={{ height: '100%', overflowY: 'scroll' }}>
<Flex vertical gap={'large'}> <Flex vertical gap={'large'}>
<ActionHandler
actions={actions}
loading={editFormState.loading}
ref={actionHandlerRef}
>
<InfoCollapse <InfoCollapse
title='Host Information' title='Host Information'
icon={<InfoCircleIcon />} icon={<InfoCircleIcon />}
active={collapseState.info} active={collapseState.info}
onToggle={(expanded) => onToggle={(expanded) => updateCollapseState('info', expanded)}
updateCollapseState('info', expanded)
}
collapseKey='info' collapseKey='info'
> >
<EditObjectForm
id={hostId}
type='host'
style={{ height: '100%' }}
ref={editFormRef}
onStateChange={(state) => {
console.log('Got edit form state change', state)
setEditFormState((prev) => ({ ...prev, ...state }))
}}
>
{({ loading, isEditing, objectData }) => {
return (
<ObjectInfo <ObjectInfo
loading={loading} loading={loading}
indicator={<LoadingOutlined />} indicator={<LoadingOutlined />}
@ -137,15 +152,17 @@ const HostInfo = () => {
type='host' type='host'
objectData={objectData} objectData={objectData}
/> />
)
}}
</EditObjectForm>
</InfoCollapse> </InfoCollapse>
</ActionHandler>
<InfoCollapse <InfoCollapse
title='Notes' title='Notes'
icon={<NoteIcon />} icon={<NoteIcon />}
active={collapseState.notes} active={collapseState.notes}
onToggle={(expanded) => onToggle={(expanded) => updateCollapseState('notes', expanded)}
updateCollapseState('notes', expanded)
}
collapseKey='notes' collapseKey='notes'
> >
<Card> <Card>
@ -162,7 +179,7 @@ const HostInfo = () => {
} }
collapseKey='auditLogs' collapseKey='auditLogs'
> >
{loading ? ( {editFormState.loading ? (
<InfoCollapsePlaceholder /> <InfoCollapsePlaceholder />
) : ( ) : (
<ObjectTable <ObjectTable
@ -175,11 +192,19 @@ const HostInfo = () => {
</Flex> </Flex>
</div> </div>
</Flex> </Flex>
)}
</ActionHandler> <Modal
) open={hostOTPOpen}
destroyOnHidden={true}
width={650}
onCancel={() => {
setHostOTPOpen(false)
}} }}
</EditObjectForm> footer={false}
>
<HostOTP id={hostId} />
</Modal>
</>
) )
} }

View File

@ -0,0 +1,138 @@
import PropTypes from 'prop-types'
import React, { useContext, useEffect, useState, useRef } from 'react'
import { Input, Result, Typography, Flex, Progress, Button } from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import { ApiServerContext } from '../../context/ApiServerContext'
import CopyButton from '../../common/CopyButton'
import OTPIcon from '../../../Icons/OTPIcon'
const { Text } = Typography
const HostOTP = ({ id }) => {
const { fetchHostOTP, sendObjectAction } = useContext(ApiServerContext)
const [hostObject, setHostObject] = useState(null)
const [loading, setLoading] = useState(true)
const [timeRemaining, setTimeRemaining] = useState(0)
const [totalTime, setTotalTime] = useState(0)
const [initialized, setInitialized] = useState(false)
const intervalRef = useRef(null)
const fetchNewOTP = () => {
setLoading(true)
setHostObject(null) // Reset to show loading
fetchHostOTP(id, (hostOTPObject) => {
setHostObject(hostOTPObject)
setLoading(false)
if (hostOTPObject?.otpExpiresAt) {
const now = Date.now()
const expiresAt = new Date(hostOTPObject.otpExpiresAt).getTime()
const remaining = Math.max(0, expiresAt - now)
setTimeRemaining(remaining)
setTotalTime(remaining)
}
})
}
const sendTestAction = () => {
console.log('Sending test action...')
sendObjectAction(id, 'host', { method: 'testMethod' }, (result) => {
console.log('Got callback', result)
})
}
useEffect(() => {
if (hostObject === null && initialized == false) {
setInitialized(true)
fetchNewOTP()
}
}, [id])
useEffect(() => {
if (hostObject && timeRemaining > 0) {
intervalRef.current = setInterval(() => {
setTimeRemaining((prev) => {
const newTime = prev - 1000
if (newTime <= 0) {
// OTP expired, fetch a new one
fetchNewOTP()
return 0
}
return newTime
})
}, 1000)
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
}
}
}
}, [hostObject, timeRemaining])
// Clean up interval on unmount
useEffect(() => {
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
}
}
}, [])
const progressPercent =
totalTime > 0 ? Math.max(0, (timeRemaining / totalTime) * 100) : 0
return (
<Flex vertical align='center'>
<Button onClick={sendTestAction}>Test Action</Button>
<Result
title={'Connect a Host.'}
subTitle={<Text>Enter the following one time passcode.</Text>}
icon={<OTPIcon />}
>
<Flex justify='center'>
<Flex gap={'small'} align='center' justify='center'>
<CopyButton
size='default'
text={hostObject?.otp}
disabled={loading}
/>
<div>
<Input.OTP
disabled={loading}
size='large'
value={hostObject?.otp}
onChange={(e) => e.preventDefault()} // prevent typing
onKeyDown={(e) => e.preventDefault()} // prevent key input
onPaste={(e) => e.preventDefault()} // prevent pasting
/>
</div>
<div style={{ margin: '0 6px 0 8px', paddingBottom: '5px' }}>
{loading ? (
<Text>
<LoadingOutlined />
</Text>
) : (
<Progress
type='circle'
showInfo={false}
strokeColor='#32D74B'
size={14}
strokeWidth={14}
percent={progressPercent}
/>
)}
</div>
</Flex>
</Flex>
</Result>
</Flex>
)
}
HostOTP.propTypes = {
id: PropTypes.string.isRequired
}
export default HostOTP

View File

@ -0,0 +1,7 @@
import React from 'react'
import Icon from '@ant-design/icons'
import { ReactComponent as CustomIconSvg } from '../../assets/icons/otpicon.min.svg'
const OTPIcon = (props) => <Icon component={CustomIconSvg} {...props} />
export default OTPIcon

View File

@ -2,6 +2,7 @@ import HostIcon from '../../components/Icons/HostIcon'
import InfoCircleIcon from '../../components/Icons/InfoCircleIcon' import InfoCircleIcon from '../../components/Icons/InfoCircleIcon'
import ReloadIcon from '../../components/Icons/ReloadIcon' import ReloadIcon from '../../components/Icons/ReloadIcon'
import EditIcon from '../../components/Icons/EditIcon' import EditIcon from '../../components/Icons/EditIcon'
import OTPIcon from '../../components/Icons/OTPIcon'
export const Host = { export const Host = {
name: 'host', name: 'host',
@ -15,7 +16,7 @@ export const Host = {
default: true, default: true,
row: true, row: true,
icon: InfoCircleIcon, icon: InfoCircleIcon,
url: (_id) => `/dashboard/production/hosts/info?hostId=${_id}` url: (_id) => `/dashboard/management/hosts/info?hostId=${_id}`
}, },
{ {
@ -23,14 +24,21 @@ export const Host = {
label: 'Reload', label: 'Reload',
icon: ReloadIcon, icon: ReloadIcon,
url: (_id) => url: (_id) =>
`/dashboard/production/hosts/info?hostId=${_id}&action=reload` `/dashboard/management/hosts/info?hostId=${_id}&action=reload`
},
{
name: 'connect',
label: 'Connect',
icon: OTPIcon,
url: (_id) =>
`/dashboard/management/hosts/info?hostId=${_id}&action=hostOTP`
}, },
{ {
name: 'edit', name: 'edit',
label: 'Edit', label: 'Edit',
row: true, row: true,
icon: EditIcon, icon: EditIcon,
url: (_id) => `/dashboard/production/hosts/info?hostId=${_id}&action=edit` url: (_id) => `/dashboard/management/hosts/info?hostId=${_id}&action=edit`
} }
], ],
columns: ['name', '_id', 'state', 'tags', 'connectedAt'], columns: ['name', '_id', 'state', 'tags', 'connectedAt'],
@ -61,30 +69,95 @@ export const Host = {
}, },
{ {
name: 'state', name: 'state',
label: 'Status', label: 'State',
type: 'state', type: 'state',
objectType: 'host', objectType: 'host',
showName: false, showName: false,
readOnly: true readOnly: true
}, },
{ {
name: 'host', name: 'active',
label: 'Host', label: 'Active',
type: 'text', type: 'bool',
required: true required: true
}, },
{
name: 'online',
label: 'Online',
type: 'bool',
readOnly: true
},
{
name: 'deviceInfo.os',
label: 'Operating System',
type: 'text',
required: false,
readOnly: true,
value: (objectData) => {
if (
objectData.deviceInfo?.os?.type &&
objectData.deviceInfo?.os?.release &&
objectData.deviceInfo?.os?.arch
) {
return `${objectData.deviceInfo.os.type} ${objectData.deviceInfo.os.release} (${objectData.deviceInfo.os.arch})`
}
}
},
{
name: 'deviceInfo.os.hostname',
label: 'Hostname',
type: 'text',
required: false,
readOnly: true
},
{
name: 'deviceInfo.cpu.model',
label: 'CPU Model',
type: 'text',
required: false,
readOnly: true
},
{
name: 'deviceInfo.cpu',
label: 'CPU Info',
type: 'text',
required: false,
readOnly: true,
value: (objectData) => {
if (
objectData.deviceInfo?.cpu?.cores &&
objectData.deviceInfo?.cpu?.speedMHz
) {
return `Cores: ${objectData.deviceInfo.cpu.cores}, Speed: ${objectData.deviceInfo.cpu.speedMHz} MHz`
}
}
},
{
name: 'deviceInfo.user.username',
label: 'User',
type: 'text',
required: false,
readOnly: true
},
{
name: 'deviceInfo.user.homedir',
label: 'User Home',
type: 'text',
required: false,
readOnly: true
},
{
name: 'deviceInfo.process.nodeVersion',
label: 'NodeJS Version',
type: 'text',
required: false,
readOnly: true
},
{ {
name: 'tags', name: 'tags',
label: 'Tags', label: 'Tags',
type: 'tags', type: 'tags',
required: false required: false
},
{
name: 'operatingSystem',
label: 'Operating System',
type: 'text',
required: false,
readOnly: true
} }
] ]
} }