Autosave: 20260515-131832
This commit is contained in:
parent
988cc71aad
commit
9acc94b352
BIN
assets/pasted-20260515-131818-8a9b448d.png
Normal file
BIN
assets/pasted-20260515-131818-8a9b448d.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 195 KiB |
@ -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}:
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 = () => {
|
||||
{''}
|
||||
</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
|
||||
currentUser={currentUser}
|
||||
isFetchingQuery={isFetchingQuery}
|
||||
|
||||
984
frontend/src/pages/device-approval-center.tsx
Normal file
984
frontend/src/pages/device-approval-center.tsx
Normal 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;
|
||||
@ -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) => (
|
||||
<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 (
|
||||
<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>)
|
||||
}
|
||||
};
|
||||
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 (
|
||||
<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>
|
||||
<title>{getPageTitle('Starter Page')}</title>
|
||||
<title>{getPageTitle('Remote Admin System')}</title>
|
||||
</Head>
|
||||
|
||||
<SectionFullScreen bg='violet'>
|
||||
<div
|
||||
className={`flex ${
|
||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
||||
} min-h-screen w-full`}
|
||||
>
|
||||
{contentType === 'image' && contentPosition !== 'background'
|
||||
? imageBlock(illustrationImage)
|
||||
: null}
|
||||
{contentType === 'video' && contentPosition !== 'background'
|
||||
? videoBlock(illustrationVideo)
|
||||
: null}
|
||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
||||
<CardBoxComponentTitle title="Welcome to your Remote Admin System app!"/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<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>
|
||||
<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 className='min-h-screen bg-slate-950 text-white'>
|
||||
<div className='mx-auto max-w-7xl px-6 py-6 lg:px-8'>
|
||||
<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'>
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<BaseButtons>
|
||||
<BaseButton
|
||||
href='/login'
|
||||
label='Login'
|
||||
color='info'
|
||||
className='w-full'
|
||||
/>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<BaseButton color='whiteDark' href='/dashboard' label='Admin interface' />
|
||||
<BaseButton color='info' href='/login' label='Login' />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
</BaseButtons>
|
||||
</CardBox>
|
||||
<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'
|
||||
>
|
||||
<div className='relative overflow-hidden px-6 py-6'>
|
||||
<div className='absolute -left-10 top-0 h-36 w-36 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'>
|
||||
<div className='mb-6 flex items-center justify-between'>
|
||||
<div>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.2em] text-slate-400'>Control preview</p>
|
||||
<h2 className='mt-2 text-2xl font-semibold'>Approval center</h2>
|
||||
</div>
|
||||
<div className='flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/5'>
|
||||
<BaseIcon path={mdiMonitorDashboard} size={24} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-3 sm:grid-cols-2'>
|
||||
<div className='rounded-2xl border border-amber-400/20 bg-amber-400/10 p-4 text-amber-100'>
|
||||
<div className='text-xs uppercase tracking-[0.2em] text-amber-200/80'>Pending review</div>
|
||||
<div className='mt-2 text-3xl font-semibold'>2</div>
|
||||
<p className='mt-2 text-sm text-amber-100/80'>New devices waiting for an approval decision.</p>
|
||||
</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>
|
||||
|
||||
<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'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>
|
||||
</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>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</SectionFullScreen>
|
||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
|
||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<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
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||
Home.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user