Autosave: 20260515-131832

This commit is contained in:
Flatlogic Bot 2026-05-15 13:18:29 +00:00
parent 988cc71aad
commit 9acc94b352
9 changed files with 1388 additions and 168 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

View File

@ -213,6 +213,27 @@ router.put('/:id', wrapAsync(async (req, res) => {
res.status(200).send(payload); 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 * @swagger
* /api/devices/{id}: * /api/devices/{id}:
@ -416,6 +437,13 @@ router.get('/autocomplete', async (req, res) => {
res.status(200).send(payload); 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 * @swagger
* /api/devices/{id}: * /api/devices/{id}:

View File

@ -1,15 +1,22 @@
const db = require('../db/models'); const db = require('../db/models');
const DevicesDBApi = require('../db/api/devices'); const DevicesDBApi = require('../db/api/devices');
const processFile = require("../middlewares/upload"); const processFile = require('../middlewares/upload');
const ValidationError = require('./notifications/errors/validation'); const ValidationError = require('./notifications/errors/validation');
const csv = require('csv-parser'); const csv = require('csv-parser');
const axios = require('axios');
const config = require('../config');
const stream = require('stream'); 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 { module.exports = class DevicesService {
static async create(data, currentUser) { static async create(data, currentUser) {
@ -28,9 +35,9 @@ module.exports = class DevicesService {
await transaction.rollback(); await transaction.rollback();
throw error; throw error;
} }
}; }
static async bulkImport(req, res, sendInvitationEmails = true, host) { static async bulkImport(req, res) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
@ -38,7 +45,7 @@ module.exports = class DevicesService {
const bufferStream = new stream.PassThrough(); const bufferStream = new stream.PassThrough();
const results = []; 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) => { await new Promise((resolve, reject) => {
bufferStream bufferStream
@ -49,13 +56,13 @@ module.exports = class DevicesService {
resolve(); resolve();
}) })
.on('error', (error) => reject(error)); .on('error', (error) => reject(error));
}) });
await DevicesDBApi.bulkImport(results, { await DevicesDBApi.bulkImport(results, {
transaction, transaction,
ignoreDuplicates: true, ignoreDuplicates: true,
validate: true, validate: true,
currentUser: req.currentUser currentUser: req.currentUser,
}); });
await transaction.commit(); await transaction.commit();
@ -68,9 +75,9 @@ module.exports = class DevicesService {
static async update(data, id, currentUser) { static async update(data, id, currentUser) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
let devices = await DevicesDBApi.findBy( const devices = await DevicesDBApi.findBy(
{id}, { id },
{transaction}, { transaction },
); );
if (!devices) { if (!devices) {
@ -90,12 +97,11 @@ module.exports = class DevicesService {
await transaction.commit(); await transaction.commit();
return updatedDevices; return updatedDevices;
} catch (error) { } catch (error) {
await transaction.rollback(); await transaction.rollback();
throw error; throw error;
} }
}; }
static async deleteByIds(ids, currentUser) { static async deleteByIds(ids, currentUser) {
const transaction = await db.sequelize.transaction(); 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;
}
}
}; };

View File

@ -1,6 +1,5 @@
import React, {useEffect, useRef} from 'react' import React, { useEffect, useRef, useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { useState } from 'react'
import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
import BaseDivider from './BaseDivider' import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon' import BaseIcon from './BaseIcon'

View File

@ -1,5 +1,4 @@
import React, { ReactNode, useEffect } from 'react' import React, { ReactNode, useEffect, useState } from 'react'
import { useState } from 'react'
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
import menuAside from '../menuAside' import menuAside from '../menuAside'

View File

@ -40,6 +40,14 @@ const menuAside: MenuAsideItem[] = [
icon: 'mdiMonitor' in icon ? icon['mdiMonitor' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, icon: 'mdiMonitor' in icon ? icon['mdiMonitor' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_DEVICES' 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', href: '/device_sessions/device_sessions-list',
label: 'Device sessions', label: 'Device sessions',

View File

@ -6,6 +6,8 @@ import type { ReactElement } from 'react'
import LayoutAuthenticated from '../layouts/Authenticated' import LayoutAuthenticated from '../layouts/Authenticated'
import SectionMain from '../components/SectionMain' import SectionMain from '../components/SectionMain'
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton' import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'
import CardBox from '../components/CardBox'
import BaseButton from '../components/BaseButton'
import BaseIcon from "../components/BaseIcon"; import BaseIcon from "../components/BaseIcon";
import { getPageTitle } from '../config' import { getPageTitle } from '../config'
import Link from "next/link"; import Link from "next/link";
@ -103,6 +105,35 @@ const Dashboard = () => {
{''} {''}
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
{hasPermission(currentUser, 'READ_DEVICES') && (
<CardBox
isList
className='mb-6 overflow-hidden border border-slate-900 bg-slate-950 text-white shadow-2xl'
cardBoxClassName='p-0'
>
<div className='relative overflow-hidden px-6 py-6'>
<div className='absolute -left-10 top-0 h-32 w-32 rounded-full bg-sky-400/20 blur-3xl' />
<div className='absolute right-0 top-4 h-32 w-32 rounded-full bg-violet-500/20 blur-3xl' />
<div className='relative flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between'>
<div>
<div className='mb-2 inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-sky-200'>
Device operations
</div>
<h2 className='text-2xl font-semibold'>Approval center is ready for first-day operations</h2>
<p className='mt-2 max-w-3xl text-sm text-slate-300'>
Review pending enrollments, approve trusted devices, and keep each decision visible in the audit trail.
</p>
</div>
<div className='flex flex-wrap gap-2'>
<BaseButton color='info' href='/device-approval-center' label='Open approval center' />
<BaseButton color='whiteDark' href='/devices/devices-list' label='Inventory' />
</div>
</div>
</div>
</CardBox>
)}
{hasPermission(currentUser, 'CREATE_ROLES') && <WidgetCreator {hasPermission(currentUser, 'CREATE_ROLES') && <WidgetCreator
currentUser={currentUser} currentUser={currentUser}
isFetchingQuery={isFetchingQuery} isFetchingQuery={isFetchingQuery}

View File

@ -0,0 +1,984 @@
import {
mdiAccessPointNetwork,
mdiAlertCircleOutline,
mdiChartTimelineVariant,
mdiCheckCircleOutline,
mdiChevronRight,
mdiClockOutline,
mdiCloseCircleOutline,
mdiLanPending,
mdiMagnify,
mdiMonitorDashboard,
mdiOpenInNew,
mdiRefresh,
mdiShieldCheckOutline,
} from '@mdi/js';
import axios from 'axios';
import Head from 'next/head';
import Link from 'next/link';
import React, { ReactElement, useCallback, useEffect, useMemo, useState } from 'react';
import BaseButton from '../components/BaseButton';
import BaseDivider from '../components/BaseDivider';
import BaseIcon from '../components/BaseIcon';
import CardBox from '../components/CardBox';
import CardBoxModal from '../components/CardBoxModal';
import FormField from '../components/FormField';
import NotificationBar from '../components/NotificationBar';
import SectionMain from '../components/SectionMain';
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
import { getPageTitle } from '../config';
import { hasPermission } from '../helpers/userPermissions';
import LayoutAuthenticated from '../layouts/Authenticated';
import { useAppSelector } from '../stores/hooks';
type ApprovalStatus = 'pending' | 'approved' | 'blocked';
type ApprovalFilter = 'all' | ApprovalStatus;
type Person = {
id?: string;
firstName?: string;
lastName?: string;
email?: string;
};
type DeviceSession = {
id: string;
server_node?: string | null;
connected_at?: string | null;
disconnected_at?: string | null;
disconnect_reason?: string | null;
socket_session_key?: string | null;
client_instance_key?: string | null;
createdAt?: string | null;
};
type SecurityEvent = {
id: string;
event_type?: string | null;
severity?: 'info' | 'warning' | 'high' | 'critical' | null;
occurred_at?: string | null;
createdAt?: string | null;
details_json?: string | null;
source_ip?: string | null;
user?: Person | null;
device?: {
id?: string;
device_name?: string | null;
computer_name?: string | null;
approval_status?: ApprovalStatus | null;
} | null;
};
type DeviceRecord = {
id: string;
device_name?: string | null;
computer_name?: string | null;
current_username?: string | null;
agent_version?: string | null;
os_version?: string | null;
public_ip?: string | null;
local_ip?: string | null;
mac_address?: string | null;
device_fingerprint?: string | null;
approval_status?: ApprovalStatus | null;
is_online?: boolean | null;
first_seen_at?: string | null;
last_seen_at?: string | null;
last_heartbeat_at?: string | null;
reconnect_attempts?: number | string | null;
ram_usage_percent?: number | string | null;
cpu_usage_percent?: number | string | null;
approved_by?: Person | null;
device_sessions_device?: DeviceSession[];
security_events_device?: SecurityEvent[];
};
type SummaryResponse = {
counts: {
total: number;
pending: number;
approved: number;
blocked: number;
online: number;
};
filters: {
query: string;
status: ApprovalFilter;
};
devices: DeviceRecord[];
};
type EventsResponse = {
rows: SecurityEvent[];
};
type ActionState = {
status: Extract<ApprovalStatus, 'approved' | 'blocked'>;
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<ApprovalStatus, string> = {
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<string, string> = {
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<string, string | null | undefined>;
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 = <T extends Record<string, string | null | undefined>>(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 }) => (
<div className='rounded-2xl border border-dashed border-gray-300 bg-gray-50 px-5 py-8 text-center dark:border-dark-700 dark:bg-dark-800'>
<div className='mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-white text-blue-500 shadow-sm dark:bg-dark-900'>
<BaseIcon path={mdiShieldCheckOutline} size={24} />
</div>
<h3 className='text-lg font-semibold text-gray-900 dark:text-white'>{title}</h3>
<p className='mt-2 text-sm text-gray-500 dark:text-gray-400'>{description}</p>
{action ? <div className='mt-5 flex justify-center'>{action}</div> : null}
</div>
);
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<SummaryResponse | null>(null);
const [selectedDeviceId, setSelectedDeviceId] = useState<string | null>(null);
const [selectedDevice, setSelectedDevice] = useState<DeviceRecord | null>(null);
const [activeFilter, setActiveFilter] = useState<ApprovalFilter>('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<SecurityEvent[]>([]);
const [actionState, setActionState] = useState<ActionState | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const loadSummary = useCallback(
async (preferredDeviceId?: string | null) => {
setLoadingSummary(true);
setErrorMessage('');
try {
const { data } = await axios.get<SummaryResponse>('/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<DeviceRecord>(`/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<EventsResponse>('/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<ApprovalStatus, 'approved' | 'blocked'>) => {
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 (
<>
<Head>
<title>{getPageTitle('Device approval center')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiMonitorDashboard} title='Device approval center' main>
<div className='flex flex-wrap gap-2'>
<BaseButton
color='whiteDark'
icon={mdiRefresh}
label='Refresh'
onClick={() => {
setFeedback(null);
loadSummary(selectedDeviceId).catch((error) => {
console.error('Manual refresh failed', error);
});
loadRecentEvents().catch((error) => {
console.error('Manual events refresh failed', error);
});
}}
/>
<BaseButton color='info' href='/devices/devices-list' label='Full inventory' />
</div>
</SectionTitleLineWithButton>
<CardBox isList className='mb-6 overflow-hidden border border-slate-900 bg-slate-950 text-white shadow-2xl' cardBoxClassName='p-0'>
<div className='relative overflow-hidden px-6 py-8 md:px-8'>
<div className='absolute -left-12 top-0 h-40 w-40 rounded-full bg-sky-400/20 blur-3xl' />
<div className='absolute right-0 top-8 h-44 w-44 rounded-full bg-violet-500/20 blur-3xl' />
<div className='relative grid gap-8 xl:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]'>
<div>
<div className='mb-4 inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-sky-200'>
<BaseIcon path={mdiShieldCheckOutline} size={18} />
Authorized-device workflow
</div>
<h2 className='max-w-3xl text-3xl font-semibold tracking-tight md:text-4xl'>
Review new enrollments, approve only trusted computers, and keep an audit trail from day one.
</h2>
<p className='mt-4 max-w-2xl text-sm leading-7 text-slate-300 md:text-base'>
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.
</p>
<div className='mt-6 flex flex-wrap gap-3'>
{canCreateDevices ? (
<BaseButton color='info' href='/devices/devices-new' label='Manual device entry' />
) : null}
{canReadSecurityEvents ? (
<BaseButton color='whiteDark' href='/security_events/security_events-list' label='Audit log' />
) : null}
</div>
</div>
<div className='grid gap-3 sm:grid-cols-2'>
{METRIC_META.map((metric) => (
<div key={metric.key} className={`rounded-2xl border p-4 shadow-lg shadow-black/10 ${metric.className}`}>
<div className='mb-3 flex items-center justify-between'>
<div className='flex h-10 w-10 items-center justify-center rounded-2xl border border-white/10 bg-slate-950/30'>
<BaseIcon path={metric.icon} size={22} />
</div>
<span className='text-xs uppercase tracking-[0.2em] text-white/60'>Fleet</span>
</div>
<div className='text-3xl font-semibold'>
{summary?.counts[metric.key] ?? 0}
</div>
<div className='mt-1 text-sm font-medium'>{metric.label}</div>
<p className='mt-2 text-xs leading-5 text-white/70'>{metric.helper}</p>
</div>
))}
</div>
</div>
</div>
</CardBox>
{errorMessage ? (
<NotificationBar color='danger' icon={mdiAlertCircleOutline}>
{errorMessage}
</NotificationBar>
) : null}
<div className='grid gap-6 xl:grid-cols-5'>
<div className='xl:col-span-2'>
<CardBox className='h-full'>
<div className='mb-4 flex items-start justify-between gap-3'>
<div>
<h2 className='text-xl font-semibold'>Approval queue</h2>
<p className='mt-1 text-sm text-gray-500 dark:text-gray-400'>
Search newly seen devices and narrow the queue by approval state.
</p>
</div>
<Link
href='/devices/devices-list'
className='inline-flex items-center gap-1 text-sm font-medium text-blue-600 transition hover:text-blue-700'
>
Inventory
<BaseIcon path={mdiChevronRight} size={16} />
</Link>
</div>
<FormField help='Search by device name, hostname, username, or IP address.' icons={[mdiMagnify]}>
<input
name='deviceSearch'
value={queryInput}
onChange={(event) => setQueryInput(event.target.value)}
placeholder='Search review queue'
/>
</FormField>
<div className='mb-5 flex flex-wrap gap-2'>
{FILTERS.map((filter) => (
<BaseButton
key={filter.id}
color={activeFilter === filter.id ? 'info' : 'whiteDark'}
label={filter.label}
outline={activeFilter !== filter.id}
onClick={() => {
setActiveFilter(filter.id);
setFeedback(null);
}}
small
/>
))}
</div>
<div className='space-y-3'>
{loadingSummary ? (
Array.from({ length: 4 }).map((_, index) => (
<div
key={`device-skeleton-${index}`}
className='h-28 animate-pulse rounded-2xl border border-gray-200 bg-gray-50 dark:border-dark-700 dark:bg-dark-800'
/>
))
) : summary?.devices?.length ? (
summary.devices.map((device) => {
const status = (device.approval_status || 'pending') as ApprovalStatus;
const isSelected = device.id === selectedDeviceId;
return (
<button
key={device.id}
type='button'
onClick={() => {
setFeedback(null);
setSelectedDeviceId(device.id);
}}
className={`w-full rounded-2xl border p-4 text-left transition-all duration-150 ${
isSelected
? 'border-blue-500 bg-blue-50 shadow-sm ring-2 ring-blue-100 dark:border-blue-500 dark:bg-dark-900 dark:ring-blue-900/50'
: 'border-gray-200 bg-white hover:border-blue-300 hover:bg-blue-50/40 dark:border-dark-700 dark:hover:border-blue-500/60'
}`}
>
<div className='flex items-start justify-between gap-3'>
<div>
<div className='flex items-center gap-2'>
<span className='text-base font-semibold text-gray-900 dark:text-white'>
{device.device_name || device.computer_name || 'Unnamed device'}
</span>
<span className={`inline-flex h-2.5 w-2.5 rounded-full ${device.is_online ? 'bg-emerald-500' : 'bg-gray-300'}`} />
</div>
<p className='mt-1 text-sm text-gray-500 dark:text-gray-400'>
{device.computer_name || 'No hostname'} {device.current_username || 'No signed-in user'}
</p>
</div>
<span className={`rounded-full px-2.5 py-1 text-xs font-semibold capitalize ${statusClasses[status]}`}>
{status}
</span>
</div>
<div className='mt-4 grid gap-3 text-sm text-gray-500 dark:text-gray-400 sm:grid-cols-2'>
<div>
<div className='text-xs uppercase tracking-[0.2em] text-gray-400 dark:text-gray-500'>Last heartbeat</div>
<div className='mt-1 font-medium text-gray-700 dark:text-gray-200'>{formatRelativeTime(device.last_heartbeat_at)}</div>
</div>
<div>
<div className='text-xs uppercase tracking-[0.2em] text-gray-400 dark:text-gray-500'>Resource usage</div>
<div className='mt-1 font-medium text-gray-700 dark:text-gray-200'>CPU {formatNumber(device.cpu_usage_percent)} RAM {formatNumber(device.ram_usage_percent)}</div>
</div>
</div>
</button>
);
})
) : (
<EmptyState
title='No devices match this queue'
description='Try another filter, clear the search, or add a device manually if you are simulating the first enrollment flow.'
action={
canCreateDevices ? <BaseButton color='info' href='/devices/devices-new' label='Add device' /> : undefined
}
/>
)}
</div>
</CardBox>
</div>
<div className='xl:col-span-3'>
<CardBox className='h-full'>
{feedback ? (
<NotificationBar
color={feedback.color}
icon={feedback.color === 'success' ? mdiCheckCircleOutline : mdiAlertCircleOutline}
>
{feedback.message}
</NotificationBar>
) : null}
{!selectedDeviceId && !loadingDetail ? (
<EmptyState
title='Pick a device to inspect'
description='Select an item from the queue to review host details, heartbeat health, and the recent audit timeline.'
action={<BaseButton color='info' href='/devices/devices-list' label='Browse inventory' />}
/>
) : loadingDetail ? (
<div className='space-y-4'>
<div className='h-10 animate-pulse rounded-xl bg-gray-100 dark:bg-dark-800' />
<div className='grid gap-4 md:grid-cols-2'>
<div className='h-28 animate-pulse rounded-2xl bg-gray-100 dark:bg-dark-800' />
<div className='h-28 animate-pulse rounded-2xl bg-gray-100 dark:bg-dark-800' />
</div>
<div className='h-44 animate-pulse rounded-2xl bg-gray-100 dark:bg-dark-800' />
</div>
) : selectedDevice ? (
<>
<div className='mb-6 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
<div>
<div className='mb-2 flex flex-wrap items-center gap-2'>
<span className='rounded-full bg-blue-50 px-2.5 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-blue-700'>
Device review
</span>
<span className={`rounded-full px-2.5 py-1 text-xs font-semibold capitalize ${statusClasses[selectedDeviceStatus]}`}>
{selectedDeviceStatus}
</span>
<span className={`rounded-full px-2.5 py-1 text-xs font-semibold ${selectedDevice.is_online ? 'border border-emerald-200 bg-emerald-50 text-emerald-700' : 'border border-gray-200 bg-gray-50 text-gray-600'}`}>
{selectedDevice.is_online ? 'Online' : 'Offline'}
</span>
</div>
<h2 className='text-2xl font-semibold text-gray-900 dark:text-white'>
{selectedDevice.device_name || selectedDevice.computer_name || 'Unnamed device'}
</h2>
<p className='mt-2 text-sm text-gray-500 dark:text-gray-400'>
{selectedDevice.computer_name || 'No hostname yet'} {selectedDevice.current_username || 'No local user reported'}
</p>
</div>
<div className='flex flex-wrap gap-2'>
<BaseButton
color='whiteDark'
href={`/devices/devices-view/?id=${selectedDevice.id}`}
icon={mdiOpenInNew}
label='Device record'
/>
{canUpdateDevices ? (
<>
<BaseButton
color='info'
label={selectedDeviceStatus === 'approved' ? 'Approved' : 'Approve'}
disabled={selectedDeviceStatus === 'approved'}
onClick={() => openActionModal('approved')}
/>
<BaseButton
color='danger'
outline={selectedDeviceStatus !== 'blocked'}
label={selectedDeviceStatus === 'blocked' ? 'Blocked' : 'Block'}
disabled={selectedDeviceStatus === 'blocked'}
onClick={() => openActionModal('blocked')}
/>
</>
) : (
<span className='rounded-full border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-500 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-400'>
Read-only access
</span>
)}
</div>
</div>
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
{[
{ 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) => (
<div key={stat.label} className='rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-700 dark:bg-dark-800'>
<div className='mb-3 flex items-center justify-between'>
<div className='text-xs font-semibold uppercase tracking-[0.2em] text-gray-400 dark:text-gray-500'>
{stat.label}
</div>
<BaseIcon path={stat.icon} size={18} className='text-blue-500' />
</div>
<div className='text-lg font-semibold text-gray-900 dark:text-white'>{stat.value}</div>
</div>
))}
</div>
<BaseDivider />
<div className='grid gap-6 xl:grid-cols-2'>
<div>
<div className='mb-4'>
<h3 className='text-lg font-semibold'>Host profile</h3>
<p className='text-sm text-gray-500 dark:text-gray-400'>System identity, network details, and reviewer information.</p>
</div>
<div className='grid gap-4 sm:grid-cols-2'>
{[
{ 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) => (
<div key={item.label} className='rounded-2xl border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900'>
<div className='text-xs font-semibold uppercase tracking-[0.2em] text-gray-400 dark:text-gray-500'>
{item.label}
</div>
<div className='mt-2 break-words text-sm font-medium text-gray-800 dark:text-gray-100'>
{item.value}
</div>
</div>
))}
</div>
</div>
<div>
<div className='mb-4'>
<h3 className='text-lg font-semibold'>Connection history</h3>
<p className='text-sm text-gray-500 dark:text-gray-400'>Most recent sessions reported for this device.</p>
</div>
<div className='space-y-3'>
{recentSessions.length ? (
recentSessions.map((session) => (
<div key={session.id} className='rounded-2xl border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900'>
<div className='flex flex-wrap items-center justify-between gap-2'>
<div className='text-sm font-semibold text-gray-900 dark:text-white'>
{session.server_node || 'Primary node'}
</div>
<span className='rounded-full border border-gray-200 bg-gray-50 px-2.5 py-1 text-xs font-medium text-gray-600 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-300'>
{session.disconnect_reason || 'connected'}
</span>
</div>
<div className='mt-3 grid gap-3 text-sm text-gray-500 dark:text-gray-400 sm:grid-cols-2'>
<div>
<div className='text-xs uppercase tracking-[0.2em] text-gray-400 dark:text-gray-500'>Connected</div>
<div className='mt-1'>{formatDate(session.connected_at)}</div>
</div>
<div>
<div className='text-xs uppercase tracking-[0.2em] text-gray-400 dark:text-gray-500'>Disconnected</div>
<div className='mt-1'>{formatDate(session.disconnected_at)}</div>
</div>
</div>
</div>
))
) : (
<EmptyState
title='No device sessions yet'
description='Once the agent reports connect and disconnect cycles, the most recent sessions will appear here.'
/>
)}
</div>
</div>
</div>
{canReadSecurityEvents ? (
<>
<BaseDivider />
<div>
<div className='mb-4 flex flex-wrap items-center justify-between gap-3'>
<div>
<h3 className='text-lg font-semibold'>Audit timeline for this device</h3>
<p className='text-sm text-gray-500 dark:text-gray-400'>Approval changes, registrations, and other related events.</p>
</div>
<Link
href='/security_events/security_events-list'
className='inline-flex items-center gap-1 text-sm font-medium text-blue-600 transition hover:text-blue-700'
>
Full audit log
<BaseIcon path={mdiChevronRight} size={16} />
</Link>
</div>
<div className='space-y-3'>
{selectedDeviceEvents.length ? (
selectedDeviceEvents.map((event) => (
<div key={event.id} className='rounded-2xl border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900'>
<div className='flex flex-wrap items-start justify-between gap-3'>
<div>
<div className='text-sm font-semibold capitalize text-gray-900 dark:text-white'>
{(event.event_type || 'activity').replace(/_/g, ' ')}
</div>
<p className='mt-1 text-sm text-gray-500 dark:text-gray-400'>{getEventSummary(event)}</p>
</div>
<span className={`rounded-full px-2.5 py-1 text-xs font-semibold capitalize ${severityClasses[event.severity || 'info'] || severityClasses.info}`}>
{event.severity || 'info'}
</span>
</div>
<div className='mt-3 flex flex-wrap gap-4 text-xs uppercase tracking-[0.18em] text-gray-400 dark:text-gray-500'>
<span>{formatDate(event.occurred_at || event.createdAt)}</span>
<span>{event.source_ip || 'No source IP'}</span>
</div>
</div>
))
) : (
<EmptyState
title='No audit activity for this device yet'
description='Approval decisions and other security events will be listed here as the workflow expands.'
/>
)}
</div>
</div>
</>
) : null}
</>
) : (
<EmptyState
title='This device is no longer available'
description='Refresh the queue to pick another device or return to the inventory to inspect the full record.'
action={<BaseButton color='info' href='/devices/devices-list' label='Open inventory' />}
/>
)}
</CardBox>
</div>
</div>
{canReadSecurityEvents ? (
<CardBox className='mt-6'>
<div className='mb-4 flex flex-wrap items-center justify-between gap-3'>
<div>
<h2 className='text-xl font-semibold'>Recent audit activity</h2>
<p className='mt-1 text-sm text-gray-500 dark:text-gray-400'>A quick view of the latest security-relevant events across the admin panel.</p>
</div>
<BaseButton color='whiteDark' href='/security_events/security_events-list' label='Open audit log' />
</div>
{loadingEvents ? (
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-3'>
{Array.from({ length: 3 }).map((_, index) => (
<div key={`event-skeleton-${index}`} className='h-32 animate-pulse rounded-2xl bg-gray-100 dark:bg-dark-800' />
))}
</div>
) : recentEvents.length ? (
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-3'>
{recentEvents.map((event) => (
<div key={event.id} className='rounded-2xl border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900'>
<div className='mb-3 flex items-center justify-between gap-2'>
<span className={`rounded-full px-2.5 py-1 text-xs font-semibold capitalize ${severityClasses[event.severity || 'info'] || severityClasses.info}`}>
{event.severity || 'info'}
</span>
<span className='text-xs uppercase tracking-[0.2em] text-gray-400 dark:text-gray-500'>
{formatDate(event.occurred_at || event.createdAt)}
</span>
</div>
<div className='text-sm font-semibold capitalize text-gray-900 dark:text-white'>
{(event.event_type || 'activity').replace(/_/g, ' ')}
</div>
<p className='mt-2 text-sm text-gray-500 dark:text-gray-400'>
{getEventSummary(event)}
</p>
<div className='mt-4 text-xs uppercase tracking-[0.18em] text-gray-400 dark:text-gray-500'>
{event.device?.device_name || event.device?.computer_name || 'Security event'}
</div>
</div>
))}
</div>
) : (
<EmptyState
title='No audit events yet'
description='As approvals, logins, and enrollment actions occur, they will surface here for quick review.'
/>
)}
</CardBox>
) : null}
</SectionMain>
<CardBoxModal
title={actionState?.title || 'Confirm action'}
buttonColor={actionState?.status === 'blocked' ? 'danger' : 'info'}
buttonLabel={isSubmitting ? 'Saving…' : actionState?.status === 'blocked' ? 'Block device' : 'Approve device'}
isActive={Boolean(actionState)}
onConfirm={handleApprovalDecision}
onCancel={() => {
if (!isSubmitting) {
setActionState(null);
}
}}
>
<p className='text-sm text-gray-500 dark:text-gray-400'>
{actionState?.description}
</p>
<div className='rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-700 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300'>
<div className='font-semibold text-gray-900 dark:text-white'>
{selectedDevice?.device_name || selectedDevice?.computer_name || 'Selected device'}
</div>
<div className='mt-1'>
{selectedDevice?.computer_name || 'No hostname'} {selectedDevice?.current_username || 'No local user'}
</div>
</div>
</CardBoxModal>
</>
);
};
DeviceApprovalCenter.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated permission='READ_DEVICES'>{page}</LayoutAuthenticated>;
};
export default DeviceApprovalCenter;

View File

@ -1,166 +1,217 @@
import {
import React, { useEffect, useState } from 'react'; mdiAccessPointNetwork,
import type { ReactElement } from 'react'; mdiChevronRight,
mdiHistory,
mdiLockOutline,
mdiMonitorDashboard,
mdiShieldCheckOutline,
} from '@mdi/js';
import Head from 'next/head'; import Head from 'next/head';
import Link from 'next/link'; import Link from 'next/link';
import React from 'react';
import type { ReactElement } from 'react';
import BaseButton from '../components/BaseButton'; import BaseButton from '../components/BaseButton';
import BaseIcon from '../components/BaseIcon';
import CardBox from '../components/CardBox'; 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 { getPageTitle } from '../config';
import { useAppSelector } from '../stores/hooks'; import LayoutGuest from '../layouts/Guest';
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
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 workflow = [
const [illustrationImage, setIllustrationImage] = useState({ 'Enroll a device and capture host metadata.',
src: undefined, 'Review heartbeat and system identity in the approval queue.',
photographer: undefined, 'Approve only trusted computers into the authorized fleet.',
photographer_url: undefined, 'Track decisions in the audit timeline for follow-up and reporting.',
}) ];
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' export default function Home() {
// Fetch Pexels image/video
useEffect(() => {
async function fetchData() {
const image = await getPexelsImage();
const video = await getPexelsVideo();
setIllustrationImage(image);
setIllustrationVideo(video);
}
fetchData();
}, []);
const imageBlock = (image) => (
<div
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
style={{
backgroundImage: `${
image
? `url(${image?.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}
>
<div className='flex justify-center w-full bg-blue-300/20'>
<a
className='text-[8px]'
href={image?.photographer_url}
target='_blank'
rel='noreferrer'
>
Photo by {image?.photographer} on Pexels
</a>
</div>
</div>
);
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return ( return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'> <>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video?.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video?.user?.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
};
return (
<div
style={
contentPosition === 'background'
? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}
: {}
}
>
<Head> <Head>
<title>{getPageTitle('Starter Page')}</title> <title>{getPageTitle('Remote Admin System')}</title>
</Head> </Head>
<SectionFullScreen bg='violet'> <div className='min-h-screen bg-slate-950 text-white'>
<div <div className='mx-auto max-w-7xl px-6 py-6 lg:px-8'>
className={`flex ${ <header className='flex flex-col gap-4 rounded-2xl border border-white/10 bg-white/5 px-5 py-4 backdrop-blur lg:flex-row lg:items-center lg:justify-between'>
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row' <div>
} min-h-screen w-full`} <Link href='/' className='text-lg font-semibold tracking-wide text-white'>
Remote Admin System
</Link>
<p className='mt-1 text-sm text-slate-300'>Secure web operations for approved computers and admin-reviewed access.</p>
</div>
<div className='flex flex-wrap gap-2'>
<BaseButton color='whiteDark' href='/dashboard' label='Admin interface' />
<BaseButton color='info' href='/login' label='Login' />
</div>
</header>
<main className='py-14 md:py-20'>
<section className='grid items-center gap-10 xl:grid-cols-[minmax(0,1.08fr)_minmax(0,0.92fr)]'>
<div>
<div className='inline-flex items-center gap-2 rounded-full border border-sky-400/20 bg-sky-400/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.22em] text-sky-200'>
<BaseIcon path={mdiShieldCheckOutline} size={18} />
First-day control center
</div>
<h1 className='mt-6 max-w-4xl text-4xl font-semibold tracking-tight text-white md:text-6xl'>
Modern remote administration starts with trusted enrollment and a clean audit trail.
</h1>
<p className='mt-6 max-w-2xl text-base leading-8 text-slate-300 md:text-lg'>
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.
</p>
<div className='mt-8 flex flex-wrap gap-3'>
<BaseButton color='info' href='/login' label='Open secure login' />
<BaseButton color='whiteDark' href='/dashboard' label='Go to admin panel' />
</div>
<div className='mt-10 grid gap-3 sm:grid-cols-3'>
{[
{ label: 'Secure access', value: 'JWT login' },
{ label: 'Device review', value: 'Approval queue' },
{ label: 'Traceability', value: 'Audit history' },
].map((item) => (
<div key={item.label} className='rounded-2xl border border-white/10 bg-white/5 px-4 py-4'>
<div className='text-xs uppercase tracking-[0.2em] text-slate-400'>{item.label}</div>
<div className='mt-2 text-xl font-semibold text-white'>{item.value}</div>
</div>
))}
</div>
</div>
<CardBox
isList
className='overflow-hidden border border-white/10 bg-slate-900/90 text-white shadow-2xl shadow-black/20 backdrop-blur'
cardBoxClassName='p-0'
> >
{contentType === 'image' && contentPosition !== 'background' <div className='relative overflow-hidden px-6 py-6'>
? imageBlock(illustrationImage) <div className='absolute -left-10 top-0 h-36 w-36 rounded-full bg-sky-400/20 blur-3xl' />
: null} <div className='absolute right-0 top-8 h-44 w-44 rounded-full bg-violet-500/20 blur-3xl' />
{contentType === 'video' && contentPosition !== 'background' <div className='relative'>
? videoBlock(illustrationVideo) <div className='mb-6 flex items-center justify-between'>
: null} <div>
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'> <p className='text-xs font-semibold uppercase tracking-[0.2em] text-slate-400'>Control preview</p>
<CardBox className='w-full md:w-3/5 lg:w-2/3'> <h2 className='mt-2 text-2xl font-semibold'>Approval center</h2>
<CardBoxComponentTitle title="Welcome to your Remote Admin System app!"/> </div>
<div className='flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/5'>
<div className="space-y-3"> <BaseIcon path={mdiMonitorDashboard} size={24} />
<p className='text-center '>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p> </div>
<p className='text-center '>For guides and documentation please check
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
</div> </div>
<BaseButtons> <div className='grid gap-3 sm:grid-cols-2'>
<BaseButton <div className='rounded-2xl border border-amber-400/20 bg-amber-400/10 p-4 text-amber-100'>
href='/login' <div className='text-xs uppercase tracking-[0.2em] text-amber-200/80'>Pending review</div>
label='Login' <div className='mt-2 text-3xl font-semibold'>2</div>
color='info' <p className='mt-2 text-sm text-amber-100/80'>New devices waiting for an approval decision.</p>
className='w-full' </div>
/> <div className='rounded-2xl border border-emerald-400/20 bg-emerald-400/10 p-4 text-emerald-100'>
<div className='text-xs uppercase tracking-[0.2em] text-emerald-200/80'>Authorized fleet</div>
<div className='mt-2 text-3xl font-semibold'>Ready</div>
<p className='mt-2 text-sm text-emerald-100/80'>Approved devices stay clearly separated from blocked ones.</p>
</div>
</div>
</BaseButtons> <div className='mt-6 rounded-2xl border border-white/10 bg-white/5 p-4'>
<div className='mb-4 flex items-center justify-between'>
<div className='text-sm font-semibold text-white'>Today&apos;s workflow</div>
<div className='inline-flex items-center gap-1 text-xs uppercase tracking-[0.2em] text-sky-200'>
Live queue
<BaseIcon path={mdiChevronRight} size={14} />
</div>
</div>
<div className='space-y-3'>
{workflow.slice(0, 3).map((item, index) => (
<div key={item} className='flex items-start gap-3 rounded-xl border border-white/10 bg-slate-950/40 px-3 py-3'>
<div className='flex h-8 w-8 items-center justify-center rounded-full bg-sky-400/15 text-sm font-semibold text-sky-200'>
{index + 1}
</div>
<p className='text-sm leading-6 text-slate-300'>{item}</p>
</div>
))}
</div>
</div>
</div>
</div>
</CardBox> </CardBox>
</section>
<section className='mt-16 grid gap-4 md:grid-cols-3'>
{highlights.map((item) => (
<CardBox key={item.title} isList className='border border-white/10 bg-white/5 text-white backdrop-blur'>
<div className='flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/5 text-sky-200'>
<BaseIcon path={item.icon} size={22} />
</div>
<h3 className='mt-5 text-xl font-semibold'>{item.title}</h3>
<p className='mt-3 text-sm leading-7 text-slate-300'>{item.description}</p>
</CardBox>
))}
</section>
<section className='mt-16 rounded-3xl border border-white/10 bg-gradient-to-br from-slate-900 via-slate-900 to-slate-950 px-6 py-8'>
<div className='grid gap-8 lg:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] lg:items-center'>
<div>
<div className='inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-slate-300'>
<BaseIcon path={mdiLockOutline} size={16} />
Built for safe admin flow
</div>
<h2 className='mt-4 text-3xl font-semibold'>What the first delivery gives you immediately</h2>
<p className='mt-3 text-sm leading-7 text-slate-300'>
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.
</p>
</div>
<div className='grid gap-3 md:grid-cols-2'>
{workflow.map((step, index) => (
<div key={step} className='rounded-2xl border border-white/10 bg-white/5 p-4'>
<div className='text-xs uppercase tracking-[0.2em] text-slate-400'>Step {index + 1}</div>
<p className='mt-3 text-sm leading-7 text-slate-200'>{step}</p>
</div>
))}
</div> </div>
</div> </div>
</SectionFullScreen> </section>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'> </main>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p> </div>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
<footer className='border-t border-white/10 bg-slate-950/80'>
<div className='mx-auto flex max-w-7xl flex-col gap-3 px-6 py-6 text-sm text-slate-400 md:flex-row md:items-center md:justify-between lg:px-8'>
<p>© 2026 Remote Admin System. Secure access for approved devices only.</p>
<div className='flex flex-wrap items-center gap-4'>
<Link href='/dashboard' className='transition hover:text-white'>
Admin interface
</Link>
<Link href='/login' className='transition hover:text-white'>
Login
</Link>
<Link href='/privacy-policy/' className='transition hover:text-white'>
Privacy Policy Privacy Policy
</Link> </Link>
</div> </div>
</div> </div>
</footer>
</div>
</>
); );
} }
Starter.getLayout = function getLayout(page: ReactElement) { Home.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>; return <LayoutGuest>{page}</LayoutGuest>;
}; };