diff --git a/assets/pasted-20260515-131818-8a9b448d.png b/assets/pasted-20260515-131818-8a9b448d.png new file mode 100644 index 0000000..53bc7a0 Binary files /dev/null and b/assets/pasted-20260515-131818-8a9b448d.png differ diff --git a/backend/src/routes/devices.js b/backend/src/routes/devices.js index 59f1d40..2f07d10 100644 --- a/backend/src/routes/devices.js +++ b/backend/src/routes/devices.js @@ -213,6 +213,27 @@ router.put('/:id', wrapAsync(async (req, res) => { res.status(200).send(payload); })); + +router.put('/:id/approval-status', wrapAsync(async (req, res) => { + if (!req.body || !req.body.approval_status) { + const error = new Error('approval_status is required.'); + error.code = 400; + throw error; + } + + const payload = await DevicesService.changeApprovalStatus( + req.params.id, + req.body.approval_status, + req.currentUser, + { + sourceIp: req.ip, + userAgent: req.get('user-agent'), + }, + ); + + res.status(200).send(payload); +})); + /** * @swagger * /api/devices/{id}: @@ -416,6 +437,13 @@ router.get('/autocomplete', async (req, res) => { res.status(200).send(payload); }); + +router.get('/approval-center/summary', wrapAsync(async (req, res) => { + const payload = await DevicesService.getApprovalCenterSummary(req.query); + + res.status(200).send(payload); +})); + /** * @swagger * /api/devices/{id}: diff --git a/backend/src/services/devices.js b/backend/src/services/devices.js index 1513ede..b1c2ebc 100644 --- a/backend/src/services/devices.js +++ b/backend/src/services/devices.js @@ -1,15 +1,22 @@ const db = require('../db/models'); const DevicesDBApi = require('../db/api/devices'); -const processFile = require("../middlewares/upload"); +const processFile = require('../middlewares/upload'); const ValidationError = require('./notifications/errors/validation'); const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); const stream = require('stream'); +const { Op } = db.Sequelize; +const ALLOWED_APPROVAL_ACTIONS = ['approved', 'blocked']; +const APPROVAL_CENTER_FILTERS = ['all', 'pending', 'approved', 'blocked']; +const createHttpError = (message, code = 400) => { + const error = new Error(message); + error.code = code; + return error; +}; +const toPlain = (record) => record.get({ plain: true }); module.exports = class DevicesService { static async create(data, currentUser) { @@ -28,9 +35,9 @@ module.exports = class DevicesService { await transaction.rollback(); throw error; } - }; + } - static async bulkImport(req, res, sendInvitationEmails = true, host) { + static async bulkImport(req, res) { const transaction = await db.sequelize.transaction(); try { @@ -38,7 +45,7 @@ module.exports = class DevicesService { const bufferStream = new stream.PassThrough(); const results = []; - await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream + await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); await new Promise((resolve, reject) => { bufferStream @@ -49,13 +56,13 @@ module.exports = class DevicesService { resolve(); }) .on('error', (error) => reject(error)); - }) + }); await DevicesDBApi.bulkImport(results, { - transaction, - ignoreDuplicates: true, - validate: true, - currentUser: req.currentUser + transaction, + ignoreDuplicates: true, + validate: true, + currentUser: req.currentUser, }); await transaction.commit(); @@ -68,9 +75,9 @@ module.exports = class DevicesService { static async update(data, id, currentUser) { const transaction = await db.sequelize.transaction(); try { - let devices = await DevicesDBApi.findBy( - {id}, - {transaction}, + const devices = await DevicesDBApi.findBy( + { id }, + { transaction }, ); if (!devices) { @@ -90,12 +97,11 @@ module.exports = class DevicesService { await transaction.commit(); return updatedDevices; - } catch (error) { await transaction.rollback(); throw error; } - }; + } static async deleteByIds(ids, currentUser) { const transaction = await db.sequelize.transaction(); @@ -132,7 +138,121 @@ module.exports = class DevicesService { } } - + static async getApprovalCenterSummary(filter = {}) { + const query = typeof filter.query === 'string' ? filter.query.trim() : ''; + const status = APPROVAL_CENTER_FILTERS.includes(filter.status) ? filter.status : 'all'; + const limit = Math.min(Math.max(Number(filter.limit) || 16, 6), 40); + const where = {}; + + if (status !== 'all') { + where.approval_status = status; + } + + if (query) { + where[Op.or] = [ + { device_name: { [Op.iLike]: `%${query}%` } }, + { computer_name: { [Op.iLike]: `%${query}%` } }, + { current_username: { [Op.iLike]: `%${query}%` } }, + { public_ip: { [Op.iLike]: `%${query}%` } }, + { local_ip: { [Op.iLike]: `%${query}%` } }, + ]; + } + + const [devices, total, pending, approved, blocked, online] = await Promise.all([ + db.devices.findAll({ + where, + include: [ + { + model: db.users, + as: 'approved_by', + attributes: ['id', 'firstName', 'lastName', 'email'], + }, + ], + order: [ + ['is_online', 'DESC'], + ['last_heartbeat_at', 'DESC'], + ['last_seen_at', 'DESC'], + ['createdAt', 'DESC'], + ], + limit, + }), + db.devices.count(), + db.devices.count({ where: { approval_status: 'pending' } }), + db.devices.count({ where: { approval_status: 'approved' } }), + db.devices.count({ where: { approval_status: 'blocked' } }), + db.devices.count({ where: { is_online: true } }), + ]); + + return { + counts: { + total, + pending, + approved, + blocked, + online, + }, + filters: { + query, + status, + }, + devices: devices.map(toPlain), + }; + } + + static async changeApprovalStatus(id, approvalStatus, currentUser, meta = {}) { + if (!ALLOWED_APPROVAL_ACTIONS.includes(approvalStatus)) { + throw createHttpError('approval_status must be either approved or blocked.', 400); + } + + const transaction = await db.sequelize.transaction(); + + try { + const device = await db.devices.findByPk(id, { transaction }); + + if (!device) { + throw createHttpError('Device not found.', 404); + } + + const previousStatus = device.approval_status; + + if (previousStatus !== approvalStatus) { + await device.update( + { + approval_status: approvalStatus, + approved_byId: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await db.security_events.create( + { + event_type: approvalStatus === 'approved' ? 'device_approved' : 'device_blocked', + occurred_at: new Date(), + source_ip: meta.sourceIp || null, + user_agent: meta.userAgent || null, + details_json: JSON.stringify({ + previousStatus, + nextStatus: approvalStatus, + deviceName: device.device_name, + computerName: device.computer_name, + approvedById: currentUser.id, + }), + severity: approvalStatus === 'approved' ? 'info' : 'high', + userId: currentUser.id, + deviceId: device.id, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + } + + await transaction.commit(); + return DevicesDBApi.findBy({ id }); + } catch (error) { + await transaction.rollback(); + throw error; + } + } }; - - diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index eb155e3..fb0fca2 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -1,6 +1,5 @@ -import React, {useEffect, useRef} from 'react' +import React, { useEffect, useRef, useState } from 'react' import Link from 'next/link' -import { useState } from 'react' import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import BaseDivider from './BaseDivider' import BaseIcon from './BaseIcon' diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..73d8391 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,5 +1,4 @@ -import React, { ReactNode, useEffect } from 'react' -import { useState } from 'react' +import React, { ReactNode, useEffect, useState } from 'react' import jwt from 'jsonwebtoken'; import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import menuAside from '../menuAside' diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 6501c9c..513c9f7 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -40,6 +40,14 @@ const menuAside: MenuAsideItem[] = [ icon: 'mdiMonitor' in icon ? icon['mdiMonitor' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, permissions: 'READ_DEVICES' }, + { + href: '/device-approval-center', + label: 'Approval center', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: icon.mdiShieldCheckOutline ?? icon.mdiTable, + permissions: 'READ_DEVICES' + }, { href: '/device_sessions/device_sessions-list', label: 'Device sessions', diff --git a/frontend/src/pages/dashboard.tsx b/frontend/src/pages/dashboard.tsx index 4c90d79..40f3439 100644 --- a/frontend/src/pages/dashboard.tsx +++ b/frontend/src/pages/dashboard.tsx @@ -6,6 +6,8 @@ import type { ReactElement } from 'react' import LayoutAuthenticated from '../layouts/Authenticated' import SectionMain from '../components/SectionMain' import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton' +import CardBox from '../components/CardBox' +import BaseButton from '../components/BaseButton' import BaseIcon from "../components/BaseIcon"; import { getPageTitle } from '../config' import Link from "next/link"; @@ -103,6 +105,35 @@ const Dashboard = () => { {''} + + {hasPermission(currentUser, 'READ_DEVICES') && ( + +
+
+
+
+
+
+ Device operations +
+

Approval center is ready for first-day operations

+

+ Review pending enrollments, approve trusted devices, and keep each decision visible in the audit trail. +

+
+
+ + +
+
+
+ + )} + {hasPermission(currentUser, 'CREATE_ROLES') && ; + title: string; + description: string; +}; + +const FILTERS: Array<{ id: ApprovalFilter; label: string }> = [ + { id: 'pending', label: 'Pending review' }, + { id: 'approved', label: 'Approved' }, + { id: 'blocked', label: 'Blocked' }, + { id: 'all', label: 'All devices' }, +]; + +const METRIC_META = [ + { + key: 'pending', + label: 'Pending review', + icon: mdiLanPending, + helper: 'Needs an admin decision before it joins the approved fleet.', + className: 'border-amber-500/30 bg-amber-500/10 text-amber-100', + }, + { + key: 'approved', + label: 'Approved', + icon: mdiCheckCircleOutline, + helper: 'Ready for future automation and policy rollout.', + className: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-100', + }, + { + key: 'blocked', + label: 'Blocked', + icon: mdiCloseCircleOutline, + helper: 'Explicitly denied from the authorized device pool.', + className: 'border-rose-500/30 bg-rose-500/10 text-rose-100', + }, + { + key: 'online', + label: 'Online now', + icon: mdiAccessPointNetwork, + helper: 'Active heartbeat seen recently from the current fleet.', + className: 'border-sky-500/30 bg-sky-500/10 text-sky-100', + }, +] as const; + +const statusClasses: Record = { + pending: 'border border-amber-200 bg-amber-50 text-amber-700', + approved: 'border border-emerald-200 bg-emerald-50 text-emerald-700', + blocked: 'border border-rose-200 bg-rose-50 text-rose-700', +}; + +const severityClasses: Record = { + info: 'border border-sky-200 bg-sky-50 text-sky-700', + warning: 'border border-amber-200 bg-amber-50 text-amber-700', + high: 'border border-orange-200 bg-orange-50 text-orange-700', + critical: 'border border-rose-200 bg-rose-50 text-rose-700', +}; + +const formatNumber = (value?: number | string | null) => { + if (value === null || value === undefined || value === '') { + return '—'; + } + + const numericValue = Number(value); + + if (Number.isNaN(numericValue)) { + return `${value}`; + } + + return `${numericValue.toFixed(1)}%`; +}; + +const formatDate = (value?: string | null) => { + if (!value) { + return '—'; + } + + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return '—'; + } + + return new Intl.DateTimeFormat('en-US', { + dateStyle: 'medium', + timeStyle: 'short', + }).format(date); +}; + +const formatRelativeTime = (value?: string | null) => { + if (!value) { + return 'No heartbeat yet'; + } + + const timestamp = new Date(value).getTime(); + + if (Number.isNaN(timestamp)) { + return 'No heartbeat yet'; + } + + const diffMs = timestamp - Date.now(); + const diffMinutes = Math.round(diffMs / (1000 * 60)); + const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }); + + if (Math.abs(diffMinutes) < 60) { + return rtf.format(diffMinutes, 'minute'); + } + + const diffHours = Math.round(diffMinutes / 60); + + if (Math.abs(diffHours) < 24) { + return rtf.format(diffHours, 'hour'); + } + + const diffDays = Math.round(diffHours / 24); + return rtf.format(diffDays, 'day'); +}; + +const getDisplayName = (person?: Person | null) => { + if (!person) { + return 'Unassigned'; + } + + const fullName = [person.firstName, person.lastName].filter(Boolean).join(' ').trim(); + return fullName || person.email || 'Unassigned'; +}; + +const getEventSummary = (event?: SecurityEvent) => { + if (!event?.details_json) { + return 'No additional context saved for this event.'; + } + + if (!event.details_json.trim().startsWith('{')) { + return event.details_json; + } + + try { + const parsed = JSON.parse(event.details_json) as Record; + const pieces = [parsed.deviceName, parsed.computerName, parsed.previousStatus, parsed.nextStatus].filter(Boolean); + + if (pieces.length) { + return pieces.join(' • '); + } + } catch { + return event.details_json; + } + + return event.details_json; +}; + +const sortByNewest = >(items: T[], primaryKey: keyof T, secondaryKey?: keyof T) => { + return [...items].sort((first, second) => { + const firstTimestamp = new Date((first[primaryKey] || first[secondaryKey || primaryKey] || '') as string).getTime(); + const secondTimestamp = new Date((second[primaryKey] || second[secondaryKey || primaryKey] || '') as string).getTime(); + + return secondTimestamp - firstTimestamp; + }); +}; + +const EmptyState = ({ title, description, action }: { title: string; description: string; action?: React.ReactNode }) => ( +
+
+ +
+

{title}

+

{description}

+ {action ?
{action}
: null} +
+); + +const DeviceApprovalCenter = () => { + const { currentUser } = useAppSelector((state) => state.auth); + const canCreateDevices = hasPermission(currentUser, 'CREATE_DEVICES'); + const canUpdateDevices = hasPermission(currentUser, 'UPDATE_DEVICES'); + const canReadSecurityEvents = hasPermission(currentUser, 'READ_SECURITY_EVENTS'); + + const [summary, setSummary] = useState(null); + const [selectedDeviceId, setSelectedDeviceId] = useState(null); + const [selectedDevice, setSelectedDevice] = useState(null); + const [activeFilter, setActiveFilter] = useState('pending'); + const [queryInput, setQueryInput] = useState(''); + const [query, setQuery] = useState(''); + const [loadingSummary, setLoadingSummary] = useState(false); + const [loadingDetail, setLoadingDetail] = useState(false); + const [loadingEvents, setLoadingEvents] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + const [feedback, setFeedback] = useState<{ color: 'success' | 'danger'; message: string } | null>(null); + const [recentEvents, setRecentEvents] = useState([]); + const [actionState, setActionState] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const loadSummary = useCallback( + async (preferredDeviceId?: string | null) => { + setLoadingSummary(true); + setErrorMessage(''); + + try { + const { data } = await axios.get('/devices/approval-center/summary', { + params: { + status: activeFilter, + query, + limit: 18, + }, + }); + + setSummary(data); + setSelectedDeviceId((currentDeviceId) => { + const desiredDeviceId = preferredDeviceId ?? currentDeviceId; + + if (desiredDeviceId && data.devices.some((device) => device.id === desiredDeviceId)) { + return desiredDeviceId; + } + + return data.devices[0]?.id ?? null; + }); + } catch (error) { + console.error('Failed to load approval center summary', error); + setErrorMessage('Unable to load the approval queue right now. Try refreshing the page.'); + } finally { + setLoadingSummary(false); + } + }, + [activeFilter, query], + ); + + const loadSelectedDevice = useCallback(async (deviceId: string) => { + setLoadingDetail(true); + + try { + const { data } = await axios.get(`/devices/${deviceId}`); + setSelectedDevice(data); + } catch (error) { + console.error('Failed to load device detail', error); + setSelectedDevice(null); + } finally { + setLoadingDetail(false); + } + }, []); + + const loadRecentEvents = useCallback(async () => { + if (!canReadSecurityEvents) { + setRecentEvents([]); + return; + } + + setLoadingEvents(true); + + try { + const { data } = await axios.get('/security_events', { + params: { + page: 0, + limit: 6, + field: 'occurred_at', + sort: 'desc', + }, + }); + + setRecentEvents(data.rows || []); + } catch (error) { + console.error('Failed to load recent security events', error); + setRecentEvents([]); + } finally { + setLoadingEvents(false); + } + }, [canReadSecurityEvents]); + + useEffect(() => { + const timeoutId = window.setTimeout(() => { + setQuery(queryInput.trim()); + }, 250); + + return () => window.clearTimeout(timeoutId); + }, [queryInput]); + + useEffect(() => { + if (!currentUser) { + return; + } + + loadSummary().catch((error) => { + console.error('Approval center summary effect failed', error); + }); + }, [currentUser, loadSummary]); + + useEffect(() => { + if (!currentUser) { + return; + } + + loadRecentEvents().catch((error) => { + console.error('Recent events effect failed', error); + }); + }, [currentUser, loadRecentEvents]); + + useEffect(() => { + if (!selectedDeviceId) { + setSelectedDevice(null); + return; + } + + loadSelectedDevice(selectedDeviceId).catch((error) => { + console.error('Selected device effect failed', error); + }); + }, [selectedDeviceId, loadSelectedDevice]); + + const recentSessions = useMemo( + () => sortByNewest(selectedDevice?.device_sessions_device || [], 'connected_at', 'createdAt').slice(0, 4), + [selectedDevice], + ); + + const selectedDeviceEvents = useMemo( + () => sortByNewest(selectedDevice?.security_events_device || [], 'occurred_at', 'createdAt').slice(0, 5), + [selectedDevice], + ); + + const openActionModal = (status: Extract) => { + const modalCopy = status === 'approved' + ? { + status, + title: 'Approve this device?', + description: 'This will mark the device as authorized and write an audit event for the review action.', + } + : { + status, + title: 'Block this device?', + description: 'This keeps the device out of the approved pool and records the decision in the security timeline.', + }; + + setActionState(modalCopy); + }; + + const handleApprovalDecision = async () => { + if (!actionState || !selectedDeviceId || isSubmitting) { + return; + } + + setIsSubmitting(true); + setFeedback(null); + + try { + await axios.put(`/devices/${selectedDeviceId}/approval-status`, { + approval_status: actionState.status, + }); + + setFeedback({ + color: 'success', + message: actionState.status === 'approved' + ? 'Device approved and added to the authorized fleet.' + : 'Device blocked and logged for follow-up.', + }); + setActionState(null); + + await Promise.all([ + loadSummary(selectedDeviceId), + loadRecentEvents(), + ]); + } catch (error) { + console.error('Failed to update approval status', error); + setFeedback({ + color: 'danger', + message: 'The approval decision could not be saved. Please try again.', + }); + } finally { + setIsSubmitting(false); + } + }; + + const selectedDeviceStatus = (selectedDevice?.approval_status || 'pending') as ApprovalStatus; + + return ( + <> + + {getPageTitle('Device approval center')} + + + +
+ { + setFeedback(null); + loadSummary(selectedDeviceId).catch((error) => { + console.error('Manual refresh failed', error); + }); + loadRecentEvents().catch((error) => { + console.error('Manual events refresh failed', error); + }); + }} + /> + +
+
+ + +
+
+
+
+
+
+ + Authorized-device workflow +
+

+ Review new enrollments, approve only trusted computers, and keep an audit trail from day one. +

+

+ This first slice turns the generated CRUD into an operations workflow: a queue for device review, + a fast approval decision, and a clear history of what changed and when. +

+
+ {canCreateDevices ? ( + + ) : null} + {canReadSecurityEvents ? ( + + ) : null} +
+
+
+ {METRIC_META.map((metric) => ( +
+
+
+ +
+ Fleet +
+
+ {summary?.counts[metric.key] ?? 0} +
+
{metric.label}
+

{metric.helper}

+
+ ))} +
+
+
+ + + {errorMessage ? ( + + {errorMessage} + + ) : null} + +
+
+ +
+
+

Approval queue

+

+ Search newly seen devices and narrow the queue by approval state. +

+
+ + Inventory + + +
+ + + setQueryInput(event.target.value)} + placeholder='Search review queue' + /> + + +
+ {FILTERS.map((filter) => ( + { + setActiveFilter(filter.id); + setFeedback(null); + }} + small + /> + ))} +
+ +
+ {loadingSummary ? ( + Array.from({ length: 4 }).map((_, index) => ( +
+ )) + ) : summary?.devices?.length ? ( + summary.devices.map((device) => { + const status = (device.approval_status || 'pending') as ApprovalStatus; + const isSelected = device.id === selectedDeviceId; + + return ( + + ); + }) + ) : ( + : undefined + } + /> + )} +
+ +
+ +
+ + {feedback ? ( + + {feedback.message} + + ) : null} + + {!selectedDeviceId && !loadingDetail ? ( + } + /> + ) : loadingDetail ? ( +
+
+
+
+
+
+
+
+ ) : selectedDevice ? ( + <> +
+
+
+ + Device review + + + {selectedDeviceStatus} + + + {selectedDevice.is_online ? 'Online' : 'Offline'} + +
+

+ {selectedDevice.device_name || selectedDevice.computer_name || 'Unnamed device'} +

+

+ {selectedDevice.computer_name || 'No hostname yet'} • {selectedDevice.current_username || 'No local user reported'} +

+
+
+ + {canUpdateDevices ? ( + <> + openActionModal('approved')} + /> + openActionModal('blocked')} + /> + + ) : ( + + Read-only access + + )} +
+
+ +
+ {[ + { label: 'Last heartbeat', value: formatRelativeTime(selectedDevice.last_heartbeat_at), icon: mdiClockOutline }, + { label: 'CPU usage', value: formatNumber(selectedDevice.cpu_usage_percent), icon: mdiChartTimelineVariant }, + { label: 'RAM usage', value: formatNumber(selectedDevice.ram_usage_percent), icon: mdiMonitorDashboard }, + { label: 'Reconnect attempts', value: `${selectedDevice.reconnect_attempts ?? 0}`, icon: mdiRefresh }, + ].map((stat) => ( +
+
+
+ {stat.label} +
+ +
+
{stat.value}
+
+ ))} +
+ + + +
+
+
+

Host profile

+

System identity, network details, and reviewer information.

+
+
+ {[ + { label: 'Agent version', value: selectedDevice.agent_version || '—' }, + { label: 'OS version', value: selectedDevice.os_version || '—' }, + { label: 'Public IP', value: selectedDevice.public_ip || '—' }, + { label: 'Local IP', value: selectedDevice.local_ip || '—' }, + { label: 'MAC address', value: selectedDevice.mac_address || '—' }, + { label: 'Fingerprint', value: selectedDevice.device_fingerprint || '—' }, + { label: 'First seen', value: formatDate(selectedDevice.first_seen_at) }, + { label: 'Reviewed by', value: getDisplayName(selectedDevice.approved_by) }, + ].map((item) => ( +
+
+ {item.label} +
+
+ {item.value} +
+
+ ))} +
+
+ +
+
+

Connection history

+

Most recent sessions reported for this device.

+
+
+ {recentSessions.length ? ( + recentSessions.map((session) => ( +
+
+
+ {session.server_node || 'Primary node'} +
+ + {session.disconnect_reason || 'connected'} + +
+
+
+
Connected
+
{formatDate(session.connected_at)}
+
+
+
Disconnected
+
{formatDate(session.disconnected_at)}
+
+
+
+ )) + ) : ( + + )} +
+
+
+ + {canReadSecurityEvents ? ( + <> + +
+
+
+

Audit timeline for this device

+

Approval changes, registrations, and other related events.

+
+ + Full audit log + + +
+
+ {selectedDeviceEvents.length ? ( + selectedDeviceEvents.map((event) => ( +
+
+
+
+ {(event.event_type || 'activity').replace(/_/g, ' ')} +
+

{getEventSummary(event)}

+
+ + {event.severity || 'info'} + +
+
+ {formatDate(event.occurred_at || event.createdAt)} + {event.source_ip || 'No source IP'} +
+
+ )) + ) : ( + + )} +
+
+ + ) : null} + + ) : ( + } + /> + )} + +
+
+ + {canReadSecurityEvents ? ( + +
+
+

Recent audit activity

+

A quick view of the latest security-relevant events across the admin panel.

+
+ +
+ {loadingEvents ? ( +
+ {Array.from({ length: 3 }).map((_, index) => ( +
+ ))} +
+ ) : recentEvents.length ? ( +
+ {recentEvents.map((event) => ( +
+
+ + {event.severity || 'info'} + + + {formatDate(event.occurred_at || event.createdAt)} + +
+
+ {(event.event_type || 'activity').replace(/_/g, ' ')} +
+

+ {getEventSummary(event)} +

+
+ {event.device?.device_name || event.device?.computer_name || 'Security event'} +
+
+ ))} +
+ ) : ( + + )} + + ) : null} + + + { + if (!isSubmitting) { + setActionState(null); + } + }} + > +

+ {actionState?.description} +

+
+
+ {selectedDevice?.device_name || selectedDevice?.computer_name || 'Selected device'} +
+
+ {selectedDevice?.computer_name || 'No hostname'} • {selectedDevice?.current_username || 'No local user'} +
+
+
+ + ); +}; + +DeviceApprovalCenter.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; + +export default DeviceApprovalCenter; diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index 153b6a4..4f9cd02 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -1,166 +1,217 @@ - -import React, { useEffect, useState } from 'react'; -import type { ReactElement } from 'react'; +import { + mdiAccessPointNetwork, + mdiChevronRight, + mdiHistory, + mdiLockOutline, + mdiMonitorDashboard, + mdiShieldCheckOutline, +} from '@mdi/js'; import Head from 'next/head'; import Link from 'next/link'; +import React from 'react'; +import type { ReactElement } from 'react'; import BaseButton from '../components/BaseButton'; +import BaseIcon from '../components/BaseIcon'; import CardBox from '../components/CardBox'; -import SectionFullScreen from '../components/SectionFullScreen'; -import LayoutGuest from '../layouts/Guest'; -import BaseDivider from '../components/BaseDivider'; -import BaseButtons from '../components/BaseButtons'; import { getPageTitle } from '../config'; -import { useAppSelector } from '../stores/hooks'; -import CardBoxComponentTitle from "../components/CardBoxComponentTitle"; -import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'; +import LayoutGuest from '../layouts/Guest'; +const highlights = [ + { + title: 'Approved devices only', + description: 'Keep enrollment under review before any machine joins your trusted fleet.', + icon: mdiShieldCheckOutline, + }, + { + title: 'Heartbeat-first visibility', + description: 'Surface online status, reconnect health, and recent activity in one panel.', + icon: mdiAccessPointNetwork, + }, + { + title: 'Audit-ready operations', + description: 'Every approval decision should be visible, timestamped, and easy to revisit.', + icon: mdiHistory, + }, +]; -export default function Starter() { - const [illustrationImage, setIllustrationImage] = useState({ - src: undefined, - photographer: undefined, - photographer_url: undefined, - }) - const [illustrationVideo, setIllustrationVideo] = useState({video_files: []}) - const [contentType, setContentType] = useState('image'); - const [contentPosition, setContentPosition] = useState('background'); - const textColor = useAppSelector((state) => state.style.linkColor); - - const title = 'Remote Admin System' - - // Fetch Pexels image/video - useEffect(() => { - async function fetchData() { - const image = await getPexelsImage(); - const video = await getPexelsVideo(); - setIllustrationImage(image); - setIllustrationVideo(video); - } - fetchData(); - }, []); - - const imageBlock = (image) => ( - - ); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- - -
) - } - }; +const workflow = [ + 'Enroll a device and capture host metadata.', + 'Review heartbeat and system identity in the approval queue.', + 'Approve only trusted computers into the authorized fleet.', + 'Track decisions in the audit timeline for follow-up and reporting.', +]; +export default function Home() { return ( -
+ <> - {getPageTitle('Starter Page')} + {getPageTitle('Remote Admin System')} - -
- {contentType === 'image' && contentPosition !== 'background' - ? imageBlock(illustrationImage) - : null} - {contentType === 'video' && contentPosition !== 'background' - ? videoBlock(illustrationVideo) - : null} -
- - - -
-

This is a React.js/Node.js app generated by the Flatlogic Web App Generator

-

For guides and documentation please check - your local README.md and the Flatlogic documentation

+
+
+
+
+ + Remote Admin System + +

Secure web operations for approved computers and admin-reviewed access.

- - - +
+ + +
+
- - +
+
+
+
+ + First-day control center +
+

+ Modern remote administration starts with trusted enrollment and a clean audit trail. +

+

+ The app is now shaped around the first real workflow your team needs: review newly connected devices, + approve only the machines you trust, and keep every decision visible in the admin panel. +

+ +
+ + +
+ +
+ {[ + { label: 'Secure access', value: 'JWT login' }, + { label: 'Device review', value: 'Approval queue' }, + { label: 'Traceability', value: 'Audit history' }, + ].map((item) => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+
+ + +
+
+
+
+
+
+

Control preview

+

Approval center

+
+
+ +
+
+ +
+
+
Pending review
+
2
+

New devices waiting for an approval decision.

+
+
+
Authorized fleet
+
Ready
+

Approved devices stay clearly separated from blocked ones.

+
+
+ +
+
+
Today's workflow
+
+ Live queue + +
+
+
+ {workflow.slice(0, 3).map((item, index) => ( +
+
+ {index + 1} +
+

{item}

+
+ ))} +
+
+
+
+ +
+ +
+ {highlights.map((item) => ( + +
+ +
+

{item.title}

+

{item.description}

+
+ ))} +
+ +
+
+
+
+ + Built for safe admin flow +
+

What the first delivery gives you immediately

+

+ Instead of a generic shell, the product now points to a concrete operational loop: review, approve, + and trace device access decisions inside the admin experience. +

+
+
+ {workflow.map((step, index) => ( +
+
Step {index + 1}
+

{step}

+
+ ))} +
+
+
+
-
- -
-

© 2026 {title}. All rights reserved

- - Privacy Policy - -
-
+
+
+

© 2026 Remote Admin System. Secure access for approved devices only.

+
+ + Admin interface + + + Login + + + Privacy Policy + +
+
+
+
+ ); } -Starter.getLayout = function getLayout(page: ReactElement) { +Home.getLayout = function getLayout(page: ReactElement) { return {page}; }; -