diff --git a/backend/src/index.js b/backend/src/index.js index 2bc1c54..d473575 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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 }), diff --git a/backend/src/routes/maiServer.js b/backend/src/routes/maiServer.js new file mode 100644 index 0000000..5482441 --- /dev/null +++ b/backend/src/routes/maiServer.js @@ -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; diff --git a/backend/src/routes/maiServerPublic.js b/backend/src/routes/maiServerPublic.js new file mode 100644 index 0000000..4662373 --- /dev/null +++ b/backend/src/routes/maiServerPublic.js @@ -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; diff --git a/backend/src/services/maiServer.js b/backend/src/services/maiServer.js new file mode 100644 index 0000000..a5b0681 --- /dev/null +++ b/backend/src/services/maiServer.js @@ -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; + } + } +}; diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index 89767ec..0c65f89 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -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: [ diff --git a/frontend/src/pages/mai-server.tsx b/frontend/src/pages/mai-server.tsx index c2d6ec9..1c3ae02 100644 --- a/frontend/src/pages/mai-server.tsx +++ b/frontend/src/pages/mai-server.tsx @@ -1,328 +1,974 @@ 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 axios from 'axios'; +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 { hasPermission } from '../helpers/userPermissions'; import LayoutAuthenticated from '../layouts/Authenticated'; -import { useAppSelector } from '../stores/hooks'; +import { findMe } from '../stores/authSlice'; +import { useAppDispatch, useAppSelector } from '../stores/hooks'; -type MetricState = Record<'profiles' | 'conversations' | 'messages' | 'announcements', string>; +ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, BarElement, Tooltip, Legend, Filler); -type MetricConfig = { - key: keyof MetricState; - title: string; - detail: string; - endpoint: string; - permission: string; - iconPath: string; +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; }; -const moduleLabels = [ - 'Ai', - 'AS Ai', - 'MAI', - 'MAI SERVER ALIYO MOMOT', - 'D MOTHERBOARD', - 'D RAM', - 'D ROM', - 'D WLAN', - 'ALIYO MOMOT BANK ALIYO MOMOT', -]; +type Summary = { + profiles: number | null; + verifiedProfiles: number | null; + disabledProfiles: number | null; + conversations: number | null; + messages: number | null; + announcements: number | null; +}; -const readinessItems = [ - { label: 'Register', description: 'Account onboarding is available through the existing sign up flow.' }, - { label: 'Authentification', description: 'Session access can be enabled directly from this control page.' }, - { label: 'Profile', description: 'Profile data stays synced with the authenticated application user.' }, - { label: 'Password', description: 'Password reset and recovery reuse the built-in auth endpoints.' }, - { label: 'Country', description: 'Regional profile details can be completed from the profile screen.' }, -]; +type WindowSummary = { + profiles: number | null; + conversations: number | null; + messages: number | null; + announcements: number | null; +}; -const adminItems = [ - { label: 'Admin', description: 'Administrator-level visibility is preserved through the role model.' }, - { label: 'Profile database', description: 'Profile records are available through the existing user management module.' }, - { label: 'iSecurity', description: 'Email verification and JWT session protection stay active for secure access.' }, - { label: 'iManager', description: 'Operational shortcuts stay routed through dashboard, profile, and entity pages.' }, - { label: 'iAuthentifier', description: 'Authentication status is surfaced clearly for the current session.' }, -]; +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; +}; -const metricConfigs: MetricConfig[] = [ - { - key: 'profiles', - title: 'Profiles', - detail: 'Registered profile records', - endpoint: '/users/count', - permission: 'READ_USERS', - iconPath: icon.mdiAccountGroup, - }, - { - key: 'conversations', - title: 'Marketing conversations', - detail: 'Lead and outreach threads', - endpoint: '/conversations/count', - permission: 'READ_CONVERSATIONS', - iconPath: icon.mdiChartTimelineVariant, - }, - { - key: 'messages', - title: 'Message flow', - detail: 'Communication volume', - endpoint: '/messages/count', - permission: 'READ_MESSAGES', - iconPath: icon.mdiMessageTextOutline, - }, - { - key: 'announcements', - title: 'Marketing broadcasts', - detail: 'Announcement and campaign records', - endpoint: '/announcements/count', - permission: 'READ_ANNOUNCEMENTS', - iconPath: icon.mdiBullhornOutline, - }, -]; +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; + 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 ( + +
+
+

{title}

+

{value}

+

{subtitle}

+
+
+ +
+
+
+ ); +} + +function BarChartCard({ + title, + description, + rows, + emptyLabel, +}: { + title: string; + description: string; + rows: ChartBucket; + emptyLabel: string; +}) { + return ( + +
+
+

{title}

+

{description}

+
+
+ +
+
+ {rows.length ? ( +
+ +
+ ) : ( +
+ {emptyLabel} +
+ )} +
+ ); +} const MaiServerPage = () => { + const dispatch = useAppDispatch(); const { currentUser } = useAppSelector((state) => state.auth); - const iconsColor = useAppSelector((state) => state.style.iconsColor); const corners = useAppSelector((state) => state.style.corners); - const cardsStyle = useAppSelector((state) => state.style.cardsStyle); - const [isAuthenticationEnabled, setIsAuthenticationEnabled] = React.useState(false); - const [metrics, setMetrics] = React.useState({ - profiles: '—', - conversations: '—', - messages: '—', - announcements: '—', + const [selectedRange, setSelectedRange] = React.useState(30); + const [overview, setOverview] = React.useState(null); + const [isLoading, setIsLoading] = React.useState(true); + const [loadError, setLoadError] = React.useState(''); + const [actionTone, setActionTone] = React.useState('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(() => { - if (!currentUser) return; + loadOverview(selectedRange); + }, [loadOverview, selectedRange]); - const loadMetrics = async () => { - const requests = metricConfigs.map(async (metric) => { - if (!hasPermission(currentUser, metric.permission)) { - return { key: metric.key, value: 'Private' }; - } - - try { - const response = await axios.get(metric.endpoint); - return { key: metric.key, value: String(response.data?.count ?? 0) }; - } catch (error) { - console.error(`Failed to load ${metric.key} metric`, error); - return { key: metric.key, value: 'Unavailable' }; - } - }); - - const results = await Promise.all(requests); - - setMetrics((previous) => { - const next = { ...previous }; - - results.forEach((result) => { - next[result.key] = result.value; - }); - - return next; - }); - }; - - loadMetrics(); + React.useEffect(() => { + setProfileValues({ + firstName: currentUser?.firstName || '', + lastName: currentUser?.lastName || '', + phoneNumber: currentUser?.phoneNumber || '', + }); }, [currentUser]); - const fullName = [currentUser?.firstName, currentUser?.lastName].filter(Boolean).join(' '); - const displayName = fullName || currentUser?.email || 'Authenticated user'; - const emailAddress = currentUser?.email || 'No email connected'; - const provider = currentUser?.provider || 'local'; - const roleName = currentUser?.app_role?.name || 'Workspace member'; - const countryValueRaw = currentUser?.country || currentUser?.organization?.country; - const countryName = typeof countryValueRaw === 'string' && countryValueRaw ? countryValueRaw : 'Not configured'; - const emailVerificationStatus = currentUser?.emailVerified ? 'Verified' : 'Pending'; - const authenticationStatus = isAuthenticationEnabled ? 'Enabled for this session' : 'Standby'; - const authenticationTone = isAuthenticationEnabled ? 'text-green-600 dark:text-green-400' : 'text-amber-600 dark:text-amber-400'; + 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) => { + const { name, value } = event.target; + setProfileValues((previous) => ({ + ...previous, + [name]: value, + })); + }; + + const handlePasswordChange = (event: React.ChangeEvent) => { + const { name, value } = event.target; + setPasswordValues((previous) => ({ + ...previous, + [name]: value, + })); + }; + + const handleSaveProfile = async (event: React.FormEvent) => { + 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) => { + 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 ( <> - {getPageTitle('Aliyo Momot MAi Server')} + {getPageTitle('MAi Server')} - - {''} + + - -
-
-

- MAi server Aliyo Momot 1 and 2 · Server login -

-

- Branded authentication, profiles, email access, and marketing visibility in one place. -

-

- This separate in-app dashboard keeps the existing login flow intact while giving you a dedicated - Aliyo Momot MAi Server experience for server entry readiness, profile context, email visibility, and - high-level marketing statistics. -

-
-
- setIsAuthenticationEnabled((previous) => !previous)} - /> - -
-
+ {actionMessage ? ( + + {actionMessage} + + ) : null} -
- {moduleLabels.map((label) => ( -
- {label} -
- ))} -
-
+ {loadError ? ( + + {loadError} + + ) : null} -
- {metricConfigs.map((metric) => ( - -
-
-

{metric.title}

-

{metrics[metric.key]}

-

{metric.detail}

+
+ +
+
+
+ + Aliyo Momot MAi Server +
+

+ Operate onboarding, authentication, and live business metrics from one control room. +

+

+ This private dashboard now combines real account controls, live workspace analytics, and a production handoff checklist built from your existing Flatlogic entities. +

+
+ {rangeOptions.map((range) => ( + setSelectedRange(range)} + /> + ))}
-
- + +
+
+
+

Operator status

+

{overview?.authStatus.displayName || currentUser?.email || 'Loading…'}

+
+
+ +
+
+
+
+
Email
+
{overview?.authStatus.email || currentUser?.email || '—'}
+
+
+
Role
+
{overview?.authStatus.roleName || 'Workspace member'}
+
+
+
Authentication
+
{overview?.authStatus.authenticationStatusLabel || 'Loading…'}
+
+
+
Verification
+
{overview?.authStatus.verificationStatusLabel || 'Loading…'}
+
+
+
+
+
+ + +
+
+

Admin actions

+

Authentication and operator controls

+
+
+ +
+
+
+ + + + loadOverview(selectedRange)} + /> +
+
+
+ Live control plane + Every action here hits a real backend endpoint, then refreshes the authenticated user context. +
+
+ Handoff score + {handoff ? `${handoff.readyCount}/${handoff.totalCount} launch checks are currently passing.` : 'Loading launch readiness…'} +
+
+
+
+ +
+ {primaryStats.map((card) => ( + ))}
-
- -
- +
+ {businessStats.map((card) => ( + + ))} +
+ +
+ +
-

Register & profile

-

Profile, password, and regional setup blocks

+

Activity analytics

+

{rangeLabel} of profile, conversation, and message growth

+
+
+
- -
- {readinessItems.map((item) => ( -
- -
-

{item.label}

-

{item.description}

-
-
- ))} -
- -
-

Current user

-

{displayName}

-

{roleName}

-

Country / region: {countryName}

+
+
+ +
+ +
+ + +
+ +
+ + + +
+ +
-
- +
-

Admin profile database

-

Administrative controls inspired by the shared mockup

-
-
- -
- {adminItems.map((item) => ( -
- -
-

{item.label}

-

{item.description}

-
-
- ))} -
- -
-
-

Server entry database

-

Protected

-

Access remains tied to your authenticated app session.

-
-
-

Email database

-

{emailVerificationStatus}

-

Primary email readiness for outreach and verification.

+

Profile controls

+

Edit the current MAi Server operator

+
+
+
+
+ + + + + + + + + + + + + + -
- +
-

Server login

-

Email and authentication details for the current operator

+

Password controls

+

Update credentials without leaving MAi Server

+
+
+
+
+ + + + + + + + + + + + +
- - - - - - - - - - - - - -
-
+
+ +
+
+

Production handoff

+

Launch checklist built from live workspace data

+
+
+ +
+
+
+
-

Live status

-

{authenticationStatus}

+

Readiness

+

{handoff ? `${handoff.completionRate}%` : '—'}

- +
+ {handoff ? `${handoff.readyCount}/${handoff.totalCount} checks passing` : 'Loading checks…'} +
+
+
+
- -
- setIsAuthenticationEnabled(true)} /> - - +
+ {handoffItems.map((item) => ( +
+
+
+
+ + {item.label} +
+

{item.description}

+
+
{item.value}
+
+
+ ))}
+ +
+ +
+
+

Recent profiles

+

Latest onboarding records

+
+ +
+
+ {overview?.recentProfiles.length ? overview.recentProfiles.map((profile) => ( +
+
{profile.name}
+
{profile.email}
+
{profile.provider} • {profile.statusLabel}
+
Created {formatDateTime(profile.createdAt)}
+
+ )) :
No profile records are visible for this range.
} +
+
+ + +
+
+

Recent projects

+

Workspace launch surfaces

+
+ +
+
+ {overview?.recentProjects.length ? overview.recentProjects.map((project) => ( +
+
{project.title}
+
{project.visibility} • {project.statusLabel}
+
Updated {formatDateTime(project.updatedAt)}
+
+ )) :
No project records are visible.
} +
+
+ + +
+
+

Recent endpoints

+

Current AI connectors

+
+ +
+
+ {overview?.recentAgentEndpoints.length ? overview.recentAgentEndpoints.map((endpoint) => ( +
+
{endpoint.name}
+
{endpoint.provider} • {endpoint.status}
+
{endpoint.requiresAuth ? 'Requires authentication' : 'Public access'} • Updated {formatDateTime(endpoint.updatedAt)}
+
+ )) :
No agent endpoint records are visible.
} +
+
+
diff --git a/frontend/src/pages/web_pages/mai-server.tsx b/frontend/src/pages/web_pages/mai-server.tsx new file mode 100644 index 0000000..d9605d7 --- /dev/null +++ b/frontend/src/pages/web_pages/mai-server.tsx @@ -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 ( + +
+
+

{title}

+

{value}

+

{subtitle}

+
+
+ +
+
+
+ ); +} + +function BarChartCard({ + title, + description, + rows, + emptyLabel, +}: { + title: string; + description: string; + rows: ChartBucket; + emptyLabel: string; +}) { + return ( + +
+
+

{title}

+

{description}

+
+
+ +
+
+ {rows.length ? ( +
+ +
+ ) : ( +
+ {emptyLabel} +
+ )} +
+ ); +} + +export default function MaiServerPublicPage() { + const [selectedRange, setSelectedRange] = React.useState(30); + const [overview, setOverview] = React.useState(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 ( + <> + + MAi Server Public + + +
+
+
+
+
+
+ + Public MAi Server +
+

+ Turn your live SaaS workspace into a credible AI agent launch story. +

+

+ This public overview surfaces real onboarding, conversation, content, and endpoint activity from the product instead of static marketing screenshots. +

+
+ {rangeOptions.map((range) => ( + setSelectedRange(range)} + /> + ))} +
+
+ + loadOverview(selectedRange)} /> +
+ {loadError ?
{loadError}
: null} +
+ + +

Launch readiness

+

{overview ? `${overview.handoff.completionRate}% production-ready` : 'Loading…'}

+

+ A lightweight handoff summary built from the same app data driving the private operations dashboard. +

+
+
+
+
+ {(overview?.handoff.items || []).slice(0, 4).map((item) => ( +
+
+ +
+
{item.label}
+
{item.value}
+
+
+
+ ))} +
+ +
+ +
+ {heroStats.map((card) => ( + + ))} +
+ +
+ {businessStats.map((card) => ( + + ))} +
+
+
+ +
+
+ +
+
+

Live growth curve

+

{rangeLabel} of profile, conversation, and message growth

+
+
+ +
+
+
+ +
+
+ + +

Why this matters

+
+
+ Real proof instead of mock data + Investors, customers, and partners can see actual usage movement from the live product. +
+
+ A direct bridge to the private workspace + The public page supports storytelling while keeping sensitive records inside the authenticated dashboard. +
+
+ Operational depth for an AI agent SaaS + Endpoint, widget, media, and asset signals make the platform look like a real product system, not a landing page shell. +
+
+
+
+ +
+ + +
+ +
+ + + +
+ +
+ + + +
+
+

Production handoff checklist

+

What a buyer or stakeholder can trust from this page

+
+
+ +
+
+
+ {(overview?.handoff.items || []).map((item) => ( +
+
+ +
+
{item.label}
+
{item.description}
+
{item.value}
+
+
+
+ ))} +
+
+
+
+
+ + ); +} + +MaiServerPublicPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +};