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);
|
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}:
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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'
|
||||||
|
|||||||
@ -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'
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
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 {
|
||||||
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'
|
|
||||||
|
|
||||||
// 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>)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
return (
|
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
|
||||||
{contentType === 'image' && contentPosition !== 'background'
|
</Link>
|
||||||
? imageBlock(illustrationImage)
|
<p className='mt-1 text-sm text-slate-300'>Secure web operations for approved computers and admin-reviewed access.</p>
|
||||||
: 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>
|
</div>
|
||||||
|
<div className='flex flex-wrap gap-2'>
|
||||||
<BaseButtons>
|
<BaseButton color='whiteDark' href='/dashboard' label='Admin interface' />
|
||||||
<BaseButton
|
<BaseButton color='info' href='/login' label='Login' />
|
||||||
href='/login'
|
</div>
|
||||||
label='Login'
|
</header>
|
||||||
color='info'
|
|
||||||
className='w-full'
|
|
||||||
/>
|
|
||||||
|
|
||||||
</BaseButtons>
|
<main className='py-14 md:py-20'>
|
||||||
</CardBox>
|
<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>
|
||||||
</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>;
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user