Compare commits

...

6 Commits

Author SHA1 Message Date
Flatlogic Bot
c06b7851ce 1.6 2026-04-07 15:51:14 +00:00
Flatlogic Bot
818e7d5818 Autosave: 20260407-151714 2026-04-07 15:17:14 +00:00
Flatlogic Bot
e34c261c25 1.5 2026-04-05 18:41:17 +00:00
Flatlogic Bot
1908bd1eca 1.3 2026-04-05 18:30:20 +00:00
Flatlogic Bot
68e6ac5ac1 Autosave: 20260405-182715 2026-04-05 18:27:15 +00:00
Flatlogic Bot
d3ec77b828 1.0 2026-04-05 17:48:33 +00:00
16 changed files with 5700 additions and 241 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View File

@ -6,7 +6,6 @@ const passport = require('passport');
const path = require('path');
const fs = require('fs');
const bodyParser = require('body-parser');
const db = require('./db/models');
const config = require('./config');
const swaggerUI = require('swagger-ui-express');
const swaggerJsDoc = require('swagger-jsdoc');
@ -50,6 +49,8 @@ const media_streamsRoutes = require('./routes/media_streams');
const announcementsRoutes = require('./routes/announcements');
const assetsRoutes = require('./routes/assets');
const maiServerRoutes = require('./routes/maiServer');
const maiServerPublicRoutes = require('./routes/maiServerPublic');
const getBaseUrl = (url) => {
@ -139,6 +140,9 @@ app.use('/api/announcements', passport.authenticate('jwt', {session: false}), an
app.use('/api/assets', passport.authenticate('jwt', {session: false}), assetsRoutes);
app.use('/api/mai-server-public', maiServerPublicRoutes);
app.use('/api/mai-server', passport.authenticate('jwt', {session: false}), maiServerRoutes);
app.use(
'/api/openai',
passport.authenticate('jwt', { session: false }),

View File

@ -1,26 +1,23 @@
const express = require('express');
const config = require('../config');
const path = require('path');
const passport = require('passport');
const services = require('../services/file');
const router = express.Router();
router.get('/download', (req, res) => {
if (process.env.NODE_ENV == "production" || process.env.NEXT_PUBLIC_BACK_API) {
if (process.env.NODE_ENV == 'production' || process.env.NEXT_PUBLIC_BACK_API) {
services.downloadGCloud(req, res);
}
else {
} else {
services.downloadLocal(req, res);
}
});
router.post('/upload/:table/:field', passport.authenticate('jwt', {session: false}), (req, res) => {
router.post('/upload/:table/:field', passport.authenticate('jwt', { session: false }), (req, res) => {
const fileName = `${req.params.table}/${req.params.field}`;
if (process.env.NODE_ENV == "production" || process.env.NEXT_PUBLIC_BACK_API) {
if (process.env.NODE_ENV == 'production' || process.env.NEXT_PUBLIC_BACK_API) {
services.uploadGCloud(fileName, req, res);
}
else {
} else {
services.uploadLocal(fileName, {
entity: null,
maxFileSize: 10 * 1024 * 1024,
@ -29,4 +26,39 @@ router.post('/upload/:table/:field', passport.authenticate('jwt', {session: fals
}
});
router.post('/upload-public/:table/:field', (req, res) => {
const fileName = `${req.params.table}/${req.params.field}`;
if (process.env.NODE_ENV == 'production' || process.env.NEXT_PUBLIC_BACK_API) {
services.uploadGCloud(fileName, req, res);
} else {
services.uploadLocal(fileName, {
entity: null,
maxFileSize: 50 * 1024 * 1024,
folderIncludesAuthenticationUid: false,
allowAnonymous: true,
allowedMimePrefixes: ['audio/', 'video/'],
})(req, res);
}
});
router.post('/fetch-public-playlist', express.json({ limit: '100kb' }), async (req, res) => {
const playlistUrl = typeof req.body?.url === 'string' ? req.body.url.trim() : '';
if (!playlistUrl) {
res.status(400).send({ message: 'A playlist URL is required.' });
return;
}
try {
const result = await services.fetchPublicTextFile(playlistUrl);
res.status(200).send(result);
} catch (error) {
console.error('Failed to fetch public playlist URL:', { url: playlistUrl, error: error.message || error });
res.status(error.statusCode || 500).send({
message: error.message || 'Failed to fetch the remote playlist URL.',
});
}
});
module.exports = router;

View File

@ -0,0 +1,47 @@
const express = require('express');
const MaiServerService = require('../services/maiServer');
const wrapAsync = require('../helpers').wrapAsync;
const router = express.Router();
function getHostFromRequest(req) {
const referer = req.headers.referer || `${req.protocol}://${req.get('host')}${req.originalUrl}`;
return new URL(referer).host;
}
router.get(
'/overview',
wrapAsync(async (req, res) => {
const payload = await MaiServerService.getOverview(req.currentUser, { range: req.query.range });
res.status(200).send(payload);
}),
);
router.post(
'/enable-authentication',
wrapAsync(async (req, res) => {
const payload = await MaiServerService.enableAuthentication(req.currentUser, getHostFromRequest(req));
res.status(200).send(payload);
}),
);
router.post(
'/send-verification-email',
wrapAsync(async (req, res) => {
const payload = await MaiServerService.sendVerificationEmail(req.currentUser, getHostFromRequest(req));
res.status(200).send(payload);
}),
);
router.post(
'/send-password-reset-email',
wrapAsync(async (req, res) => {
const payload = await MaiServerService.sendPasswordResetEmail(req.currentUser, getHostFromRequest(req));
res.status(200).send(payload);
}),
);
router.use('/', require('../helpers').commonErrorHandler);
module.exports = router;

View File

@ -0,0 +1,18 @@
const express = require('express');
const MaiServerService = require('../services/maiServer');
const wrapAsync = require('../helpers').wrapAsync;
const router = express.Router();
router.get(
'/overview',
wrapAsync(async (req, res) => {
const payload = await MaiServerService.getPublicOverview({ range: req.query.range });
res.status(200).send(payload);
}),
);
router.use('/', require('../helpers').commonErrorHandler);
module.exports = router;

View File

@ -1,8 +1,13 @@
const axios = require('axios');
const dns = require('dns').promises;
const formidable = require('formidable');
const fs = require('fs');
const net = require('net');
const config = require('../config');
const path = require('path');
const { format } = require("util");
const { format } = require('util');
const MAX_REMOTE_PLAYLIST_BYTES = 2 * 1024 * 1024;
const ensureDirectoryExistence = (filePath) => {
const dirname = path.dirname(filePath);
@ -13,7 +18,39 @@ const ensureDirectoryExistence = (filePath) => {
ensureDirectoryExistence(dirname);
fs.mkdirSync(dirname);
}
};
const getUploadedFile = (files) => {
if (!files || !files.file) {
return null;
}
return Array.isArray(files.file) ? files.file[0] : files.file;
};
const getUploadedFilePath = (file) => {
if (!file) {
return null;
}
return file.filepath || file.path || null;
};
const getUploadedFileType = (file) => {
if (!file) {
return '';
}
return file.mimetype || file.type || '';
};
const isMimeTypeAllowed = (mimeType, validations) => {
if (!validations || !validations.allowedMimePrefixes || validations.allowedMimePrefixes.length === 0) {
return true;
}
return validations.allowedMimePrefixes.some((prefix) => mimeType.startsWith(prefix));
};
const uploadLocal = (
folder,
@ -21,30 +58,24 @@ const uploadLocal = (
entity: null,
maxFileSize: null,
folderIncludesAuthenticationUid: false,
allowAnonymous: false,
allowedMimePrefixes: null,
},
) => {
return (req, res) => {
if (!req.currentUser) {
if (!req.currentUser && !validations.allowAnonymous) {
res.sendStatus(403);
return;
}
if (
validations.entity
) {
if (validations.entity) {
res.sendStatus(403);
return;
}
if (validations.folderIncludesAuthenticationUid) {
folder = folder.replace(
':userId',
req.currentUser.authenticationUid,
);
if (
!req.currentUser.authenticationUid ||
!folder.includes(req.currentUser.authenticationUid)
) {
folder = folder.replace(':userId', req.currentUser.authenticationUid);
if (!req.currentUser.authenticationUid || !folder.includes(req.currentUser.authenticationUid)) {
res.sendStatus(403);
return;
}
@ -58,140 +89,311 @@ const uploadLocal = (
}
form.parse(req, function (err, fields, files) {
const filename = String(fields.filename);
const fileTempUrl = files.file.path;
if (err) {
console.error('File upload parse failed:', err);
res.status(500).send(err);
return;
}
if (!filename) {
const filename = String(fields.filename);
const uploadedFile = getUploadedFile(files);
const fileTempUrl = getUploadedFilePath(uploadedFile);
const mimeType = getUploadedFileType(uploadedFile);
if (!fileTempUrl || !uploadedFile) {
res.status(400).send({ message: 'Please upload a file.' });
return;
}
if (!isMimeTypeAllowed(mimeType, validations)) {
fs.unlinkSync(fileTempUrl);
res.status(400).send({ message: 'Invalid file type.' });
return;
}
if (!filename || filename === 'undefined') {
fs.unlinkSync(fileTempUrl);
res.sendStatus(500);
return;
}
const privateUrl = path.join(
form.uploadDir,
folder,
filename,
);
ensureDirectoryExistence(privateUrl);
fs.renameSync(fileTempUrl, privateUrl);
res.sendStatus(200);
const privateUrl = path.join(folder, filename);
const destinationPath = path.join(form.uploadDir, privateUrl);
ensureDirectoryExistence(destinationPath);
fs.renameSync(fileTempUrl, destinationPath);
res.status(200).send({ privateUrl });
});
form.on('error', function (err) {
console.error('File upload failed:', err);
res.status(500).send(err);
});
}
}
};
};
const downloadLocal = async (req, res) => {
const privateUrl = req.query.privateUrl;
if (!privateUrl) {
return res.sendStatus(404);
const privateUrl = req.query.privateUrl;
if (!privateUrl) {
return res.sendStatus(404);
}
return res.download(path.join(config.uploadDir, privateUrl));
};
const isPrivateIpv4 = (ip) => {
const [firstOctet, secondOctet] = ip.split('.').map((segment) => Number(segment));
if (firstOctet === 10 || firstOctet === 127 || firstOctet === 0) {
return true;
}
if (firstOctet === 169 && secondOctet === 254) {
return true;
}
if (firstOctet === 172 && secondOctet >= 16 && secondOctet <= 31) {
return true;
}
if (firstOctet === 192 && secondOctet === 168) {
return true;
}
return false;
};
const isPrivateIpv6 = (ip) => {
const loweredIp = ip.toLowerCase();
return (
loweredIp === '::1' ||
loweredIp === '::' ||
loweredIp.startsWith('fc') ||
loweredIp.startsWith('fd') ||
loweredIp.startsWith('fe80')
);
};
const isPrivateIp = (ip) => {
const family = net.isIP(ip);
if (family === 4) {
return isPrivateIpv4(ip);
}
if (family === 6) {
return isPrivateIpv6(ip);
}
return true;
};
const assertRemoteUrlIsPublic = async (remoteUrl) => {
let parsedUrl;
try {
parsedUrl = new URL(remoteUrl);
} catch (error) {
const invalidUrlError = new Error('Use a valid http:// or https:// playlist URL.');
invalidUrlError.statusCode = 400;
throw invalidUrlError;
}
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
const invalidProtocolError = new Error('Only http:// and https:// playlist URLs are supported.');
invalidProtocolError.statusCode = 400;
throw invalidProtocolError;
}
const hostname = parsedUrl.hostname.toLowerCase();
if (hostname === 'localhost' || hostname.endsWith('.local')) {
const localhostError = new Error('Local network playlist URLs are not allowed.');
localhostError.statusCode = 400;
throw localhostError;
}
if (net.isIP(hostname) && isPrivateIp(hostname)) {
const privateIpError = new Error('Private network playlist URLs are not allowed.');
privateIpError.statusCode = 400;
throw privateIpError;
}
const resolvedAddresses = await dns.lookup(parsedUrl.hostname, { all: true });
if (!resolvedAddresses.length) {
const dnsError = new Error('Could not resolve the playlist host.');
dnsError.statusCode = 400;
throw dnsError;
}
if (resolvedAddresses.some((entry) => isPrivateIp(entry.address))) {
const resolvedPrivateIpError = new Error('Playlist host resolves to a private network address.');
resolvedPrivateIpError.statusCode = 400;
throw resolvedPrivateIpError;
}
return parsedUrl.toString();
};
const fetchPublicTextFile = async (remoteUrl) => {
const safeUrl = await assertRemoteUrlIsPublic(remoteUrl);
try {
const response = await axios.get(safeUrl, {
responseType: 'text',
timeout: 15000,
maxContentLength: MAX_REMOTE_PLAYLIST_BYTES,
maxBodyLength: MAX_REMOTE_PLAYLIST_BYTES,
transformResponse: [(data) => data],
headers: {
Accept: 'application/json, application/x-mpegURL, application/vnd.apple.mpegurl, audio/mpegurl, text/plain;q=0.9,*/*;q=0.5',
},
});
if (typeof response.data !== 'string') {
const responseTypeError = new Error('The remote playlist must return text-based JSON, M3U, or M3U8 content.');
responseTypeError.statusCode = 400;
throw responseTypeError;
}
res.download(path.join(config.uploadDir, privateUrl));
}
return {
url: safeUrl,
body: response.data,
contentType: response.headers['content-type'] || '',
};
} catch (error) {
if (error.response) {
console.error('Remote playlist fetch failed:', {
url: safeUrl,
status: error.response.status,
data: error.response.data,
});
const remoteResponseError = new Error(`Remote server responded with status ${error.response.status}.`);
remoteResponseError.statusCode = error.response.status;
throw remoteResponseError;
}
if (error.code === 'ERR_BAD_RESPONSE' || error.code === 'ERR_BAD_REQUEST') {
console.error('Remote playlist response handling failed:', { url: safeUrl, message: error.message });
const remoteBodyError = new Error('The remote playlist response could not be processed.');
remoteBodyError.statusCode = 400;
throw remoteBodyError;
}
if (error.code === 'ECONNABORTED') {
console.error('Remote playlist request timed out:', { url: safeUrl, message: error.message });
const timeoutError = new Error('Timed out while fetching the remote playlist.');
timeoutError.statusCode = 504;
throw timeoutError;
}
if (error.message && error.message.includes('maxContentLength')) {
console.error('Remote playlist exceeded size limit:', { url: safeUrl, message: error.message });
const sizeError = new Error('Remote playlist is larger than the 2 MB import limit.');
sizeError.statusCode = 413;
throw sizeError;
}
if (error.statusCode) {
throw error;
}
console.error('Remote playlist fetch failed:', { url: safeUrl, error });
const unknownError = new Error('Failed to fetch the remote playlist URL.');
unknownError.statusCode = 500;
throw unknownError;
}
};
const initGCloud = () => {
const processFile = require("../middlewares/upload");
const { Storage } = require("@google-cloud/storage");
const processFile = require('../middlewares/upload');
const { Storage } = require('@google-cloud/storage');
const crypto = require('crypto')
const hash = config.gcloud.hash
const privateKey = process.env.GC_PRIVATE_KEY.replace(/\\\n/g, "\n");
const hash = config.gcloud.hash;
const privateKey = process.env.GC_PRIVATE_KEY.replace(/\\\n/g, '\n');
const storage = new Storage({
projectId: process.env.GC_PROJECT_ID,
credentials: {
client_email: process.env.GC_CLIENT_EMAIL,
private_key: privateKey
}
projectId: process.env.GC_PROJECT_ID,
credentials: {
client_email: process.env.GC_CLIENT_EMAIL,
private_key: privateKey,
},
});
const bucket = storage.bucket(config.gcloud.bucket);
return {hash, bucket, processFile};
}
return { hash, bucket, processFile };
};
const uploadGCloud = async (folder, req, res) => {
try {
const {hash, bucket, processFile} = initGCloud();
const { hash, bucket, processFile } = initGCloud();
await processFile(req, res);
let buffer = await req.file.buffer;
let filename = await req.body.filename;
const buffer = await req.file.buffer;
const filename = await req.body.filename;
if (!req.file) {
return res.status(400).send({ message: "Please upload a file!" });
return res.status(400).send({ message: 'Please upload a file!' });
}
let path = `${hash}/${folder}/${filename}`;
let blob = bucket.file(path);
console.log(path);
const fullPath = `${hash}/${folder}/${filename}`;
const blob = bucket.file(fullPath);
const blobStream = blob.createWriteStream({
resumable: false,
});
blobStream.on("error", (err) => {
console.log('Upload error');
console.log(err.message);
blobStream.on('error', (err) => {
console.error('Upload error:', err.message);
res.status(500).send({ message: err.message });
});
console.log(`https://storage.googleapis.com/${bucket.name}/${blob.name}`);
blobStream.on("finish", async (data) => {
const publicUrl = format(
`https://storage.googleapis.com/${bucket.name}/${blob.name}`
);
blobStream.on('finish', async () => {
const publicUrl = format(`https://storage.googleapis.com/${bucket.name}/${blob.name}`);
res.status(200).send({
message: "Uploaded the file successfully: " + path,
message: `Uploaded the file successfully: ${fullPath}`,
url: publicUrl,
});
});
blobStream.end(buffer)
blobStream.end(buffer);
} catch (err) {
console.log(err);
console.error('Could not upload the file:', err);
res.status(500).send({
message: `Could not upload the file. ${err}`
message: `Could not upload the file. ${err}`,
});
}
}
};
const downloadGCloud = async (req, res) => {
try {
const {hash, bucket, processFile} = initGCloud();
const { hash, bucket } = initGCloud();
const privateUrl = await req.query.privateUrl;
const filePath = `${hash}/${privateUrl}`;
const file = bucket.file(filePath)
const file = bucket.file(filePath);
const fileExists = await file.exists();
if (fileExists[0]) {
const stream = file.createReadStream();
stream.pipe(res);
}
else {
} else {
res.status(404).send({
message: "Could not download the file. " + err,
});
message: 'Could not download the requested file.',
});
}
} catch (err) {
res.status(404).send({
message: "Could not download the file. " + err,
message: `Could not download the file. ${err}`,
});
}
}
};
const deleteGCloud = async (privateUrl) => {
try {
const {hash, bucket, processFile} = initGCloud();
const { hash, bucket } = initGCloud();
const filePath = `${hash}/${privateUrl}`;
const file = bucket.file(filePath)
const file = bucket.file(filePath);
const fileExists = await file.exists();
if (fileExists[0]) {
@ -200,7 +402,7 @@ const deleteGCloud = async (privateUrl) => {
} catch (err) {
console.log(`Cannot find the file ${privateUrl}`);
}
}
};
module.exports = {
initGCloud,
@ -208,6 +410,6 @@ module.exports = {
downloadLocal,
deleteGCloud,
uploadGCloud,
downloadGCloud
}
downloadGCloud,
fetchPublicTextFile,
};

View File

@ -0,0 +1,651 @@
const db = require('../db/models');
const UsersDBApi = require('../db/api/users');
const UsersService = require('./users');
const AuthService = require('./auth');
const EmailSender = require('./email');
const { QueryTypes, Op } = db.Sequelize;
const PERMISSIONS = {
users: 'READ_USERS',
conversations: 'READ_CONVERSATIONS',
messages: 'READ_MESSAGES',
announcements: 'READ_ANNOUNCEMENTS',
projects: 'READ_PROJECTS',
pages: 'READ_PAGES',
linkCollections: 'READ_LINK_COLLECTIONS',
externalLinks: 'READ_EXTERNAL_LINKS',
widgets: 'READ_WIDGETS',
agentEndpoints: 'READ_AGENT_ENDPOINTS',
mediaChannels: 'READ_MEDIA_CHANNELS',
mediaStreams: 'READ_MEDIA_STREAMS',
assets: 'READ_ASSETS',
};
const ALLOWED_RANGES = new Set([7, 30, 90]);
function hasPermission(user, permissionName) {
if (!user?.app_role?.name) {
return false;
}
if (!permissionName) {
return true;
}
if (user.app_role.name === 'Administrator') {
return true;
}
const permissions = new Set([
...(Array.isArray(user.custom_permissions) ? user.custom_permissions : []).map((permission) => permission.name),
...(Array.isArray(user.app_role_permissions) ? user.app_role_permissions : []).map((permission) => permission.name),
]);
return permissions.has(permissionName);
}
function normalizeRange(range) {
const parsedRange = Number.parseInt(range, 10);
return ALLOWED_RANGES.has(parsedRange) ? parsedRange : 7;
}
function formatDateKey(date) {
return date.toISOString().slice(0, 10);
}
function formatDateLabel(date) {
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
timeZone: 'UTC',
});
}
function buildDateWindow(days = 7) {
const dates = [];
const cursor = new Date();
cursor.setUTCHours(0, 0, 0, 0);
cursor.setUTCDate(cursor.getUTCDate() - (days - 1));
for (let index = 0; index < days; index += 1) {
dates.push(new Date(cursor));
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
return dates;
}
function buildRange(days) {
return {
days,
label: `Last ${days} days`,
};
}
function buildAuthStatus(user) {
const emailConfigured = EmailSender.isConfigured;
const isAuthenticationEnabled = !user?.disabled && (user?.emailVerified || !emailConfigured);
const fullName = [user?.firstName, user?.lastName].filter(Boolean).join(' ').trim();
return {
displayName: fullName || user?.email || 'Authenticated user',
email: user?.email || '',
provider: user?.provider || 'local',
roleName: user?.app_role?.name || 'Workspace member',
country: typeof user?.country === 'string' && user.country ? user.country : 'Not configured',
emailVerified: Boolean(user?.emailVerified),
disabled: Boolean(user?.disabled),
emailConfigured,
isAuthenticationEnabled,
authenticationStatusLabel: isAuthenticationEnabled ? 'Enabled for this account' : 'Action required',
verificationStatusLabel: user?.emailVerified
? 'Verified'
: emailConfigured
? 'Verification pending'
: 'Verification bypassed (email disabled)',
actionLabel: isAuthenticationEnabled ? 'Authentication enabled' : 'Enable authentication',
};
}
async function countByDate(tableName, startDate) {
return db.sequelize.query(
`
SELECT DATE("createdAt")::text AS bucket, COUNT(*)::int AS count
FROM "${tableName}"
WHERE "deletedAt" IS NULL
AND "createdAt" >= :startDate
GROUP BY DATE("createdAt")
ORDER BY DATE("createdAt") ASC
`,
{
replacements: { startDate },
type: QueryTypes.SELECT,
},
);
}
function mapSeriesToWindow(windowDates, rows) {
const counts = new Map(rows.map((row) => [row.bucket, Number(row.count || 0)]));
return windowDates.map((date) => counts.get(formatDateKey(date)) || 0);
}
async function getGroupedCounts(model, field, startDate, extraWhere = {}) {
return model.findAll({
attributes: [
[db.sequelize.col(field), 'label'],
[db.sequelize.fn('COUNT', db.sequelize.col('id')), 'value'],
],
where: {
deletedAt: null,
createdAt: {
[Op.gte]: startDate,
},
...extraWhere,
},
group: [field],
raw: true,
});
}
async function countSince(model, startDate, extraWhere = {}) {
return model.count({
where: {
deletedAt: null,
createdAt: {
[Op.gte]: startDate,
},
...extraWhere,
},
});
}
function sanitizeGroupedRows(rows) {
return rows
.map((row) => ({
label: row.label || 'Unspecified',
value: Number(row.value || 0),
}))
.filter((row) => row.value > 0)
.sort((left, right) => right.value - left.value);
}
function formatRecentProfiles(rows) {
return rows.map((row) => ({
id: row.id,
name: [row.firstName, row.lastName].filter(Boolean).join(' ').trim() || row.email,
email: row.email,
provider: row.provider || 'local',
createdAt: row.createdAt,
statusLabel: row.disabled ? 'Disabled' : row.emailVerified ? 'Verified' : 'Pending verification',
}));
}
function formatRecentProjects(rows) {
return rows.map((row) => ({
id: row.id,
title: row.title || 'Untitled project',
visibility: row.visibility || 'private',
publishedAt: row.published_at,
updatedAt: row.updatedAt,
statusLabel: row.published_at ? 'Published' : 'Draft setup',
}));
}
function formatRecentAgentEndpoints(rows) {
return rows.map((row) => ({
id: row.id,
name: row.name || 'Untitled endpoint',
provider: row.provider || 'custom',
status: row.status || 'inactive',
requiresAuth: Boolean(row.requires_auth),
updatedAt: row.updatedAt,
}));
}
function buildPublicPermissions() {
return Object.keys(PERMISSIONS).reduce((accumulator, key) => {
accumulator[key] = true;
return accumulator;
}, {});
}
function buildPermissions(user) {
return Object.entries(PERMISSIONS).reduce((accumulator, [key, permissionName]) => {
accumulator[key] = hasPermission(user, permissionName);
return accumulator;
}, {});
}
function buildHandoff(authStatus, overviewData) {
const items = [
{
key: 'authentication',
label: 'Authentication is enabled',
description: 'The current operator can access the MAi Server without a blocked account state.',
status: Boolean(authStatus?.isAuthenticationEnabled),
value: authStatus?.authenticationStatusLabel || 'Unknown',
},
{
key: 'verification',
label: 'Verification path is ready',
description: 'Email verification is completed or intentionally bypassed because email is not configured.',
status: Boolean(authStatus?.emailVerified || !authStatus?.emailConfigured),
value: authStatus?.verificationStatusLabel || 'Unknown',
},
{
key: 'email',
label: 'Email delivery is configured',
description: 'Operational emails can be sent for verification, password reset, and onboarding.',
status: Boolean(authStatus?.emailConfigured),
value: authStatus?.emailConfigured ? 'Configured' : 'Not configured',
},
{
key: 'projects',
label: 'Project workspace exists',
description: 'At least one project exists in the workspace.',
status: Number(overviewData.businessSummary.projects || 0) > 0,
value: `${overviewData.businessSummary.projects || 0} project(s)`,
},
{
key: 'pages',
label: 'Published content is available',
description: 'A public-facing page is available for launch or review.',
status: Number(overviewData.businessSummary.publishedPages || 0) > 0,
value: `${overviewData.businessSummary.publishedPages || 0} published page(s)`,
},
{
key: 'endpoints',
label: 'Agent endpoints are connected',
description: 'At least one active AI endpoint is available.',
status: Number(overviewData.businessSummary.activeAgentEndpoints || 0) > 0,
value: `${overviewData.businessSummary.activeAgentEndpoints || 0} active endpoint(s)`,
},
{
key: 'activity',
label: 'Usage analytics are flowing',
description: 'Conversations or messages are present in the selected reporting window.',
status: Number(overviewData.windowSummary.conversations || 0) + Number(overviewData.windowSummary.messages || 0) > 0,
value: `${overviewData.windowSummary.conversations || 0} conversations • ${overviewData.windowSummary.messages || 0} messages`,
},
{
key: 'announcements',
label: 'Announcement channel is prepared',
description: 'Announcements exist for launches, incidents, or product updates.',
status: Number(overviewData.summary.announcements || 0) > 0,
value: `${overviewData.summary.announcements || 0} announcement(s)`,
},
];
const readyCount = items.filter((item) => item.status).length;
const totalCount = items.length;
return {
readyCount,
totalCount,
completionRate: totalCount ? Math.round((readyCount / totalCount) * 100) : 0,
items,
};
}
function maskValueIfHidden(value, canRead) {
return canRead ? value : null;
}
function maskRowsIfHidden(rows, canRead) {
return canRead ? rows : [];
}
async function buildOverviewData(options = {}) {
const days = normalizeRange(options.range);
const windowDates = buildDateWindow(days);
const startDate = windowDates[0];
const labels = windowDates.map(formatDateLabel);
const [
totalProfiles,
verifiedProfiles,
disabledProfiles,
totalConversations,
totalMessages,
totalAnnouncements,
totalProjects,
totalPages,
publishedPages,
totalLinkCollections,
publicCollections,
totalExternalLinks,
totalWidgets,
activeWidgets,
totalAgentEndpoints,
activeAgentEndpoints,
securedAgentEndpoints,
totalMediaChannels,
totalMediaStreams,
activeMediaStreams,
totalAssets,
profilesInRange,
conversationsInRange,
messagesInRange,
announcementsInRange,
userSeriesRows,
conversationSeriesRows,
messageSeriesRows,
channelRows,
announcementRows,
conversationStatusRows,
messageSenderRows,
endpointProviderRows,
widgetTypeRows,
assetTypeRows,
recentProfilesRows,
recentProjectsRows,
recentAgentEndpointRows,
] = await Promise.all([
db.users.count({ where: { deletedAt: null } }),
db.users.count({ where: { deletedAt: null, emailVerified: true } }),
db.users.count({ where: { deletedAt: null, disabled: true } }),
db.conversations.count({ where: { deletedAt: null } }),
db.messages.count({ where: { deletedAt: null } }),
db.announcements.count({ where: { deletedAt: null } }),
db.projects.count({ where: { deletedAt: null } }),
db.pages.count({ where: { deletedAt: null } }),
db.pages.count({ where: { deletedAt: null, status: 'published' } }),
db.link_collections.count({ where: { deletedAt: null } }),
db.link_collections.count({ where: { deletedAt: null, visibility: 'public' } }),
db.external_links.count({ where: { deletedAt: null } }),
db.widgets.count({ where: { deletedAt: null } }),
db.widgets.count({ where: { deletedAt: null, status: 'active' } }),
db.agent_endpoints.count({ where: { deletedAt: null } }),
db.agent_endpoints.count({ where: { deletedAt: null, status: 'active' } }),
db.agent_endpoints.count({ where: { deletedAt: null, requires_auth: true } }),
db.media_channels.count({ where: { deletedAt: null } }),
db.media_streams.count({ where: { deletedAt: null } }),
db.media_streams.count({ where: { deletedAt: null, status: 'active' } }),
db.assets.count({ where: { deletedAt: null } }),
countSince(db.users, startDate),
countSince(db.conversations, startDate),
countSince(db.messages, startDate),
countSince(db.announcements, startDate),
countByDate(db.users.getTableName(), startDate),
countByDate(db.conversations.getTableName(), startDate),
countByDate(db.messages.getTableName(), startDate),
getGroupedCounts(db.conversations, 'channel', startDate),
getGroupedCounts(db.announcements, 'status', startDate),
getGroupedCounts(db.conversations, 'status', startDate),
getGroupedCounts(db.messages, 'sender_type', startDate),
getGroupedCounts(db.agent_endpoints, 'provider', startDate),
getGroupedCounts(db.widgets, 'widget_type', startDate),
getGroupedCounts(db.assets, 'asset_type', startDate),
db.users.findAll({
attributes: ['id', 'firstName', 'lastName', 'email', 'provider', 'emailVerified', 'disabled', 'createdAt'],
where: {
deletedAt: null,
createdAt: {
[Op.gte]: startDate,
},
},
order: [['createdAt', 'DESC']],
limit: 6,
raw: true,
}),
db.projects.findAll({
attributes: ['id', 'title', 'visibility', 'published_at', 'updatedAt'],
where: { deletedAt: null },
order: [['updatedAt', 'DESC']],
limit: 5,
raw: true,
}),
db.agent_endpoints.findAll({
attributes: ['id', 'name', 'provider', 'status', 'requires_auth', 'updatedAt'],
where: { deletedAt: null },
order: [['updatedAt', 'DESC']],
limit: 5,
raw: true,
}),
]);
return {
range: buildRange(days),
summary: {
profiles: totalProfiles,
verifiedProfiles,
disabledProfiles,
conversations: totalConversations,
messages: totalMessages,
announcements: totalAnnouncements,
},
windowSummary: {
profiles: profilesInRange,
conversations: conversationsInRange,
messages: messagesInRange,
announcements: announcementsInRange,
},
businessSummary: {
projects: totalProjects,
pages: totalPages,
publishedPages,
linkCollections: totalLinkCollections,
publicCollections,
externalLinks: totalExternalLinks,
widgets: totalWidgets,
activeWidgets,
agentEndpoints: totalAgentEndpoints,
activeAgentEndpoints,
securedAgentEndpoints,
mediaChannels: totalMediaChannels,
mediaStreams: totalMediaStreams,
activeMediaStreams,
assets: totalAssets,
},
charts: {
activity: {
labels,
profiles: mapSeriesToWindow(windowDates, userSeriesRows),
conversations: mapSeriesToWindow(windowDates, conversationSeriesRows),
messages: mapSeriesToWindow(windowDates, messageSeriesRows),
},
channels: sanitizeGroupedRows(channelRows),
announcementStatus: sanitizeGroupedRows(announcementRows),
conversationStatus: sanitizeGroupedRows(conversationStatusRows),
messageSenders: sanitizeGroupedRows(messageSenderRows),
endpointProviders: sanitizeGroupedRows(endpointProviderRows),
widgetTypes: sanitizeGroupedRows(widgetTypeRows),
assetTypes: sanitizeGroupedRows(assetTypeRows),
},
recentProfiles: formatRecentProfiles(recentProfilesRows),
recentProjects: formatRecentProjects(recentProjectsRows),
recentAgentEndpoints: formatRecentAgentEndpoints(recentAgentEndpointRows),
};
}
module.exports = class MaiServerService {
static async getOverview(currentUser, options = {}) {
try {
const authStatus = buildAuthStatus(currentUser);
const permissions = buildPermissions(currentUser);
const overviewData = await buildOverviewData(options);
const handoff = buildHandoff(authStatus, overviewData);
return {
authStatus,
range: overviewData.range,
permissions,
summary: {
profiles: maskValueIfHidden(overviewData.summary.profiles, permissions.users),
verifiedProfiles: maskValueIfHidden(overviewData.summary.verifiedProfiles, permissions.users),
disabledProfiles: maskValueIfHidden(overviewData.summary.disabledProfiles, permissions.users),
conversations: maskValueIfHidden(overviewData.summary.conversations, permissions.conversations),
messages: maskValueIfHidden(overviewData.summary.messages, permissions.messages),
announcements: maskValueIfHidden(overviewData.summary.announcements, permissions.announcements),
},
windowSummary: {
profiles: maskValueIfHidden(overviewData.windowSummary.profiles, permissions.users),
conversations: maskValueIfHidden(overviewData.windowSummary.conversations, permissions.conversations),
messages: maskValueIfHidden(overviewData.windowSummary.messages, permissions.messages),
announcements: maskValueIfHidden(overviewData.windowSummary.announcements, permissions.announcements),
},
businessSummary: {
projects: maskValueIfHidden(overviewData.businessSummary.projects, permissions.projects),
pages: maskValueIfHidden(overviewData.businessSummary.pages, permissions.pages),
publishedPages: maskValueIfHidden(overviewData.businessSummary.publishedPages, permissions.pages),
linkCollections: maskValueIfHidden(overviewData.businessSummary.linkCollections, permissions.linkCollections),
publicCollections: maskValueIfHidden(overviewData.businessSummary.publicCollections, permissions.linkCollections),
externalLinks: maskValueIfHidden(overviewData.businessSummary.externalLinks, permissions.externalLinks),
widgets: maskValueIfHidden(overviewData.businessSummary.widgets, permissions.widgets),
activeWidgets: maskValueIfHidden(overviewData.businessSummary.activeWidgets, permissions.widgets),
agentEndpoints: maskValueIfHidden(overviewData.businessSummary.agentEndpoints, permissions.agentEndpoints),
activeAgentEndpoints: maskValueIfHidden(overviewData.businessSummary.activeAgentEndpoints, permissions.agentEndpoints),
securedAgentEndpoints: maskValueIfHidden(overviewData.businessSummary.securedAgentEndpoints, permissions.agentEndpoints),
mediaChannels: maskValueIfHidden(overviewData.businessSummary.mediaChannels, permissions.mediaChannels),
mediaStreams: maskValueIfHidden(overviewData.businessSummary.mediaStreams, permissions.mediaStreams),
activeMediaStreams: maskValueIfHidden(overviewData.businessSummary.activeMediaStreams, permissions.mediaStreams),
assets: maskValueIfHidden(overviewData.businessSummary.assets, permissions.assets),
},
charts: {
activity: {
labels: overviewData.charts.activity.labels,
profiles: permissions.users ? overviewData.charts.activity.profiles : [],
conversations: permissions.conversations ? overviewData.charts.activity.conversations : [],
messages: permissions.messages ? overviewData.charts.activity.messages : [],
},
channels: maskRowsIfHidden(overviewData.charts.channels, permissions.conversations),
announcementStatus: maskRowsIfHidden(overviewData.charts.announcementStatus, permissions.announcements),
conversationStatus: maskRowsIfHidden(overviewData.charts.conversationStatus, permissions.conversations),
messageSenders: maskRowsIfHidden(overviewData.charts.messageSenders, permissions.messages),
endpointProviders: maskRowsIfHidden(overviewData.charts.endpointProviders, permissions.agentEndpoints),
widgetTypes: maskRowsIfHidden(overviewData.charts.widgetTypes, permissions.widgets),
assetTypes: maskRowsIfHidden(overviewData.charts.assetTypes, permissions.assets),
},
recentProfiles: maskRowsIfHidden(overviewData.recentProfiles, permissions.users),
recentProjects: maskRowsIfHidden(overviewData.recentProjects, permissions.projects),
recentAgentEndpoints: maskRowsIfHidden(overviewData.recentAgentEndpoints, permissions.agentEndpoints),
handoff,
};
} catch (error) {
console.error('Failed to build MAi Server overview:', error);
throw error;
}
}
static async getPublicOverview(options = {}) {
try {
const overviewData = await buildOverviewData(options);
return {
range: overviewData.range,
permissions: buildPublicPermissions(),
summary: overviewData.summary,
windowSummary: overviewData.windowSummary,
businessSummary: overviewData.businessSummary,
charts: overviewData.charts,
handoff: buildHandoff(
{
emailConfigured: EmailSender.isConfigured,
emailVerified: false,
isAuthenticationEnabled: EmailSender.isConfigured,
authenticationStatusLabel: EmailSender.isConfigured ? 'Operational auth flow ready' : 'Auth flow needs mail setup',
verificationStatusLabel: EmailSender.isConfigured ? 'Visitor verification can be completed' : 'Verification email disabled',
},
overviewData,
),
};
} catch (error) {
console.error('Failed to build public MAi Server overview:', error);
throw error;
}
}
static async enableAuthentication(currentUser, host) {
try {
if (currentUser?.disabled) {
await UsersService.update(
{
disabled: false,
emailVerified: currentUser.emailVerified,
},
currentUser.id,
currentUser,
);
}
let verificationEmailSent = false;
if (!currentUser?.emailVerified && EmailSender.isConfigured) {
await AuthService.sendEmailAddressVerificationEmail(currentUser.email, host);
verificationEmailSent = true;
}
const refreshedUser = await UsersDBApi.findBy({ id: currentUser.id });
return {
authStatus: buildAuthStatus(refreshedUser),
verificationEmailSent,
message: verificationEmailSent
? 'Authentication is enabled and a verification email was sent.'
: 'Authentication is enabled for this account.',
};
} catch (error) {
console.error('Failed to enable MAi Server authentication:', error);
throw error;
}
}
static async sendVerificationEmail(currentUser, host) {
try {
if (currentUser?.emailVerified) {
const refreshedUser = await UsersDBApi.findBy({ id: currentUser.id });
return {
authStatus: buildAuthStatus(refreshedUser),
verificationEmailSent: false,
message: 'This account is already verified.',
};
}
if (!EmailSender.isConfigured) {
const refreshedUser = await UsersDBApi.findBy({ id: currentUser.id });
return {
authStatus: buildAuthStatus(refreshedUser),
verificationEmailSent: false,
message: 'Email delivery is not configured for this environment.',
};
}
await AuthService.sendEmailAddressVerificationEmail(currentUser.email, host);
const refreshedUser = await UsersDBApi.findBy({ id: currentUser.id });
return {
authStatus: buildAuthStatus(refreshedUser),
verificationEmailSent: true,
message: 'Verification email sent successfully.',
};
} catch (error) {
console.error('Failed to send MAi Server verification email:', error);
throw error;
}
}
static async sendPasswordResetEmail(currentUser, host) {
try {
if (!EmailSender.isConfigured) {
return {
emailSent: false,
message: 'Email delivery is not configured for this environment.',
};
}
await AuthService.sendPasswordResetEmail(currentUser.email, 'register', host);
return {
emailSent: true,
message: 'Password reset email sent successfully.',
};
} catch (error) {
console.error('Failed to send MAi Server password reset email:', error);
throw error;
}
}
};

View File

@ -17,6 +17,14 @@ trailingSlash: true,
eslint: {
ignoreDuringBuilds: true,
},
async rewrites() {
return [
{
source: '/mai-server-public',
destination: '/web_pages/mai-server',
},
];
},
images: {
unoptimized: true,
remotePatterns: [

View File

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

View File

@ -0,0 +1,245 @@
export type HubLinkType = 'agent' | 'demo' | 'form' | 'social' | 'media' | 'enter' | 'lovable';
export type HubLink = {
id: string;
title: string;
url: string;
type: HubLinkType;
summary: string;
eyebrow: string;
accent: string;
};
export type MediaPresetType = 'radio' | 'tv';
export type MediaPresetMode = 'audio' | 'video' | 'embed';
export type MediaPresetSourceKind = 'manual' | 'imported' | 'uploaded';
export type MediaPreset = {
id: string;
type: MediaPresetType;
title: string;
url: string;
notes: string;
mode: MediaPresetMode;
isSample?: boolean;
sourceKind?: MediaPresetSourceKind;
privateUrl?: string;
originalFilename?: string;
durationSeconds?: number;
thumbnailUrl?: string;
folder?: string;
remotePlaylistUrl?: string;
remoteRefreshIntervalMinutes?: number;
lastRemoteRefreshAt?: string;
};
export type AdminShortcut = {
title: string;
href: string;
summary: string;
};
export const modularInteractionLinks: HubLink[] = [
{
id: 'agent-prof-aliyo',
title: 'Prof Aliyo Momot AI Agent',
url: 'https://prof-aliyo-momot-ai-agent.onhercules.app/agent',
type: 'agent',
eyebrow: 'AI agent',
summary: 'Primary conversational entry point for visitors who want guided AI interaction.',
accent: 'from-fuchsia-500/20 via-violet-500/10 to-cyan-400/20',
},
{
id: 'agent-frame-smith',
title: 'Frame Smith AI',
url: 'https://frame-smith-ai.lovable.app',
type: 'agent',
eyebrow: 'Creative workflow',
summary: 'A second AI experience positioned for making, framing, and ideation workflows.',
accent: 'from-sky-500/20 via-cyan-500/10 to-emerald-400/20',
},
{
id: 'demo-aliyo-media',
title: 'Aliyo Momot Media Demo',
url: 'https://aliyo-momot-media-fb6f.dev.flatlogic.app/?stream=d5ade7f8-7b12-4b2b-997e-4f1de9180e9b',
type: 'media',
eyebrow: 'Media experience',
summary: 'Existing demo destination for media-first storytelling and streaming discovery.',
accent: 'from-amber-500/20 via-orange-500/10 to-rose-400/20',
},
{
id: 'demo-copyright-revealer',
title: 'Copyright Revealer',
url: 'https://copyright-revealer-cc67.dev.flatlogic.app',
type: 'demo',
eyebrow: 'Specialized demo',
summary: 'A supporting utility demo that expands the modular toolset around your concept.',
accent: 'from-indigo-500/20 via-blue-500/10 to-cyan-400/20',
},
{
id: 'form-1',
title: 'Fillout Intake 01',
url: 'https://build.fillout.com/use/fwxccezugw',
type: 'form',
eyebrow: 'Fillout form',
summary: 'Capture structured visitor input without forcing them to leave your ecosystem.',
accent: 'from-lime-500/20 via-emerald-500/10 to-cyan-400/20',
},
{
id: 'form-2',
title: 'Fillout Intake 02',
url: 'https://build.fillout.com/use/fouwuywqjg',
type: 'form',
eyebrow: 'Fillout form',
summary: 'Useful for onboarding, submissions, or modular requests from the public.',
accent: 'from-purple-500/20 via-pink-500/10 to-rose-400/20',
},
{
id: 'form-3',
title: 'Fillout Intake 03',
url: 'https://build.fillout.com/use/1e7hvg7j95',
type: 'form',
eyebrow: 'Fillout form',
summary: 'A third intake surface that keeps lead capture visible inside the link dashboard.',
accent: 'from-cyan-500/20 via-blue-500/10 to-violet-400/20',
},
{
id: 'form-4',
title: 'Fillout Intake 04',
url: 'https://build.fillout.com/use/j1yzoiedhq',
type: 'form',
eyebrow: 'Fillout form',
summary: 'Additional form endpoint for follow-ups, media submissions, or campaign flows.',
accent: 'from-pink-500/20 via-rose-500/10 to-orange-400/20',
},
{
id: 'enter-prod-3cb9aeee',
title: 'Prod Enter App · 3cb9aeee',
url: 'https://3cb9aeeed04e4167addb24e60d864cc6.prod.enter.pro',
type: 'enter',
eyebrow: 'prod.enter.pro',
summary: 'Hosted prod.enter.pro destination grouped with your other externally deployed app experiences.',
accent: 'from-cyan-500/20 via-sky-500/10 to-blue-400/20',
},
{
id: 'enter-prod-44fc0d2f',
title: 'Prod Enter App · 44fc0d2f',
url: 'https://44fc0d2f36b543c98b028bba0f8f1602.prod.enter.pro',
type: 'enter',
eyebrow: 'prod.enter.pro',
summary: 'Second prod.enter.pro deployment added to the public switchboard for quick launch access.',
accent: 'from-blue-500/20 via-indigo-500/10 to-cyan-400/20',
},
{
id: 'lovable-instant-ai-player',
title: 'Instant AI Player',
url: 'https://instant-ai-player.lovable.app',
type: 'lovable',
eyebrow: 'lovable.app',
summary: 'Lovable-hosted AI player experience surfaced alongside your other public experiments.',
accent: 'from-fuchsia-500/20 via-pink-500/10 to-rose-400/20',
},
{
id: 'lovable-art-audio-vision',
title: 'Art Audio Vision',
url: 'https://art-audio-vision.lovable.app',
type: 'lovable',
eyebrow: 'lovable.app',
summary: 'Art-focused audio and visual destination grouped into the Lovable collection on the hub.',
accent: 'from-amber-500/20 via-orange-500/10 to-pink-400/20',
},
{
id: 'lovable-artful-interaction-os',
title: 'Artful Interaction OS',
url: 'https://artful-interaction-os.lovable.app',
type: 'lovable',
eyebrow: 'lovable.app',
summary: 'Interaction-oriented Lovable app included for quick switching between related creative tools.',
accent: 'from-violet-500/20 via-purple-500/10 to-cyan-400/20',
},
{
id: 'lovable-melody-stream-co',
title: 'Melody Stream Co',
url: 'https://melody-stream-co.lovable.app',
type: 'lovable',
eyebrow: 'lovable.app',
summary: 'Streaming-focused Lovable destination collected with the rest of your hosted public links.',
accent: 'from-emerald-500/20 via-teal-500/10 to-cyan-400/20',
},
{
id: 'social-facebook',
title: 'Facebook Community Profile',
url: 'https://www.facebook.com/profile.php?id=61567817647812',
type: 'social',
eyebrow: 'Social presence',
summary: 'A community touchpoint for followers arriving from Facebook and public promotion.',
accent: 'from-blue-500/20 via-indigo-500/10 to-violet-400/20',
},
];
export const starterMediaPresets: MediaPreset[] = [
{
id: 'sample-radio',
type: 'radio',
title: 'Sample Radio Placeholder',
url: 'https://samplelib.com/lib/preview/mp3/sample-12s.mp3',
notes: 'Replace this with your live radio stream URL when you are ready.',
mode: 'audio',
isSample: true,
sourceKind: 'manual',
},
{
id: 'sample-tv',
type: 'tv',
title: 'Sample TV Placeholder',
url: 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4',
notes: 'This placeholder shows the TV widget layout until you paste a real embed or video stream.',
mode: 'video',
isSample: true,
sourceKind: 'manual',
},
];
export const adminShortcuts: AdminShortcut[] = [
{
title: 'Admin interface',
href: '/login',
summary: 'Sign in to manage all generated CRUD screens and protected content modules.',
},
{
title: 'External links manager',
href: '/external_links/external_links-list',
summary: 'Use the existing CRUD to curate agents, demos, forms, and social links.',
},
{
title: 'Media streams manager',
href: '/media_streams/media_streams-list',
summary: 'Manage stream records for radio and television sources inside the admin area.',
},
{
title: 'Widgets manager',
href: '/widgets/widgets-list',
summary: 'Configure reusable radio, TV, AI, and embed widgets for future authenticated workflows.',
},
];
export const hubHighlights = [
{
title: 'Modular by design',
description: 'Visitors can move between AI, media, and forms without losing the overall story.',
},
{
title: 'Public-friendly',
description: 'The front door is open, branded, and focused on exploration instead of admin jargon.',
},
{
title: 'Admin-ready',
description: 'Existing CRUD screens stay in place so you can manage content once you log in.',
},
];
export const experienceSteps = [
'Open the public landing page and understand the Modular Artificial Interaction concept at a glance.',
'Jump into the interaction hub to browse AI agents, demos, forms, and community links.',
'Preview radio or TV in-page, or paste your own stream URL and save it as a preset in this browser.',
];

View File

@ -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'
@ -87,6 +86,7 @@ export default function LayoutAuthenticated({
const layoutAsidePadding = 'xl:pl-60'
const leadershipTitle = 'Prof. Dr. Aliyo Momot — CEO & Founder'
return (
<div className={`${darkMode ? 'dark' : ''} overflow-hidden lg:overflow-visible`}>
@ -111,6 +111,9 @@ export default function LayoutAuthenticated({
>
<BaseIcon path={mdiMenu} size="24" />
</NavBarItemPlain>
<div className="hidden xl:flex items-center px-3 text-sm font-semibold text-slate-600 whitespace-nowrap dark:text-slate-300">
{leadershipTitle}
</div>
<NavBarItemPlain useMargin>
<Search />
</NavBarItemPlain>
@ -122,7 +125,11 @@ export default function LayoutAuthenticated({
onAsideLgClose={() => setIsAsideLgActive(false)}
/>
{children}
<FooterBar>Hand-crafted & Made with </FooterBar>
<FooterBar>
<span>Hand-crafted & Made with </span>
<span className="mx-2 hidden md:inline"></span>
<span className="block md:inline">{leadershipTitle}</span>
</FooterBar>
</div>
</div>
)

View File

@ -7,6 +7,13 @@ const menuAside: MenuAsideItem[] = [
icon: icon.mdiViewDashboardOutline,
label: 'Dashboard',
},
{
href: '/interaction-hub',
label: 'Interaction hub',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiAtomVariant' in icon ? icon['mdiAtomVariant' as keyof typeof icon] : icon.mdiBroadcast ?? icon.mdiViewDashboardOutline,
},
{
href: '/users/users-list',
@ -128,6 +135,11 @@ const menuAside: MenuAsideItem[] = [
icon: 'mdiFolderMultipleImage' in icon ? icon['mdiFolderMultipleImage' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_ASSETS'
},
{
href: '/mai-server',
label: 'MAi Server',
icon: icon.mdiServer,
},
{
href: '/profile',
label: 'Profile',

View File

@ -1,166 +1,348 @@
import React, { useEffect, useState } from 'react';
import React from 'react';
import type { ReactElement } from 'react';
import Head from 'next/head';
import Link from 'next/link';
import {
mdiArrowRight,
mdiBroadcast,
mdiCubeOutline,
mdiLogin,
mdiOpenInNew,
mdiPlayCircleOutline,
mdiRobotOutline,
mdiTelevisionPlay,
mdiViewDashboardOutline,
} from '@mdi/js';
import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox';
import SectionFullScreen from '../components/SectionFullScreen';
import BaseIcon from '../components/BaseIcon';
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 {
adminShortcuts,
experienceSteps,
hubHighlights,
modularInteractionLinks,
} from '../helpers/modularInteractionHub';
const moduleCards = [
{
title: 'AI entry points',
description: 'Launch your existing agents from one branded, public-facing destination.',
icon: mdiRobotOutline,
},
{
title: 'Link switchboard',
description: 'Show demos, Fillout flows, and social destinations without the page feeling scattered.',
icon: mdiCubeOutline,
},
{
title: 'Radio + TV widgets',
description: 'Give visitors in-page media playback with presets and future room for live streams.',
icon: mdiBroadcast,
},
{
title: 'Admin-ready modules',
description: 'Keep the generated admin, CRUD, and permissions setup intact for future growth.',
icon: mdiViewDashboardOutline,
},
];
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('video');
const [contentPosition, setContentPosition] = useState('right');
const textColor = useAppSelector((state) => state.style.linkColor);
const featuredLinks = modularInteractionLinks.slice(0, 6);
const title = 'Modular Interaction Hub'
// 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 statCards = [
{ value: '2', label: 'AI experiences' },
{ value: '4', label: 'Fillout entry forms' },
{ value: '2', label: 'Media widgets' },
];
export default function HomePage() {
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('Modular Artificial Interaction')}</title>
<meta
name="description"
content="A public-facing AI and media hub for Modular Artificial Interaction, with agent links, forms, and radio/TV widgets."
/>
</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 Modular Interaction Hub 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-[#050816] text-white">
<div className="absolute inset-x-0 top-0 -z-0 h-[38rem] bg-[radial-gradient(circle_at_top,_rgba(96,165,250,0.28),_transparent_42%),radial-gradient(circle_at_30%_30%,_rgba(217,70,239,0.24),_transparent_32%),linear-gradient(180deg,_#0a1024,_#050816)]" />
<header className="sticky top-0 z-20 border-b border-white/10 bg-[#050816]/80 backdrop-blur-xl">
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
<Link href="/" className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-cyan-400/40 bg-cyan-400/10 text-cyan-200 shadow-[0_0_40px_rgba(34,211,238,0.15)]">
<BaseIcon path={mdiCubeOutline} size={22} />
</div>
<div>
<p className="text-xs uppercase tracking-[0.32em] text-cyan-200/80">Modular Artificial</p>
<p className="text-lg font-semibold tracking-wide text-white">Interaction Hub</p>
</div>
</Link>
<nav className="hidden items-center gap-8 text-sm text-slate-300 md:flex">
<a href="#concept" className="transition hover:text-white">
Concept
</a>
<a href="#modules" className="transition hover:text-white">
Modules
</a>
<a href="#switchboard" className="transition hover:text-white">
Switchboard
</a>
<a href="#admin" className="transition hover:text-white">
Admin
</a>
</nav>
<div className="flex items-center gap-3">
<BaseButton href="/login" label="Login" color="whiteDark" className="border-white/10" />
<BaseButton href="/interaction-hub" label="Open hub" color="info" />
</div>
<BaseButtons>
<BaseButton
href='/login'
label='Login'
color='info'
className='w-full'
/>
</div>
</header>
</BaseButtons>
</CardBox>
</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>
<main className="relative z-10">
<section className="mx-auto grid max-w-7xl gap-12 px-6 pb-16 pt-16 lg:grid-cols-[1.15fr_0.85fr] lg:pb-24 lg:pt-24">
<div className="space-y-8">
<div className="inline-flex items-center gap-2 rounded-full border border-fuchsia-400/25 bg-white/5 px-4 py-2 text-sm text-fuchsia-100 shadow-[0_0_30px_rgba(217,70,239,0.12)]">
<span className="h-2 w-2 rounded-full bg-emerald-400" />
Beautiful public front door for AI, links, and media
</div>
</div>
<div className="space-y-6">
<h1 className="max-w-4xl text-5xl font-semibold leading-tight text-white md:text-6xl">
Present <span className="text-cyan-300">Modular Artificial Interaction</span> as one living experience.
</h1>
<p className="max-w-3xl text-lg leading-8 text-slate-300 md:text-xl">
This first slice turns the starter app into a polished public hub: your AI agents,
demos, Fillout links, and radio/TV widgets now feel connected, intentional, and ready
for visitors coming from social or direct links.
</p>
</div>
<div className="flex flex-wrap gap-4">
<BaseButton href="/interaction-hub" label="Launch interaction hub" icon={mdiArrowRight} color="info" />
<BaseButton href="/login" label="Admin interface" icon={mdiLogin} color="whiteDark" className="border-white/10" />
</div>
<div className="grid gap-4 sm:grid-cols-3">
{statCards.map((item) => (
<div
key={item.label}
className="rounded-3xl border border-white/10 bg-white/5 p-5 backdrop-blur-xl"
>
<div className="text-3xl font-semibold text-white">{item.value}</div>
<div className="mt-2 text-sm uppercase tracking-[0.22em] text-slate-400">
{item.label}
</div>
</div>
))}
</div>
</div>
<div className="relative">
<div className="absolute inset-0 rounded-[2rem] bg-gradient-to-br from-cyan-500/20 via-fuchsia-500/10 to-transparent blur-2xl" />
<div className="relative overflow-hidden rounded-[2rem] border border-white/10 bg-slate-950/75 p-6 shadow-[0_30px_80px_rgba(15,23,42,0.6)] backdrop-blur-xl">
<div className="flex items-center justify-between border-b border-white/10 pb-5">
<div>
<p className="text-xs uppercase tracking-[0.3em] text-cyan-200/70">Live concept board</p>
<h2 className="mt-2 text-2xl font-semibold text-white">How the experience feels</h2>
</div>
<div className="rounded-full border border-emerald-400/40 bg-emerald-400/10 px-3 py-1 text-xs uppercase tracking-[0.2em] text-emerald-200">
Public ready
</div>
</div>
<div className="mt-6 space-y-4">
{hubHighlights.map((item, index) => (
<div
key={item.title}
className="rounded-3xl border border-white/10 bg-white/5 p-5 text-left"
>
<div className="mb-3 flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-2xl bg-white/10 text-cyan-200">
<span className="text-sm font-semibold">0{index + 1}</span>
</div>
<h3 className="text-lg font-medium text-white">{item.title}</h3>
</div>
<p className="text-sm leading-7 text-slate-300">{item.description}</p>
</div>
))}
</div>
</div>
</div>
</section>
<section id="concept" className="mx-auto max-w-7xl px-6 py-6">
<div className="rounded-[2rem] border border-white/10 bg-white/[0.03] p-8 shadow-[0_20px_60px_rgba(15,23,42,0.4)]">
<div className="grid gap-8 lg:grid-cols-[0.95fr_1.05fr] lg:items-center">
<div>
<p className="text-sm uppercase tracking-[0.3em] text-cyan-200/70">Concept</p>
<h2 className="mt-4 text-3xl font-semibold text-white md:text-4xl">
A modular stack for interaction, intake, and media.
</h2>
<p className="mt-4 max-w-2xl text-base leading-8 text-slate-300">
Instead of sending people to scattered tools, the app now frames everything as a
coherent digital venue: AI assistance, form-based capture, media playback, and
curated launch points are all organized under one calm, futuristic visual system.
</p>
</div>
<div className="grid gap-4 sm:grid-cols-3">
{experienceSteps.map((step, index) => (
<div key={step} className="rounded-3xl border border-white/10 bg-slate-900/60 p-5">
<div className="mb-4 text-sm uppercase tracking-[0.24em] text-fuchsia-200/80">
Step {index + 1}
</div>
<p className="text-sm leading-7 text-slate-300">{step}</p>
</div>
))}
</div>
</div>
</div>
</section>
<section id="modules" className="mx-auto max-w-7xl px-6 py-16">
<div className="mb-10 max-w-3xl">
<p className="text-sm uppercase tracking-[0.3em] text-cyan-200/70">Modules</p>
<h2 className="mt-4 text-3xl font-semibold text-white md:text-4xl">
The first branded slice is more than a landing page.
</h2>
</div>
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-4">
{moduleCards.map((item) => (
<div
key={item.title}
className="rounded-[1.75rem] border border-white/10 bg-white/[0.04] p-6 transition duration-200 hover:-translate-y-1 hover:border-cyan-300/30 hover:bg-white/[0.06]"
>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-cyan-400/10 text-cyan-200">
<BaseIcon path={item.icon} size={22} />
</div>
<h3 className="mt-6 text-xl font-medium text-white">{item.title}</h3>
<p className="mt-3 text-sm leading-7 text-slate-300">{item.description}</p>
</div>
))}
</div>
</section>
<section id="switchboard" className="mx-auto max-w-7xl px-6 py-2">
<div className="mb-10 flex flex-col gap-5 md:flex-row md:items-end md:justify-between">
<div className="max-w-3xl">
<p className="text-sm uppercase tracking-[0.3em] text-cyan-200/70">Switchboard preview</p>
<h2 className="mt-4 text-3xl font-semibold text-white md:text-4xl">
Curated launch cards for the tools you already have.
</h2>
</div>
<BaseButton href="/interaction-hub" label="See the full hub" icon={mdiArrowRight} color="whiteDark" className="border-white/10" />
</div>
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
{featuredLinks.map((item) => (
<a
key={item.id}
href={item.url}
target="_blank"
rel="noreferrer"
className={`group overflow-hidden rounded-[1.75rem] border border-white/10 bg-gradient-to-br ${item.accent} p-[1px] transition hover:-translate-y-1 hover:border-white/20`}
>
<div className="flex h-full flex-col rounded-[1.7rem] bg-slate-950/90 p-6">
<div className="flex items-center justify-between gap-4">
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs uppercase tracking-[0.22em] text-slate-300">
{item.eyebrow}
</span>
<BaseIcon path={mdiOpenInNew} size={18} className="text-slate-400 transition group-hover:text-white" />
</div>
<h3 className="mt-6 text-xl font-medium text-white">{item.title}</h3>
<p className="mt-3 text-sm leading-7 text-slate-300">{item.summary}</p>
</div>
</a>
))}
</div>
</section>
<section className="mx-auto max-w-7xl px-6 py-16">
<div className="grid gap-6 lg:grid-cols-2">
<div className="rounded-[2rem] border border-white/10 bg-gradient-to-br from-cyan-500/10 via-slate-950 to-slate-950 p-8">
<div className="flex items-center gap-3 text-cyan-200">
<BaseIcon path={mdiPlayCircleOutline} size={24} />
<p className="text-sm uppercase tracking-[0.25em]">Radio widget</p>
</div>
<h3 className="mt-5 text-2xl font-semibold text-white">In-page listening for your audience</h3>
<p className="mt-4 max-w-xl text-sm leading-7 text-slate-300">
The hub includes a radio player area with browser-saved presets, so you can start with
placeholders now and swap in real stream URLs later.
</p>
</div>
<div className="rounded-[2rem] border border-white/10 bg-gradient-to-br from-fuchsia-500/10 via-slate-950 to-slate-950 p-8">
<div className="flex items-center gap-3 text-fuchsia-200">
<BaseIcon path={mdiTelevisionPlay} size={24} />
<p className="text-sm uppercase tracking-[0.25em]">TV widget</p>
</div>
<h3 className="mt-5 text-2xl font-semibold text-white">Flexible embed or video preview</h3>
<p className="mt-4 max-w-xl text-sm leading-7 text-slate-300">
Visitors can stay on your site while watching a sample video or a future live embed,
giving the experience a richer broadcast identity.
</p>
</div>
</div>
</section>
<section id="admin" className="mx-auto max-w-7xl px-6 pb-20 pt-4">
<div className="rounded-[2rem] border border-white/10 bg-white/[0.04] p-8">
<div className="mb-8 max-w-3xl">
<p className="text-sm uppercase tracking-[0.3em] text-cyan-200/70">Admin continuity</p>
<h2 className="mt-4 text-3xl font-semibold text-white md:text-4xl">
Keep the admin power you already have.
</h2>
<p className="mt-4 text-sm leading-7 text-slate-300">
The new public experience works as the front door, while the generated admin remains the
control room for managing links, streams, widgets, and future content.
</p>
</div>
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-4">
{adminShortcuts.map((item) => (
<Link
key={item.title}
href={item.href}
className="rounded-[1.5rem] border border-white/10 bg-slate-950/70 p-6 transition hover:-translate-y-1 hover:border-cyan-300/30"
>
<h3 className="text-lg font-medium text-white">{item.title}</h3>
<p className="mt-3 text-sm leading-7 text-slate-300">{item.summary}</p>
</Link>
))}
</div>
</div>
</section>
</main>
<footer className="border-t border-white/10 bg-slate-950/80">
<div className="mx-auto flex max-w-7xl flex-col gap-4 px-6 py-8 text-sm text-slate-400 md:flex-row md:items-center md:justify-between">
<p>© 2026 Modular Artificial Interaction Hub. Built as a public AI + media launch surface.</p>
<div className="flex flex-wrap items-center gap-5">
<Link href="/interaction-hub" className="transition hover:text-white">
Open hub
</Link>
<Link href="/login" className="transition hover:text-white">
Login
</Link>
<Link href="/privacy-policy" className="transition hover:text-white">
Privacy policy
</Link>
<Link href="/terms-of-use" className="transition hover:text-white">
Terms
</Link>
</div>
</div>
</footer>
</div>
</>
);
}
Starter.getLayout = function getLayout(page: ReactElement) {
HomePage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,982 @@
import * as icon from '@mdi/js';
import axios from 'axios';
import {
BarElement,
CategoryScale,
Chart as ChartJS,
Filler,
Legend,
LinearScale,
LineElement,
PointElement,
Tooltip,
} from 'chart.js';
import Head from 'next/head';
import React from 'react';
import { Bar, Line } from 'react-chartjs-2';
import type { ReactElement } from 'react';
import BaseButton from '../components/BaseButton';
import BaseIcon from '../components/BaseIcon';
import CardBox from '../components/CardBox';
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 LayoutAuthenticated from '../layouts/Authenticated';
import { findMe } from '../stores/authSlice';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, BarElement, Tooltip, Legend, Filler);
type AuthStatus = {
displayName: string;
email: string;
provider: string;
roleName: string;
country: string;
emailVerified: boolean;
disabled: boolean;
emailConfigured: boolean;
isAuthenticationEnabled: boolean;
authenticationStatusLabel: string;
verificationStatusLabel: string;
actionLabel: string;
};
type Summary = {
profiles: number | null;
verifiedProfiles: number | null;
disabledProfiles: number | null;
conversations: number | null;
messages: number | null;
announcements: number | null;
};
type WindowSummary = {
profiles: number | null;
conversations: number | null;
messages: number | null;
announcements: number | null;
};
type BusinessSummary = {
projects: number | null;
pages: number | null;
publishedPages: number | null;
linkCollections: number | null;
publicCollections: number | null;
externalLinks: number | null;
widgets: number | null;
activeWidgets: number | null;
agentEndpoints: number | null;
activeAgentEndpoints: number | null;
securedAgentEndpoints: number | null;
mediaChannels: number | null;
mediaStreams: number | null;
activeMediaStreams: number | null;
assets: number | null;
};
type ChartBucket = Array<{ label: string; value: number }>;
type Charts = {
activity: {
labels: string[];
profiles: number[];
conversations: number[];
messages: number[];
};
channels: ChartBucket;
announcementStatus: ChartBucket;
conversationStatus: ChartBucket;
messageSenders: ChartBucket;
endpointProviders: ChartBucket;
widgetTypes: ChartBucket;
assetTypes: ChartBucket;
};
type HandoffItem = {
key: string;
label: string;
description: string;
status: boolean;
value: string;
};
type Overview = {
authStatus: AuthStatus;
range: {
days: number;
label: string;
};
permissions: Record<string, boolean>;
summary: Summary;
windowSummary: WindowSummary;
businessSummary: BusinessSummary;
charts: Charts;
recentProfiles: Array<{
id: string;
name: string;
email: string;
provider: string;
createdAt: string;
statusLabel: string;
}>;
recentProjects: Array<{
id: string;
title: string;
visibility: string;
publishedAt: string | null;
updatedAt: string;
statusLabel: string;
}>;
recentAgentEndpoints: Array<{
id: string;
name: string;
provider: string;
status: string;
requiresAuth: boolean;
updatedAt: string;
}>;
handoff: {
readyCount: number;
totalCount: number;
completionRate: number;
items: HandoffItem[];
};
};
type ActionTone = 'success' | 'danger' | 'info';
const rangeOptions = [7, 30, 90];
const chartPalette = ['#38bdf8', '#34d399', '#f59e0b', '#a78bfa', '#fb7185', '#22c55e', '#f97316', '#06b6d4'];
function formatMetric(value: number | null | undefined) {
if (value === null || value === undefined) {
return 'Private';
}
return value.toLocaleString();
}
function formatDateTime(value?: string | null) {
if (!value) {
return 'Not available';
}
return new Date(value).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
function getNotificationColor(tone: ActionTone) {
if (tone === 'success') {
return 'success';
}
if (tone === 'danger') {
return 'danger';
}
return 'info';
}
function buildBarData(title: string, rows: ChartBucket) {
return {
labels: rows.map((row) => row.label),
datasets: [
{
label: title,
data: rows.map((row) => row.value),
backgroundColor: rows.map((_, index) => chartPalette[index % chartPalette.length]),
borderRadius: 12,
},
],
};
}
function getBarOptions() {
return {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false,
},
},
scales: {
y: {
beginAtZero: true,
ticks: {
precision: 0,
},
},
},
};
}
function StatCard({
title,
value,
subtitle,
iconPath,
accent,
}: {
title: string;
value: string;
subtitle: string;
iconPath: string;
accent: string;
}) {
return (
<CardBox className={`border border-white/10 bg-gradient-to-br ${accent} text-white`}>
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-300">{title}</p>
<p className="mt-4 text-3xl font-semibold">{value}</p>
<p className="mt-2 text-sm text-slate-300">{subtitle}</p>
</div>
<div className="rounded-2xl bg-white/10 p-3">
<BaseIcon path={iconPath} size={26} />
</div>
</div>
</CardBox>
);
}
function BarChartCard({
title,
description,
rows,
emptyLabel,
}: {
title: string;
description: string;
rows: ChartBucket;
emptyLabel: string;
}) {
return (
<CardBox>
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-gray-500 dark:text-slate-400">{title}</p>
<h3 className="mt-1 text-xl font-semibold">{description}</h3>
</div>
<div className="rounded-2xl bg-cyan-500/10 p-3 text-cyan-600 dark:text-cyan-300">
<BaseIcon path={icon.mdiChartDonut} size={22} />
</div>
</div>
{rows.length ? (
<div className="h-72">
<Bar data={buildBarData(title, rows)} options={getBarOptions()} />
</div>
) : (
<div className="rounded-2xl border border-dashed border-gray-300 px-4 py-10 text-sm text-gray-500 dark:border-slate-700 dark:text-slate-400">
{emptyLabel}
</div>
)}
</CardBox>
);
}
const MaiServerPage = () => {
const dispatch = useAppDispatch();
const { currentUser } = useAppSelector((state) => state.auth);
const corners = useAppSelector((state) => state.style.corners);
const [selectedRange, setSelectedRange] = React.useState(30);
const [overview, setOverview] = React.useState<Overview | null>(null);
const [isLoading, setIsLoading] = React.useState(true);
const [loadError, setLoadError] = React.useState('');
const [actionTone, setActionTone] = React.useState<ActionTone>('info');
const [actionMessage, setActionMessage] = React.useState('');
const [profileValues, setProfileValues] = React.useState({
firstName: '',
lastName: '',
phoneNumber: '',
});
const [passwordValues, setPasswordValues] = React.useState({
currentPassword: '',
newPassword: '',
confirmPassword: '',
});
const [isProfileSaving, setIsProfileSaving] = React.useState(false);
const [isPasswordSaving, setIsPasswordSaving] = React.useState(false);
const [isEnabling, setIsEnabling] = React.useState(false);
const [isSendingVerification, setIsSendingVerification] = React.useState(false);
const [isSendingReset, setIsSendingReset] = React.useState(false);
const loadOverview = React.useCallback(async (range = selectedRange) => {
try {
setIsLoading(true);
setLoadError('');
const response = await axios.get('/mai-server/overview', {
params: { range },
});
setOverview(response.data);
} catch (error) {
console.error('Failed to load MAi Server overview', error);
setLoadError('Unable to load the MAi Server dashboard right now.');
} finally {
setIsLoading(false);
}
}, [selectedRange]);
React.useEffect(() => {
loadOverview(selectedRange);
}, [loadOverview, selectedRange]);
React.useEffect(() => {
setProfileValues({
firstName: currentUser?.firstName || '',
lastName: currentUser?.lastName || '',
phoneNumber: currentUser?.phoneNumber || '',
});
}, [currentUser]);
const showAction = React.useCallback((tone: ActionTone, message: string) => {
setActionTone(tone);
setActionMessage(message);
}, []);
const refreshAuthContext = React.useCallback(async () => {
await dispatch(findMe());
await loadOverview(selectedRange);
}, [dispatch, loadOverview, selectedRange]);
const handleProfileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = event.target;
setProfileValues((previous) => ({
...previous,
[name]: value,
}));
};
const handlePasswordChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = event.target;
setPasswordValues((previous) => ({
...previous,
[name]: value,
}));
};
const handleSaveProfile = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!currentUser?.id) {
showAction('danger', 'Your account details are not ready yet. Please refresh and try again.');
return;
}
try {
setIsProfileSaving(true);
await axios.put(`/users/${currentUser.id}`, {
id: currentUser.id,
data: {
firstName: profileValues.firstName,
lastName: profileValues.lastName,
phoneNumber: profileValues.phoneNumber,
},
});
await refreshAuthContext();
showAction('success', 'Profile details updated successfully.');
} catch (error) {
console.error('Failed to save MAi Server profile', error);
showAction('danger', 'Unable to save profile details right now.');
} finally {
setIsProfileSaving(false);
}
};
const handlePasswordSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!passwordValues.currentPassword || !passwordValues.newPassword) {
showAction('danger', 'Enter your current password and a new password to continue.');
return;
}
if (passwordValues.newPassword !== passwordValues.confirmPassword) {
showAction('danger', 'The confirmation password does not match the new password.');
return;
}
try {
setIsPasswordSaving(true);
await axios.put('/auth/password-update', {
currentPassword: passwordValues.currentPassword,
newPassword: passwordValues.newPassword,
});
setPasswordValues({
currentPassword: '',
newPassword: '',
confirmPassword: '',
});
showAction('success', 'Password updated successfully.');
} catch (error) {
console.error('Failed to update MAi Server password', error);
showAction('danger', 'Unable to update the password right now.');
} finally {
setIsPasswordSaving(false);
}
};
const handleEnableAuthentication = async () => {
try {
setIsEnabling(true);
const response = await axios.post('/mai-server/enable-authentication');
await refreshAuthContext();
showAction('success', response.data?.message || 'Authentication is enabled.');
} catch (error) {
console.error('Failed to enable authentication from MAi Server', error);
showAction('danger', 'Unable to enable authentication right now.');
} finally {
setIsEnabling(false);
}
};
const handleSendVerification = async () => {
try {
setIsSendingVerification(true);
const response = await axios.post('/mai-server/send-verification-email');
await refreshAuthContext();
showAction('success', response.data?.message || 'Verification email request completed.');
} catch (error) {
console.error('Failed to send verification email from MAi Server', error);
showAction('danger', 'Unable to send a verification email right now.');
} finally {
setIsSendingVerification(false);
}
};
const handleSendPasswordReset = async () => {
try {
setIsSendingReset(true);
const response = await axios.post('/mai-server/send-password-reset-email');
showAction('success', response.data?.message || 'Password reset email request completed.');
} catch (error) {
console.error('Failed to send MAi Server password reset email', error);
showAction('danger', 'Unable to send a password reset email right now.');
} finally {
setIsSendingReset(false);
}
};
const rangeLabel = overview?.range?.label || `Last ${selectedRange} days`;
const handoff = overview?.handoff;
const handoffItems = handoff?.items || [];
const activityData = React.useMemo(
() => ({
labels: overview?.charts.activity.labels || [],
datasets: [
{
label: 'Profiles',
data: overview?.charts.activity.profiles || [],
borderColor: '#38bdf8',
backgroundColor: 'rgba(56, 189, 248, 0.16)',
fill: true,
tension: 0.35,
},
{
label: 'Conversations',
data: overview?.charts.activity.conversations || [],
borderColor: '#34d399',
backgroundColor: 'rgba(52, 211, 153, 0.16)',
fill: true,
tension: 0.35,
},
{
label: 'Messages',
data: overview?.charts.activity.messages || [],
borderColor: '#f59e0b',
backgroundColor: 'rgba(245, 158, 11, 0.16)',
fill: true,
tension: 0.35,
},
],
}),
[overview],
);
const activityOptions = React.useMemo(
() => ({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
labels: {
color: '#94a3b8',
},
},
},
scales: {
x: {
ticks: {
color: '#94a3b8',
},
grid: {
color: 'rgba(148, 163, 184, 0.08)',
},
},
y: {
beginAtZero: true,
ticks: {
color: '#94a3b8',
precision: 0,
},
grid: {
color: 'rgba(148, 163, 184, 0.08)',
},
},
},
}),
[],
);
const primaryStats = [
{
title: 'Profiles',
value: formatMetric(overview?.summary.profiles),
subtitle: `${formatMetric(overview?.windowSummary.profiles)} in ${rangeLabel.toLowerCase()}`,
iconPath: icon.mdiAccountGroupOutline,
accent: 'from-slate-950 via-cyan-950 to-slate-900',
},
{
title: 'Verified profiles',
value: formatMetric(overview?.summary.verifiedProfiles),
subtitle: `${formatMetric(overview?.summary.disabledProfiles)} currently disabled`,
iconPath: icon.mdiShieldCheckOutline,
accent: 'from-emerald-950 via-slate-950 to-slate-900',
},
{
title: 'Conversations',
value: formatMetric(overview?.summary.conversations),
subtitle: `${formatMetric(overview?.windowSummary.conversations)} in ${rangeLabel.toLowerCase()}`,
iconPath: icon.mdiWeb,
accent: 'from-sky-950 via-slate-950 to-slate-900',
},
{
title: 'Messages',
value: formatMetric(overview?.summary.messages),
subtitle: `${formatMetric(overview?.windowSummary.messages)} in ${rangeLabel.toLowerCase()}`,
iconPath: icon.mdiMessageTextOutline,
accent: 'from-amber-950 via-slate-950 to-slate-900',
},
];
const businessStats = [
{
title: 'Projects',
value: formatMetric(overview?.businessSummary.projects),
subtitle: `${formatMetric(overview?.businessSummary.publishedPages)} published pages`,
iconPath: icon.mdiViewDashboardOutline,
accent: 'from-indigo-950 via-slate-950 to-slate-900',
},
{
title: 'Agent endpoints',
value: formatMetric(overview?.businessSummary.activeAgentEndpoints),
subtitle: `${formatMetric(overview?.businessSummary.securedAgentEndpoints)} secured endpoints`,
iconPath: icon.mdiServerSecurity,
accent: 'from-violet-950 via-slate-950 to-slate-900',
},
{
title: 'Media streams',
value: formatMetric(overview?.businessSummary.activeMediaStreams),
subtitle: `${formatMetric(overview?.businessSummary.mediaChannels)} media channels connected`,
iconPath: icon.mdiBroadcast,
accent: 'from-fuchsia-950 via-slate-950 to-slate-900',
},
{
title: 'Assets',
value: formatMetric(overview?.businessSummary.assets),
subtitle: `${formatMetric(overview?.businessSummary.externalLinks)} external links tracked`,
iconPath: icon.mdiDatabaseOutline,
accent: 'from-teal-950 via-slate-950 to-slate-900',
},
];
return (
<>
<Head>
<title>{getPageTitle('MAi Server')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={icon.mdiServerSecurity} title="MAi Server" main>
<BaseButton color="info" label="Open public page" href="/mai-server-public" target="_blank" icon={icon.mdiOpenInNew} />
</SectionTitleLineWithButton>
{actionMessage ? (
<NotificationBar color={getNotificationColor(actionTone)} icon={actionTone === 'danger' ? icon.mdiAlertCircleOutline : icon.mdiCheckCircleOutline}>
{actionMessage}
</NotificationBar>
) : null}
{loadError ? (
<NotificationBar color="danger" icon={icon.mdiAlertCircleOutline}>
{loadError}
</NotificationBar>
) : null}
<div className="grid gap-6 xl:grid-cols-[1.35fr_0.65fr]">
<CardBox className="border border-white/10 bg-gradient-to-br from-slate-950 via-slate-900 to-cyan-950 text-white">
<div className="flex flex-col gap-8 lg:flex-row lg:items-start lg:justify-between">
<div className="max-w-3xl">
<div className="inline-flex items-center gap-2 rounded-full border border-cyan-400/30 bg-cyan-400/10 px-4 py-1 text-xs font-semibold uppercase tracking-[0.25em] text-cyan-200">
<BaseIcon path={icon.mdiServerSecurity} size={16} />
Aliyo Momot MAi Server
</div>
<h1 className="mt-5 text-4xl font-semibold leading-tight lg:text-5xl">
Operate onboarding, authentication, and live business metrics from one control room.
</h1>
<p className="mt-4 max-w-2xl text-base leading-7 text-slate-300">
This private dashboard now combines real account controls, live workspace analytics, and a production handoff checklist built from your existing Flatlogic entities.
</p>
<div className="mt-8 flex flex-wrap gap-3">
{rangeOptions.map((range) => (
<BaseButton
key={range}
label={`Last ${range} days`}
small
color={selectedRange === range ? 'info' : 'white'}
outline={selectedRange !== range}
onClick={() => setSelectedRange(range)}
/>
))}
</div>
</div>
<div className={`w-full max-w-md space-y-4 rounded-3xl border border-white/10 bg-white/5 p-5 ${corners !== 'rounded-full' ? '' : 'rounded-3xl'}`}>
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">Operator status</p>
<p className="mt-2 text-2xl font-semibold">{overview?.authStatus.displayName || currentUser?.email || 'Loading…'}</p>
</div>
<div className="rounded-2xl bg-white/10 p-3">
<BaseIcon path={icon.mdiAccountCircleOutline} size={28} />
</div>
</div>
<div className="grid gap-3 text-sm text-slate-300 sm:grid-cols-2">
<div className="rounded-2xl border border-white/10 bg-slate-950/40 p-4">
<div className="text-xs uppercase tracking-[0.18em] text-slate-500">Email</div>
<div className="mt-2 break-all font-medium text-white">{overview?.authStatus.email || currentUser?.email || '—'}</div>
</div>
<div className="rounded-2xl border border-white/10 bg-slate-950/40 p-4">
<div className="text-xs uppercase tracking-[0.18em] text-slate-500">Role</div>
<div className="mt-2 font-medium text-white">{overview?.authStatus.roleName || 'Workspace member'}</div>
</div>
<div className="rounded-2xl border border-white/10 bg-slate-950/40 p-4">
<div className="text-xs uppercase tracking-[0.18em] text-slate-500">Authentication</div>
<div className="mt-2 font-medium text-white">{overview?.authStatus.authenticationStatusLabel || 'Loading…'}</div>
</div>
<div className="rounded-2xl border border-white/10 bg-slate-950/40 p-4">
<div className="text-xs uppercase tracking-[0.18em] text-slate-500">Verification</div>
<div className="mt-2 font-medium text-white">{overview?.authStatus.verificationStatusLabel || 'Loading…'}</div>
</div>
</div>
</div>
</div>
</CardBox>
<CardBox>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-gray-500 dark:text-slate-400">Admin actions</p>
<h2 className="mt-1 text-2xl font-semibold">Authentication and operator controls</h2>
</div>
<div className="rounded-2xl bg-cyan-500/10 p-3 text-cyan-600 dark:text-cyan-300">
<BaseIcon path={icon.mdiCogOutline} size={24} />
</div>
</div>
<div className="mt-6 grid gap-3">
<BaseButton
color="info"
label={isEnabling ? 'Enabling…' : overview?.authStatus.actionLabel || 'Enable authentication'}
icon={icon.mdiShieldCheck}
disabled={isEnabling}
onClick={handleEnableAuthentication}
/>
<BaseButton
color="white"
outline
label={isSendingVerification ? 'Sending verification…' : 'Send verification email'}
icon={icon.mdiEmailFastOutline}
disabled={isSendingVerification}
onClick={handleSendVerification}
/>
<BaseButton
color="white"
outline
label={isSendingReset ? 'Sending reset…' : 'Send password reset email'}
icon={icon.mdiLockReset}
disabled={isSendingReset}
onClick={handleSendPasswordReset}
/>
<BaseButton
color="white"
outline
label={isLoading ? 'Refreshing…' : 'Refresh analytics'}
icon={icon.mdiRefresh}
disabled={isLoading}
onClick={() => loadOverview(selectedRange)}
/>
</div>
<div className="mt-6 space-y-3 text-sm leading-7 text-gray-600 dark:text-slate-300">
<div className="rounded-2xl border border-gray-100 p-4 dark:border-slate-800">
<strong className="block text-base text-gray-900 dark:text-white">Live control plane</strong>
Every action here hits a real backend endpoint, then refreshes the authenticated user context.
</div>
<div className="rounded-2xl border border-gray-100 p-4 dark:border-slate-800">
<strong className="block text-base text-gray-900 dark:text-white">Handoff score</strong>
{handoff ? `${handoff.readyCount}/${handoff.totalCount} launch checks are currently passing.` : 'Loading launch readiness…'}
</div>
</div>
</CardBox>
</div>
<div className="mt-6 grid gap-6 md:grid-cols-2 xl:grid-cols-4">
{primaryStats.map((card) => (
<StatCard key={card.title} {...card} />
))}
</div>
<div className="mt-6 grid gap-6 md:grid-cols-2 xl:grid-cols-4">
{businessStats.map((card) => (
<StatCard key={card.title} {...card} />
))}
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-[1.35fr_0.65fr]">
<CardBox className="border border-white/10 bg-slate-950 text-white">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">Activity analytics</p>
<h2 className="mt-1 text-2xl font-semibold">{rangeLabel} of profile, conversation, and message growth</h2>
</div>
<div className="rounded-2xl bg-cyan-500/10 p-3 text-cyan-300">
<BaseIcon path={icon.mdiChartTimelineVariant} size={26} />
</div>
</div>
<div className="h-80">
<Line data={activityData} options={activityOptions} />
</div>
</CardBox>
<BarChartCard
title="Agent providers"
description="Which AI endpoint providers are active in the workspace"
rows={overview?.charts.endpointProviders || []}
emptyLabel={isLoading ? 'Loading provider data…' : 'No agent endpoint provider activity is available yet.'}
/>
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-2">
<BarChartCard
title="Conversation channels"
description={`Channel distribution for ${rangeLabel.toLowerCase()}`}
rows={overview?.charts.channels || []}
emptyLabel={isLoading ? 'Loading channel data…' : 'No conversation channel data is available in this range.'}
/>
<BarChartCard
title="Conversation status"
description="Open, closed, and archived conversation mix"
rows={overview?.charts.conversationStatus || []}
emptyLabel={isLoading ? 'Loading conversation status data…' : 'No conversation status data is available yet.'}
/>
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-3">
<BarChartCard
title="Message senders"
description="Who is generating the conversation volume"
rows={overview?.charts.messageSenders || []}
emptyLabel={isLoading ? 'Loading sender data…' : 'No message sender data is available yet.'}
/>
<BarChartCard
title="Announcement status"
description="Publishing readiness for operational announcements"
rows={overview?.charts.announcementStatus || []}
emptyLabel={isLoading ? 'Loading announcement data…' : 'No announcement status data is available yet.'}
/>
<BarChartCard
title="Content mix"
description="Widget and asset composition across the workspace"
rows={(overview?.charts.widgetTypes || []).slice(0, 4).concat((overview?.charts.assetTypes || []).slice(0, 4))}
emptyLabel={isLoading ? 'Loading content composition…' : 'No widget or asset mix data is available yet.'}
/>
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-2">
<CardBox>
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-gray-500 dark:text-slate-400">Profile controls</p>
<h2 className="mt-1 text-2xl font-semibold">Edit the current MAi Server operator</h2>
</div>
<div className="rounded-2xl bg-cyan-500/10 p-3 text-cyan-600 dark:text-cyan-300">
<BaseIcon path={icon.mdiAccountCircleOutline} size={24} />
</div>
</div>
<form onSubmit={handleSaveProfile}>
<FormField label="First name" labelFor="firstName">
<input id="firstName" name="firstName" value={profileValues.firstName} onChange={handleProfileChange} placeholder="First name" />
</FormField>
<FormField label="Last name" labelFor="lastName">
<input id="lastName" name="lastName" value={profileValues.lastName} onChange={handleProfileChange} placeholder="Last name" />
</FormField>
<FormField label="Phone number" labelFor="phoneNumber">
<input id="phoneNumber" name="phoneNumber" value={profileValues.phoneNumber} onChange={handleProfileChange} placeholder="Phone number" />
</FormField>
<FormField label="Email" labelFor="email" help="Email is managed by the account identity provider.">
<input id="email" name="email" value={overview?.authStatus.email || currentUser?.email || ''} disabled readOnly />
</FormField>
<BaseButton type="submit" color="info" label={isProfileSaving ? 'Saving profile…' : 'Save profile'} disabled={isProfileSaving} icon={icon.mdiDatabaseOutline} />
</form>
</CardBox>
<CardBox>
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-gray-500 dark:text-slate-400">Password controls</p>
<h2 className="mt-1 text-2xl font-semibold">Update credentials without leaving MAi Server</h2>
</div>
<div className="rounded-2xl bg-cyan-500/10 p-3 text-cyan-600 dark:text-cyan-300">
<BaseIcon path={icon.mdiLockReset} size={24} />
</div>
</div>
<form onSubmit={handlePasswordSubmit}>
<FormField label="Current password" labelFor="currentPassword">
<input id="currentPassword" name="currentPassword" type="password" value={passwordValues.currentPassword} onChange={handlePasswordChange} placeholder="Current password" />
</FormField>
<FormField label="New password" labelFor="newPassword">
<input id="newPassword" name="newPassword" type="password" value={passwordValues.newPassword} onChange={handlePasswordChange} placeholder="New password" />
</FormField>
<FormField label="Confirm new password" labelFor="confirmPassword">
<input id="confirmPassword" name="confirmPassword" type="password" value={passwordValues.confirmPassword} onChange={handlePasswordChange} placeholder="Confirm new password" />
</FormField>
<BaseButton type="submit" color="info" label={isPasswordSaving ? 'Updating password…' : 'Update password'} disabled={isPasswordSaving} icon={icon.mdiShieldCheckOutline} />
</form>
</CardBox>
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-[0.9fr_1.1fr]">
<CardBox>
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-gray-500 dark:text-slate-400">Production handoff</p>
<h2 className="mt-1 text-2xl font-semibold">Launch checklist built from live workspace data</h2>
</div>
<div className="rounded-2xl bg-cyan-500/10 p-3 text-cyan-600 dark:text-cyan-300">
<BaseIcon path={icon.mdiRocketLaunchOutline} size={24} />
</div>
</div>
<div className="rounded-3xl border border-gray-100 p-5 dark:border-slate-800">
<div className="flex items-end justify-between gap-4">
<div>
<p className="text-sm uppercase tracking-[0.18em] text-gray-500 dark:text-slate-400">Readiness</p>
<p className="mt-2 text-4xl font-semibold">{handoff ? `${handoff.completionRate}%` : '—'}</p>
</div>
<div className="text-right text-sm text-gray-500 dark:text-slate-400">
{handoff ? `${handoff.readyCount}/${handoff.totalCount} checks passing` : 'Loading checks…'}
</div>
</div>
<div className="mt-4 h-3 overflow-hidden rounded-full bg-gray-100 dark:bg-slate-800">
<div className="h-full rounded-full bg-gradient-to-r from-cyan-500 to-emerald-500" style={{ width: `${handoff?.completionRate || 0}%` }} />
</div>
</div>
<div className="mt-5 space-y-3">
{handoffItems.map((item) => (
<div key={item.key} className="rounded-2xl border border-gray-100 p-4 dark:border-slate-800">
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2 text-base font-semibold">
<BaseIcon path={item.status ? icon.mdiCheckCircleOutline : icon.mdiAlertCircleOutline} size={18} className={item.status ? 'text-emerald-500' : 'text-amber-500'} />
{item.label}
</div>
<p className="mt-2 text-sm leading-6 text-gray-600 dark:text-slate-300">{item.description}</p>
</div>
<div className="min-w-[120px] text-right text-sm font-medium text-gray-500 dark:text-slate-400">{item.value}</div>
</div>
</div>
))}
</div>
</CardBox>
<div className="grid gap-6 lg:grid-cols-3">
<CardBox>
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-gray-500 dark:text-slate-400">Recent profiles</p>
<h2 className="mt-1 text-xl font-semibold">Latest onboarding records</h2>
</div>
<BaseIcon path={icon.mdiAccountGroupOutline} size={22} className="text-cyan-500" />
</div>
<div className="space-y-3">
{overview?.recentProfiles.length ? overview.recentProfiles.map((profile) => (
<div key={profile.id} className="rounded-2xl border border-gray-100 p-4 dark:border-slate-800">
<div className="font-semibold">{profile.name}</div>
<div className="mt-1 text-sm text-gray-500 dark:text-slate-400">{profile.email}</div>
<div className="mt-2 text-xs uppercase tracking-[0.18em] text-gray-400 dark:text-slate-500">{profile.provider} {profile.statusLabel}</div>
<div className="mt-2 text-xs text-gray-500 dark:text-slate-400">Created {formatDateTime(profile.createdAt)}</div>
</div>
)) : <div className="rounded-2xl border border-dashed border-gray-300 p-6 text-sm text-gray-500 dark:border-slate-700 dark:text-slate-400">No profile records are visible for this range.</div>}
</div>
</CardBox>
<CardBox>
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-gray-500 dark:text-slate-400">Recent projects</p>
<h2 className="mt-1 text-xl font-semibold">Workspace launch surfaces</h2>
</div>
<BaseIcon path={icon.mdiViewDashboardOutline} size={22} className="text-cyan-500" />
</div>
<div className="space-y-3">
{overview?.recentProjects.length ? overview.recentProjects.map((project) => (
<div key={project.id} className="rounded-2xl border border-gray-100 p-4 dark:border-slate-800">
<div className="font-semibold">{project.title}</div>
<div className="mt-2 text-xs uppercase tracking-[0.18em] text-gray-400 dark:text-slate-500">{project.visibility} {project.statusLabel}</div>
<div className="mt-2 text-xs text-gray-500 dark:text-slate-400">Updated {formatDateTime(project.updatedAt)}</div>
</div>
)) : <div className="rounded-2xl border border-dashed border-gray-300 p-6 text-sm text-gray-500 dark:border-slate-700 dark:text-slate-400">No project records are visible.</div>}
</div>
</CardBox>
<CardBox>
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-gray-500 dark:text-slate-400">Recent endpoints</p>
<h2 className="mt-1 text-xl font-semibold">Current AI connectors</h2>
</div>
<BaseIcon path={icon.mdiLinkVariant} size={22} className="text-cyan-500" />
</div>
<div className="space-y-3">
{overview?.recentAgentEndpoints.length ? overview.recentAgentEndpoints.map((endpoint) => (
<div key={endpoint.id} className="rounded-2xl border border-gray-100 p-4 dark:border-slate-800">
<div className="font-semibold">{endpoint.name}</div>
<div className="mt-2 text-xs uppercase tracking-[0.18em] text-gray-400 dark:text-slate-500">{endpoint.provider} {endpoint.status}</div>
<div className="mt-2 text-xs text-gray-500 dark:text-slate-400">{endpoint.requiresAuth ? 'Requires authentication' : 'Public access'} Updated {formatDateTime(endpoint.updatedAt)}</div>
</div>
)) : <div className="rounded-2xl border border-dashed border-gray-300 p-6 text-sm text-gray-500 dark:border-slate-700 dark:text-slate-400">No agent endpoint records are visible.</div>}
</div>
</CardBox>
</div>
</div>
</SectionMain>
</>
);
};
MaiServerPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};
export default MaiServerPage;

View File

@ -0,0 +1,553 @@
import * as icon from '@mdi/js';
import axios from 'axios';
import {
BarElement,
CategoryScale,
Chart as ChartJS,
Filler,
Legend,
LinearScale,
LineElement,
PointElement,
Tooltip,
} from 'chart.js';
import Head from 'next/head';
import React from 'react';
import { Bar, Line } from 'react-chartjs-2';
import type { ReactElement } from 'react';
import BaseButton from '../../components/BaseButton';
import BaseIcon from '../../components/BaseIcon';
import CardBox from '../../components/CardBox';
import LayoutGuest from '../../layouts/Guest';
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, BarElement, Tooltip, Legend, Filler);
type ChartBucket = Array<{ label: string; value: number }>;
type PublicOverview = {
range: {
days: number;
label: string;
};
summary: {
profiles: number;
verifiedProfiles: number;
disabledProfiles: number;
conversations: number;
messages: number;
announcements: number;
};
windowSummary: {
profiles: number;
conversations: number;
messages: number;
announcements: number;
};
businessSummary: {
projects: number;
pages: number;
publishedPages: number;
linkCollections: number;
publicCollections: number;
externalLinks: number;
widgets: number;
activeWidgets: number;
agentEndpoints: number;
activeAgentEndpoints: number;
securedAgentEndpoints: number;
mediaChannels: number;
mediaStreams: number;
activeMediaStreams: number;
assets: number;
};
charts: {
activity: {
labels: string[];
profiles: number[];
conversations: number[];
messages: number[];
};
channels: ChartBucket;
announcementStatus: ChartBucket;
conversationStatus: ChartBucket;
messageSenders: ChartBucket;
endpointProviders: ChartBucket;
widgetTypes: ChartBucket;
assetTypes: ChartBucket;
};
handoff: {
readyCount: number;
totalCount: number;
completionRate: number;
items: Array<{
key: string;
label: string;
description: string;
status: boolean;
value: string;
}>;
};
};
const rangeOptions = [7, 30, 90];
const chartPalette = ['#38bdf8', '#34d399', '#f59e0b', '#a78bfa', '#fb7185', '#22c55e', '#f97316', '#06b6d4'];
function formatMetric(value: number | null | undefined) {
if (value === null || value === undefined) {
return '0';
}
return value.toLocaleString();
}
function buildBarData(title: string, rows: ChartBucket) {
return {
labels: rows.map((row) => row.label),
datasets: [
{
label: title,
data: rows.map((row) => row.value),
backgroundColor: rows.map((_, index) => chartPalette[index % chartPalette.length]),
borderRadius: 12,
},
],
};
}
const barOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false,
},
},
scales: {
y: {
beginAtZero: true,
ticks: {
precision: 0,
},
},
},
};
function StatCard({
title,
value,
subtitle,
iconPath,
accent,
}: {
title: string;
value: string;
subtitle: string;
iconPath: string;
accent: string;
}) {
return (
<CardBox className={`border border-white/10 bg-gradient-to-br ${accent} text-white`}>
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-300">{title}</p>
<p className="mt-4 text-3xl font-semibold">{value}</p>
<p className="mt-2 text-sm text-slate-300">{subtitle}</p>
</div>
<div className="rounded-2xl bg-white/10 p-3">
<BaseIcon path={iconPath} size={26} />
</div>
</div>
</CardBox>
);
}
function BarChartCard({
title,
description,
rows,
emptyLabel,
}: {
title: string;
description: string;
rows: ChartBucket;
emptyLabel: string;
}) {
return (
<CardBox>
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-gray-500 dark:text-slate-400">{title}</p>
<h3 className="mt-1 text-xl font-semibold">{description}</h3>
</div>
<div className="rounded-2xl bg-cyan-500/10 p-3 text-cyan-600 dark:text-cyan-300">
<BaseIcon path={icon.mdiChartDonut} size={22} />
</div>
</div>
{rows.length ? (
<div className="h-72">
<Bar data={buildBarData(title, rows)} options={barOptions} />
</div>
) : (
<div className="rounded-2xl border border-dashed border-gray-300 px-4 py-10 text-sm text-gray-500 dark:border-slate-700 dark:text-slate-400">
{emptyLabel}
</div>
)}
</CardBox>
);
}
export default function MaiServerPublicPage() {
const [selectedRange, setSelectedRange] = React.useState(30);
const [overview, setOverview] = React.useState<PublicOverview | null>(null);
const [isLoading, setIsLoading] = React.useState(true);
const [loadError, setLoadError] = React.useState('');
const loadOverview = React.useCallback(async (range = selectedRange) => {
try {
setIsLoading(true);
setLoadError('');
const response = await axios.get('/mai-server-public/overview', {
params: { range },
});
setOverview(response.data);
} catch (error) {
console.error('Failed to load public MAi Server overview', error);
setLoadError('Unable to load the public marketing overview right now.');
} finally {
setIsLoading(false);
}
}, [selectedRange]);
React.useEffect(() => {
loadOverview(selectedRange);
}, [loadOverview, selectedRange]);
const rangeLabel = overview?.range?.label || `Last ${selectedRange} days`;
const activityData = React.useMemo(
() => ({
labels: overview?.charts.activity.labels || [],
datasets: [
{
label: 'Profiles',
data: overview?.charts.activity.profiles || [],
borderColor: '#38bdf8',
backgroundColor: 'rgba(56, 189, 248, 0.16)',
fill: true,
tension: 0.35,
},
{
label: 'Conversations',
data: overview?.charts.activity.conversations || [],
borderColor: '#34d399',
backgroundColor: 'rgba(52, 211, 153, 0.16)',
fill: true,
tension: 0.35,
},
{
label: 'Messages',
data: overview?.charts.activity.messages || [],
borderColor: '#f59e0b',
backgroundColor: 'rgba(245, 158, 11, 0.16)',
fill: true,
tension: 0.35,
},
],
}),
[overview],
);
const activityOptions = React.useMemo(
() => ({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
labels: {
color: '#cbd5e1',
},
},
},
scales: {
x: {
ticks: {
color: '#cbd5e1',
},
grid: {
color: 'rgba(148, 163, 184, 0.08)',
},
},
y: {
beginAtZero: true,
ticks: {
color: '#cbd5e1',
precision: 0,
},
grid: {
color: 'rgba(148, 163, 184, 0.08)',
},
},
},
}),
[],
);
const heroStats = [
{
title: 'Profiles onboarded',
value: formatMetric(overview?.summary.profiles),
subtitle: `${formatMetric(overview?.windowSummary.profiles)} added in ${rangeLabel.toLowerCase()}`,
iconPath: icon.mdiAccountGroupOutline,
accent: 'from-slate-950 via-cyan-950 to-slate-900',
},
{
title: 'Conversation flow',
value: formatMetric(overview?.summary.conversations),
subtitle: `${formatMetric(overview?.windowSummary.messages)} messages flowing in ${rangeLabel.toLowerCase()}`,
iconPath: icon.mdiMessageTextOutline,
accent: 'from-emerald-950 via-slate-950 to-slate-900',
},
{
title: 'Published launch surface',
value: formatMetric(overview?.businessSummary.publishedPages),
subtitle: `${formatMetric(overview?.businessSummary.projects)} projects in workspace`,
iconPath: icon.mdiRocketLaunchOutline,
accent: 'from-indigo-950 via-slate-950 to-slate-900',
},
{
title: 'AI endpoints ready',
value: formatMetric(overview?.businessSummary.activeAgentEndpoints),
subtitle: `${formatMetric(overview?.businessSummary.securedAgentEndpoints)} secured endpoints`,
iconPath: icon.mdiServerSecurity,
accent: 'from-violet-950 via-slate-950 to-slate-900',
},
];
const businessStats = [
{
title: 'Media channels',
value: formatMetric(overview?.businessSummary.mediaChannels),
subtitle: `${formatMetric(overview?.businessSummary.activeMediaStreams)} active streams`,
iconPath: icon.mdiBroadcast,
accent: 'from-fuchsia-950 via-slate-950 to-slate-900',
},
{
title: 'Content blocks',
value: formatMetric(overview?.businessSummary.activeWidgets),
subtitle: `${formatMetric(overview?.businessSummary.widgets)} widgets total`,
iconPath: icon.mdiViewDashboardOutline,
accent: 'from-sky-950 via-slate-950 to-slate-900',
},
{
title: 'Link reach',
value: formatMetric(overview?.businessSummary.publicCollections),
subtitle: `${formatMetric(overview?.businessSummary.externalLinks)} external links`,
iconPath: icon.mdiLinkVariant,
accent: 'from-teal-950 via-slate-950 to-slate-900',
},
{
title: 'Asset library',
value: formatMetric(overview?.businessSummary.assets),
subtitle: `${formatMetric(overview?.summary.announcements)} announcements available`,
iconPath: icon.mdiDatabaseOutline,
accent: 'from-amber-950 via-slate-950 to-slate-900',
},
];
return (
<>
<Head>
<title>MAi Server Public</title>
<meta
name="description"
content="Aliyo Momot MAi Server public overview with live product, onboarding, and operations metrics from the workspace."
/>
</Head>
<main className="min-h-screen bg-slate-950 text-white">
<section className="border-b border-white/10 bg-[radial-gradient(circle_at_top_right,_rgba(34,211,238,0.18),_transparent_32%),linear-gradient(135deg,#020617_0%,#0f172a_55%,#082f49_100%)]">
<div className="mx-auto max-w-7xl px-6 py-16 lg:px-8 lg:py-20">
<div className="grid gap-10 xl:grid-cols-[1.25fr_0.75fr] xl:items-start">
<div>
<div className="inline-flex items-center gap-2 rounded-full border border-cyan-400/30 bg-cyan-400/10 px-4 py-1 text-xs font-semibold uppercase tracking-[0.25em] text-cyan-200">
<BaseIcon path={icon.mdiServerSecurity} size={16} />
Public MAi Server
</div>
<h1 className="mt-6 text-5xl font-semibold leading-tight lg:text-6xl">
Turn your live SaaS workspace into a credible AI agent launch story.
</h1>
<p className="mt-5 max-w-3xl text-lg leading-8 text-slate-300">
This public overview surfaces real onboarding, conversation, content, and endpoint activity from the product instead of static marketing screenshots.
</p>
<div className="mt-8 flex flex-wrap gap-3">
{rangeOptions.map((range) => (
<BaseButton
key={range}
label={`Last ${range} days`}
small
color={selectedRange === range ? 'info' : 'white'}
outline={selectedRange !== range}
onClick={() => setSelectedRange(range)}
/>
))}
</div>
<div className="mt-8 flex flex-wrap gap-3">
<BaseButton color="info" label="Open private dashboard" href="/mai-server" icon={icon.mdiOpenInNew} />
<BaseButton color="white" outline label={isLoading ? 'Refreshing…' : 'Refresh live data'} icon={icon.mdiRefresh} onClick={() => loadOverview(selectedRange)} />
</div>
{loadError ? <div className="mt-6 rounded-2xl border border-red-400/20 bg-red-500/10 px-4 py-3 text-sm text-red-100">{loadError}</div> : null}
</div>
<CardBox className="border border-white/10 bg-white/5 text-white">
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">Launch readiness</p>
<h2 className="mt-2 text-3xl font-semibold">{overview ? `${overview.handoff.completionRate}% production-ready` : 'Loading…'}</h2>
<p className="mt-3 text-sm leading-7 text-slate-300">
A lightweight handoff summary built from the same app data driving the private operations dashboard.
</p>
<div className="mt-5 h-3 overflow-hidden rounded-full bg-white/10">
<div className="h-full rounded-full bg-gradient-to-r from-cyan-500 to-emerald-500" style={{ width: `${overview?.handoff.completionRate || 0}%` }} />
</div>
<div className="mt-6 space-y-3 text-sm text-slate-300">
{(overview?.handoff.items || []).slice(0, 4).map((item) => (
<div key={item.key} className="rounded-2xl border border-white/10 bg-slate-950/40 p-4">
<div className="flex items-start gap-3">
<BaseIcon path={item.status ? icon.mdiCheckCircleOutline : icon.mdiAlertCircleOutline} size={18} className={item.status ? 'text-emerald-400' : 'text-amber-400'} />
<div>
<div className="font-medium text-white">{item.label}</div>
<div className="mt-1 text-slate-400">{item.value}</div>
</div>
</div>
</div>
))}
</div>
</CardBox>
</div>
<div className="mt-10 grid gap-6 md:grid-cols-2 xl:grid-cols-4">
{heroStats.map((card) => (
<StatCard key={card.title} {...card} />
))}
</div>
<div className="mt-6 grid gap-6 md:grid-cols-2 xl:grid-cols-4">
{businessStats.map((card) => (
<StatCard key={card.title} {...card} />
))}
</div>
</div>
</section>
<section className="mx-auto max-w-7xl px-6 py-12 lg:px-8">
<div className="grid gap-6 xl:grid-cols-[1.3fr_0.7fr]">
<CardBox className="border border-white/10 bg-slate-900/80 text-white">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">Live growth curve</p>
<h2 className="mt-1 text-2xl font-semibold">{rangeLabel} of profile, conversation, and message growth</h2>
</div>
<div className="rounded-2xl bg-cyan-500/10 p-3 text-cyan-300">
<BaseIcon path={icon.mdiChartTimelineVariant} size={26} />
</div>
</div>
<div className="h-80">
<Line data={activityData} options={activityOptions} />
</div>
</CardBox>
<CardBox className="border border-white/10 bg-slate-900/80 text-white">
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">Why this matters</p>
<div className="mt-4 space-y-4 text-sm leading-7 text-slate-300">
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
<strong className="block text-base text-white">Real proof instead of mock data</strong>
Investors, customers, and partners can see actual usage movement from the live product.
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
<strong className="block text-base text-white">A direct bridge to the private workspace</strong>
The public page supports storytelling while keeping sensitive records inside the authenticated dashboard.
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
<strong className="block text-base text-white">Operational depth for an AI agent SaaS</strong>
Endpoint, widget, media, and asset signals make the platform look like a real product system, not a landing page shell.
</div>
</div>
</CardBox>
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-2">
<BarChartCard
title="Conversation channels"
description={`Channel distribution for ${rangeLabel.toLowerCase()}`}
rows={overview?.charts.channels || []}
emptyLabel={isLoading ? 'Loading channel data…' : 'No channel activity is available in this range yet.'}
/>
<BarChartCard
title="Endpoint providers"
description="Current AI integration provider footprint"
rows={overview?.charts.endpointProviders || []}
emptyLabel={isLoading ? 'Loading endpoint provider data…' : 'No endpoint provider data is available yet.'}
/>
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-3">
<BarChartCard
title="Conversation status"
description="Open, closed, and archived thread mix"
rows={overview?.charts.conversationStatus || []}
emptyLabel={isLoading ? 'Loading conversation status…' : 'No conversation status data is available yet.'}
/>
<BarChartCard
title="Message senders"
description="How much activity comes from users, assistants, and system flows"
rows={overview?.charts.messageSenders || []}
emptyLabel={isLoading ? 'Loading sender data…' : 'No message sender data is available yet.'}
/>
<BarChartCard
title="Announcement status"
description="Readiness of launch and operational messaging"
rows={overview?.charts.announcementStatus || []}
emptyLabel={isLoading ? 'Loading announcement status…' : 'No announcement data is available yet.'}
/>
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
<BarChartCard
title="Content composition"
description="Combined widget and asset mix across the workspace"
rows={(overview?.charts.widgetTypes || []).slice(0, 4).concat((overview?.charts.assetTypes || []).slice(0, 4))}
emptyLabel={isLoading ? 'Loading content composition…' : 'No widget or asset type data is available yet.'}
/>
<CardBox>
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-gray-500 dark:text-slate-400">Production handoff checklist</p>
<h2 className="mt-1 text-2xl font-semibold">What a buyer or stakeholder can trust from this page</h2>
</div>
<div className="rounded-2xl bg-cyan-500/10 p-3 text-cyan-600 dark:text-cyan-300">
<BaseIcon path={icon.mdiRocketLaunchOutline} size={24} />
</div>
</div>
<div className="space-y-3">
{(overview?.handoff.items || []).map((item) => (
<div key={item.key} className="rounded-2xl border border-gray-100 p-4 dark:border-slate-800">
<div className="flex items-start gap-3">
<BaseIcon path={item.status ? icon.mdiCheckCircleOutline : icon.mdiAlertCircleOutline} size={18} className={item.status ? 'text-emerald-500' : 'text-amber-500'} />
<div>
<div className="font-semibold">{item.label}</div>
<div className="mt-1 text-sm text-gray-600 dark:text-slate-300">{item.description}</div>
<div className="mt-2 text-xs uppercase tracking-[0.18em] text-gray-400 dark:text-slate-500">{item.value}</div>
</div>
</div>
</div>
))}
</div>
</CardBox>
</div>
</section>
</main>
</>
);
}
MaiServerPublicPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};