This commit is contained in:
Flatlogic Bot 2026-05-15 19:17:21 +00:00
parent eb455b41dd
commit 83cdb092cd
53 changed files with 10242 additions and 10698 deletions

View File

@ -36,6 +36,7 @@
"sequelize": "6.35.2",
"sequelize-json-schema": "^2.1.1",
"sqlite": "4.0.15",
"stripe": "^22.1.1",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.0",
"tedious": "^18.2.4"

View File

@ -0,0 +1,183 @@
module.exports = {
async up(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
await queryInterface.createTable(
'subscription_plans',
{
id: {
type: Sequelize.DataTypes.UUID,
defaultValue: Sequelize.DataTypes.UUIDV4,
primaryKey: true,
},
name: {
type: Sequelize.DataTypes.TEXT,
},
slug: {
type: Sequelize.DataTypes.TEXT,
allowNull: false,
unique: true,
},
description: {
type: Sequelize.DataTypes.TEXT,
},
price_monthly: {
type: Sequelize.DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
},
currency: {
type: Sequelize.DataTypes.TEXT,
allowNull: false,
defaultValue: 'usd',
},
stripe_product_id: {
type: Sequelize.DataTypes.TEXT,
},
stripe_price_id: {
type: Sequelize.DataTypes.TEXT,
},
message_limit: {
type: Sequelize.DataTypes.INTEGER,
},
agent_limit: {
type: Sequelize.DataTypes.INTEGER,
},
is_featured: {
type: Sequelize.DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
is_active: {
type: Sequelize.DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
},
features_json: {
type: Sequelize.DataTypes.TEXT,
},
createdById: {
type: Sequelize.DataTypes.UUID,
references: {
key: 'id',
model: 'users',
},
},
updatedById: {
type: Sequelize.DataTypes.UUID,
references: {
key: 'id',
model: 'users',
},
},
createdAt: { type: Sequelize.DataTypes.DATE },
updatedAt: { type: Sequelize.DataTypes.DATE },
deletedAt: { type: Sequelize.DataTypes.DATE },
importHash: {
type: Sequelize.DataTypes.STRING(255),
allowNull: true,
unique: true,
},
},
{ transaction },
);
await queryInterface.createTable(
'subscriptions',
{
id: {
type: Sequelize.DataTypes.UUID,
defaultValue: Sequelize.DataTypes.UUIDV4,
primaryKey: true,
},
userId: {
type: Sequelize.DataTypes.UUID,
references: {
key: 'id',
model: 'users',
},
},
planId: {
type: Sequelize.DataTypes.UUID,
references: {
key: 'id',
model: 'subscription_plans',
},
},
status: {
type: Sequelize.DataTypes.ENUM,
values: ['trialing', 'active', 'canceled', 'past_due'],
allowNull: false,
defaultValue: 'active',
},
stripe_customer_id: {
type: Sequelize.DataTypes.TEXT,
},
stripe_subscription_id: {
type: Sequelize.DataTypes.TEXT,
},
current_period_start: {
type: Sequelize.DataTypes.DATE,
},
current_period_end: {
type: Sequelize.DataTypes.DATE,
},
cancel_at_period_end: {
type: Sequelize.DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
createdById: {
type: Sequelize.DataTypes.UUID,
references: {
key: 'id',
model: 'users',
},
},
updatedById: {
type: Sequelize.DataTypes.UUID,
references: {
key: 'id',
model: 'users',
},
},
createdAt: { type: Sequelize.DataTypes.DATE },
updatedAt: { type: Sequelize.DataTypes.DATE },
deletedAt: { type: Sequelize.DataTypes.DATE },
importHash: {
type: Sequelize.DataTypes.STRING(255),
allowNull: true,
unique: true,
},
},
{ transaction },
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
async down(queryInterface) {
const transaction = await queryInterface.sequelize.transaction();
try {
await queryInterface.dropTable('subscriptions', { transaction });
await queryInterface.dropTable('subscription_plans', { transaction });
if (queryInterface.sequelize.getDialect() === 'postgres') {
await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_subscriptions_status";', {
transaction,
});
}
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
};

View File

@ -0,0 +1,70 @@
module.exports = {
async up(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
await queryInterface.createTable(
'billing_settings',
{
id: {
type: Sequelize.DataTypes.UUID,
defaultValue: Sequelize.DataTypes.UUIDV4,
primaryKey: true,
},
key: {
type: Sequelize.DataTypes.TEXT,
allowNull: false,
unique: true,
defaultValue: 'default',
},
stripe_secret_key: {
type: Sequelize.DataTypes.TEXT,
},
stripe_webhook_secret: {
type: Sequelize.DataTypes.TEXT,
},
createdById: {
type: Sequelize.DataTypes.UUID,
references: {
key: 'id',
model: 'users',
},
},
updatedById: {
type: Sequelize.DataTypes.UUID,
references: {
key: 'id',
model: 'users',
},
},
createdAt: { type: Sequelize.DataTypes.DATE },
updatedAt: { type: Sequelize.DataTypes.DATE },
deletedAt: { type: Sequelize.DataTypes.DATE },
importHash: {
type: Sequelize.DataTypes.STRING(255),
allowNull: true,
unique: true,
},
},
{ transaction },
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
async down(queryInterface) {
const transaction = await queryInterface.sequelize.transaction();
try {
await queryInterface.dropTable('billing_settings', { transaction });
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
};

View File

@ -0,0 +1,46 @@
module.exports = function (sequelize, DataTypes) {
const billing_settings = sequelize.define(
'billing_settings',
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
key: {
type: DataTypes.TEXT,
allowNull: false,
unique: true,
defaultValue: 'default',
},
stripe_secret_key: {
type: DataTypes.TEXT,
},
stripe_webhook_secret: {
type: DataTypes.TEXT,
},
importHash: {
type: DataTypes.STRING(255),
allowNull: true,
unique: true,
},
},
{
timestamps: true,
paranoid: true,
freezeTableName: true,
},
);
billing_settings.associate = (db) => {
db.billing_settings.belongsTo(db.users, {
as: 'createdBy',
});
db.billing_settings.belongsTo(db.users, {
as: 'updatedBy',
});
};
return billing_settings;
};

View File

@ -0,0 +1,88 @@
module.exports = function (sequelize, DataTypes) {
const subscription_plans = sequelize.define(
'subscription_plans',
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
name: {
type: DataTypes.TEXT,
},
slug: {
type: DataTypes.TEXT,
allowNull: false,
unique: true,
},
description: {
type: DataTypes.TEXT,
},
price_monthly: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
},
currency: {
type: DataTypes.TEXT,
allowNull: false,
defaultValue: 'usd',
},
stripe_product_id: {
type: DataTypes.TEXT,
},
stripe_price_id: {
type: DataTypes.TEXT,
},
message_limit: {
type: DataTypes.INTEGER,
},
agent_limit: {
type: DataTypes.INTEGER,
},
is_featured: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
is_active: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
},
features_json: {
type: DataTypes.TEXT,
},
importHash: {
type: DataTypes.STRING(255),
allowNull: true,
unique: true,
},
},
{
timestamps: true,
paranoid: true,
freezeTableName: true,
},
);
subscription_plans.associate = (db) => {
db.subscription_plans.hasMany(db.subscriptions, {
as: 'subscriptions_plan',
foreignKey: {
name: 'planId',
},
constraints: false,
});
db.subscription_plans.belongsTo(db.users, {
as: 'createdBy',
});
db.subscription_plans.belongsTo(db.users, {
as: 'updatedBy',
});
};
return subscription_plans;
};

View File

@ -0,0 +1,73 @@
module.exports = function (sequelize, DataTypes) {
const subscriptions = sequelize.define(
'subscriptions',
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
status: {
type: DataTypes.ENUM,
values: ['trialing', 'active', 'canceled', 'past_due'],
allowNull: false,
defaultValue: 'active',
},
stripe_customer_id: {
type: DataTypes.TEXT,
},
stripe_subscription_id: {
type: DataTypes.TEXT,
},
current_period_start: {
type: DataTypes.DATE,
},
current_period_end: {
type: DataTypes.DATE,
},
cancel_at_period_end: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
importHash: {
type: DataTypes.STRING(255),
allowNull: true,
unique: true,
},
},
{
timestamps: true,
paranoid: true,
freezeTableName: true,
},
);
subscriptions.associate = (db) => {
db.subscriptions.belongsTo(db.users, {
as: 'user',
foreignKey: {
name: 'userId',
},
constraints: false,
});
db.subscriptions.belongsTo(db.subscription_plans, {
as: 'plan',
foreignKey: {
name: 'planId',
},
constraints: false,
});
db.subscriptions.belongsTo(db.users, {
as: 'createdBy',
});
db.subscriptions.belongsTo(db.users, {
as: 'updatedBy',
});
};
return subscriptions;
};

View File

@ -172,6 +172,14 @@ provider: {
constraints: false,
});
db.users.hasMany(db.subscriptions, {
as: 'subscriptions_user',
foreignKey: {
name: 'userId',
},
constraints: false,
});
//end loop
@ -252,4 +260,3 @@ function trimStringFields(users) {
return users;
}

View File

@ -0,0 +1,88 @@
const db = require('../models');
const PLAN_DATA = [
{
name: 'Starter',
slug: 'starter',
description: 'For solo builders who want a clean AI workspace and the basics of billing.',
price_monthly: 19,
currency: 'usd',
stripe_product_id: 'prod_mock_starter',
stripe_price_id: 'price_mock_starter_monthly',
message_limit: 300,
agent_limit: 3,
is_featured: false,
is_active: true,
features_json: JSON.stringify([
'300 AI messages every month',
'3 custom agents',
'Conversation history and export',
]),
},
{
name: 'Pro',
slug: 'pro',
description: 'For power users who need more throughput, better limits, and a polished workspace.',
price_monthly: 49,
currency: 'usd',
stripe_product_id: 'prod_mock_pro',
stripe_price_id: 'price_mock_pro_monthly',
message_limit: 1500,
agent_limit: 10,
is_featured: true,
is_active: true,
features_json: JSON.stringify([
'1,500 AI messages every month',
'10 custom agents',
'Priority billing support',
]),
},
{
name: 'Team',
slug: 'team',
description: 'For heavier shared use with room for more agents, more messages, and longer workflows.',
price_monthly: 149,
currency: 'usd',
stripe_product_id: 'prod_mock_team',
stripe_price_id: 'price_mock_team_monthly',
message_limit: 5000,
agent_limit: 30,
is_featured: false,
is_active: true,
features_json: JSON.stringify([
'5,000 AI messages every month',
'30 custom agents',
'Shared workspace billing controls',
]),
},
];
module.exports = {
async up() {
const adminUser = await db.users.findOne({
order: [['createdAt', 'ASC']],
});
for (const plan of PLAN_DATA) {
const [record] = await db.subscription_plans.findOrCreate({
where: {
slug: plan.slug,
},
defaults: {
...plan,
createdById: adminUser ? adminUser.id : null,
updatedById: adminUser ? adminUser.id : null,
},
});
await record.update({
...plan,
updatedById: adminUser ? adminUser.id : null,
});
}
},
async down(queryInterface) {
await queryInterface.bulkDelete('subscription_plans', null, {});
},
};

View File

@ -36,6 +36,8 @@ const attachmentsRoutes = require('./routes/attachments');
const usage_eventsRoutes = require('./routes/usage_events');
const workspaceRoutes = require('./routes/workspace');
const billingRoutes = require('./routes/billing');
const billingWebhookRoutes = require('./routes/billingWebhook');
const getBaseUrl = (url) => {
@ -87,6 +89,7 @@ app.use('/api-docs', function (req, res, next) {
app.use(cors({origin: true}));
require('./auth/auth');
app.use('/api/billing/webhook', bodyParser.raw({ type: 'application/json' }), billingWebhookRoutes);
app.use(bodyParser.json());
app.use('/api/auth', authRoutes);
@ -111,6 +114,7 @@ app.use('/api/attachments', passport.authenticate('jwt', {session: false}), atta
app.use('/api/usage_events', passport.authenticate('jwt', {session: false}), usage_eventsRoutes);
app.use('/api/workspace', passport.authenticate('jwt', { session: false }), workspaceRoutes);
app.use('/api/billing', passport.authenticate('jwt', { session: false }), billingRoutes);
app.use(
'/api/openai',

View File

@ -0,0 +1,73 @@
const express = require('express');
const BillingService = require('../services/billing');
const wrapAsync = require('../helpers').wrapAsync;
const router = express.Router();
router.get(
'/plans',
wrapAsync(async (req, res) => {
const plans = await BillingService.listPlans();
res.status(200).send({ plans });
}),
);
router.get(
'/current',
wrapAsync(async (req, res) => {
const subscription = await BillingService.getCurrentSubscription(req.currentUser);
res.status(200).send({ subscription });
}),
);
router.get(
'/stripe-settings',
wrapAsync(async (req, res) => {
const settings = await BillingService.getStripeSettings(req.currentUser, req.get('host'));
res.status(200).send({ settings });
}),
);
router.put(
'/stripe-settings',
wrapAsync(async (req, res) => {
const settings = await BillingService.updateStripeSettings(
req.body.data,
req.currentUser,
req.get('host'),
);
res.status(200).send({ settings });
}),
);
router.post(
'/subscribe',
wrapAsync(async (req, res) => {
const checkout = await BillingService.subscribe(req.body.planId, req.currentUser);
res.status(200).send(checkout);
}),
);
router.get(
'/checkout-session',
wrapAsync(async (req, res) => {
const subscription = await BillingService.confirmCheckoutSession(
req.query.sessionId,
req.currentUser,
);
res.status(200).send({ subscription });
}),
);
router.post(
'/cancel',
wrapAsync(async (req, res) => {
const subscription = await BillingService.cancel(req.currentUser);
res.status(200).send({ subscription });
}),
);
router.use('/', require('../helpers').commonErrorHandler);
module.exports = router;

View File

@ -0,0 +1,20 @@
const express = require('express');
const BillingService = require('../services/billing');
const wrapAsync = require('../helpers').wrapAsync;
const router = express.Router();
router.post(
'/',
wrapAsync(async (req, res) => {
const result = await BillingService.handleWebhook(
req.body,
req.headers['stripe-signature'],
);
res.status(200).send(result);
}),
);
router.use('/', require('../helpers').commonErrorHandler);
module.exports = router;

View File

@ -0,0 +1,857 @@
const Stripe = require('stripe');
const db = require('../db/models');
const config = require('../config');
const ForbiddenError = require('./notifications/errors/forbidden');
const ACTIVE_STATUSES = ['trialing', 'active', 'past_due'];
const Op = db.Sequelize.Op;
const STRIPE_WEBHOOK_EVENTS = [
'checkout.session.completed',
'customer.subscription.created',
'customer.subscription.updated',
'customer.subscription.deleted',
];
function parseFeatures(featuresJson) {
if (!featuresJson) {
return [];
}
const parsed = JSON.parse(featuresJson);
if (Array.isArray(parsed)) {
return parsed;
}
return [];
}
function createHttpError(message, code) {
const error = new Error(message);
error.code = code;
return error;
}
function maskSecret(value) {
if (!value) {
return '';
}
if (value.length <= 8) {
return value;
}
return `${value.slice(0, 4)}••••${value.slice(-4)}`;
}
async function findStripeSettingsRecord() {
return db.billing_settings.findOne({
where: {
key: 'default',
},
});
}
async function findOrCreateStripeSettingsRecord(currentUserId) {
const existingRecord = await findStripeSettingsRecord();
if (existingRecord) {
return existingRecord;
}
return db.billing_settings.create({
createdById: currentUserId || null,
key: 'default',
updatedById: currentUserId || null,
});
}
async function getStripeSecretKeyValue() {
const settings = await findStripeSettingsRecord();
if (settings && settings.stripe_secret_key) {
return settings.stripe_secret_key;
}
return process.env.STRIPE_SECRET_KEY || '';
}
async function getStripeWebhookSecretValue() {
const settings = await findStripeSettingsRecord();
if (settings && settings.stripe_webhook_secret) {
return settings.stripe_webhook_secret;
}
return process.env.STRIPE_WEBHOOK_SECRET || '';
}
async function getStripeClient() {
const secretKey = await getStripeSecretKeyValue();
if (!secretKey) {
throw createHttpError('Stripe is not configured. Set STRIPE_SECRET_KEY.', 500);
}
return new Stripe(secretKey);
}
function getFrontendBaseUrl() {
if (process.env.FRONTEND_APP_URL) {
return process.env.FRONTEND_APP_URL.replace(/\/$/, '');
}
if (process.env.NEXT_PUBLIC_BACK_API) {
return process.env.NEXT_PUBLIC_BACK_API.replace(/\/api\/?$/, '');
}
if (process.env.FULL_DOMAIN) {
return `https://${process.env.FULL_DOMAIN.replace(/\/$/, '')}`;
}
if (process.env.HOST_FQDN) {
return `https://${process.env.HOST_FQDN.replace(/\/$/, '')}`;
}
if (process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'dev_stage') {
throw createHttpError(
'Stripe checkout is missing FRONTEND_APP_URL or NEXT_PUBLIC_BACK_API.',
500,
);
}
return 'http://localhost:3000';
}
function getStripeWebhookUrl() {
return `${getFrontendBaseUrl()}/api/billing/webhook`;
}
function isMockStripeId(value) {
if (!value) {
return false;
}
return String(value).includes('_mock_');
}
function normalizeStripeSubscriptionStatus(status) {
if (status === 'trialing') {
return 'trialing';
}
if (status === 'active') {
return 'active';
}
if (status === 'canceled') {
return 'canceled';
}
if (status === 'past_due') {
return 'past_due';
}
if (status === 'unpaid') {
return 'past_due';
}
if (status === 'incomplete') {
return 'past_due';
}
if (status === 'incomplete_expired') {
return 'canceled';
}
if (status === 'paused') {
return 'past_due';
}
return 'past_due';
}
function serializePlan(plan) {
if (!plan) {
return null;
}
return {
id: plan.id,
name: plan.name,
slug: plan.slug,
description: plan.description,
priceMonthly: plan.price_monthly,
currency: plan.currency,
stripeProductId: plan.stripe_product_id,
stripePriceId: plan.stripe_price_id,
messageLimit: plan.message_limit,
agentLimit: plan.agent_limit,
isFeatured: plan.is_featured,
isActive: plan.is_active,
features: parseFeatures(plan.features_json),
};
}
function serializeSubscription(subscription) {
if (!subscription) {
return null;
}
return {
id: subscription.id,
status: subscription.status,
stripeCustomerId: subscription.stripe_customer_id,
stripeSubscriptionId: subscription.stripe_subscription_id,
currentPeriodStart: subscription.current_period_start,
currentPeriodEnd: subscription.current_period_end,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
plan: serializePlan(subscription.plan),
};
}
function getStripeWebhookUrlForDisplay(requestHost) {
if (requestHost) {
const normalizedHost = String(requestHost)
.replace(/^https?:\/\//, '')
.replace(/\/$/, '');
return `https://${normalizedHost}/api/billing/webhook`;
}
try {
return getStripeWebhookUrl();
} catch (error) {
return '/api/billing/webhook';
}
}
function serializeStripeSettings(settings, plans, requestHost) {
const savedSecretKey = settings ? settings.stripe_secret_key || '' : '';
const savedWebhookSecret = settings ? settings.stripe_webhook_secret || '' : '';
return {
plans: plans.map(serializePlan),
stripeSecretKey: savedSecretKey,
stripeSecretKeyPreview: maskSecret(savedSecretKey || process.env.STRIPE_SECRET_KEY || ''),
stripeSecretKeySource: savedSecretKey ? 'saved' : process.env.STRIPE_SECRET_KEY ? 'env' : 'missing',
stripeWebhookSecret: savedWebhookSecret,
stripeWebhookSecretPreview: maskSecret(savedWebhookSecret || process.env.STRIPE_WEBHOOK_SECRET || ''),
stripeWebhookSecretSource: savedWebhookSecret ? 'saved' : process.env.STRIPE_WEBHOOK_SECRET ? 'env' : 'missing',
webhookEndpoint: getStripeWebhookUrlForDisplay(requestHost),
webhookEvents: STRIPE_WEBHOOK_EVENTS,
};
}
function getStripePriceIdFromSubscription(stripeSubscription) {
if (!stripeSubscription || !stripeSubscription.items || !Array.isArray(stripeSubscription.items.data)) {
return '';
}
for (const item of stripeSubscription.items.data) {
if (item && item.price && item.price.id) {
return item.price.id;
}
}
return '';
}
function getCustomerEmail(checkoutSession) {
if (checkoutSession.customer_details && checkoutSession.customer_details.email) {
return checkoutSession.customer_details.email;
}
if (checkoutSession.customer_email) {
return checkoutSession.customer_email;
}
return '';
}
module.exports = class BillingService {
static ensureCurrentUser(currentUser) {
if (!currentUser || !currentUser.id) {
throw new ForbiddenError();
}
}
static ensureAdministrator(currentUser) {
this.ensureCurrentUser(currentUser);
if (currentUser.app_role?.name !== config.roles.admin) {
throw new ForbiddenError();
}
}
static async listPlans() {
const plans = await db.subscription_plans.findAll({
where: {
is_active: true,
},
order: [
['price_monthly', 'ASC'],
['createdAt', 'ASC'],
],
});
return plans.map(serializePlan);
}
static async getCurrentSubscription(currentUser) {
this.ensureCurrentUser(currentUser);
const subscription = await this.findVisibleSubscription(currentUser.id);
return serializeSubscription(subscription);
}
static async getStripeSettings(currentUser, requestHost) {
this.ensureAdministrator(currentUser);
const settings = await findOrCreateStripeSettingsRecord(currentUser.id);
const plans = await db.subscription_plans.findAll({
order: [
['price_monthly', 'ASC'],
['createdAt', 'ASC'],
],
});
return serializeStripeSettings(settings, plans, requestHost);
}
static async updateStripeSettings(data, currentUser, requestHost) {
this.ensureAdministrator(currentUser);
if (!data || typeof data !== 'object') {
throw createHttpError('Stripe settings payload is required.', 400);
}
const settings = await findOrCreateStripeSettingsRecord(currentUser.id);
if (typeof data.stripeSecretKey === 'string') {
settings.stripe_secret_key = data.stripeSecretKey.trim();
}
if (typeof data.stripeWebhookSecret === 'string') {
settings.stripe_webhook_secret = data.stripeWebhookSecret.trim();
}
settings.updatedById = currentUser.id;
if (!settings.createdById) {
settings.createdById = currentUser.id;
}
await settings.save();
if (Array.isArray(data.plans)) {
for (const planData of data.plans) {
if (!planData || !planData.id) {
throw createHttpError('Each Stripe plan mapping must include an id.', 400);
}
const plan = await db.subscription_plans.findOne({
where: {
id: planData.id,
},
});
if (!plan) {
throw createHttpError(`Subscription plan ${planData.id} was not found.`, 404);
}
if (typeof planData.stripeProductId === 'string') {
plan.stripe_product_id = planData.stripeProductId.trim();
}
if (typeof planData.stripePriceId === 'string') {
plan.stripe_price_id = planData.stripePriceId.trim();
}
plan.updatedById = currentUser.id;
await plan.save();
}
}
return this.getStripeSettings(currentUser, requestHost);
}
static async subscribe(planId, currentUser) {
this.ensureCurrentUser(currentUser);
if (!planId) {
throw createHttpError('Subscription plan is required.', 400);
}
const plan = await db.subscription_plans.findOne({
where: {
id: planId,
is_active: true,
},
});
if (!plan) {
throw createHttpError('Subscription plan not found.', 404);
}
if (!plan.stripe_price_id) {
throw createHttpError('This plan does not have a Stripe price yet.', 400);
}
if (isMockStripeId(plan.stripe_price_id)) {
throw createHttpError(
'This plan is still using a mock Stripe price. Replace it with a real Stripe price ID first.',
400,
);
}
const stripe = await getStripeClient();
const customerId = await this.findOrCreateStripeCustomer(currentUser);
const frontendBaseUrl = getFrontendBaseUrl();
const successUrl = `${frontendBaseUrl}/billing?checkout=success&session_id={CHECKOUT_SESSION_ID}`;
const cancelUrl = `${frontendBaseUrl}/billing?checkout=canceled`;
const checkoutSession = await stripe.checkout.sessions.create({
allow_promotion_codes: true,
cancel_url: cancelUrl,
client_reference_id: currentUser.id,
customer: customerId,
line_items: [
{
price: plan.stripe_price_id,
quantity: 1,
},
],
metadata: {
planId: plan.id,
userId: currentUser.id,
},
mode: 'subscription',
subscription_data: {
metadata: {
planId: plan.id,
userId: currentUser.id,
},
},
success_url: successUrl,
});
if (!checkoutSession.url) {
throw createHttpError('Stripe did not return a checkout URL.', 500);
}
return {
checkoutSessionId: checkoutSession.id,
checkoutUrl: checkoutSession.url,
};
}
static async confirmCheckoutSession(sessionId, currentUser) {
this.ensureCurrentUser(currentUser);
if (!sessionId) {
throw createHttpError('Checkout session ID is required.', 400);
}
const stripe = await getStripeClient();
const checkoutSession = await stripe.checkout.sessions.retrieve(sessionId);
const sessionUserId = checkoutSession.metadata ? checkoutSession.metadata.userId : null;
const sessionEmail = getCustomerEmail(checkoutSession);
if (
checkoutSession.client_reference_id !== currentUser.id &&
sessionUserId !== currentUser.id &&
sessionEmail !== currentUser.email
) {
throw new ForbiddenError();
}
if (checkoutSession.mode !== 'subscription') {
throw createHttpError('This checkout session does not contain a subscription.', 400);
}
if (!checkoutSession.subscription) {
return serializeSubscription(await this.findVisibleSubscription(currentUser.id));
}
const stripeSubscription = await stripe.subscriptions.retrieve(checkoutSession.subscription);
const subscription = await this.syncStripeSubscription(stripeSubscription);
return serializeSubscription(subscription);
}
static async cancel(currentUser) {
this.ensureCurrentUser(currentUser);
const currentSubscription = await this.findActiveSubscription(currentUser.id);
if (!currentSubscription) {
throw createHttpError('No active subscription to cancel.', 404);
}
if (
!currentSubscription.stripe_subscription_id ||
isMockStripeId(currentSubscription.stripe_subscription_id)
) {
currentSubscription.status = 'canceled';
currentSubscription.cancel_at_period_end = false;
currentSubscription.current_period_end = new Date();
currentSubscription.updatedById = currentUser.id;
await currentSubscription.save();
const subscription = await this.findVisibleSubscription(currentUser.id);
return serializeSubscription(subscription);
}
const stripe = await getStripeClient();
const stripeSubscription = await stripe.subscriptions.update(
currentSubscription.stripe_subscription_id,
{
cancel_at_period_end: true,
},
);
const subscription = await this.syncStripeSubscription(stripeSubscription);
return serializeSubscription(subscription);
}
static async handleWebhook(rawBody, signature) {
const webhookSecret = await getStripeWebhookSecretValue();
if (!webhookSecret) {
throw createHttpError('Stripe webhook is not configured. Set STRIPE_WEBHOOK_SECRET.', 500);
}
if (!signature) {
throw createHttpError('Stripe-Signature header is required.', 400);
}
const stripe = await getStripeClient();
const event = stripe.webhooks.constructEvent(
rawBody,
signature,
webhookSecret,
);
if (event.type === 'checkout.session.completed') {
const checkoutSession = event.data.object;
if (checkoutSession.mode === 'subscription' && checkoutSession.subscription) {
const stripeSubscription = await stripe.subscriptions.retrieve(checkoutSession.subscription);
await this.syncStripeSubscription(stripeSubscription);
}
}
if (
event.type === 'customer.subscription.created' ||
event.type === 'customer.subscription.updated' ||
event.type === 'customer.subscription.deleted'
) {
await this.syncStripeSubscription(event.data.object);
}
return {
received: true,
type: event.type,
};
}
static async syncStripeSubscription(stripeSubscription) {
const userId = await this.resolveUserId(stripeSubscription);
const plan = await this.resolvePlan(stripeSubscription);
const transaction = await db.sequelize.transaction();
try {
let subscription = await this.findSubscriptionByStripeSubscriptionId(
stripeSubscription.id,
transaction,
);
if (!subscription && stripeSubscription.customer) {
subscription = await this.findSubscriptionByStripeCustomerId(
String(stripeSubscription.customer),
userId,
transaction,
);
}
if (!subscription) {
subscription = db.subscriptions.build({
createdById: userId,
stripe_customer_id: stripeSubscription.customer ? String(stripeSubscription.customer) : null,
stripe_subscription_id: stripeSubscription.id,
userId,
});
}
subscription.userId = userId;
subscription.planId = plan.id;
subscription.status = normalizeStripeSubscriptionStatus(stripeSubscription.status);
subscription.stripe_customer_id = stripeSubscription.customer
? String(stripeSubscription.customer)
: null;
subscription.stripe_subscription_id = stripeSubscription.id;
subscription.current_period_start = stripeSubscription.current_period_start
? new Date(stripeSubscription.current_period_start * 1000)
: null;
subscription.current_period_end = stripeSubscription.current_period_end
? new Date(stripeSubscription.current_period_end * 1000)
: null;
subscription.cancel_at_period_end = Boolean(stripeSubscription.cancel_at_period_end);
subscription.updatedById = userId;
await subscription.save({ transaction });
await db.subscriptions.update(
{
status: 'canceled',
updatedById: userId,
},
{
transaction,
where: {
id: {
[Op.ne]: subscription.id,
},
status: {
[Op.in]: ACTIVE_STATUSES,
},
userId,
},
},
);
const visibleSubscription = await this.findSubscriptionById(subscription.id, transaction);
await transaction.commit();
return visibleSubscription;
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async resolveUserId(stripeSubscription) {
if (
stripeSubscription.metadata &&
stripeSubscription.metadata.userId &&
typeof stripeSubscription.metadata.userId === 'string'
) {
return stripeSubscription.metadata.userId;
}
const existingSubscription = await this.findSubscriptionByStripeSubscriptionId(
stripeSubscription.id,
);
if (existingSubscription && existingSubscription.userId) {
return existingSubscription.userId;
}
if (stripeSubscription.customer) {
const existingCustomerSubscription = await this.findSubscriptionByStripeCustomerId(
String(stripeSubscription.customer),
);
if (existingCustomerSubscription && existingCustomerSubscription.userId) {
return existingCustomerSubscription.userId;
}
}
throw createHttpError(
`Stripe subscription ${stripeSubscription.id} is missing a user mapping.`,
500,
);
}
static async resolvePlan(stripeSubscription) {
if (
stripeSubscription.metadata &&
stripeSubscription.metadata.planId &&
typeof stripeSubscription.metadata.planId === 'string'
) {
const planById = await db.subscription_plans.findOne({
where: {
id: stripeSubscription.metadata.planId,
},
});
if (planById) {
return planById;
}
}
const stripePriceId = getStripePriceIdFromSubscription(stripeSubscription);
if (stripePriceId) {
const planByPrice = await db.subscription_plans.findOne({
where: {
stripe_price_id: stripePriceId,
},
});
if (planByPrice) {
return planByPrice;
}
}
const existingSubscription = await this.findSubscriptionByStripeSubscriptionId(
stripeSubscription.id,
);
if (existingSubscription && existingSubscription.planId) {
const existingPlan = await db.subscription_plans.findOne({
where: {
id: existingSubscription.planId,
},
});
if (existingPlan) {
return existingPlan;
}
}
throw createHttpError(
`Stripe subscription ${stripeSubscription.id} could not be matched to a plan.`,
500,
);
}
static async findOrCreateStripeCustomer(currentUser) {
const existingSubscription = await db.subscriptions.findOne({
order: [['createdAt', 'DESC']],
where: {
stripe_customer_id: {
[Op.ne]: null,
},
userId: currentUser.id,
},
});
if (
existingSubscription &&
existingSubscription.stripe_customer_id &&
!isMockStripeId(existingSubscription.stripe_customer_id)
) {
return existingSubscription.stripe_customer_id;
}
const stripe = await getStripeClient();
const fullName = [currentUser.firstName, currentUser.lastName].filter(Boolean).join(' ').trim();
const customer = await stripe.customers.create({
email: currentUser.email || undefined,
metadata: {
userId: currentUser.id,
},
name: fullName || undefined,
});
return customer.id;
}
static async findActiveSubscription(userId, transaction) {
return db.subscriptions.findOne({
where: {
status: {
[Op.in]: ACTIVE_STATUSES,
},
stripe_subscription_id: {
[Op.notLike]: '%_mock_%',
},
userId,
},
include: [
{
model: db.subscription_plans,
as: 'plan',
},
],
order: [['createdAt', 'DESC']],
transaction,
});
}
static async findVisibleSubscription(userId, transaction) {
const activeSubscription = await this.findActiveSubscription(userId, transaction);
if (activeSubscription) {
return activeSubscription;
}
return db.subscriptions.findOne({
where: {
stripe_subscription_id: {
[Op.notLike]: '%_mock_%',
},
userId,
},
include: [
{
model: db.subscription_plans,
as: 'plan',
},
],
order: [['createdAt', 'DESC']],
transaction,
});
}
static async findSubscriptionById(id, transaction) {
return db.subscriptions.findOne({
where: {
id,
},
include: [
{
model: db.subscription_plans,
as: 'plan',
},
],
transaction,
});
}
static async findSubscriptionByStripeSubscriptionId(stripeSubscriptionId, transaction) {
if (!stripeSubscriptionId) {
return null;
}
return db.subscriptions.findOne({
where: {
stripe_subscription_id: stripeSubscriptionId,
},
include: [
{
model: db.subscription_plans,
as: 'plan',
},
],
order: [['createdAt', 'DESC']],
transaction,
});
}
static async findSubscriptionByStripeCustomerId(stripeCustomerId, userId, transaction) {
if (!stripeCustomerId) {
return null;
}
const where = {
stripe_customer_id: stripeCustomerId,
};
if (userId) {
where.userId = userId;
}
return db.subscriptions.findOne({
where,
include: [
{
model: db.subscription_plans,
as: 'plan',
},
],
order: [['createdAt', 'DESC']],
transaction,
});
}
};

View File

@ -1,5 +1,10 @@
const fs = require('fs');
const path = require('path');
const config = require('../config');
const db = require('../db/models');
const { LocalAIApi } = require('../ai/LocalAIApi');
const AttachmentsDBApi = require('../db/api/attachments');
const ValidationError = require('./notifications/errors/validation');
const { Op } = db.Sequelize;
@ -8,6 +13,10 @@ const DEFAULT_CONVERSATION_TITLE = 'New conversation';
const MAX_MESSAGE_LENGTH = 8000;
const MAX_TITLE_LENGTH = 120;
const MAX_CONTEXT_MESSAGES = 12;
const MAX_ATTACHMENTS = 5;
const MAX_ATTACHMENT_TEXT_LENGTH = 12000;
const MAX_INLINE_IMAGE_BYTES = 2 * 1024 * 1024;
const IMAGE_INPUT_MODEL = 'gpt-5.2';
function normalizeText(value) {
if (typeof value !== 'string') {
@ -26,6 +35,189 @@ function cleanMarkdownPreview(value) {
.trim();
}
function normalizeAttachmentText(value) {
const normalized = normalizeText(value);
if (!normalized) {
return null;
}
if (normalized.length <= MAX_ATTACHMENT_TEXT_LENGTH) {
return normalized;
}
return `${normalized.slice(0, MAX_ATTACHMENT_TEXT_LENGTH)}...`;
}
function normalizeAttachmentsInput(value) {
if (!Array.isArray(value)) {
return [];
}
return value
.slice(0, MAX_ATTACHMENTS)
.map((item) => {
const kind = item?.kind === 'image' ? 'image' : 'file';
const filename = normalizeText(item?.filename);
const mimeType = normalizeText(item?.mime_type);
const fileRelation = Array.isArray(item?.file) ? item.file.slice(0, 1) : [];
const imageRelation = Array.isArray(item?.image) ? item.image.slice(0, 1) : [];
const relation = kind === 'image' ? imageRelation : fileRelation;
if (!filename || relation.length === 0) {
return null;
}
return {
kind,
filename,
mime_type: mimeType || null,
size_bytes: Number(item?.size_bytes || 0) || null,
storage_key: normalizeText(item?.storage_key) || relation[0]?.privateUrl || null,
notes: normalizeAttachmentText(item?.notes),
file: kind === 'file' ? relation : [],
image: kind === 'image' ? relation : [],
};
})
.filter(Boolean);
}
function buildAttachmentTitleSeed(attachments) {
if (!attachments.length) {
return DEFAULT_CONVERSATION_TITLE;
}
if (attachments.length === 1) {
return `Review ${attachments[0].filename}`;
}
return `Review ${attachments.length} attached files`;
}
function buildAttachmentSummaryForAi(attachment) {
const lines = [];
const typeLabel = attachment.kind === 'image' ? 'image' : 'file';
const mimeType = attachment.mime_type ? ` (${attachment.mime_type})` : '';
lines.push(`Attached ${typeLabel}: ${attachment.filename}${mimeType}`);
if (attachment.notes) {
lines.push(`Begin attached content: ${attachment.filename}`);
lines.push(attachment.notes);
lines.push(`End attached content: ${attachment.filename}`);
}
return lines.join('\n');
}
async function attachmentImageInputUrl(attachment) {
if (!attachment) {
return null;
}
const relatedFile = attachment.kind === 'image'
? Array.isArray(attachment.image) ? attachment.image[0] : null
: Array.isArray(attachment.file) ? attachment.file[0] : null;
if (relatedFile?.privateUrl) {
const absolutePath = path.join(config.uploadDir, relatedFile.privateUrl);
try {
const fileBuffer = await fs.promises.readFile(absolutePath);
if (fileBuffer.length <= MAX_INLINE_IMAGE_BYTES) {
const mimeType = normalizeText(attachment.mime_type) || 'application/octet-stream';
return `data:${mimeType};base64,${fileBuffer.toString('base64')}`;
}
} catch (error) {
console.error('[workspace] Failed to read image attachment from local storage.', {
attachmentId: attachment.id,
privateUrl: relatedFile.privateUrl,
message: error.message,
});
}
}
const publicUrl = normalizeText(relatedFile?.publicUrl);
if (!publicUrl) {
return null;
}
return publicUrl;
}
function buildMessageContentForAi(message) {
const content = normalizeText(message.content_markdown || message.content);
const attachments = Array.isArray(message.attachments_message) ? message.attachments_message : [];
if (!attachments.length) {
return content;
}
const attachmentSummary = attachments.map(buildAttachmentSummaryForAi).join('\n\n');
if (!content) {
return `User attached files without additional text.\n\n${attachmentSummary}`;
}
return `${content}\n\n${attachmentSummary}`;
}
async function buildUserMessageContentBlocksForAi(message) {
const blocks = [];
const content = normalizeText(message.content_markdown || message.content);
const attachments = Array.isArray(message.attachments_message) ? message.attachments_message : [];
if (content) {
blocks.push({
type: 'input_text',
text: content,
});
}
for (const attachment of attachments) {
const summary = buildAttachmentSummaryForAi(attachment);
if (summary) {
blocks.push({
type: 'input_text',
text: summary,
});
}
if (attachment.kind !== 'image') {
continue;
}
const imageInputUrl = await attachmentImageInputUrl(attachment);
if (!imageInputUrl) {
continue;
}
blocks.push({
type: 'input_image',
image_url: imageInputUrl,
});
}
if (!blocks.length) {
return null;
}
return blocks;
}
function messageHasImageAttachments(message) {
const attachments = Array.isArray(message?.attachments_message) ? message.attachments_message : [];
for (const attachment of attachments) {
if (attachment?.kind === 'image') {
return true;
}
}
return false;
}
function buildVisibleAgentWhere(currentUser) {
return {
is_active: true,
@ -94,7 +286,7 @@ function buildAssistantFailureMessage(errorMessage) {
].join('\n');
}
function buildAiInput(agent, historyMessages) {
async function buildAiInput(agent, historyMessages) {
const input = [];
const systemPrompt = normalizeText(agent?.system_prompt);
const agentDescription = normalizeText(agent?.description);
@ -116,7 +308,20 @@ function buildAiInput(agent, historyMessages) {
continue;
}
const content = normalizeText(message.content_markdown || message.content);
if (message.role === 'user') {
const contentBlocks = await buildUserMessageContentBlocksForAi(message);
if (!contentBlocks) {
continue;
}
input.push({
role: message.role,
content: contentBlocks,
});
continue;
}
const content = buildMessageContentForAi(message);
if (!content) {
continue;
}
@ -144,12 +349,29 @@ async function requestAssistantReply(conversationId, assistantMessageId, agent)
[Op.in]: ['user', 'assistant', 'system'],
},
},
include: [
{
model: db.attachments,
as: 'attachments_message',
include: [
{
model: db.file,
as: 'file',
},
{
model: db.file,
as: 'image',
},
],
},
],
order: [['sequence', 'DESC']],
limit: MAX_CONTEXT_MESSAGES,
});
const orderedMessages = [...historyMessages].reverse();
const input = buildAiInput(agent, orderedMessages);
const input = await buildAiInput(agent, orderedMessages);
const hasImageInput = orderedMessages.some(messageHasImageAttachments);
if (input.length === 0) {
throw new Error('AI input could not be built from the conversation history.');
@ -159,7 +381,9 @@ async function requestAssistantReply(conversationId, assistantMessageId, agent)
input,
};
if (agent?.model) {
if (hasImageInput) {
payload.model = IMAGE_INPUT_MODEL;
} else if (agent?.model) {
payload.model = agent.model;
}
@ -211,6 +435,22 @@ async function findLatestUserMessageBeforeSequence(conversationId, sequence, tra
[Op.lt]: sequence,
},
},
include: [
{
model: db.attachments,
as: 'attachments_message',
include: [
{
model: db.file,
as: 'file',
},
{
model: db.file,
as: 'image',
},
],
},
],
order: [['sequence', 'DESC']],
transaction,
});
@ -417,6 +657,35 @@ function serializeAgent(agent) {
};
}
function serializeAttachment(attachment) {
if (!attachment) {
return null;
}
const relatedFile = attachment.kind === 'image'
? Array.isArray(attachment.image) ? attachment.image[0] : null
: Array.isArray(attachment.file) ? attachment.file[0] : null;
return {
id: attachment.id,
kind: attachment.kind,
filename: attachment.filename,
mime_type: attachment.mime_type,
size_bytes: attachment.size_bytes,
storage_key: attachment.storage_key,
notes: attachment.notes,
file: relatedFile
? {
id: relatedFile.id,
name: relatedFile.name,
sizeInBytes: relatedFile.sizeInBytes,
privateUrl: relatedFile.privateUrl,
publicUrl: relatedFile.publicUrl,
}
: null,
};
}
function serializeMessage(message) {
return {
id: message.id,
@ -437,6 +706,11 @@ function serializeMessage(message) {
email: message.author_user.email,
}
: null,
attachments: Array.isArray(message.attachments_message)
? message.attachments_message
.map(serializeAttachment)
.filter(Boolean)
: [],
};
}
@ -600,6 +874,20 @@ module.exports = class WorkspaceService {
as: 'author_user',
attributes: ['id', 'firstName', 'lastName', 'email'],
},
{
model: db.attachments,
as: 'attachments_message',
include: [
{
model: db.file,
as: 'file',
},
{
model: db.file,
as: 'image',
},
],
},
],
},
],
@ -798,8 +1086,9 @@ module.exports = class WorkspaceService {
static async sendMessage(id, data, currentUser) {
const content = normalizeText(data?.content);
const attachments = normalizeAttachmentsInput(data?.attachments);
if (!content) {
if (!content && !attachments.length) {
throw new ValidationError();
}
@ -813,6 +1102,8 @@ module.exports = class WorkspaceService {
let userMessageId = null;
let agentId = null;
let agentModel = null;
let titleSeed = content || buildAttachmentTitleSeed(attachments);
let aiSourceContent = '';
try {
const conversation = await findOwnedConversation(id, currentUser, createTransaction);
@ -855,8 +1146,8 @@ module.exports = class WorkspaceService {
const userMessage = await db.messages.create(
{
role: 'user',
content,
content_markdown: content,
content: content || '',
content_markdown: content || '',
delivery_status: 'completed',
sent_at: sentAt,
completed_at: sentAt,
@ -869,6 +1160,26 @@ module.exports = class WorkspaceService {
{ transaction: createTransaction },
);
for (const attachment of attachments) {
await AttachmentsDBApi.create(
{
kind: attachment.kind,
filename: attachment.filename,
mime_type: attachment.mime_type,
size_bytes: attachment.size_bytes,
storage_key: attachment.storage_key,
notes: attachment.notes,
message: userMessage.id,
file: attachment.file,
image: attachment.image,
},
{
currentUser,
transaction: createTransaction,
},
);
}
const assistantMessage = await db.messages.create(
{
role: 'assistant',
@ -895,15 +1206,19 @@ module.exports = class WorkspaceService {
{ transaction: createTransaction },
);
const inputTokens = estimateTokens(content);
aiSourceContent = buildMessageContentForAi({
content_markdown: content || '',
content: content || '',
attachments_message: attachments,
});
await createUsageEvent(
{
event_type: 'message_sent',
occurred_at: sentAt,
input_tokens: inputTokens,
input_tokens: estimateTokens(aiSourceContent),
output_tokens: 0,
total_tokens: inputTokens,
total_tokens: estimateTokens(aiSourceContent),
cost_usd: 0,
provider: 'local-ai',
model: agent?.model || 'default-ai-model',
@ -934,10 +1249,10 @@ module.exports = class WorkspaceService {
assistantMessageId,
conversationId,
currentUser,
sourceContent: content,
sourceContent: aiSourceContent,
agentId,
agentModel,
titleSeed: content,
titleSeed,
metadataAction: 'initial',
});
@ -960,6 +1275,7 @@ module.exports = class WorkspaceService {
let sourceContent = '';
let agentId = null;
let agentModel = null;
let titleSeed = DEFAULT_CONVERSATION_TITLE;
try {
const conversation = await findOwnedConversation(id, currentUser, transaction);
@ -1019,9 +1335,10 @@ module.exports = class WorkspaceService {
conversationId = conversation.id;
assistantMessageId = assistantMessage.id;
sourceContent = sourceMessage.content_markdown || sourceMessage.content || '';
sourceContent = buildMessageContentForAi(sourceMessage);
agentId = agent?.id || null;
agentModel = agent?.model || 'default-ai-model';
titleSeed = sourceMessage.content_markdown || sourceMessage.content || buildAttachmentTitleSeed(sourceMessage.attachments_message || []);
} catch (error) {
await transaction.rollback();
throw error;
@ -1034,7 +1351,7 @@ module.exports = class WorkspaceService {
sourceContent,
agentId,
agentModel,
titleSeed: sourceContent,
titleSeed,
metadataAction: 'retry',
});
@ -1057,6 +1374,7 @@ module.exports = class WorkspaceService {
let sourceContent = '';
let agentId = null;
let agentModel = null;
let titleSeed = DEFAULT_CONVERSATION_TITLE;
try {
const conversation = await findOwnedConversation(id, currentUser, transaction);
@ -1116,9 +1434,10 @@ module.exports = class WorkspaceService {
conversationId = conversation.id;
assistantMessageId = assistantMessage.id;
sourceContent = sourceMessage.content_markdown || sourceMessage.content || '';
sourceContent = buildMessageContentForAi(sourceMessage);
agentId = agent?.id || null;
agentModel = agent?.model || 'default-ai-model';
titleSeed = sourceMessage.content_markdown || sourceMessage.content || buildAttachmentTitleSeed(sourceMessage.attachments_message || []);
} catch (error) {
await transaction.rollback();
throw error;
@ -1131,7 +1450,7 @@ module.exports = class WorkspaceService {
sourceContent,
agentId,
agentModel,
titleSeed: sourceContent,
titleSeed,
metadataAction: 'regenerate',
});

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,240 @@
import Link from 'next/link';
import React from 'react';
import BaseIcon from '../BaseIcon';
export const actionButtonClassName =
'inline-flex items-center justify-center rounded-[8px] border px-4 py-2 text-sm font-medium';
export const inputClassName =
'w-full rounded-[10px] border border-slate-200 bg-white px-4 py-3 text-[14px] text-slate-900 outline-none transition-colors placeholder:text-slate-400 focus:border-slate-300';
export const textAreaClassName = `${inputClassName} min-h-[132px] resize-y leading-6`;
export function formatDateTime(value?: string | null) {
if (!value) {
return 'No date';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString('en-US', {
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
month: 'short',
year: 'numeric',
});
}
export function formatShortDate(value?: string | null) {
if (!value) {
return 'No activity yet';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString('en-US', {
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
month: 'short',
});
}
export function formatRole(value?: string | null) {
if (!value) {
return 'Unknown';
}
return value
.replace(/_/g, ' ')
.replace(/\b\w/g, (letter) => letter.toUpperCase());
}
export function formatName(person: any) {
if (!person) {
return 'No user';
}
const fullName = [person.firstName, person.lastName].filter(Boolean).join(' ').trim();
if (fullName) {
return fullName;
}
if (person.email) {
return person.email;
}
if (person.name) {
return person.name;
}
if (person.title) {
return person.title;
}
return 'Unnamed record';
}
export function formatMoney(value: any) {
const number = Number(value || 0);
if (!number) {
return '$0.00';
}
return `$${number.toFixed(2)}`;
}
export function formatTokens(value: any) {
const number = Number(value || 0);
if (!number) {
return '0';
}
return number.toLocaleString('en-US');
}
export function EntityIntro({
backHref,
backLabel,
description,
kicker,
title,
}: {
backHref: string;
backLabel: string;
description: string;
kicker: string;
title: string;
}) {
return (
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-6">
<Link className="inline-flex items-center gap-2 text-[12px] font-medium text-slate-500" href={backHref}>
{backLabel}
</Link>
<p className="mt-4 text-[11px] font-medium uppercase tracking-[0.28em] text-slate-400">{kicker}</p>
<h1 className="mt-3 text-[2rem] font-semibold tracking-[-0.04em] text-slate-900">{title}</h1>
<p className="mt-3 max-w-3xl text-sm leading-6 text-slate-500">{description}</p>
</div>
);
}
export function EntitySection({
children,
description,
icon,
title,
}: {
children: React.ReactNode;
description: string;
icon: string;
title: string;
}) {
return (
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-6">
<div className="mb-5 flex items-start gap-3">
<div className="mt-0.5 inline-flex h-10 w-10 items-center justify-center rounded-[10px] border border-slate-200 bg-slate-50 text-slate-600">
<BaseIcon path={icon} size={20} />
</div>
<div>
<p className="text-[15px] font-medium text-slate-900">{title}</p>
<p className="mt-1 text-sm leading-6 text-slate-500">{description}</p>
</div>
</div>
{children}
</div>
);
}
export function EntityValueCard({
label,
value,
}: {
label: string;
value: React.ReactNode;
}) {
return (
<div className="rounded-[10px] border border-slate-200 px-4 py-3">
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-slate-400">{label}</p>
<div className="mt-2 text-[15px] leading-6 text-slate-900">{value}</div>
</div>
);
}
export function EntityLinkCard({
description,
href,
icon,
label,
value,
}: {
description: string;
href?: string;
icon: string;
label: string;
value: string;
}) {
const content = (
<div className="rounded-[10px] border border-slate-200 px-4 py-3 transition-colors hover:border-slate-300">
<div className="flex items-start gap-3">
<div className="mt-0.5 inline-flex h-9 w-9 items-center justify-center rounded-[10px] border border-slate-200 bg-slate-50 text-slate-600">
<BaseIcon path={icon} size={18} />
</div>
<div className="min-w-0">
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-slate-400">{label}</p>
<p className="mt-2 text-[15px] font-medium text-slate-900">{value}</p>
<p className="mt-1 text-[13px] leading-6 text-slate-500">{description}</p>
</div>
</div>
</div>
);
if (!href) {
return content;
}
return <Link href={href}>{content}</Link>;
}
export function EntityAsideCard({
children,
title,
}: {
children: React.ReactNode;
title: string;
}) {
return (
<div className="rounded-[12px] border border-slate-200 bg-white px-5 py-5">
<p className="text-[11px] font-medium uppercase tracking-[0.28em] text-slate-400">{title}</p>
<div className="mt-4">{children}</div>
</div>
);
}
export function EntityEmptyState({
description,
title,
}: {
description: string;
title: string;
}) {
return (
<div className="rounded-[12px] border border-dashed border-slate-200 bg-slate-50 px-6 py-12 text-center">
<p className="text-[11px] font-medium uppercase tracking-[0.2em] text-slate-400">No data</p>
<h2 className="mt-3 text-[22px] font-semibold tracking-[-0.03em] text-slate-900">{title}</h2>
<p className="mt-3 text-[14px] leading-6 text-slate-500">{description}</p>
</div>
);
}

View File

@ -0,0 +1,222 @@
import React from 'react';
import { Field } from 'formik';
import { mdiRobotOutline, mdiTextBoxOutline, mdiTuneVariant } from '@mdi/js';
import BaseIcon from '../BaseIcon';
const inputClassName =
'w-full rounded-[10px] border border-slate-200 bg-white px-3.5 py-2.5 text-[14px] text-slate-900 outline-none placeholder:text-slate-400 focus:border-slate-900';
const textareaClassName =
'w-full rounded-[10px] border border-slate-200 bg-white px-3.5 py-3 text-[14px] leading-6 text-slate-900 outline-none placeholder:text-slate-400 focus:border-slate-900';
const labelClassName = 'mb-2 block text-[12px] font-medium text-slate-600';
function ToggleCard({
help,
label,
name,
onToggle,
value,
}: {
help: string;
label: string;
name: string;
onToggle: (name: string, value: boolean) => void;
value: boolean;
}) {
return (
<button
className="flex w-full items-start justify-between gap-4 rounded-[10px] border border-slate-200 bg-white px-4 py-3 text-left"
onClick={() => onToggle(name, !value)}
type="button"
>
<div>
<p className="text-[14px] font-medium text-slate-900">{label}</p>
<p className="mt-1 text-[13px] leading-6 text-slate-500">{help}</p>
</div>
<span
className={`relative mt-0.5 inline-flex h-6 w-11 shrink-0 rounded-full transition-colors ${
value ? 'bg-slate-900' : 'bg-slate-200'
}`}
>
<span
className={`absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform ${
value ? 'translate-x-5' : 'translate-x-0.5'
}`}
/>
</span>
</button>
);
}
type Props = {
setFieldValue: (field: string, value: any) => void;
values: any;
};
export default function AgentFormSections({ setFieldValue, values }: Props) {
return (
<>
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-6">
<div className="mb-5 flex items-start gap-3">
<div className="mt-0.5 inline-flex h-10 w-10 items-center justify-center rounded-[10px] border border-slate-200 bg-slate-50 text-slate-600">
<BaseIcon path={mdiRobotOutline} size={20} />
</div>
<div>
<p className="text-[15px] font-medium text-slate-900">Identity</p>
<p className="mt-1 text-sm leading-6 text-slate-500">
Give the agent a name, a model, and a short description people can recognize quickly.
</p>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className={labelClassName} htmlFor="name">
Name
</label>
<Field
className={inputClassName}
id="name"
name="name"
placeholder="Code Helper"
/>
</div>
<div>
<label className={labelClassName} htmlFor="model">
Model
</label>
<Field
className={inputClassName}
id="model"
name="model"
placeholder="gpt-5.5"
/>
</div>
<div className="md:col-span-2">
<label className={labelClassName} htmlFor="description">
Description
</label>
<Field
as="textarea"
className={`${textareaClassName} min-h-[120px]`}
id="description"
name="description"
placeholder="Helpful assistant for planning, drafting, coding, or review."
/>
</div>
</div>
</div>
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-6">
<div className="mb-5 flex items-start gap-3">
<div className="mt-0.5 inline-flex h-10 w-10 items-center justify-center rounded-[10px] border border-slate-200 bg-slate-50 text-slate-600">
<BaseIcon path={mdiTextBoxOutline} size={20} />
</div>
<div>
<p className="text-[15px] font-medium text-slate-900">Prompting</p>
<p className="mt-1 text-sm leading-6 text-slate-500">
Define the standing instructions this agent should follow in every conversation.
</p>
</div>
</div>
<div>
<label className={labelClassName} htmlFor="system_prompt">
System prompt
</label>
<Field
as="textarea"
className={`${textareaClassName} min-h-[240px]`}
id="system_prompt"
name="system_prompt"
placeholder="You are a concise engineering assistant. Explain tradeoffs clearly, propose the simplest implementation first, and never hide failures."
/>
<p className="mt-2 text-[12px] leading-5 text-slate-400">
Keep it direct. This is the instruction set the model receives before the user message.
</p>
</div>
</div>
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-6">
<div className="mb-5 flex items-start gap-3">
<div className="mt-0.5 inline-flex h-10 w-10 items-center justify-center rounded-[10px] border border-slate-200 bg-slate-50 text-slate-600">
<BaseIcon path={mdiTuneVariant} size={20} />
</div>
<div>
<p className="text-[15px] font-medium text-slate-900">Behavior</p>
<p className="mt-1 text-sm leading-6 text-slate-500">
Tune response shape, token budget, and any structured metadata you want to keep with the agent.
</p>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className={labelClassName} htmlFor="temperature">
Temperature
</label>
<Field
className={inputClassName}
id="temperature"
name="temperature"
placeholder="0.2"
type="number"
/>
<p className="mt-2 text-[12px] leading-5 text-slate-400">
Lower is more deterministic. Higher is more exploratory.
</p>
</div>
<div>
<label className={labelClassName} htmlFor="max_output_tokens">
Max output tokens
</label>
<Field
className={inputClassName}
id="max_output_tokens"
name="max_output_tokens"
placeholder="1200"
type="number"
/>
<p className="mt-2 text-[12px] leading-5 text-slate-400">
Leave blank if you want the model default.
</p>
</div>
<div className="md:col-span-2">
<label className={labelClassName} htmlFor="metadata_json">
Metadata JSON
</label>
<Field
as="textarea"
className={`${textareaClassName} min-h-[140px] font-mono text-[13px]`}
id="metadata_json"
name="metadata_json"
placeholder='{"surface":"workspace","role":"engineer"}'
/>
<p className="mt-2 text-[12px] leading-5 text-slate-400">
Optional. Use valid JSON if you want to tag the agent with structured metadata.
</p>
</div>
</div>
<div className="mt-5 grid gap-3 md:grid-cols-2">
<ToggleCard
help="Keep this available in the workspace for active use."
label="Active"
name="is_active"
onToggle={setFieldValue}
value={Boolean(values.is_active)}
/>
<ToggleCard
help="Mark this as one of the shared default presets."
label="Default"
name="is_default"
onToggle={setFieldValue}
value={Boolean(values.is_default)}
/>
</div>
</div>
</>
);
}

View File

@ -1,14 +1,12 @@
import React from 'react';
import ImageField from '../ImageField';
import Link from 'next/link';
import { mdiRobotOutline, mdiTextBoxOutline } from '@mdi/js';
import BaseIcon from '../BaseIcon';
import ListActionsPopover from '../ListActionsPopover';
import { useAppSelector } from '../../stores/hooks';
import dataFormatter from '../../helpers/dataFormatter';
import { Pagination } from '../Pagination';
import {saveFile} from "../../helpers/fileSaver";
import LoadingSpinner from "../LoadingSpinner";
import Link from 'next/link';
import {hasPermission} from "../../helpers/userPermissions";
import LoadingSpinner from '../LoadingSpinner';
import { hasPermission } from '../../helpers/userPermissions';
type Props = {
@ -20,6 +18,61 @@ type Props = {
onPageChange: (page: number) => void;
};
function truncateText(value: string, limit: number) {
if (!value) {
return '';
}
if (value.length <= limit) {
return value;
}
return `${value.slice(0, limit).trim()}`;
}
function formatTemperature(value: any) {
if (value === null || value === undefined || value === '') {
return 'Auto';
}
return value;
}
function formatTokenLimit(value: any) {
if (!value) {
return 'Default';
}
return `${value} max tokens`;
}
function getMetadataBadges(value: string) {
if (!value) {
return [];
}
try {
const parsed = JSON.parse(value);
const badges = [];
if (parsed.surface) {
badges.push(String(parsed.surface));
}
if (parsed.role) {
badges.push(String(parsed.role));
}
if (parsed.style) {
badges.push(String(parsed.style));
}
return badges.slice(0, 2);
} catch (error) {
return [];
}
}
const CardAgents = ({
agents,
loading,
@ -28,172 +81,160 @@ const CardAgents = ({
numPages,
onPageChange,
}: Props) => {
const asideScrollbarsStyle = useAppSelector(
(state) => state.style.asideScrollbarsStyle,
);
const bgColor = useAppSelector((state) => state.style.cardsColor);
const darkMode = useAppSelector((state) => state.style.darkMode);
const corners = useAppSelector((state) => state.style.corners);
const focusRing = useAppSelector((state) => state.style.focusRingColor);
const currentUser = useAppSelector((state) => state.auth.currentUser);
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_AGENTS')
const currentUser = useAppSelector((state) => state.auth.currentUser);
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_AGENTS');
const hasCreatePermission = hasPermission(currentUser, 'CREATE_AGENTS');
return (
<div className={'p-4'}>
<div className="px-5 py-5">
{loading && <LoadingSpinner />}
<ul
role='list'
className='grid grid-cols-1 gap-x-6 gap-y-8 lg:grid-cols-3 2xl:grid-cols-4 xl:gap-x-8'
role="list"
className="grid grid-cols-1 gap-4 lg:grid-cols-2 2xl:grid-cols-3"
>
{!loading && agents.map((item, index) => (
{!loading && agents.map((item) => (
<li
key={item.id}
className={`overflow-hidden ${corners !== 'rounded-full'? corners : 'rounded-3xl'} border ${focusRing} border-gray-200 dark:border-dark-700 ${
darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle
}`}
className="overflow-hidden rounded-[12px] border border-slate-200 bg-white shadow-[0_20px_50px_-42px_rgba(15,23,42,0.28)]"
>
<div className={`flex items-center ${bgColor} p-6 gap-x-4 border-b border-gray-900/5 bg-gray-50 dark:bg-dark-800 relative`}>
<Link href={`/agents/agents-view/?id=${item.id}`} className='text-lg font-bold leading-6 line-clamp-1'>
{item.name}
</Link>
<div className='ml-auto '>
<ListActionsPopover
onDelete={onDelete}
itemId={item.id}
pathEdit={`/agents/agents-edit/?id=${item.id}`}
pathView={`/agents/agents-view/?id=${item.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
<div className="flex items-start gap-4 border-b border-slate-200 px-5 py-5">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-[10px] border border-slate-200 bg-slate-50 text-slate-700">
<BaseIcon path={mdiRobotOutline} size={20} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
<Link
href={`/agents/agents-view/?id=${item.id}`}
className="block truncate text-[17px] font-semibold tracking-[-0.03em] text-slate-900"
>
{item.name || 'Untitled agent'}
</Link>
<p className="mt-1 text-[13px] text-slate-500">
{item.model || 'Default model'}
</p>
</div>
<div className="shrink-0">
<ListActionsPopover
onDelete={onDelete}
itemId={item.id}
pathEdit={`/agents/agents-edit/?id=${item.id}`}
pathView={`/agents/agents-view/?id=${item.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<span
className={`inline-flex items-center rounded-[999px] px-2.5 py-1 text-[11px] font-medium ${
item.is_active
? 'bg-emerald-50 text-emerald-700'
: 'bg-slate-100 text-slate-600'
}`}
>
{item.is_active ? 'Active' : 'Inactive'}
</span>
<span className="inline-flex items-center rounded-[999px] bg-slate-100 px-2.5 py-1 text-[11px] font-medium text-slate-700">
{item.is_default ? 'Default' : 'Custom'}
</span>
{getMetadataBadges(item.metadata_json).map((badge) => (
<span
key={`${item.id}-${badge}`}
className="inline-flex items-center rounded-[999px] bg-blue-50 px-2.5 py-1 text-[11px] font-medium text-blue-700"
>
{badge}
</span>
))}
</div>
</div>
</div>
<dl className='divide-y divide-stone-300 dark:divide-dark-700 px-6 py-4 text-sm leading-6 h-64 overflow-y-auto'>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Name</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.name }
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Description</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.description }
</div>
</dd>
</div>
<div className="space-y-4 px-5 py-5">
<div>
<p className="text-[14px] leading-6 text-slate-600">
{truncateText(
item.description || item.system_prompt || 'No description yet.',
180,
)}
</p>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Model</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.model }
</div>
</dd>
{item.system_prompt && (
<div className="rounded-[10px] border border-slate-200 bg-slate-50 px-4 py-3">
<div className="flex items-center gap-2 text-[11px] font-medium uppercase tracking-[0.18em] text-slate-400">
<BaseIcon path={mdiTextBoxOutline} size={14} />
System prompt
</div>
<p className="mt-2 text-[13px] leading-6 text-slate-600">
{truncateText(item.system_prompt, 220)}
</p>
</div>
)}
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Systemprompt</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.system_prompt }
</div>
</dd>
<div className="grid grid-cols-2 gap-3">
<div className="rounded-[10px] border border-slate-200 px-4 py-3">
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-slate-400">
Temperature
</p>
<p className="mt-2 text-[15px] font-medium text-slate-900">
{formatTemperature(item.temperature)}
</p>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Temperature</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.temperature }
</div>
</dd>
<div className="rounded-[10px] border border-slate-200 px-4 py-3">
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-slate-400">
Output
</p>
<p className="mt-2 text-[15px] font-medium text-slate-900">
{formatTokenLimit(item.max_output_tokens)}
</p>
</div>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Maxoutputtokens</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.max_output_tokens }
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Isdefault</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ dataFormatter.booleanFormatter(item.is_default) }
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Isactive</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ dataFormatter.booleanFormatter(item.is_active) }
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>MetadataJSON</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.metadata_json }
</div>
</dd>
</div>
</dl>
<div className="flex flex-wrap gap-2 pt-1">
<Link
href={`/agents/agents-view/?id=${item.id}`}
className="inline-flex items-center justify-center rounded-[8px] border border-slate-200 bg-white px-3 py-2 text-[13px] font-medium text-slate-700"
>
Open
</Link>
{hasUpdatePermission && (
<Link
href={`/agents/agents-edit/?id=${item.id}`}
className="inline-flex items-center justify-center rounded-[8px] bg-slate-900 px-3 py-2 text-[13px] font-medium text-white"
>
Edit
</Link>
)}
</div>
</div>
</li>
))}
{!loading && agents.length === 0 && (
<div className='col-span-full flex items-center justify-center h-40'>
<p className=''>No data to display</p>
<div className="col-span-full rounded-[12px] border border-dashed border-slate-200 bg-slate-50 px-6 py-12 text-center">
<p className="text-[11px] font-medium uppercase tracking-[0.2em] text-slate-400">
No agents yet
</p>
<h3 className="mt-3 text-[22px] font-semibold tracking-[-0.03em] text-slate-900">
Create the first assistant profile for this workspace.
</h3>
<p className="mx-auto mt-3 max-w-xl text-[14px] leading-6 text-slate-500">
Add a reusable agent preset with a model, prompt, and response settings so the
workspace can start conversations with consistent behavior.
</p>
{hasCreatePermission && (
<div className="mt-5">
<Link
href="/agents/agents-new"
className="inline-flex items-center justify-center rounded-[8px] bg-slate-900 px-4 py-2 text-sm font-medium text-white"
>
New agent
</Link>
</div>
)}
</div>
)}
</ul>
<div className={'flex items-center justify-center my-6'}>
<div className="mt-6 flex items-center justify-center">
<Pagination
currentPage={currentPage}
numPages={numPages}

View File

@ -1,6 +1,6 @@
import React, { useEffect, useState, useMemo } from 'react'
import { createPortal } from 'react-dom';
import { ToastContainer, toast } from 'react-toastify';
import { toast } from 'react-toastify';
import BaseButton from '../BaseButton'
import CardBoxModal from '../CardBoxModal'
import CardBox from "../CardBox";
@ -17,6 +17,7 @@ import _ from 'lodash';
import dataFormatter from '../../helpers/dataFormatter'
import {dataGridStyles} from "../../styles";
import DataGridLoadingOverlay from '../DataGridLoadingOverlay';
import CardAgents from './CardAgents';
@ -445,8 +446,18 @@ const TableSampleAgents = ({ filterItems, setFilterItems, filters, showGrid }) =
<p>Are you sure you want to delete this item?</p>
</CardBoxModal>
{dataGrid}
{showGrid ? (
dataGrid
) : (
<CardAgents
agents={agents ?? []}
loading={isDataGridLoading}
onDelete={handleDeleteModalAction}
currentPage={currentPage}
numPages={numPages}
onPageChange={onPageChange}
/>
)}
@ -461,7 +472,6 @@ const TableSampleAgents = ({ filterItems, setFilterItems, filters, showGrid }) =
/>,
document.getElementById('delete-rows-button'),
)}
<ToastContainer />
</>
)
}

View File

@ -1,18 +1,26 @@
import React from 'react'
import { MenuAsideItem } from '../interfaces'
import AsideMenuLayer from './AsideMenuLayer'
import OverlayLayer from './OverlayLayer'
type Props = {
menu: MenuAsideItem[]
isAsideMobileExpanded: boolean
onAsideMobileClose: () => void
}
export default function AsideMenu({
isAsideMobileExpanded = false,
...props
}: Props) {
return (
<AsideMenuLayer
menu={props.menu}
className="left-0"
/>
<>
<AsideMenuLayer
menu={props.menu}
className={`${isAsideMobileExpanded ? 'translate-x-0' : '-translate-x-full sm:translate-x-0'} left-0`}
onAsideMobileCloseClick={props.onAsideMobileClose}
/>
{isAsideMobileExpanded && <OverlayLayer zIndex="z-30" onClick={props.onAsideMobileClose} />}
</>
)
}

View File

@ -1,4 +1,6 @@
import React from 'react'
import { mdiClose } from '@mdi/js'
import BaseIcon from './BaseIcon'
import AsideMenuList from './AsideMenuList'
import { MenuAsideItem } from '../interfaces'
import { useAppSelector } from '../stores/hooks'
@ -7,17 +9,23 @@ import { useAppSelector } from '../stores/hooks'
type Props = {
menu: MenuAsideItem[]
className?: string
onAsideMobileCloseClick: () => void
}
export default function AsideMenuLayer({ menu, className = '' }: Props) {
export default function AsideMenuLayer({ menu, className = '', ...props }: Props) {
const asideScrollbarsStyle = useAppSelector((state) => state.style.asideScrollbarsStyle)
const darkMode = useAppSelector((state) => state.style.darkMode)
const handleAsideMobileCloseClick = (e: React.MouseEvent) => {
e.preventDefault()
props.onAsideMobileCloseClick()
}
return (
<aside
id='asideMenu'
className={`${className} fixed inset-y-0 z-40 flex h-screen w-64 overflow-hidden border-r border-slate-200 bg-white transition-position shadow-[0_24px_60px_-42px_rgba(15,23,42,0.28)] lg:shadow-none dark:border-dark-700 dark:bg-dark-900`}
className={`${className} fixed inset-y-0 z-40 flex h-screen w-64 transform overflow-hidden border-r border-slate-200 bg-white transition-transform duration-200 ease-out shadow-[0_24px_60px_-42px_rgba(15,23,42,0.28)] sm:shadow-none dark:border-dark-700 dark:bg-dark-900`}
>
<div
className="flex flex-1 flex-col overflow-hidden bg-white dark:bg-dark-900"
@ -30,6 +38,13 @@ export default function AsideMenuLayer({ menu, className = '' }: Props) {
AI Chat Workspace
</span>
</div>
<button
className="inline-flex rounded-[8px] border border-slate-200 bg-white p-2 text-slate-500 sm:hidden dark:border-dark-700 dark:bg-dark-900 dark:text-slate-300"
onClick={handleAsideMobileCloseClick}
type="button"
>
<BaseIcon path={mdiClose} size={16} />
</button>
</div>
<div
className={`flex-1 overflow-y-auto overflow-x-hidden ${

View File

@ -0,0 +1,122 @@
import Link from 'next/link';
import type { BillingPlan } from '../../lib/billingPlans';
import { formatBillingLimit, formatBillingPrice } from '../../lib/billingPlans';
type Props = {
plan: BillingPlan;
current?: boolean;
actionDisabled?: boolean;
actionHref?: string;
actionLabel: string;
actionLoading?: boolean;
caption?: string;
onAction?: () => void;
};
const primaryButtonClassName =
'inline-flex h-10 items-center justify-center rounded-[9px] bg-slate-900 px-4 text-sm font-medium text-white';
const secondaryButtonClassName =
'inline-flex h-10 items-center justify-center rounded-[9px] border border-slate-200 bg-white px-4 text-sm font-medium text-slate-700';
export default function PricingPlanCard({
plan,
current = false,
actionDisabled = false,
actionHref,
actionLabel,
actionLoading = false,
caption,
onAction,
}: Props) {
const actionClassName = current || plan.isFeatured ? primaryButtonClassName : secondaryButtonClassName;
return (
<article
className={`flex h-full flex-col rounded-[14px] border px-5 py-5 ${
plan.isFeatured
? 'border-slate-900 bg-slate-900 text-white shadow-[0_28px_90px_-48px_rgba(15,23,42,0.7)]'
: 'border-slate-200 bg-white text-slate-900'
}`}
>
<div className="flex items-start justify-between gap-3">
<div>
<p
className={`text-[11px] font-semibold uppercase tracking-[0.28em] ${
plan.isFeatured ? 'text-slate-300' : 'text-slate-400'
}`}
>
{plan.name}
</p>
<h3 className="mt-3 text-[2rem] font-semibold tracking-[-0.05em]">
{formatBillingPrice(plan.priceMonthly, plan.currency)}
</h3>
<p className={`mt-1 text-sm ${plan.isFeatured ? 'text-slate-300' : 'text-slate-500'}`}>
per month
</p>
</div>
{current && (
<span
className={`rounded-full px-3 py-1 text-[11px] font-medium uppercase tracking-[0.18em] ${
plan.isFeatured
? 'border border-white/20 bg-white/10 text-white/90'
: 'border border-slate-200 bg-slate-50 text-slate-600'
}`}
>
Current
</span>
)}
{!current && plan.isFeatured && (
<span className="rounded-full border border-white/20 bg-white/10 px-3 py-1 text-[11px] font-medium uppercase tracking-[0.18em] text-white/90">
Popular
</span>
)}
</div>
<p className={`mt-5 text-sm leading-6 ${plan.isFeatured ? 'text-slate-200' : 'text-slate-500'}`}>
{plan.description}
</p>
<div
className={`mt-5 grid gap-2 rounded-[12px] border px-4 py-4 text-sm ${
plan.isFeatured ? 'border-white/15 bg-white/5 text-slate-200' : 'border-slate-200 bg-slate-50 text-slate-600'
}`}
>
<div>{formatBillingLimit(plan.messageLimit, 'messages / month')}</div>
<div>{formatBillingLimit(plan.agentLimit, 'custom agents')}</div>
</div>
<ul className={`mt-5 grid gap-3 text-sm leading-6 ${plan.isFeatured ? 'text-slate-200' : 'text-slate-600'}`}>
{plan.features.map((feature) => (
<li className="flex items-start gap-3" key={feature}>
<span className={`mt-1 h-1.5 w-1.5 rounded-full ${plan.isFeatured ? 'bg-white/80' : 'bg-slate-900'}`} />
<span>{feature}</span>
</li>
))}
</ul>
<div className="mt-6">
{actionHref ? (
<Link className={actionClassName} href={actionHref}>
{actionLabel}
</Link>
) : (
<button
className={`${actionClassName} ${actionDisabled ? 'cursor-not-allowed opacity-60' : ''}`}
disabled={actionDisabled}
onClick={onAction}
type="button"
>
{actionLoading ? 'Processing…' : actionLabel}
</button>
)}
</div>
{caption && (
<p className={`mt-4 text-[12px] leading-5 ${plan.isFeatured ? 'text-slate-400' : 'text-slate-400'}`}>
{caption}
</p>
)}
</article>
);
}

View File

@ -1,150 +0,0 @@
import React, { useState, useEffect } from 'react';
import useDevCompilationStatus from '../hooks/useDevCompilationStatus';
const DevModeBadge: React.FC = () => {
const [isVisible, setIsVisible] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(true);
const compilationStatus = useDevCompilationStatus();
const [badgeStyles, setBadgeStyles] = useState<React.CSSProperties>({
position: 'fixed',
bottom: '20px',
left: '70px',
background: 'rgba(0, 0, 0, 0.85)',
color: 'white',
padding: '15px',
borderRadius: '8px',
fontFamily: 'sans-serif',
fontSize: '14px',
lineHeight: '1.5',
textAlign: 'left',
zIndex: 2147483647,
boxShadow: '0 4px 10px rgba(0, 0, 0, 0.3)',
whiteSpace: 'pre-wrap',
transition: 'width 0.3s cubic-bezier(0.25, 0.1, 0.25, 1), padding 0.3s ease-in-out, opacity 0.3s ease-in-out, background-color 0.3s ease-in-out', // Improved transition for width
opacity: 0,
pointerEvents: 'none',
width: '340px',
maxWidth: '340px',
height: 'auto',
overflow: 'hidden',
cursor: 'pointer',
});
const fullText = `🚧 Your app is running in development mode.
Current request is compiling and may take a few moments.
💡 Tip: Set up a stable environment to run your app in production modepages will load instantly without compilation delays.`;
const collapsedText = '🚧 DEV stage';
useEffect(() => {
if (compilationStatus === 'ready') {
setIsCollapsed(true);
} else {
setIsCollapsed(false);
}
}, [compilationStatus]);
useEffect(() => {
if (process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'dev_stage') {
setIsVisible(true);
setBadgeStyles(prev => ({
...prev,
opacity: 1,
width: '120px',
maxWidth: '120px',
padding: '6px 10px',
borderRadius: '18px',
whiteSpace: 'nowrap',
fontSize: '12px',
cursor: 'pointer',
pointerEvents: 'auto',
}));
} else {
setIsVisible(false);
setBadgeStyles(prev => ({ ...prev, opacity: 0 }));
}
}, []);
useEffect(() => {
if (!isVisible) return;
if (isCollapsed) {
setBadgeStyles(prev => ({
...prev,
width: '140px',
maxWidth: '160px',
padding: '6px 20px',
borderRadius: '18px',
whiteSpace: 'nowrap',
fontSize: '12px',
}));
} else {
setBadgeStyles(prev => ({
...prev,
width: '340px',
maxWidth: '340px',
padding: '15px',
borderRadius: '8px',
whiteSpace: 'pre-wrap',
fontSize: '14px',
}));
}
}, [isCollapsed, isVisible]);
const handleToggleCollapse = (e: React.MouseEvent) => {
e.stopPropagation();
setIsCollapsed(prev => !prev);
};
if (!isVisible) {
return null;
}
return (
<div style={badgeStyles} onClick={isCollapsed ? handleToggleCollapse : undefined}>
<button
onClick={handleToggleCollapse}
style={{
position: 'absolute',
top: isCollapsed ? '3px' : '5px',
right: isCollapsed ? '2px' : '5px',
background: 'none',
border: 'none',
color: 'white',
fontSize: isCollapsed ? '10px' : '18px',
cursor: 'pointer',
padding: '2px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
lineHeight: '1',
width: '24px',
height: isCollapsed ? '24px' : '24px',
borderRadius: '50%',
backgroundColor: 'rgba(255, 255, 255, 0.1)',
transition: 'background-color 0.2s ease, font-size 0.2s ease, width 0.2s ease, height 0.2s ease',
}}
aria-label={isCollapsed ? "Expand message" : "Collapse message"}
>
{isCollapsed ? '+' : '×'}
</button>
{!isCollapsed && (
<div style={{ marginRight: '20px' }}>
{fullText}
</div>
)}
{isCollapsed && (
<div style={{ marginRight: '10px' }}>
{collapsedText}
</div>
)}
</div>
);
};
export default DevModeBadge;

View File

@ -1,6 +1,5 @@
import React from 'react';
import Link from 'next/link';
import Button from '@mui/material/Button';
import BaseIcon from './BaseIcon';
import {
mdiDotsVertical,
@ -9,7 +8,6 @@ import {
mdiTrashCan,
} from '@mdi/js';
import Popover from '@mui/material/Popover';
import { IconButton } from '@mui/material';
type Props = {
@ -31,8 +29,8 @@ const ListActionsPopover = ({
pathEdit,
pathView,
}: Props) => {
const [anchorEl, setAnchorEl] = React.useState(null);
const handleClick = (event) => {
const [anchorEl, setAnchorEl] = React.useState<HTMLElement | null>(null);
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const linkView = pathView;
@ -46,20 +44,20 @@ const ListActionsPopover = ({
return (
<>
<IconButton
<button
aria-describedby={id}
className={`inline-flex h-9 w-9 items-center justify-center rounded-[8px] border border-transparent bg-white text-slate-500 transition-colors hover:border-slate-200 hover:text-slate-900 ${className || ''}`}
onClick={handleClick}
className={`rounded-full ${className}`}
size={'small'}
type="button"
>
<BaseIcon
className={`text-black dark:text-white ${iconClassName}`}
w='w-10'
h='h-10'
size={24}
className={`${iconClassName || ''}`}
w='w-5'
h='h-5'
size={18}
path={mdiDotsVertical}
/>
</IconButton>
</button>
<Popover
id={id}
open={open}
@ -67,44 +65,48 @@ const ListActionsPopover = ({
onClose={handleClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'center',
horizontal: 'right',
}}
PaperProps={{
className:
'mt-2 overflow-hidden rounded-[10px] border border-slate-200 bg-white shadow-[0_24px_50px_-36px_rgba(15,23,42,0.35)]',
}}
>
<div className={'flex flex-col'}>
<Button
startIcon={<BaseIcon path={mdiEye} size={24} />}
className='w-full MuiButton-colorInherit'
<div className="flex min-w-[180px] flex-col p-1.5">
<Link
className="inline-flex items-center gap-3 rounded-[8px] px-3 py-2 text-[14px] font-medium text-slate-700 transition-colors hover:bg-slate-50 hover:text-slate-900"
href={linkView}
sx={{ justifyContent: "start" }}
onClick={handleClose}
>
View
</Button>
<BaseIcon className="text-slate-400" path={mdiEye} size={16} />
<span>View</span>
</Link>
{hasUpdatePermission && (
<Button
startIcon={<BaseIcon path={mdiPencilOutline} size={24} />}
className='w-full MuiButton-colorInherit'
<Link
className="inline-flex items-center gap-3 rounded-[8px] px-3 py-2 text-[14px] font-medium text-slate-700 transition-colors hover:bg-slate-50 hover:text-slate-900"
href={linkEdit}
sx={{ justifyContent: "start" }}
onClick={handleClose}
>
Edit
</Button>
<BaseIcon className="text-slate-400" path={mdiPencilOutline} size={16} />
<span>Edit</span>
</Link>
)}
{hasUpdatePermission && (
<Button
startIcon={<BaseIcon path={mdiTrashCan} size={24} />}
className='MuiButton-colorInherit'
<button
className="inline-flex items-center gap-3 rounded-[8px] px-3 py-2 text-[14px] font-medium text-rose-600 transition-colors hover:bg-rose-50"
onClick={() => {
handleClose();
onDelete(itemId);
}}
sx={{ justifyContent: "start" }}
type="button"
>
Delete
</Button>
<BaseIcon className="text-rose-500" path={mdiTrashCan} size={16} />
<span>Delete</span>
</button>
)}
</div>
</Popover>

View File

@ -5,11 +5,16 @@ import {
mdiClose,
mdiCogOutline,
mdiDeleteOutline,
mdiFileOutline,
mdiImageOutline,
mdiMenu,
mdiMicrophoneOutline,
mdiOpenInNew,
mdiPencilOutline,
mdiPaperclip,
mdiPlus,
mdiRefresh,
mdiStopCircleOutline,
} from '@mdi/js';
import axios from 'axios';
import Link from 'next/link';
@ -20,6 +25,7 @@ import BaseIcon from '../BaseIcon';
import { ADMIN_ENTRY_PERMISSIONS, hasPermission } from '../../helpers/userPermissions';
import { useAppSelector } from '../../stores/hooks';
import ChatMarkdown from './ChatMarkdown';
import FileUploader from '../Uploaders/UploadService';
type AgentSummary = {
id: string;
@ -29,6 +35,26 @@ type AgentSummary = {
is_default?: boolean;
};
type WorkspaceAttachmentFile = {
id?: string;
name: string;
sizeInBytes?: number;
privateUrl?: string;
publicUrl?: string;
new?: boolean;
};
type WorkspaceAttachment = {
id: string;
kind: 'file' | 'image';
filename: string;
mime_type?: string | null;
size_bytes?: number | null;
storage_key?: string | null;
notes?: string | null;
file?: WorkspaceAttachmentFile | null;
};
type WorkspaceMessage = {
id: string;
role: 'user' | 'assistant' | 'system' | 'tool';
@ -41,8 +67,53 @@ type WorkspaceMessage = {
sequence?: number;
optimistic?: boolean;
pending?: boolean;
attachments?: WorkspaceAttachment[];
};
type ComposerAttachment = {
id: string;
kind: 'file' | 'image';
filename: string;
mime_type: string;
size_bytes: number;
storage_key: string;
notes?: string | null;
upload: WorkspaceAttachmentFile;
};
type SpeechRecognitionAlternativeLike = {
transcript: string;
};
type SpeechRecognitionResultLike = {
0: SpeechRecognitionAlternativeLike;
isFinal: boolean;
length: number;
};
type SpeechRecognitionEventLike = {
resultIndex: number;
results: ArrayLike<SpeechRecognitionResultLike>;
};
type SpeechRecognitionLike = {
continuous: boolean;
interimResults: boolean;
lang: string;
onend: null | (() => void);
onerror: null | (() => void);
onresult: null | ((event: SpeechRecognitionEventLike) => void);
start: () => void;
stop: () => void;
};
declare global {
interface Window {
SpeechRecognition?: new () => SpeechRecognitionLike;
webkitSpeechRecognition?: new () => SpeechRecognitionLike;
}
}
type ConversationSummary = {
id: string;
title: string;
@ -137,6 +208,19 @@ const NOTES_AGENT_STARTER: AgentStarterConfig = {
],
};
const ATTACHMENT_UPLOAD_PATH = 'messages/attachments';
const MAX_ATTACHMENTS = 5;
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024;
const TEXT_ATTACHMENT_EXTENSIONS = ['txt', 'md', 'markdown', 'json', 'csv', 'tsv', 'log', 'xml', 'yml', 'yaml'];
const TEXT_ATTACHMENT_MIME_PREFIXES = ['text/'];
const TEXT_ATTACHMENT_MIME_TYPES = [
'application/json',
'application/xml',
'application/javascript',
'application/x-yaml',
];
const MAX_ATTACHMENT_TEXT_LENGTH = 12000;
function getAgentStarterConfig(agent?: AgentSummary | null): AgentStarterConfig {
const haystack = `${agent?.name || ''} ${agent?.description || ''}`.toLowerCase();
@ -217,6 +301,64 @@ const formatMessageTime = (value?: string) => {
}).format(date);
};
const getFileExtension = (filename: string) => {
const parts = filename.toLowerCase().split('.');
if (parts.length < 2) {
return '';
}
return parts[parts.length - 1];
};
const canExtractTextFromAttachment = (file: File) => {
const extension = getFileExtension(file.name);
if (TEXT_ATTACHMENT_EXTENSIONS.includes(extension)) {
return true;
}
if (TEXT_ATTACHMENT_MIME_PREFIXES.some((prefix) => file.type.startsWith(prefix))) {
return true;
}
return TEXT_ATTACHMENT_MIME_TYPES.includes(file.type);
};
const readAttachmentText = async (file: File) => {
if (!canExtractTextFromAttachment(file)) {
return null;
}
const text = (await file.text()).trim();
if (!text) {
return null;
}
if (text.length <= MAX_ATTACHMENT_TEXT_LENGTH) {
return text;
}
return `${text.slice(0, MAX_ATTACHMENT_TEXT_LENGTH)}...`;
};
const formatAttachmentSize = (value?: number | null) => {
if (!value) {
return '';
}
if (value < 1024) {
return `${value} B`;
}
if (value < 1024 * 1024) {
return `${(value / 1024).toFixed(1)} KB`;
}
return `${(value / (1024 * 1024)).toFixed(1)} MB`;
};
const getErrorMessage = (error: unknown, fallback: string) => {
if (axios.isAxiosError(error)) {
if (typeof error.response?.data === 'string') {
@ -281,6 +423,116 @@ function TypingIndicator() {
);
}
type AttachmentChipProps = {
attachment: {
id: string;
kind: 'file' | 'image';
filename: string;
file?: WorkspaceAttachmentFile | null;
size_bytes?: number | null;
};
onRemove?: (id: string) => void;
muted?: boolean;
};
function AttachmentChip({ attachment, onRemove, muted = false }: AttachmentChipProps) {
const href = attachment.file?.publicUrl || '';
const icon = attachment.kind === 'image' ? mdiImageOutline : mdiFileOutline;
const isImagePreview = attachment.kind === 'image' && Boolean(href);
if (isImagePreview) {
return (
<div
className={`overflow-hidden rounded-[10px] border ${
muted
? 'border-white/15 bg-white/10 text-white'
: 'border-slate-200 bg-white text-slate-700'
}`}
>
<a
className="block"
href={href}
rel="noreferrer"
target="_blank"
>
<img
alt={attachment.filename}
className="block h-28 w-36 object-cover"
src={href}
/>
</a>
<div className="flex items-start justify-between gap-2 px-2.5 py-2">
<div className="min-w-0">
<div className={`mb-1 flex items-center gap-1.5 text-[10px] ${muted ? 'text-white/65' : 'text-slate-400'}`}>
<BaseIcon path={mdiImageOutline} size={13} />
<span>Image</span>
</div>
<div className="truncate text-[11px] font-medium">
{attachment.filename}
</div>
{attachment.size_bytes ? (
<div className={`mt-0.5 text-[10px] ${muted ? 'text-white/55' : 'text-slate-400'}`}>
{formatAttachmentSize(attachment.size_bytes)}
</div>
) : null}
</div>
{onRemove ? (
<button
className={`inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-[6px] ${
muted
? 'text-white/60 hover:bg-white/10 hover:text-white'
: 'text-slate-400 hover:bg-slate-100 hover:text-slate-700'
}`}
onClick={() => onRemove(attachment.id)}
type="button"
>
<BaseIcon path={mdiClose} size={14} />
</button>
) : null}
</div>
</div>
);
}
return (
<div
className={`inline-flex max-w-full items-center gap-2 rounded-[8px] border px-2.5 py-1.5 text-[11px] ${
muted
? 'border-slate-200 bg-white/80 text-slate-600'
: 'border-slate-200 bg-slate-50 text-slate-700'
}`}
>
<BaseIcon path={icon} size={15} />
{href ? (
<a
className="max-w-[13rem] truncate font-medium hover:underline"
href={href}
rel="noreferrer"
target="_blank"
>
{attachment.filename}
</a>
) : (
<span className="max-w-[13rem] truncate font-medium">{attachment.filename}</span>
)}
{attachment.size_bytes ? (
<span className={muted ? 'text-slate-400' : 'text-slate-500'}>
{formatAttachmentSize(attachment.size_bytes)}
</span>
) : null}
{onRemove ? (
<button
className="inline-flex items-center justify-center text-slate-400 hover:text-slate-700"
onClick={() => onRemove(attachment.id)}
type="button"
>
<BaseIcon path={mdiClose} size={14} />
</button>
) : null}
</div>
);
}
type MessageBubbleProps = {
currentUserAvatarUrl: string;
currentUserInitial: string;
@ -355,6 +607,18 @@ function MessageBubble({
)}
</div>
{Array.isArray(message.attachments) && message.attachments.length ? (
<div className="mb-2 flex flex-wrap gap-2">
{message.attachments.map((attachment) => (
<AttachmentChip
attachment={attachment}
key={attachment.id}
muted={isUser}
/>
))}
</div>
) : null}
{message.pending ? (
<TypingIndicator />
) : isStreaming ? (
@ -438,6 +702,8 @@ function ConversationRow({ conversation, isActive, onSelect }: ConversationRowPr
export default function WorkspaceShell() {
const router = useRouter();
const composerRef = useRef<HTMLTextAreaElement | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const recognitionRef = useRef<SpeechRecognitionLike | null>(null);
const titleInputRef = useRef<HTMLInputElement | null>(null);
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const didBootstrapRef = useRef(false);
@ -452,6 +718,9 @@ export default function WorkspaceShell() {
const [loadingBootstrap, setLoadingBootstrap] = useState(true);
const [loadingConversation, setLoadingConversation] = useState(false);
const [sending, setSending] = useState(false);
const [uploadingAttachment, setUploadingAttachment] = useState(false);
const [speechSupported, setSpeechSupported] = useState(false);
const [isListening, setIsListening] = useState(false);
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [showArchived, setShowArchived] = useState(false);
const [isEditingTitle, setIsEditingTitle] = useState(false);
@ -461,6 +730,7 @@ export default function WorkspaceShell() {
const [streamingMessageId, setStreamingMessageId] = useState<string | null>(null);
const [streamingText, setStreamingText] = useState('');
const [hasBootstrapped, setHasBootstrapped] = useState(false);
const [composerAttachments, setComposerAttachments] = useState<ComposerAttachment[]>([]);
const showSuccessToast = useCallback((message: string) => {
toast(message, {
@ -476,6 +746,23 @@ export default function WorkspaceShell() {
const currentUserInitial = getUserInitial(currentUser);
const canAccessAdmin = hasPermission(currentUser, ADMIN_ENTRY_PERMISSIONS);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
if (window.SpeechRecognition || window.webkitSpeechRecognition) {
setSpeechSupported(true);
}
}, []);
useEffect(() => () => {
if (recognitionRef.current) {
recognitionRef.current.stop();
recognitionRef.current = null;
}
}, []);
const activeConversations = useMemo(
() => conversations.filter((conversation) => conversation.status !== 'archived'),
[conversations],
@ -488,6 +775,7 @@ export default function WorkspaceShell() {
() => [...(activeConversation?.messages || []), ...optimisticMessages],
[activeConversation?.messages, optimisticMessages],
);
const canSubmitMessage = Boolean(composer.trim() || composerAttachments.length);
const upsertConversation = useCallback((conversation: ConversationSummary) => {
setConversations((previous) => {
@ -500,6 +788,10 @@ export default function WorkspaceShell() {
setConversations((previous) => previous.filter((conversation) => conversation.id !== conversationId));
}, []);
const removeComposerAttachment = useCallback((attachmentId: string) => {
setComposerAttachments((previous) => previous.filter((attachment) => attachment.id !== attachmentId));
}, []);
const applyConversationPayload = useCallback(
(conversation: ConversationDetail) => {
setActiveConversation(conversation);
@ -800,16 +1092,157 @@ export default function WorkspaceShell() {
[applyConversationPayload, router],
);
const handlePickAttachments = useCallback(() => {
fileInputRef.current?.click();
}, []);
const handleAttachmentChange = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
event.target.value = '';
if (!files.length) {
return;
}
if (composerAttachments.length + files.length > MAX_ATTACHMENTS) {
setNotice({
type: 'error',
message: `You can attach up to ${MAX_ATTACHMENTS} files per message.`,
});
return;
}
setNotice(null);
setUploadingAttachment(true);
try {
const nextAttachments: ComposerAttachment[] = [];
for (const file of files) {
const extractedText = await readAttachmentText(file);
const uploadedFile = await FileUploader.upload(ATTACHMENT_UPLOAD_PATH, file, {
size: MAX_ATTACHMENT_SIZE,
});
nextAttachments.push({
id: uploadedFile.id,
kind: file.type.startsWith('image/') ? 'image' : 'file',
filename: uploadedFile.name,
mime_type: file.type || 'application/octet-stream',
size_bytes: uploadedFile.sizeInBytes || file.size,
storage_key: uploadedFile.privateUrl || '',
notes: extractedText,
upload: uploadedFile,
});
}
setComposerAttachments((previous) => [...previous, ...nextAttachments]);
} catch (error) {
setNotice({
type: 'error',
message: getErrorMessage(error, 'Failed to attach the file.'),
});
} finally {
setUploadingAttachment(false);
}
},
[composerAttachments.length],
);
const stopVoiceCapture = useCallback(() => {
if (recognitionRef.current) {
recognitionRef.current.stop();
recognitionRef.current = null;
}
setIsListening(false);
}, []);
const handleVoiceInput = useCallback(() => {
if (isListening) {
stopVoiceCapture();
return;
}
if (typeof window === 'undefined') {
return;
}
const SpeechRecognitionConstructor = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognitionConstructor) {
setNotice({
type: 'error',
message: 'Voice input is not supported in this browser.',
});
return;
}
const recognition = new SpeechRecognitionConstructor();
const initialValue = composer.trim();
let lastTranscript = '';
recognition.continuous = false;
recognition.interimResults = true;
recognition.lang = 'en-US';
recognition.onresult = (event) => {
let transcript = '';
for (let index = event.resultIndex; index < event.results.length; index += 1) {
transcript += event.results[index][0]?.transcript || '';
}
const normalizedTranscript = transcript.trim();
if (!normalizedTranscript) {
return;
}
if (normalizedTranscript === lastTranscript) {
return;
}
lastTranscript = normalizedTranscript;
if (!initialValue) {
setComposer(normalizedTranscript);
return;
}
setComposer(`${initialValue} ${normalizedTranscript}`.trim());
};
recognition.onerror = () => {
setNotice({
type: 'error',
message: 'Voice input failed. Try speaking again or use the keyboard.',
});
setIsListening(false);
recognitionRef.current = null;
};
recognition.onend = () => {
setIsListening(false);
recognitionRef.current = null;
};
recognitionRef.current = recognition;
setNotice(null);
setIsListening(true);
recognition.start();
}, [composer, isListening, stopVoiceCapture]);
const handleSendMessage = useCallback(async () => {
const messageToSend = composer.trim();
const attachmentsToSend = composerAttachments;
if (!messageToSend || sending) {
if ((!messageToSend && !attachmentsToSend.length) || sending || uploadingAttachment) {
return;
}
setNotice(null);
setSending(true);
setComposer('');
setComposerAttachments([]);
let conversation = activeConversation;
let createdConversationId: string | null = null;
@ -832,6 +1265,16 @@ export default function WorkspaceShell() {
content_markdown: messageToSend,
createdAt: new Date().toISOString(),
optimistic: true,
attachments: attachmentsToSend.map((attachment) => ({
id: attachment.id,
kind: attachment.kind,
filename: attachment.filename,
mime_type: attachment.mime_type,
size_bytes: attachment.size_bytes,
storage_key: attachment.storage_key,
notes: attachment.notes,
file: attachment.upload,
})),
},
{
id: `optimistic-assistant-${optimisticBase}`,
@ -844,6 +1287,16 @@ export default function WorkspaceShell() {
const { data } = await axios.post(`/workspace/conversations/${conversation.id}/messages`, {
content: messageToSend,
attachments: attachmentsToSend.map((attachment) => ({
kind: attachment.kind,
filename: attachment.filename,
mime_type: attachment.mime_type,
size_bytes: attachment.size_bytes,
storage_key: attachment.storage_key,
notes: attachment.notes,
file: attachment.kind === 'file' ? [attachment.upload] : [],
image: attachment.kind === 'image' ? [attachment.upload] : [],
})),
agentId: conversation.agent?.id || selectedAgentId || undefined,
});
@ -851,6 +1304,7 @@ export default function WorkspaceShell() {
} catch (error) {
setOptimisticMessages([]);
setComposer(messageToSend);
setComposerAttachments(attachmentsToSend);
setNotice({
type: 'error',
message: getErrorMessage(error, 'Failed to send the message.'),
@ -871,9 +1325,11 @@ export default function WorkspaceShell() {
applyConversationResponse,
applyConversationPayload,
composer,
composerAttachments,
router,
selectedAgentId,
sending,
uploadingAttachment,
upsertConversation,
]);
@ -961,6 +1417,14 @@ export default function WorkspaceShell() {
activeConversation ? 'h-full min-h-0 overflow-hidden' : 'min-h-[calc(100vh-3rem)]'
}`}
>
<input
accept="*/*"
className="hidden"
multiple
onChange={handleAttachmentChange}
ref={fileInputRef}
type="file"
/>
<aside
className={`absolute inset-y-0 left-0 z-20 flex w-full max-w-[220px] flex-col border-r border-slate-200 bg-[#fafafa] transition duration-200 lg:static lg:translate-x-0 ${
isSidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
@ -1290,6 +1754,58 @@ export default function WorkspaceShell() {
<div className="border-t border-slate-200 bg-white px-3.5 py-2 md:px-4">
<div className="rounded-[8px] border border-slate-200 bg-white p-2.5">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<button
className="inline-flex items-center gap-1.5 rounded-[8px] border border-slate-200 bg-white px-2.5 py-1.5 text-[11px] font-medium text-slate-600 disabled:cursor-not-allowed disabled:opacity-60"
disabled={uploadingAttachment || composerAttachments.length >= MAX_ATTACHMENTS}
onClick={handlePickAttachments}
type="button"
>
<BaseIcon path={mdiPaperclip} size={15} />
{uploadingAttachment ? 'Uploading…' : 'Attach file'}
</button>
{speechSupported ? (
<button
className={`inline-flex items-center gap-1.5 rounded-[8px] border px-2.5 py-1.5 text-[11px] font-medium disabled:cursor-not-allowed disabled:opacity-60 ${
isListening
? 'border-slate-900 bg-slate-900 text-white'
: 'border-slate-200 bg-white text-slate-600'
}`}
disabled={uploadingAttachment}
onClick={handleVoiceInput}
type="button"
>
<BaseIcon path={isListening ? mdiStopCircleOutline : mdiMicrophoneOutline} size={15} />
{isListening ? 'Stop voice' : 'Voice input'}
</button>
) : null}
</div>
<span className="text-[11px] text-slate-400">
{composerAttachments.length
? `${composerAttachments.length}/${MAX_ATTACHMENTS} attached`
: 'Add files or dictate a message'}
</span>
</div>
{composerAttachments.length ? (
<div className="mb-2 flex flex-wrap gap-2">
{composerAttachments.map((attachment) => (
<AttachmentChip
attachment={{
id: attachment.id,
kind: attachment.kind,
filename: attachment.filename,
file: attachment.upload,
size_bytes: attachment.size_bytes,
}}
key={attachment.id}
onRemove={removeComposerAttachment}
/>
))}
</div>
) : null}
<div className="grid gap-2 lg:grid-cols-[minmax(0,1fr)_165px]">
<textarea
className="min-h-[62px] w-full resize-none rounded-[10px] border border-slate-200 bg-slate-50 px-3 py-2 text-[13px] leading-5 text-slate-900 outline-none placeholder:text-slate-400 focus:border-slate-900"
@ -1325,7 +1841,7 @@ export default function WorkspaceShell() {
)}
<button
className="inline-flex items-center justify-center gap-1.5 rounded-[8px] bg-slate-900 px-3.5 py-2 text-[12px] font-medium text-white disabled:cursor-not-allowed disabled:opacity-60"
disabled={!composer.trim() || sending}
disabled={!canSubmitMessage || sending || uploadingAttachment}
onClick={() => void handleSendMessage()}
type="button"
>
@ -1411,7 +1927,7 @@ export default function WorkspaceShell() {
</label>
<button
className="inline-flex items-center justify-center gap-2 rounded-[10px] bg-slate-900 px-4 py-2.5 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-60"
disabled={!composer.trim() || sending || loadingBootstrap}
disabled={!canSubmitMessage || sending || loadingBootstrap || uploadingAttachment}
onClick={() => void handleSendMessage()}
type="button"
>
@ -1426,6 +1942,57 @@ export default function WorkspaceShell() {
<p className="mb-2.5 text-[12px] leading-5 text-slate-500">
Press Enter to start. Use Shift+Enter for a new line.
</p>
<div className="mb-2.5 flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<button
className="inline-flex items-center gap-1.5 rounded-[8px] border border-slate-200 bg-white px-2.5 py-1.5 text-[11px] font-medium text-slate-600 disabled:cursor-not-allowed disabled:opacity-60"
disabled={uploadingAttachment || composerAttachments.length >= MAX_ATTACHMENTS}
onClick={handlePickAttachments}
type="button"
>
<BaseIcon path={mdiPaperclip} size={15} />
{uploadingAttachment ? 'Uploading…' : 'Attach file'}
</button>
{speechSupported ? (
<button
className={`inline-flex items-center gap-1.5 rounded-[8px] border px-2.5 py-1.5 text-[11px] font-medium disabled:cursor-not-allowed disabled:opacity-60 ${
isListening
? 'border-slate-900 bg-slate-900 text-white'
: 'border-slate-200 bg-white text-slate-600'
}`}
disabled={uploadingAttachment}
onClick={handleVoiceInput}
type="button"
>
<BaseIcon path={isListening ? mdiStopCircleOutline : mdiMicrophoneOutline} size={15} />
{isListening ? 'Stop voice' : 'Voice input'}
</button>
) : null}
</div>
<span className="text-[11px] text-slate-400">
{composerAttachments.length
? `${composerAttachments.length}/${MAX_ATTACHMENTS} attached`
: 'Files and dictation are optional'}
</span>
</div>
{composerAttachments.length ? (
<div className="mb-2.5 flex flex-wrap gap-2">
{composerAttachments.map((attachment) => (
<AttachmentChip
attachment={{
id: attachment.id,
kind: attachment.kind,
filename: attachment.filename,
file: attachment.upload,
size_bytes: attachment.size_bytes,
}}
key={attachment.id}
onRemove={removeComposerAttachment}
/>
))}
</div>
) : null}
<textarea
className="min-h-[104px] w-full resize-none rounded-[10px] border border-slate-200 bg-white px-4 py-3 text-[14px] leading-6 text-slate-900 outline-none placeholder:text-slate-400 focus:border-slate-900"
onChange={(event) => setComposer(event.target.value)}

View File

@ -10,6 +10,17 @@ body {
@apply w-screen transition-position lg:w-auto h-full flex flex-col;
}
@media (min-width: 640px) {
.app-shell-content--with-sidebar {
padding-left: 16rem;
}
.app-shell-navbar--with-sidebar {
left: 16rem;
width: calc(100% - 16rem);
}
}
.dropdown {
@apply cursor-pointer;
}

View File

@ -1,44 +0,0 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
type CompilationStatus = 'ready' | 'compiling' | 'error' | 'initial';
const useDevCompilationStatus = (): CompilationStatus => {
const router = useRouter();
const [status, setStatus] = useState<CompilationStatus>('initial');
useEffect(() => {
if (process.env.NODE_ENV !== 'development') {
setStatus('ready');
return;
}
const handleRouteChangeStart = () => {
setStatus('compiling');
};
const handleRouteChangeComplete = () => {
setTimeout(() => setStatus('ready'), 300);
};
const handleRouteChangeError = () => {
setTimeout(() => setStatus('error'), 300);
};
router.events.on('routeChangeStart', handleRouteChangeStart);
router.events.on('routeChangeComplete', handleRouteChangeComplete);
router.events.on('routeChangeError', handleRouteChangeError);
setStatus('ready');
return () => {
router.events.off('routeChangeStart', handleRouteChangeStart);
router.events.off('routeChangeComplete', handleRouteChangeComplete);
router.events.off('routeChangeError', handleRouteChangeError);
};
}, [router]);
return status;
};
export default useDevCompilationStatus;

View File

@ -1,6 +1,8 @@
import React, { ReactNode, useEffect, useRef, useState } from 'react'
import jwt from 'jsonwebtoken';
import {
mdiMenu,
mdiClose,
mdiChevronDown,
mdiCogOutline,
mdiLogout,
@ -8,6 +10,7 @@ import {
import menuAside from '../menuAside'
import BaseIcon from '../components/BaseIcon'
import NavBar from '../components/NavBar'
import NavBarItemPlain from '../components/NavBarItemPlain'
import AsideMenu from '../components/AsideMenu'
import FooterBar from '../components/FooterBar'
import { useAppDispatch, useAppSelector } from '../stores/hooks'
@ -111,13 +114,16 @@ export default function LayoutAuthenticated({
const darkMode = useAppSelector((state) => state.style.darkMode)
const [isAsideMobileExpanded, setIsAsideMobileExpanded] = useState(false)
const [isWorkspaceAccountMenuOpen, setIsWorkspaceAccountMenuOpen] = useState(false)
const [viewportWidth, setViewportWidth] = useState(0)
const workspaceAccountMenuButtonRef = useRef(null)
const currentUserAvatarUrl = getAvatarUrl(currentUser?.avatar)
const currentUserInitial = getUserInitial(currentUser)
useEffect(() => {
const handleRouteChangeStart = () => {
setIsAsideMobileExpanded(false)
setIsWorkspaceAccountMenuOpen(false)
}
@ -130,12 +136,32 @@ export default function LayoutAuthenticated({
}
}, [router.events, dispatch])
const contentStyle = isWorkspaceRoute
? undefined
: { paddingLeft: `${asideWidth}px` }
const navStyle = isWorkspaceRoute
? undefined
: { left: `${asideWidth}px`, width: `calc(100% - ${asideWidth}px)` }
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const syncViewportWidth = () => {
setViewportWidth(window.innerWidth)
}
syncViewportWidth()
window.addEventListener('resize', syncViewportWidth)
return () => {
window.removeEventListener('resize', syncViewportWidth)
}
}, [])
const handleAsideToggle = () => {
setIsAsideMobileExpanded((previous) => !previous)
}
const hasPersistentAside = !isWorkspaceRoute && viewportWidth >= 640
const contentStyle = hasPersistentAside ? { paddingLeft: `${asideWidth}px` } : undefined
const navStyle = hasPersistentAside
? { left: `${asideWidth}px`, width: `calc(100% - ${asideWidth}px)` }
: undefined
if (!isAuthBootstrapped) {
return (
@ -204,6 +230,11 @@ export default function LayoutAuthenticated({
>
<div className="flex min-w-0 flex-1 items-center justify-between px-3 sm:px-5">
<div className="flex min-w-0 items-center">
{!isWorkspaceRoute && (
<NavBarItemPlain display="flex sm:hidden" onClick={handleAsideToggle}>
<BaseIcon path={isAsideMobileExpanded ? mdiClose : mdiMenu} size="22" />
</NavBarItemPlain>
)}
{isWorkspaceRoute && (
<Link
className="truncate text-[14px] font-semibold tracking-[-0.02em] text-slate-700 dark:text-slate-100"
@ -274,7 +305,9 @@ export default function LayoutAuthenticated({
</NavBar>
{!isWorkspaceRoute && (
<AsideMenu
isAsideMobileExpanded={isAsideMobileExpanded}
menu={menuAside}
onAsideMobileClose={() => setIsAsideMobileExpanded(false)}
/>
)}
{children}

View File

@ -0,0 +1,128 @@
export type BillingPlan = {
id?: string;
name: string;
slug: string;
description: string;
priceMonthly: number;
currency: string;
stripeProductId?: string;
stripePriceId?: string;
messageLimit?: number | null;
agentLimit?: number | null;
isFeatured?: boolean;
isActive?: boolean;
features: string[];
};
export type BillingSubscription = {
id: string;
status: string;
stripeCustomerId?: string;
stripeSubscriptionId?: string;
currentPeriodStart?: string;
currentPeriodEnd?: string;
cancelAtPeriodEnd?: boolean;
plan: BillingPlan | null;
};
export type StripeSettingsPlan = {
id?: string;
name: string;
slug: string;
stripeProductId?: string;
stripePriceId?: string;
};
export type StripeSettingsState = {
stripeSecretKey: string;
stripeSecretKeyPreview: string;
stripeSecretKeySource: string;
stripeWebhookSecret: string;
stripeWebhookSecretPreview: string;
stripeWebhookSecretSource: string;
webhookEndpoint: string;
webhookEvents: string[];
plans: StripeSettingsPlan[];
};
export const marketingPlans: BillingPlan[] = [
{
name: 'Starter',
slug: 'starter',
description: 'For solo builders who want a calm workspace and the basics of AI billing.',
priceMonthly: 19,
currency: 'usd',
stripeProductId: 'prod_mock_starter',
stripePriceId: 'price_mock_starter_monthly',
messageLimit: 300,
agentLimit: 3,
isFeatured: false,
features: [
'300 AI messages every month',
'3 custom agents',
'Conversation history and export',
],
},
{
name: 'Pro',
slug: 'pro',
description: 'For frequent users who need more messages, more agents, and a stronger daily workflow.',
priceMonthly: 49,
currency: 'usd',
stripeProductId: 'prod_mock_pro',
stripePriceId: 'price_mock_pro_monthly',
messageLimit: 1500,
agentLimit: 10,
isFeatured: true,
features: [
'1,500 AI messages every month',
'10 custom agents',
'Priority billing support',
],
},
{
name: 'Team',
slug: 'team',
description: 'For heavier shared use with more throughput and room for longer-running work.',
priceMonthly: 149,
currency: 'usd',
stripeProductId: 'prod_mock_team',
stripePriceId: 'price_mock_team_monthly',
messageLimit: 5000,
agentLimit: 30,
isFeatured: false,
features: [
'5,000 AI messages every month',
'30 custom agents',
'Shared workspace billing controls',
],
},
];
export function formatBillingPrice(amount: number, currency = 'usd') {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency.toUpperCase(),
maximumFractionDigits: 0,
}).format(amount);
}
export function formatBillingLimit(value: number | null | undefined, label: string) {
if (!value) {
return `Unlimited ${label}`;
}
return `${value.toLocaleString()} ${label}`;
}
export function formatBillingDate(value?: string) {
if (!value) {
return '';
}
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
}).format(new Date(value));
}

View File

@ -80,6 +80,11 @@ const menuAside: MenuAsideItem[] = [
label: 'Settings',
icon: icon.mdiAccountCircle,
},
{
href: '/billing',
label: 'Billing',
icon: icon.mdiCreditCardOutline,
},
{
label: 'Admin',
icon: icon.mdiCogOutline,

View File

@ -12,7 +12,6 @@ import axios from 'axios';
import { baseURLApi } from '../config';
import { useRouter } from 'next/router';
import ErrorBoundary from "../components/ErrorBoundary";
import DevModeBadge from '../components/DevModeBadge';
import 'intro.js/introjs.css';
import { appWithTranslation } from 'next-i18next';
import '../i18n';
@ -231,7 +230,6 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
stepsEnabled={stepsEnabled}
onExit={handleExit}
/>
{(process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'dev_stage') && <DevModeBadge />}
</>
)}
</Provider>

View File

@ -1,689 +1,196 @@
import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'
import Head from 'next/head'
import React, { ReactElement, useEffect, useState } from 'react'
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
import dayjs from "dayjs";
import { mdiArrowLeft, mdiRobotOutline } from '@mdi/js';
import Head from 'next/head';
import Link from 'next/link';
import React, { ReactElement, useEffect, useState } from 'react';
import { Form, Formik } from 'formik';
import CardBox from '../../components/CardBox'
import LayoutAuthenticated from '../../layouts/Authenticated'
import SectionMain from '../../components/SectionMain'
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
import { getPageTitle } from '../../config'
import AgentFormSections from '../../components/Agents/AgentFormSections';
import BaseIcon from '../../components/BaseIcon';
import SectionMain from '../../components/SectionMain';
import { getPageTitle } from '../../config';
import LayoutAuthenticated from '../../layouts/Authenticated';
import { fetch, update } from '../../stores/agents/agentsSlice';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { useRouter } from 'next/router';
import { Field, Form, Formik } from 'formik'
import FormField from '../../components/FormField'
import BaseDivider from '../../components/BaseDivider'
import BaseButtons from '../../components/BaseButtons'
import BaseButton from '../../components/BaseButton'
import FormCheckRadio from '../../components/FormCheckRadio'
import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'
import FormFilePicker from '../../components/FormFilePicker'
import FormImagePicker from '../../components/FormImagePicker'
import { SelectField } from "../../components/SelectField";
import { SelectFieldMany } from "../../components/SelectFieldMany";
import { SwitchField } from '../../components/SwitchField'
import {RichTextField} from "../../components/RichTextField";
const actionButtonClassName =
'inline-flex items-center justify-center rounded-[8px] border px-4 py-2 text-sm font-medium';
import { update, fetch } from '../../stores/agents/agentsSlice'
import { useAppDispatch, useAppSelector } from '../../stores/hooks'
import { useRouter } from 'next/router'
import {saveFile} from "../../helpers/fileSaver";
import dataFormatter from '../../helpers/dataFormatter';
import ImageField from "../../components/ImageField";
const initialAgentValues = {
description: '',
is_active: true,
is_default: false,
max_output_tokens: '',
metadata_json: '',
model: '',
name: '',
system_prompt: '',
temperature: '',
};
function getPreviewName(values: typeof initialAgentValues) {
if (values.name) {
return values.name;
}
return 'Untitled agent';
}
const EditAgentsPage = () => {
const router = useRouter()
const dispatch = useAppDispatch()
const initVals = {
'name': '',
description: '',
'model': '',
system_prompt: '',
'temperature': '',
max_output_tokens: '',
is_default: false,
is_active: false,
metadata_json: '',
}
const [initialValues, setInitialValues] = useState(initVals)
const { agents } = useAppSelector((state) => state.agents)
const { id } = router.query
const router = useRouter();
const dispatch = useAppDispatch();
const { agents, loading } = useAppSelector((state) => state.agents);
const { id } = router.query;
const [initialValues, setInitialValues] = useState(initialAgentValues);
useEffect(() => {
dispatch(fetch({ id: id }))
}, [id])
useEffect(() => {
if (typeof agents === 'object') {
setInitialValues(agents)
if (typeof id !== 'string') {
return;
}
}, [agents])
dispatch(fetch({ id }));
}, [dispatch, id]);
useEffect(() => {
if (typeof agents === 'object') {
const newInitialVal = {...initVals};
Object.keys(initVals).forEach(el => newInitialVal[el] = (agents)[el])
setInitialValues(newInitialVal);
}
}, [agents])
if (!agents || Array.isArray(agents) || typeof agents !== 'object') {
return;
}
const handleSubmit = async (data) => {
await dispatch(update({ id: id, data }))
await router.push('/agents/agents-list')
}
setInitialValues({
description: agents.description || '',
is_active: Boolean(agents.is_active),
is_default: Boolean(agents.is_default),
max_output_tokens: agents.max_output_tokens ?? '',
metadata_json: agents.metadata_json || '',
model: agents.model || '',
name: agents.name || '',
system_prompt: agents.system_prompt || '',
temperature: agents.temperature ?? '',
});
}, [agents]);
const handleSubmit = async (data: typeof initialAgentValues) => {
if (typeof id !== 'string') {
return;
}
await dispatch(update({ id, data }));
await router.push('/agents/agents-list');
};
return (
<>
<Head>
<title>{getPageTitle('Edit agents')}</title>
<title>{getPageTitle('Edit agent')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={'Edit agents'} main>
{''}
</SectionTitleLineWithButton>
<CardBox>
<Formik
enableReinitialize
initialValues={initialValues}
onSubmit={(values) => handleSubmit(values)}
>
<Form>
<FormField
label="Name"
>
<Field
name="name"
placeholder="Name"
/>
</FormField>
<FormField label="Description" hasTextareaHeight>
<Field name="description" as="textarea" placeholder="Description" />
</FormField>
<FormField
label="Model"
>
<Field
name="model"
placeholder="Model"
/>
</FormField>
<FormField label="Systemprompt" hasTextareaHeight>
<Field name="system_prompt" as="textarea" placeholder="Systemprompt" />
</FormField>
<FormField
label="Temperature"
>
<Field
type="number"
name="temperature"
placeholder="Temperature"
/>
</FormField>
<FormField
label="Maxoutputtokens"
>
<Field
type="number"
name="max_output_tokens"
placeholder="Maxoutputtokens"
/>
</FormField>
<FormField label='Isdefault' labelFor='is_default'>
<Field
name='is_default'
id='is_default'
component={SwitchField}
></Field>
</FormField>
<FormField label='Isactive' labelFor='is_active'>
<Field
name='is_active'
id='is_active'
component={SwitchField}
></Field>
</FormField>
<FormField label="MetadataJSON" hasTextareaHeight>
<Field name="metadata_json" as="textarea" placeholder="MetadataJSON" />
</FormField>
<BaseDivider />
<BaseButtons>
<BaseButton type="submit" color="info" label="Submit" />
<BaseButton type="reset" color="info" outline label="Reset" />
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/agents/agents-list')}/>
</BaseButtons>
</Form>
</Formik>
</CardBox>
<Formik
enableReinitialize
initialValues={initialValues}
onSubmit={(values) => handleSubmit(values)}
>
{({ isSubmitting, setFieldValue, values }) => {
const previewName = getPreviewName(values);
const isAgentLoading = loading && !initialValues.name && !initialValues.model;
return (
<Form>
<div className="flex w-full flex-col gap-5">
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-6">
<Link
className="inline-flex items-center gap-2 text-[12px] font-medium text-slate-500"
href="/agents/agents-list"
>
<BaseIcon path={mdiArrowLeft} size={14} />
Back to agents
</Link>
<p className="mt-4 text-[11px] font-medium uppercase tracking-[0.28em] text-slate-400">
Agent
</p>
<h1 className="mt-3 text-[2rem] font-semibold tracking-[-0.04em] text-slate-900">
Refine an existing assistant profile.
</h1>
<p className="mt-3 max-w-2xl text-sm leading-6 text-slate-500">
Update the prompt, tune the model settings, or change how this agent shows up in the workspace.
</p>
</div>
<div className="grid gap-5 2xl:grid-cols-[minmax(0,1fr)_320px]">
<div className="space-y-5">
<AgentFormSections setFieldValue={setFieldValue} values={values} />
</div>
<div className="space-y-5">
<div className="rounded-[12px] border border-slate-200 bg-white px-5 py-5">
<div className="flex items-start gap-3">
<div className="inline-flex h-11 w-11 items-center justify-center rounded-[10px] border border-slate-200 bg-slate-50 text-slate-700">
<BaseIcon path={mdiRobotOutline} size={20} />
</div>
<div className="min-w-0">
<p className="truncate text-[16px] font-semibold text-slate-900">
{previewName}
</p>
<p className="mt-1 text-[13px] text-slate-500">
{values.model || 'No model selected yet'}
</p>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<span
className={`inline-flex items-center rounded-[999px] px-2.5 py-1 text-[11px] font-medium ${
values.is_active
? 'bg-emerald-50 text-emerald-700'
: 'bg-slate-100 text-slate-600'
}`}
>
{values.is_active ? 'Active' : 'Inactive'}
</span>
<span className="inline-flex items-center rounded-[999px] bg-slate-100 px-2.5 py-1 text-[11px] font-medium text-slate-700">
{values.is_default ? 'Default preset' : 'Custom preset'}
</span>
</div>
<p className="mt-4 text-[13px] leading-6 text-slate-500">
{values.description || 'Add a short description so people understand when to use this agent.'}
</p>
</div>
<div className="rounded-[12px] border border-slate-200 bg-white px-5 py-5">
<p className="text-[11px] font-medium uppercase tracking-[0.28em] text-slate-400">
Actions
</p>
{isAgentLoading && (
<p className="mt-4 text-[13px] text-slate-500">Loading agent</p>
)}
<div className="mt-4 flex flex-wrap gap-2">
<Link
className={`${actionButtonClassName} border-slate-200 bg-white text-slate-700`}
href="/agents/agents-list"
>
Cancel
</Link>
<button
className={`${actionButtonClassName} border-slate-900 bg-slate-900 text-white`}
disabled={isSubmitting || isAgentLoading}
type="submit"
>
{isSubmitting ? 'Saving…' : 'Save changes'}
</button>
</div>
</div>
</div>
</div>
</div>
</Form>
);
}}
</Formik>
</SectionMain>
</>
)
}
);
};
EditAgentsPage.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated
permission={'UPDATE_AGENTS'}
>
{page}
</LayoutAuthenticated>
)
}
return <LayoutAuthenticated permission="UPDATE_AGENTS">{page}</LayoutAuthenticated>;
};
export default EditAgentsPage
export default EditAgentsPage;

View File

@ -4,7 +4,6 @@ import { uniqueId } from 'lodash';
import React, { ReactElement, useState } from 'react';
import axios from 'axios';
import CardBox from '../../components/CardBox';
import CardBoxModal from '../../components/CardBoxModal';
import DragDropFilePicker from '../../components/DragDropFilePicker';
import TableAgents from '../../components/Agents/TableAgents';
@ -136,14 +135,14 @@ const AgentsTablesPage = () => {
</div>
</div>
<CardBox className="mb-6 rounded-[10px] border border-slate-200 bg-white shadow-none" hasTable>
<div className="mb-6 rounded-[10px] border border-slate-200 bg-white shadow-none">
<TableAgents
filterItems={filterItems}
filters={filters}
setFilterItems={setFilterItems}
showGrid={false}
/>
</CardBox>
</div>
</SectionMain>
<CardBoxModal
buttonColor="info"

View File

@ -1,536 +1,155 @@
import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js'
import Head from 'next/head'
import React, { ReactElement } from 'react'
import CardBox from '../../components/CardBox'
import LayoutAuthenticated from '../../layouts/Authenticated'
import SectionMain from '../../components/SectionMain'
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
import { getPageTitle } from '../../config'
import { mdiArrowLeft, mdiRobotOutline } from '@mdi/js';
import Head from 'next/head';
import Link from 'next/link';
import React, { ReactElement } from 'react';
import { Form, Formik } from 'formik';
import { Field, Form, Formik } from 'formik'
import FormField from '../../components/FormField'
import BaseDivider from '../../components/BaseDivider'
import BaseButtons from '../../components/BaseButtons'
import BaseButton from '../../components/BaseButton'
import FormCheckRadio from '../../components/FormCheckRadio'
import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'
import FormFilePicker from '../../components/FormFilePicker'
import FormImagePicker from '../../components/FormImagePicker'
import { SwitchField } from '../../components/SwitchField'
import AgentFormSections from '../../components/Agents/AgentFormSections';
import BaseIcon from '../../components/BaseIcon';
import SectionMain from '../../components/SectionMain';
import { getPageTitle } from '../../config';
import LayoutAuthenticated from '../../layouts/Authenticated';
import { create } from '../../stores/agents/agentsSlice';
import { useAppDispatch } from '../../stores/hooks';
import { useRouter } from 'next/router';
import { SelectField } from '../../components/SelectField'
import { SelectFieldMany } from "../../components/SelectFieldMany";
import {RichTextField} from "../../components/RichTextField";
import { create } from '../../stores/agents/agentsSlice'
import { useAppDispatch } from '../../stores/hooks'
import { useRouter } from 'next/router'
import moment from 'moment';
const actionButtonClassName =
'inline-flex items-center justify-center rounded-[8px] border px-4 py-2 text-sm font-medium';
const initialValues = {
name: '',
description: '',
model: '',
system_prompt: '',
temperature: '',
max_output_tokens: '',
is_default: false,
is_active: false,
metadata_json: '',
description: '',
is_active: true,
is_default: false,
max_output_tokens: '',
metadata_json: '',
model: '',
name: '',
system_prompt: '',
temperature: '',
};
function getPreviewName(values: typeof initialValues) {
if (values.name) {
return values.name;
}
return 'Untitled agent';
}
const AgentsNew = () => {
const router = useRouter()
const dispatch = useAppDispatch()
const router = useRouter();
const dispatch = useAppDispatch();
const handleSubmit = async (data: typeof initialValues) => {
await dispatch(create(data));
await router.push('/agents/agents-list');
};
const handleSubmit = async (data) => {
await dispatch(create(data))
await router.push('/agents/agents-list')
}
return (
<>
<Head>
<title>{getPageTitle('New Item')}</title>
<title>{getPageTitle('New agent')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main>
{''}
</SectionTitleLineWithButton>
<CardBox>
<Formik
initialValues={
initialValues
}
onSubmit={(values) => handleSubmit(values)}
>
<Form>
<FormField
label="Name"
>
<Field
name="name"
placeholder="Name"
/>
</FormField>
<FormField label="Description" hasTextareaHeight>
<Field name="description" as="textarea" placeholder="Description" />
</FormField>
<FormField
label="Model"
>
<Field
name="model"
placeholder="Model"
/>
</FormField>
<FormField label="Systemprompt" hasTextareaHeight>
<Field name="system_prompt" as="textarea" placeholder="Systemprompt" />
</FormField>
<FormField
label="Temperature"
>
<Field
type="number"
name="temperature"
placeholder="Temperature"
/>
</FormField>
<FormField
label="Maxoutputtokens"
>
<Field
type="number"
name="max_output_tokens"
placeholder="Maxoutputtokens"
/>
</FormField>
<FormField label='Isdefault' labelFor='is_default'>
<Field
name='is_default'
id='is_default'
component={SwitchField}
></Field>
</FormField>
<FormField label='Isactive' labelFor='is_active'>
<Field
name='is_active'
id='is_active'
component={SwitchField}
></Field>
</FormField>
<FormField label="MetadataJSON" hasTextareaHeight>
<Field name="metadata_json" as="textarea" placeholder="MetadataJSON" />
</FormField>
<BaseDivider />
<BaseButtons>
<BaseButton type="submit" color="info" label="Submit" />
<BaseButton type="reset" color="info" outline label="Reset" />
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/agents/agents-list')}/>
</BaseButtons>
</Form>
</Formik>
</CardBox>
<Formik initialValues={initialValues} onSubmit={(values) => handleSubmit(values)}>
{({ isSubmitting, setFieldValue, values }) => {
const previewName = getPreviewName(values);
return (
<Form>
<div className="flex w-full flex-col gap-5">
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-6">
<Link
className="inline-flex items-center gap-2 text-[12px] font-medium text-slate-500"
href="/agents/agents-list"
>
<BaseIcon path={mdiArrowLeft} size={14} />
Back to agents
</Link>
<p className="mt-4 text-[11px] font-medium uppercase tracking-[0.28em] text-slate-400">
Agent
</p>
<h1 className="mt-3 text-[2rem] font-semibold tracking-[-0.04em] text-slate-900">
Create a reusable assistant profile.
</h1>
<p className="mt-3 max-w-2xl text-sm leading-6 text-slate-500">
Define how this agent should behave, what model it should use, and whether it belongs in the default workspace lineup.
</p>
</div>
<div className="grid gap-5 2xl:grid-cols-[minmax(0,1fr)_320px]">
<div className="space-y-5">
<AgentFormSections setFieldValue={setFieldValue} values={values} />
</div>
<div className="space-y-5">
<div className="rounded-[12px] border border-slate-200 bg-white px-5 py-5">
<div className="flex items-start gap-3">
<div className="inline-flex h-11 w-11 items-center justify-center rounded-[10px] border border-slate-200 bg-slate-50 text-slate-700">
<BaseIcon path={mdiRobotOutline} size={20} />
</div>
<div className="min-w-0">
<p className="truncate text-[16px] font-semibold text-slate-900">
{previewName}
</p>
<p className="mt-1 text-[13px] text-slate-500">
{values.model || 'No model selected yet'}
</p>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<span
className={`inline-flex items-center rounded-[999px] px-2.5 py-1 text-[11px] font-medium ${
values.is_active
? 'bg-emerald-50 text-emerald-700'
: 'bg-slate-100 text-slate-600'
}`}
>
{values.is_active ? 'Active' : 'Inactive'}
</span>
<span className="inline-flex items-center rounded-[999px] bg-slate-100 px-2.5 py-1 text-[11px] font-medium text-slate-700">
{values.is_default ? 'Default preset' : 'Custom preset'}
</span>
</div>
<p className="mt-4 text-[13px] leading-6 text-slate-500">
{values.description || 'Add a short description so people understand when to use this agent.'}
</p>
</div>
<div className="rounded-[12px] border border-slate-200 bg-white px-5 py-5">
<p className="text-[11px] font-medium uppercase tracking-[0.28em] text-slate-400">
Actions
</p>
<div className="mt-4 flex flex-wrap gap-2">
<Link
className={`${actionButtonClassName} border-slate-200 bg-white text-slate-700`}
href="/agents/agents-list"
>
Cancel
</Link>
<button
className={`${actionButtonClassName} border-slate-900 bg-slate-900 text-white`}
disabled={isSubmitting}
type="submit"
>
{isSubmitting ? 'Creating…' : 'Create agent'}
</button>
</div>
</div>
</div>
</div>
</div>
</Form>
);
}}
</Formik>
</SectionMain>
</>
)
}
);
};
AgentsNew.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated
permission={'CREATE_AGENTS'}
>
{page}
</LayoutAuthenticated>
)
}
return <LayoutAuthenticated permission="CREATE_AGENTS">{page}</LayoutAuthenticated>;
};
export default AgentsNew
export default AgentsNew;

View File

@ -1,612 +1,358 @@
import {
mdiArrowLeft,
mdiChartTimelineVariant,
mdiPencilOutline,
mdiRobotOutline,
mdiTextBoxOutline,
mdiTuneVariant,
} from '@mdi/js';
import Head from 'next/head';
import Link from 'next/link';
import React, { ReactElement, useEffect } from 'react';
import Head from 'next/head'
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
import dayjs from "dayjs";
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
import {useRouter} from "next/router";
import { fetch } from '../../stores/agents/agentsSlice'
import {saveFile} from "../../helpers/fileSaver";
import dataFormatter from '../../helpers/dataFormatter';
import ImageField from "../../components/ImageField";
import LayoutAuthenticated from "../../layouts/Authenticated";
import {getPageTitle} from "../../config";
import SectionTitleLineWithButton from "../../components/SectionTitleLineWithButton";
import SectionMain from "../../components/SectionMain";
import CardBox from "../../components/CardBox";
import BaseButton from "../../components/BaseButton";
import BaseDivider from "../../components/BaseDivider";
import {mdiChartTimelineVariant} from "@mdi/js";
import {SwitchField} from "../../components/SwitchField";
import FormField from "../../components/FormField";
import BaseIcon from '../../components/BaseIcon';
import LoadingSpinner from '../../components/LoadingSpinner';
import SectionMain from '../../components/SectionMain';
import { getPageTitle } from '../../config';
import LayoutAuthenticated from '../../layouts/Authenticated';
import { fetch } from '../../stores/agents/agentsSlice';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { useRouter } from 'next/router';
const actionButtonClassName =
'inline-flex items-center justify-center rounded-[8px] border px-4 py-2 text-sm font-medium';
function formatDate(value: string) {
if (!value) {
return 'No date';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString('en-US', {
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
month: 'short',
});
}
function formatTemperature(value: any) {
if (value === null || value === undefined || value === '') {
return 'Auto';
}
return value;
}
function formatTokenLimit(value: any) {
if (!value) {
return 'Default';
}
return `${value} max tokens`;
}
function parseMetadata(value: string) {
if (!value) {
return null;
}
try {
return JSON.parse(value);
} catch (error) {
return null;
}
}
function RelatedItem({
href,
meta,
title,
}: {
href: string;
meta: string;
title: string;
}) {
return (
<Link
className="flex items-start justify-between gap-4 rounded-[10px] border border-slate-200 bg-white px-4 py-3 transition-colors hover:border-slate-300"
href={href}
>
<div className="min-w-0">
<p className="truncate text-[14px] font-medium text-slate-900">{title}</p>
<p className="mt-1 text-[12px] leading-5 text-slate-500">{meta}</p>
</div>
<BaseIcon className="shrink-0 text-slate-400" path={mdiChartTimelineVariant} size={16} />
</Link>
);
}
const AgentsView = () => {
const router = useRouter()
const dispatch = useAppDispatch()
const { agents } = useAppSelector((state) => state.agents)
const router = useRouter();
const dispatch = useAppDispatch();
const { agents, loading } = useAppSelector((state) => state.agents);
const { id } = router.query;
const { id } = router.query;
function removeLastCharacter(str) {
console.log(str,`str`)
return str.slice(0, -1);
useEffect(() => {
if (typeof id !== 'string') {
return;
}
useEffect(() => {
dispatch(fetch({ id }));
}, [dispatch, id]);
dispatch(fetch({ id }));
}, [dispatch, id]);
const agent = !Array.isArray(agents) && agents && typeof agents === 'object' ? agents : null;
const metadata = parseMetadata(agent?.metadata_json || '');
return (
<>
<Head>
<title>{getPageTitle('View agents')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={removeLastCharacter('View agents')} main>
<BaseButton
color='info'
label='Edit'
return (
<>
<Head>
<title>{getPageTitle('Agent details')}</title>
</Head>
<SectionMain>
<div className="flex w-full flex-col gap-5">
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<Link
className="inline-flex items-center gap-2 text-[12px] font-medium text-slate-500"
href="/agents/agents-list"
>
<BaseIcon path={mdiArrowLeft} size={14} />
Back to agents
</Link>
<p className="mt-4 text-[11px] font-medium uppercase tracking-[0.28em] text-slate-400">
Agent
</p>
<h1 className="mt-3 text-[2rem] font-semibold tracking-[-0.04em] text-slate-900">
{agent?.name || 'Agent details'}
</h1>
<p className="mt-3 max-w-2xl text-sm leading-6 text-slate-500">
Review the prompt, model behavior, and recent activity linked to this assistant profile.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Link
className={`${actionButtonClassName} border-slate-200 bg-white text-slate-700`}
href="/agents/agents-list"
>
Close
</Link>
<Link
className={`${actionButtonClassName} border-slate-900 bg-slate-900 text-white`}
href={`/agents/agents-edit/?id=${id}`}
/>
</SectionTitleLineWithButton>
<CardBox>
>
<BaseIcon className="mr-1" path={mdiPencilOutline} size={16} />
Edit agent
</Link>
</div>
</div>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Name</p>
<p>{agents?.name}</p>
{loading && !agent && (
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-10">
<LoadingSpinner />
</div>
)}
{!loading && !agent && (
<div className="rounded-[12px] border border-dashed border-slate-200 bg-slate-50 px-6 py-12 text-center">
<p className="text-[11px] font-medium uppercase tracking-[0.2em] text-slate-400">
No data
</p>
<h2 className="mt-3 text-[22px] font-semibold tracking-[-0.03em] text-slate-900">
We could not load this agent.
</h2>
<p className="mt-3 text-[14px] leading-6 text-slate-500">
Try going back to the agents list and opening it again.
</p>
</div>
)}
{agent && (
<>
<div className="grid gap-5 2xl:grid-cols-[minmax(0,1fr)_320px]">
<div className="space-y-5">
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-6">
<div className="mb-5 flex items-start gap-3">
<div className="mt-0.5 inline-flex h-10 w-10 items-center justify-center rounded-[10px] border border-slate-200 bg-slate-50 text-slate-600">
<BaseIcon path={mdiRobotOutline} size={20} />
</div>
<div>
<p className="text-[15px] font-medium text-slate-900">Identity</p>
<p className="mt-1 text-sm leading-6 text-slate-500">
Basic positioning and the model this assistant runs on.
</p>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-[10px] border border-slate-200 px-4 py-3">
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-slate-400">
Name
</p>
<p className="mt-2 text-[16px] font-medium text-slate-900">
{agent.name || 'Untitled agent'}
</p>
</div>
<div className="rounded-[10px] border border-slate-200 px-4 py-3">
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-slate-400">
Model
</p>
<p className="mt-2 text-[16px] font-medium text-slate-900">
{agent.model || 'Default model'}
</p>
</div>
<div className="rounded-[10px] border border-slate-200 px-4 py-3 md:col-span-2">
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-slate-400">
Description
</p>
<p className="mt-2 text-[14px] leading-6 text-slate-600">
{agent.description || 'No description yet.'}
</p>
</div>
</div>
</div>
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-6">
<div className="mb-5 flex items-start gap-3">
<div className="mt-0.5 inline-flex h-10 w-10 items-center justify-center rounded-[10px] border border-slate-200 bg-slate-50 text-slate-600">
<BaseIcon path={mdiTextBoxOutline} size={20} />
</div>
<div>
<p className="text-[15px] font-medium text-slate-900">System prompt</p>
<p className="mt-1 text-sm leading-6 text-slate-500">
The standing instructions this agent follows in every conversation.
</p>
</div>
</div>
<div className="rounded-[10px] border border-slate-200 bg-slate-50 px-4 py-4">
<pre className="whitespace-pre-wrap text-[13px] leading-6 text-slate-700">
{agent.system_prompt || 'No system prompt yet.'}
</pre>
</div>
</div>
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-6">
<div className="mb-5 flex items-start gap-3">
<div className="mt-0.5 inline-flex h-10 w-10 items-center justify-center rounded-[10px] border border-slate-200 bg-slate-50 text-slate-600">
<BaseIcon path={mdiTuneVariant} size={20} />
</div>
<div>
<p className="text-[15px] font-medium text-slate-900">Behavior</p>
<p className="mt-1 text-sm leading-6 text-slate-500">
Output shaping, default availability, and any structured metadata stored with the agent.
</p>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-[10px] border border-slate-200 px-4 py-3">
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-slate-400">
Temperature
</p>
<p className="mt-2 text-[16px] font-medium text-slate-900">
{formatTemperature(agent.temperature)}
</p>
</div>
<div className="rounded-[10px] border border-slate-200 px-4 py-3">
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-slate-400">
Output
</p>
<p className="mt-2 text-[16px] font-medium text-slate-900">
{formatTokenLimit(agent.max_output_tokens)}
</p>
</div>
<div className="rounded-[10px] border border-slate-200 px-4 py-3">
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-slate-400">
Availability
</p>
<p className="mt-2 text-[16px] font-medium text-slate-900">
{agent.is_active ? 'Active' : 'Inactive'}
</p>
</div>
<div className="rounded-[10px] border border-slate-200 px-4 py-3">
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-slate-400">
Preset type
</p>
<p className="mt-2 text-[16px] font-medium text-slate-900">
{agent.is_default ? 'Default preset' : 'Custom preset'}
</p>
</div>
<div className="rounded-[10px] border border-slate-200 px-4 py-3 md:col-span-2">
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-slate-400">
Metadata JSON
</p>
<pre className="mt-2 whitespace-pre-wrap text-[13px] leading-6 text-slate-600">
{metadata ? JSON.stringify(metadata, null, 2) : agent.metadata_json || 'No metadata.'}
</pre>
</div>
</div>
</div>
</div>
<div className="space-y-5">
<div className="rounded-[12px] border border-slate-200 bg-white px-5 py-5">
<p className="text-[11px] font-medium uppercase tracking-[0.28em] text-slate-400">
Related conversations
</p>
<div className="mt-4 space-y-3">
{Array.isArray(agent.conversations_agent) && agent.conversations_agent.length > 0 ? (
agent.conversations_agent.map((item: any) => (
<RelatedItem
key={item.id}
href={`/conversations/conversations-view/?id=${item.id}`}
meta={`${item.status || 'unknown'} · ${formatDate(item.last_message_at)}`}
title={item.title || 'Untitled conversation'}
/>
))
) : (
<p className="text-[13px] leading-6 text-slate-500">
No conversations linked to this agent yet.
</p>
)}
</div>
</div>
<FormField label='Multi Text' hasTextareaHeight>
<textarea className={'w-full'} disabled value={agents?.description} />
</FormField>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Model</p>
<p>{agents?.model}</p>
<div className="rounded-[12px] border border-slate-200 bg-white px-5 py-5">
<p className="text-[11px] font-medium uppercase tracking-[0.28em] text-slate-400">
Usage events
</p>
<div className="mt-4 space-y-3">
{Array.isArray(agent.usage_events_agent) && agent.usage_events_agent.length > 0 ? (
agent.usage_events_agent.map((item: any) => (
<RelatedItem
key={item.id}
href={`/usage_events/usage_events-view/?id=${item.id}`}
meta={`${item.provider || 'provider'} · ${formatDate(item.occurred_at)}`}
title={item.event_type || 'Usage event'}
/>
))
) : (
<p className="text-[13px] leading-6 text-slate-500">
No usage activity has been recorded for this agent yet.
</p>
)}
</div>
</div>
</div>
<FormField label='Multi Text' hasTextareaHeight>
<textarea className={'w-full'} disabled value={agents?.system_prompt} />
</FormField>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Temperature</p>
<p>{agents?.temperature || 'No data'}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Maxoutputtokens</p>
<p>{agents?.max_output_tokens || 'No data'}</p>
</div>
<FormField label='Isdefault'>
<SwitchField
field={{name: 'is_default', value: agents?.is_default}}
form={{setFieldValue: () => null}}
disabled
/>
</FormField>
<FormField label='Isactive'>
<SwitchField
field={{name: 'is_active', value: agents?.is_active}}
form={{setFieldValue: () => null}}
disabled
/>
</FormField>
<FormField label='Multi Text' hasTextareaHeight>
<textarea className={'w-full'} disabled value={agents?.metadata_json} />
</FormField>
<>
<p className={'block font-bold mb-2'}>Conversations Agent</p>
<CardBox
className='mb-6 border border-gray-300 rounded overflow-hidden'
hasTable
>
<div className='overflow-x-auto'>
<table>
<thead>
<tr>
<th>Title</th>
<th>Summary</th>
<th>Status</th>
<th>Ispinned</th>
<th>Lastmessageat</th>
<th>ClientcontextJSON</th>
</tr>
</thead>
<tbody>
{agents.conversations_agent && Array.isArray(agents.conversations_agent) &&
agents.conversations_agent.map((item: any) => (
<tr key={item.id} onClick={() => router.push(`/conversations/conversations-view/?id=${item.id}`)}>
<td data-label="title">
{ item.title }
</td>
<td data-label="summary">
{ item.summary }
</td>
<td data-label="status">
{ item.status }
</td>
<td data-label="is_pinned">
{ dataFormatter.booleanFormatter(item.is_pinned) }
</td>
<td data-label="last_message_at">
{ dataFormatter.dateTimeFormatter(item.last_message_at) }
</td>
<td data-label="client_context_json">
{ item.client_context_json }
</td>
</tr>
))}
</tbody>
</table>
</div>
{!agents?.conversations_agent?.length && <div className={'text-center py-4'}>No data</div>}
</CardBox>
</>
<>
<p className={'block font-bold mb-2'}>Usage_events Agent</p>
<CardBox
className='mb-6 border border-gray-300 rounded overflow-hidden'
hasTable
>
<div className='overflow-x-auto'>
<table>
<thead>
<tr>
<th>Eventtype</th>
<th>Occurredat</th>
<th>Inputtokens</th>
<th>Outputtokens</th>
<th>Totaltokens</th>
<th>CostUSD</th>
<th>Provider</th>
<th>Model</th>
<th>MetadataJSON</th>
</tr>
</thead>
<tbody>
{agents.usage_events_agent && Array.isArray(agents.usage_events_agent) &&
agents.usage_events_agent.map((item: any) => (
<tr key={item.id} onClick={() => router.push(`/usage_events/usage_events-view/?id=${item.id}`)}>
<td data-label="event_type">
{ item.event_type }
</td>
<td data-label="occurred_at">
{ dataFormatter.dateTimeFormatter(item.occurred_at) }
</td>
<td data-label="input_tokens">
{ item.input_tokens }
</td>
<td data-label="output_tokens">
{ item.output_tokens }
</td>
<td data-label="total_tokens">
{ item.total_tokens }
</td>
<td data-label="cost_usd">
{ item.cost_usd }
</td>
<td data-label="provider">
{ item.provider }
</td>
<td data-label="model">
{ item.model }
</td>
<td data-label="metadata_json">
{ item.metadata_json }
</td>
</tr>
))}
</tbody>
</table>
</div>
{!agents?.usage_events_agent?.length && <div className={'text-center py-4'}>No data</div>}
</CardBox>
</>
<BaseDivider />
<BaseButton
color='info'
label='Back'
onClick={() => router.push('/agents/agents-list')}
/>
</CardBox>
</SectionMain>
</>
);
</div>
</>
)}
</div>
</SectionMain>
</>
);
};
AgentsView.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated
permission={'READ_AGENTS'}
>
{page}
</LayoutAuthenticated>
)
}
return <LayoutAuthenticated permission="READ_AGENTS">{page}</LayoutAuthenticated>;
};
export default AgentsView;
export default AgentsView;

File diff suppressed because it is too large Load Diff

View File

@ -1,417 +1,208 @@
import {
mdiArrowLeft,
mdiFileOutline,
mdiImageOutline,
mdiOpenInNew,
mdiPaperclip,
mdiPencilOutline,
} from '@mdi/js';
import Head from 'next/head';
import Link from 'next/link';
import React, { ReactElement, useEffect } from 'react';
import Head from 'next/head'
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
import dayjs from "dayjs";
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
import {useRouter} from "next/router";
import { fetch } from '../../stores/attachments/attachmentsSlice'
import {saveFile} from "../../helpers/fileSaver";
import dataFormatter from '../../helpers/dataFormatter';
import ImageField from "../../components/ImageField";
import LayoutAuthenticated from "../../layouts/Authenticated";
import {getPageTitle} from "../../config";
import SectionTitleLineWithButton from "../../components/SectionTitleLineWithButton";
import SectionMain from "../../components/SectionMain";
import CardBox from "../../components/CardBox";
import BaseButton from "../../components/BaseButton";
import BaseDivider from "../../components/BaseDivider";
import {mdiChartTimelineVariant} from "@mdi/js";
import {SwitchField} from "../../components/SwitchField";
import FormField from "../../components/FormField";
import BaseIcon from '../../components/BaseIcon';
import LoadingSpinner from '../../components/LoadingSpinner';
import {
actionButtonClassName,
EntityAsideCard,
EntityEmptyState,
EntityLinkCard,
EntitySection,
EntityValueCard,
} from '../../components/AdminEntity/PageKit';
import SectionMain from '../../components/SectionMain';
import { getPageTitle } from '../../config';
import LayoutAuthenticated from '../../layouts/Authenticated';
import { fetch } from '../../stores/attachments/attachmentsSlice';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { useRouter } from 'next/router';
function formatBytes(value: any) {
const number = Number(value || 0);
if (!number) {
return 'Unknown size';
}
return `${number.toLocaleString('en-US')} bytes`;
}
const AttachmentsView = () => {
const router = useRouter()
const dispatch = useAppDispatch()
const { attachments } = useAppSelector((state) => state.attachments)
const router = useRouter();
const dispatch = useAppDispatch();
const { attachments, loading } = useAppSelector((state) => state.attachments);
const { id } = router.query;
const { id } = router.query;
function removeLastCharacter(str) {
console.log(str,`str`)
return str.slice(0, -1);
useEffect(() => {
if (typeof id !== 'string') {
return;
}
useEffect(() => {
dispatch(fetch({ id }));
}, [dispatch, id]);
dispatch(fetch({ id }));
}, [dispatch, id]);
const attachment =
!Array.isArray(attachments) && attachments && typeof attachments === 'object' ? attachments : null;
const fileList = Array.isArray(attachment?.file) ? attachment.file : [];
const imageList = Array.isArray(attachment?.image) ? attachment.image : [];
return (
<>
<Head>
<title>{getPageTitle('View attachments')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={removeLastCharacter('View attachments')} main>
<BaseButton
color='info'
label='Edit'
return (
<>
<Head>
<title>{getPageTitle('Attachment details')}</title>
</Head>
<SectionMain>
<div className="flex w-full flex-col gap-5">
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<Link
className="inline-flex items-center gap-2 text-[12px] font-medium text-slate-500"
href="/attachments/attachments-list"
>
<BaseIcon path={mdiArrowLeft} size={14} />
Back to attachments
</Link>
<p className="mt-4 text-[11px] font-medium uppercase tracking-[0.28em] text-slate-400">
Attachment
</p>
<h1 className="mt-3 text-[2rem] font-semibold tracking-[-0.04em] text-slate-900">
{attachment?.filename || attachment?.kind || 'Attachment details'}
</h1>
<p className="mt-3 max-w-3xl text-sm leading-6 text-slate-500">
Review the uploaded asset, linked message, storage metadata, and any notes saved with this attachment.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Link
className={`${actionButtonClassName} border-slate-200 bg-white text-slate-700`}
href="/attachments/attachments-list"
>
Close
</Link>
<Link
className={`${actionButtonClassName} border-slate-900 bg-slate-900 text-white`}
href={`/attachments/attachments-edit/?id=${id}`}
/>
</SectionTitleLineWithButton>
<CardBox>
>
<BaseIcon className="mr-1" path={mdiPencilOutline} size={16} />
Edit attachment
</Link>
</div>
</div>
</div>
{loading && !attachment && (
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-10">
<LoadingSpinner />
</div>
)}
{!loading && !attachment && (
<EntityEmptyState
description="Try going back to the attachments list and opening this record again."
title="We could not load this attachment."
/>
)}
{attachment && (
<div className="grid gap-5 2xl:grid-cols-[minmax(0,1fr)_320px]">
<div className="space-y-5">
<EntitySection
description="Basic storage and delivery metadata for this uploaded asset."
icon={mdiPaperclip}
title="Overview"
>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<EntityValueCard label="Kind" value={attachment.kind || 'Unknown'} />
<EntityValueCard label="Filename" value={attachment.filename || 'No filename'} />
<EntityValueCard label="MIME type" value={attachment.mime_type || 'Unknown'} />
<EntityValueCard label="Size" value={formatBytes(attachment.size_bytes)} />
<EntityValueCard label="Storage key" value={attachment.storage_key || 'No storage key'} />
</div>
</EntitySection>
<EntitySection
description="Optional notes or guidance saved with the asset."
icon={mdiFileOutline}
title="Notes"
>
<div className="rounded-[10px] border border-slate-200 bg-slate-50 px-4 py-4">
<p className="text-[14px] leading-7 text-slate-700">
{attachment.notes || 'No notes saved with this attachment.'}
</p>
</div>
</EntitySection>
{imageList.length > 0 && (
<EntitySection
description="Image preview stored for this attachment record."
icon={mdiImageOutline}
title="Image preview"
>
<div className="overflow-hidden rounded-[12px] border border-slate-200 bg-slate-50">
<img alt={attachment.filename || 'Attachment image'} className="block max-h-[420px] w-full object-contain" src={imageList[0].publicUrl} />
</div>
</EntitySection>
)}
</div>
<div className="space-y-5">
<EntityAsideCard title="Linked records">
<div className="space-y-3">
<EntityLinkCard
description="Open the parent message this asset belongs to."
href={attachment?.message?.id ? `/messages/messages-view/?id=${attachment.message.id}` : undefined}
icon={mdiPaperclip}
label="Message"
value={attachment?.message?.content || 'No message'}
/>
</div>
</EntityAsideCard>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Message</p>
<p>{attachments?.message?.content ?? 'No data'}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Kind</p>
<p>{attachments?.kind ?? 'No data'}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>File</p>
{attachments?.file?.length
? dataFormatter.filesFormatter(attachments.file).map(link => (
<button
key={link.publicUrl}
onClick={(e) => saveFile(e, link.publicUrl, link.name)}
<EntityAsideCard title="Files">
<div className="space-y-3">
{fileList.length > 0 ? (
fileList.map((item: any) => (
<a
key={item.publicUrl}
className="flex items-start justify-between gap-4 rounded-[10px] border border-slate-200 bg-white px-4 py-3 transition-colors hover:border-slate-300"
href={item.publicUrl}
rel="noreferrer"
target="_blank"
>
{link.name}
</button>
)) : <p>No File</p>
}
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Image</p>
{attachments?.image?.length
? (
<ImageField
name={'image'}
image={attachments?.image}
className='w-20 h-20'
/>
) : <p>No Image</p>
}
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Filename</p>
<p>{attachments?.filename}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>MIMEtype</p>
<p>{attachments?.mime_type}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Sizebytes</p>
<p>{attachments?.size_bytes || 'No data'}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Storagekey</p>
<p>{attachments?.storage_key}</p>
</div>
<FormField label='Multi Text' hasTextareaHeight>
<textarea className={'w-full'} disabled value={attachments?.notes} />
</FormField>
<BaseDivider />
<BaseButton
color='info'
label='Back'
onClick={() => router.push('/attachments/attachments-list')}
/>
</CardBox>
</SectionMain>
</>
);
<div className="min-w-0">
<p className="truncate text-[14px] font-medium text-slate-900">{item.name || 'Download file'}</p>
<p className="mt-1 text-[12px] leading-5 text-slate-500">Open the stored file in a new tab.</p>
</div>
<BaseIcon className="shrink-0 text-slate-400" path={mdiOpenInNew} size={16} />
</a>
))
) : (
<p className="text-[13px] leading-6 text-slate-500">No file downloads are attached to this record.</p>
)}
</div>
</EntityAsideCard>
</div>
</div>
)}
</div>
</SectionMain>
</>
);
};
AttachmentsView.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated
permission={'READ_ATTACHMENTS'}
>
{page}
</LayoutAuthenticated>
)
}
return <LayoutAuthenticated permission="READ_ATTACHMENTS">{page}</LayoutAuthenticated>;
};
export default AttachmentsView;
export default AttachmentsView;

View File

@ -0,0 +1,274 @@
import { mdiArrowLeft, mdiCreditCardOutline } from '@mdi/js';
import axios from 'axios';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { ReactElement, useCallback, useEffect, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import BaseIcon from '../components/BaseIcon';
import PricingPlanCard from '../components/Billing/PricingPlanCard';
import SectionMain from '../components/SectionMain';
import { getPageTitle } from '../config';
import LayoutAuthenticated from '../layouts/Authenticated';
import { hasPermission } from '../helpers/userPermissions';
import {
BillingPlan,
BillingSubscription,
formatBillingDate,
formatBillingPrice,
} from '../lib/billingPlans';
import { useAppSelector } from '../stores/hooks';
function getErrorMessage(error: any) {
return error?.response?.data || error?.message || 'Something went wrong.';
}
export default function BillingPage() {
const router = useRouter();
const { currentUser } = useAppSelector((state) => state.auth);
const [plans, setPlans] = useState<BillingPlan[]>([]);
const [subscription, setSubscription] = useState<BillingSubscription | null>(null);
const [loading, setLoading] = useState(true);
const [subscribePlanId, setSubscribePlanId] = useState('');
const [canceling, setCanceling] = useState(false);
const [confirmingCheckout, setConfirmingCheckout] = useState(false);
const loadBilling = useCallback(async () => {
setLoading(true);
try {
const [plansResponse, currentResponse] = await Promise.all([
axios.get('billing/plans'),
axios.get('billing/current'),
]);
setPlans(plansResponse.data.plans || []);
setSubscription(currentResponse.data.subscription || null);
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadBilling();
}, [loadBilling]);
useEffect(() => {
if (!router.isReady) {
return;
}
const checkoutState = router.query.checkout;
const sessionId = typeof router.query.session_id === 'string' ? router.query.session_id : '';
if (checkoutState === 'success' && sessionId) {
setConfirmingCheckout(true);
axios
.get('billing/checkout-session', {
params: {
sessionId,
},
})
.then((response) => {
setSubscription(response.data.subscription || null);
toast.success('Subscription activated.');
})
.catch((error) => {
toast.error(getErrorMessage(error));
})
.finally(() => {
setConfirmingCheckout(false);
void router.replace('/billing', undefined, { shallow: true });
});
return;
}
if (checkoutState === 'canceled') {
toast.info('Checkout canceled.');
void router.replace('/billing', undefined, { shallow: true });
}
}, [router, router.isReady, router.query.checkout, router.query.session_id]);
const activePlanId = useMemo(() => {
if (!subscription) {
return '';
}
if (subscription.status !== 'active') {
return '';
}
return subscription.plan?.id || '';
}, [subscription]);
const canManageStripe = hasPermission(currentUser, 'READ_USERS');
const handleSubscribe = async (planId: string) => {
setSubscribePlanId(planId);
try {
const response = await axios.post('billing/subscribe', { planId });
if (!response.data.checkoutUrl) {
throw new Error('Stripe checkout URL was not returned.');
}
window.location.assign(response.data.checkoutUrl);
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setSubscribePlanId('');
}
};
const handleCancel = async () => {
setCanceling(true);
try {
const response = await axios.post('billing/cancel');
setSubscription(response.data.subscription || null);
if (response.data.subscription && response.data.subscription.cancelAtPeriodEnd) {
toast.success('Subscription will end at the end of the current billing period.');
} else {
toast.success('Subscription canceled.');
}
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setCanceling(false);
}
};
return (
<>
<Head>
<title>{getPageTitle('Billing')}</title>
</Head>
<SectionMain>
<div className="flex w-full flex-col gap-6">
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<Link
className="inline-flex items-center gap-2 text-sm font-medium text-slate-500"
href="/settings"
>
<BaseIcon path={mdiArrowLeft} size={18} />
Back to settings
</Link>
{canManageStripe && (
<Link
className="inline-flex h-10 items-center justify-center rounded-[9px] border border-slate-200 bg-white px-4 text-sm font-medium text-slate-700"
href="/stripe-settings"
>
Stripe settings
</Link>
)}
</div>
<p className="mt-6 text-[11px] font-medium uppercase tracking-[0.28em] text-slate-400">
Billing
</p>
<h1 className="mt-3 text-[2rem] font-semibold tracking-[-0.04em] text-slate-900">
Choose a plan and manage your Stripe subscription.
</h1>
<p className="mt-3 max-w-3xl text-sm leading-6 text-slate-500">
Pick a plan, jump into Stripe Checkout, and manage renewals from one calm billing screen.
</p>
</div>
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-5">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-[11px] font-medium uppercase tracking-[0.28em] text-slate-400">
Current plan
</p>
{subscription?.plan ? (
<>
<h2 className="mt-3 text-[1.75rem] font-semibold tracking-[-0.04em] text-slate-900">
{subscription.plan.name}
<span className="ml-3 align-middle text-base font-medium text-slate-400">
{subscription.status}
</span>
</h2>
<p className="mt-2 text-sm leading-6 text-slate-500">
{formatBillingPrice(subscription.plan.priceMonthly, subscription.plan.currency)} per month
{subscription.currentPeriodEnd
? `${
subscription.cancelAtPeriodEnd
? 'Ends'
: subscription.status === 'active'
? 'Renews'
: 'Ended'
} ${formatBillingDate(subscription.currentPeriodEnd)}`
: ''}
</p>
<div className="mt-4 grid gap-1 text-[12px] text-slate-400">
<div>Stripe customer: {subscription.stripeCustomerId || 'Not created yet'}</div>
<div>Stripe subscription: {subscription.stripeSubscriptionId || 'Not created yet'}</div>
</div>
</>
) : (
<>
<h2 className="mt-3 text-[1.75rem] font-semibold tracking-[-0.04em] text-slate-900">
No active subscription
</h2>
<p className="mt-2 text-sm leading-6 text-slate-500">
Pick one of the three plans below to start a subscription through Stripe Checkout.
</p>
</>
)}
</div>
{subscription?.status === 'active' && (
<button
className="inline-flex h-10 items-center justify-center rounded-[9px] border border-rose-200 bg-rose-50 px-4 text-sm font-medium text-rose-700"
disabled={canceling}
onClick={() => void handleCancel()}
type="button"
>
{canceling
? 'Canceling…'
: subscription.cancelAtPeriodEnd
? 'Cancellation scheduled'
: 'Cancel subscription'}
</button>
)}
</div>
</div>
{loading || confirmingCheckout ? (
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-8">
<div className="flex items-center gap-3 text-sm text-slate-500">
<BaseIcon path={mdiCreditCardOutline} size={18} />
{confirmingCheckout ? 'Confirming checkout…' : 'Loading plans…'}
</div>
</div>
) : (
<div className="grid gap-5 xl:grid-cols-3">
{plans.map((plan) => {
const isCurrent = activePlanId === plan.id;
return (
<PricingPlanCard
actionDisabled={isCurrent || subscribePlanId === plan.id}
actionLabel={isCurrent ? 'Current plan' : activePlanId ? 'Switch plan' : 'Subscribe'}
actionLoading={subscribePlanId === plan.id}
current={isCurrent}
key={plan.id || plan.slug}
onAction={() => void handleSubscribe(plan.id || '')}
plan={plan}
/>
);
})}
</div>
)}
</div>
</SectionMain>
</>
);
}
BillingPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,12 @@
import Head from 'next/head';
import Link from 'next/link';
import type { ReactElement } from 'react';
import { useEffect, useState } from 'react';
import PricingPlanCard from '../components/Billing/PricingPlanCard';
import LayoutGuest from '../layouts/Guest';
import { getPageTitle } from '../config';
import { marketingPlans } from '../lib/billingPlans';
import { useAppSelector } from '../stores/hooks';
const quickPoints = [
'Calm, chat-first workspace',
@ -27,7 +31,26 @@ const previewThreads = [
},
];
const previewMetrics = [
'3 active agents',
'12 recent threads',
'Stripe billing ready',
];
export default function LandingPage() {
const { currentUser, token } = useAppSelector((state) => state.auth);
const [hasSession, setHasSession] = useState(false);
useEffect(() => {
const storedToken =
typeof window === 'undefined' ? '' : window.localStorage.getItem('token') || '';
setHasSession(Boolean(currentUser?.id || token || storedToken));
}, [currentUser?.id, token]);
const pricingActionHref = hasSession ? '/billing' : '/login';
const pricingSectionCtaLabel = hasSession ? 'Open billing' : 'Sign in to subscribe';
return (
<>
<Head>
@ -47,13 +70,13 @@ export default function LandingPage() {
</Link>
<nav className="flex items-center gap-3">
<Link
className="rounded-full border border-white/70 bg-white/70 px-4 py-2 text-sm font-medium text-slate-700 shadow-[0_12px_30px_-24px_rgba(15,23,42,0.3)] backdrop-blur transition hover:bg-white"
className="rounded-md border border-white/70 bg-white/70 px-4 py-2 text-sm font-medium text-slate-700 shadow-[0_12px_30px_-24px_rgba(15,23,42,0.3)] backdrop-blur"
href="/login"
>
Sign in
</Link>
<Link
className="rounded-full bg-slate-900 px-4 py-2 text-sm font-medium text-white shadow-[0_18px_35px_-24px_rgba(15,23,42,0.55)] transition hover:bg-slate-800"
className="rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white shadow-[0_18px_35px_-24px_rgba(15,23,42,0.55)]"
href="/workspace"
>
Open workspace
@ -78,13 +101,13 @@ export default function LandingPage() {
<div className="mt-8 flex flex-wrap items-center gap-3">
<Link
className="rounded-full bg-slate-900 px-6 py-3 text-sm font-medium text-white shadow-[0_18px_40px_-24px_rgba(15,23,42,0.55)] transition hover:bg-slate-800"
className="rounded-md bg-slate-900 px-6 py-3 text-sm font-medium text-white shadow-[0_18px_40px_-24px_rgba(15,23,42,0.55)]"
href="/workspace"
>
Try the workspace
</Link>
<Link
className="rounded-full border border-white/70 bg-white/70 px-6 py-3 text-sm font-medium text-slate-700 backdrop-blur transition hover:bg-white"
className="rounded-md border border-white/70 bg-white/70 px-6 py-3 text-sm font-medium text-slate-700 backdrop-blur"
href="/login"
>
Sign in
@ -94,7 +117,7 @@ export default function LandingPage() {
<div className="mt-10 grid gap-3 sm:max-w-2xl">
{quickPoints.map((point) => (
<div
className="inline-flex w-fit items-center rounded-full border border-white/70 bg-white/60 px-4 py-2 text-sm text-slate-600 shadow-[0_12px_30px_-24px_rgba(15,23,42,0.28)] backdrop-blur"
className="inline-flex w-fit items-center rounded-md border border-white/70 bg-white/60 px-4 py-2 text-sm text-slate-600 shadow-[0_12px_30px_-24px_rgba(15,23,42,0.28)] backdrop-blur"
key={point}
>
{point}
@ -103,85 +126,107 @@ export default function LandingPage() {
</div>
</div>
<section className="relative lg:pt-2">
<div className="absolute inset-4 rounded-[30px] bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.95),rgba(255,255,255,0.4))] blur-2xl" />
<div className="relative overflow-hidden rounded-[34px] border border-white/80 bg-white/75 shadow-[0_35px_120px_-50px_rgba(15,23,42,0.32)] backdrop-blur">
<div className="border-b border-slate-200/80 px-5 py-3.5 xl:px-6 xl:py-4.5">
<section className="relative lg:pt-4">
<div className="absolute inset-4 rounded-[16px] bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.95),rgba(255,255,255,0.4))] blur-2xl" />
<div className="relative overflow-hidden rounded-[18px] border border-white/80 bg-white/75 shadow-[0_35px_120px_-50px_rgba(15,23,42,0.32)] backdrop-blur">
<div className="border-b border-slate-200/80 px-5 py-3.5 xl:px-6 xl:py-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.26em] text-slate-400">
Workspace preview
</p>
<h2 className="mt-2 max-w-[21rem] text-[1.45rem] font-semibold tracking-[-0.05em] text-slate-900 xl:max-w-sm xl:text-[1.8rem]">
History stays visible, but the active conversation keeps the spotlight.
<h2 className="mt-2 max-w-[20rem] text-[1.2rem] font-semibold tracking-[-0.05em] text-slate-900 xl:max-w-[23rem] xl:text-[1.45rem]">
History stays visible, while the current ask stays easy to scan.
</h2>
</div>
<div className="grid min-h-[24rem] lg:grid-cols-[170px_minmax(0,1fr)] xl:min-h-[30rem] xl:grid-cols-[205px_minmax(0,1fr)]">
<div className="border-r border-slate-200/80 bg-[#fbfaf7]/90 p-3 xl:p-4">
<button
className="inline-flex w-full items-center justify-center rounded-2xl bg-slate-900 px-4 py-2.5 text-[13px] font-medium text-white shadow-[0_16px_30px_-22px_rgba(15,23,42,0.6)] xl:text-sm"
type="button"
>
<div className="bg-[linear-gradient(180deg,rgba(252,252,253,0.92)_0%,rgba(246,247,250,0.98)_100%)] px-5 py-4 xl:px-6 xl:py-5">
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center rounded-[9px] bg-slate-900 px-3 py-2 text-[12px] font-medium text-white">
New chat
</button>
<div className="mt-4 space-y-2.5 xl:space-y-3">
{previewThreads.map((thread) => (
<div
className={`rounded-[20px] border px-3 py-3 transition xl:px-3.5 xl:py-3.5 ${
thread.active
? 'border-slate-900 bg-slate-900 text-white shadow-[0_18px_40px_-28px_rgba(15,23,42,0.6)]'
: 'border-slate-200/90 bg-white/85 text-slate-900'
} ${thread.desktopOnly ? 'hidden xl:block' : ''}`}
key={thread.title}
>
<p className="text-[13px] font-medium xl:text-sm">{thread.title}</p>
<p
className={`mt-2 text-[11px] leading-5 xl:text-xs xl:leading-6 ${
thread.active ? 'text-slate-300' : 'text-slate-500'
}`}
>
{thread.summary}
</p>
</div>
))}
</div>
</span>
<span className="inline-flex items-center rounded-[9px] border border-slate-200 bg-white px-3 py-2 text-[12px] text-slate-600">
Calm workspace
</span>
<span className="inline-flex items-center rounded-[9px] border border-slate-200 bg-white px-3 py-2 text-[12px] text-slate-600">
Stripe billing ready
</span>
</div>
<div className="flex flex-col bg-[linear-gradient(180deg,rgba(252,252,253,0.92)_0%,rgba(246,247,250,0.98)_100%)]">
<div className="border-b border-slate-200/80 px-4 py-3.5 xl:px-5 xl:py-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.26em] text-slate-400">
Current conversation
</p>
<h3 className="mt-2 text-[1.1rem] font-semibold tracking-[-0.05em] text-slate-900 xl:text-[1.5rem]">
Draft a launch checklist
</h3>
</div>
<div className="flex-1 space-y-3 px-4 py-3.5 xl:space-y-4 xl:px-5 xl:py-4.5">
<div className="ml-auto max-w-[88%] rounded-[22px] border border-slate-900 bg-slate-900 px-4 py-3.5 text-white shadow-[0_22px_45px_-30px_rgba(15,23,42,0.6)] xl:max-w-[84%] xl:rounded-[24px] xl:px-4.5 xl:py-4.5">
<p className="text-[11px] uppercase tracking-[0.22em] text-white/55">You</p>
<p className="mt-2.5 text-[13px] leading-6 xl:mt-3 xl:text-[14px] xl:leading-7">
Build a concise launch checklist for the MVP and include the most
important backoffice follow-ups.
<div className="mt-4 grid gap-3 xl:grid-cols-[minmax(0,210px)_minmax(0,1fr)]">
<div className="rounded-[14px] border border-slate-200 bg-white px-3 py-3 shadow-[0_16px_35px_-28px_rgba(15,23,42,0.18)]">
<div className="flex items-center justify-between">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">
Recent threads
</p>
<span className="text-[11px] text-slate-400">3 active</span>
</div>
<div className="max-w-[94%] rounded-[22px] border border-slate-200 bg-white px-4 py-3.5 shadow-[0_16px_35px_-28px_rgba(15,23,42,0.25)] xl:max-w-[92%] xl:rounded-[24px] xl:px-4.5 xl:py-4.5">
<p className="text-[11px] uppercase tracking-[0.22em] text-slate-400">Assistant</p>
<div className="mt-2.5 space-y-2 text-[13px] leading-6 text-slate-600 xl:mt-3 xl:space-y-2.5 xl:text-[14px] xl:leading-7">
<p className="font-semibold text-slate-900">Launch checklist</p>
<p>- Validate sign-in, new chat, rename, archive, and delete flows.</p>
<p>- Confirm agent selection and conversation persistence.</p>
<p className="hidden xl:block">
- Review visibility of users, conversations, messages, and usage events.
</p>
<div className="mt-3 space-y-2">
{previewThreads.map((thread) => (
<div
className={`rounded-[10px] border px-3 py-2.5 ${
thread.active
? 'border-slate-900 bg-slate-900 text-white'
: 'border-slate-200 bg-slate-50 text-slate-900'
} ${thread.desktopOnly ? 'hidden xl:block' : ''}`}
key={thread.title}
>
<p className="text-[12px] font-medium">{thread.title}</p>
<p
className={`mt-1 line-clamp-2 text-[11px] leading-5 ${
thread.active ? 'text-slate-300' : 'text-slate-500'
}`}
>
{thread.summary}
</p>
</div>
))}
</div>
</div>
<div className="grid gap-3">
<div className="rounded-[14px] border border-slate-200 bg-white px-4 py-4 shadow-[0_16px_35px_-28px_rgba(15,23,42,0.18)]">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">
Current conversation
</p>
<h3 className="mt-2 text-[1rem] font-semibold tracking-[-0.04em] text-slate-900">
Draft a launch checklist
</h3>
</div>
<span className="rounded-[9px] border border-slate-200 bg-slate-50 px-2.5 py-1 text-[11px] text-slate-500">
Code helper
</span>
</div>
<div className="mt-4 grid gap-2.5">
<div className="rounded-[11px] border border-slate-200 bg-slate-50 px-3 py-3">
<p className="text-[10px] uppercase tracking-[0.18em] text-slate-400">You</p>
<p className="mt-1.5 text-[12px] leading-5 text-slate-700">
Build a concise launch checklist for the MVP and include the most
important backoffice follow-ups.
</p>
</div>
<div className="rounded-[11px] border border-slate-900 bg-slate-900 px-3 py-3 text-white">
<p className="text-[10px] uppercase tracking-[0.18em] text-white/55">Assistant</p>
<p className="mt-1.5 text-[12px] leading-5 text-slate-100">
Validate sign-in, new chat, agent selection, and backoffice
visibility before launch.
</p>
</div>
</div>
</div>
</div>
<div className="border-t border-slate-200/80 px-4 py-3 xl:px-5 xl:py-3.5">
<div className="rounded-[20px] border border-white/90 bg-white px-4 py-2.5 text-[12px] text-slate-500 shadow-[0_14px_35px_-28px_rgba(15,23,42,0.18)] xl:rounded-[22px] xl:py-3 xl:text-sm">
Ask for a draft, plan, code snippet, or product spec
<div className="grid gap-2 sm:grid-cols-3">
{previewMetrics.map((metric) => (
<div
className="rounded-[10px] border border-slate-200 bg-white px-3 py-2.5 text-[12px] text-slate-600 shadow-[0_14px_35px_-30px_rgba(15,23,42,0.15)]"
key={metric}
>
{metric}
</div>
))}
</div>
</div>
</div>
@ -189,6 +234,51 @@ export default function LandingPage() {
</div>
</section>
</section>
<section className="mt-20 rounded-[22px] border border-white/70 bg-white/60 px-6 py-8 shadow-[0_24px_80px_-48px_rgba(15,23,42,0.25)] backdrop-blur lg:px-8 lg:py-9">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-2xl">
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-slate-400">
Pricing
</p>
<h2 className="mt-3 text-[2rem] font-semibold tracking-[-0.05em] text-slate-900 lg:text-[2.6rem]">
Three plans, one calm workspace, and a simple Stripe-backed billing flow.
</h2>
<p className="mt-3 text-sm leading-7 text-slate-600 lg:text-[15px]">
Start small, move to Pro when the workspace becomes daily infrastructure, or give a team room to share
more agents and more usage.
</p>
</div>
<Link
className="inline-flex h-10 items-center justify-center rounded-[9px] border border-slate-200 bg-white px-4 text-sm font-medium text-slate-700"
href={pricingActionHref}
>
{pricingSectionCtaLabel}
</Link>
</div>
<div className="mt-8 grid gap-5 xl:grid-cols-3">
{marketingPlans.map((plan) => (
<PricingPlanCard
actionHref={pricingActionHref}
actionLabel={
hasSession
? plan.isFeatured
? 'Subscribe in billing'
: `Choose ${plan.name}`
: 'Sign in to subscribe'
}
caption={
hasSession
? 'Subscriptions are completed from the billing screen through Stripe Checkout.'
: 'Only signed-in users can start a subscription.'
}
key={plan.slug}
plan={plan}
/>
))}
</div>
</section>
</main>
</div>
</>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,184 +1,146 @@
import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'
import Head from 'next/head'
import React, { ReactElement, useEffect, useState } from 'react'
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
import dayjs from "dayjs";
import CardBox from '../../components/CardBox'
import LayoutAuthenticated from '../../layouts/Authenticated'
import SectionMain from '../../components/SectionMain'
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
import { getPageTitle } from '../../config'
import { Field, Form, Formik } from 'formik'
import FormField from '../../components/FormField'
import BaseDivider from '../../components/BaseDivider'
import BaseButtons from '../../components/BaseButtons'
import BaseButton from '../../components/BaseButton'
import FormCheckRadio from '../../components/FormCheckRadio'
import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'
import FormFilePicker from '../../components/FormFilePicker'
import FormImagePicker from '../../components/FormImagePicker'
import { SelectField } from "../../components/SelectField";
import { SelectFieldMany } from "../../components/SelectFieldMany";
import { SwitchField } from '../../components/SwitchField'
import {RichTextField} from "../../components/RichTextField";
import { update, fetch } from '../../stores/permissions/permissionsSlice'
import { useAppDispatch, useAppSelector } from '../../stores/hooks'
import { useRouter } from 'next/router'
import {saveFile} from "../../helpers/fileSaver";
import dataFormatter from '../../helpers/dataFormatter';
import ImageField from "../../components/ImageField";
import { mdiArrowLeft, mdiPencilOutline, mdiShieldOutline } from '@mdi/js';
import Head from 'next/head';
import Link from 'next/link';
import React, { ReactElement, useEffect, useState } from 'react';
import { Field, Form, Formik } from 'formik';
import BaseIcon from '../../components/BaseIcon';
import {
actionButtonClassName,
EntityAsideCard,
EntityIntro,
EntitySection,
inputClassName,
} from '../../components/AdminEntity/PageKit';
import SectionMain from '../../components/SectionMain';
import { getPageTitle } from '../../config';
import LayoutAuthenticated from '../../layouts/Authenticated';
import { fetch, update } from '../../stores/permissions/permissionsSlice';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { useRouter } from 'next/router';
const initialPermissionValues = {
name: '',
};
const EditPermissionsPage = () => {
const router = useRouter()
const dispatch = useAppDispatch()
const initVals = {
'name': '',
}
const [initialValues, setInitialValues] = useState(initVals)
const { permissions } = useAppSelector((state) => state.permissions)
const { id } = router.query
const router = useRouter();
const dispatch = useAppDispatch();
const { permissions, loading } = useAppSelector((state) => state.permissions);
const { id } = router.query;
const [initialValues, setInitialValues] = useState(initialPermissionValues);
useEffect(() => {
dispatch(fetch({ id: id }))
}, [id])
useEffect(() => {
if (typeof permissions === 'object') {
setInitialValues(permissions)
if (typeof id !== 'string') {
return;
}
}, [permissions])
dispatch(fetch({ id }));
}, [dispatch, id]);
useEffect(() => {
if (typeof permissions === 'object') {
const newInitialVal = {...initVals};
Object.keys(initVals).forEach(el => newInitialVal[el] = (permissions)[el])
setInitialValues(newInitialVal);
}
}, [permissions])
if (!permissions || Array.isArray(permissions) || typeof permissions !== 'object') {
return;
}
const handleSubmit = async (data) => {
await dispatch(update({ id: id, data }))
await router.push('/permissions/permissions-list')
}
setInitialValues({
name: permissions.name || '',
});
}, [permissions]);
const handleSubmit = async (data: typeof initialPermissionValues) => {
if (typeof id !== 'string') {
return;
}
await dispatch(update({ id, data }));
await router.push(`/permissions/permissions-view/?id=${id}`);
};
return (
<>
<Head>
<title>{getPageTitle('Edit permissions')}</title>
<title>{getPageTitle('Edit permission')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={'Edit permissions'} main>
{''}
</SectionTitleLineWithButton>
<CardBox>
<Formik
enableReinitialize
initialValues={initialValues}
onSubmit={(values) => handleSubmit(values)}
>
<Form>
<Formik enableReinitialize initialValues={initialValues} onSubmit={(values) => handleSubmit(values)}>
{({ isSubmitting, values }) => {
const isPermissionLoading = loading && !initialValues.name;
return (
<Form>
<div className="flex w-full flex-col gap-5">
<EntityIntro
backHref={typeof id === 'string' ? `/permissions/permissions-view/?id=${id}` : '/permissions/permissions-list'}
backLabel="Back to permission"
description="Rename this access capability without dropping back into the old generator form."
kicker="Permission"
title="Refine this permission."
/>
<FormField
label="Name"
>
<Field
name="name"
placeholder="Name"
/>
</FormField>
<div className="grid gap-5 2xl:grid-cols-[minmax(0,1fr)_320px]">
<div className="space-y-5">
<EntitySection
description="Keep the permission key explicit and readable. This name is referenced in access checks."
icon={mdiShieldOutline}
title="Identity"
>
<div>
<label className="mb-2 block text-[13px] font-medium text-slate-700" htmlFor="name">
Permission name
</label>
<Field className={inputClassName} id="name" name="name" />
</div>
</EntitySection>
</div>
<div className="space-y-5">
<EntityAsideCard title="Preview">
<div className="flex items-start gap-3">
<div className="inline-flex h-11 w-11 items-center justify-center rounded-[10px] border border-slate-200 bg-slate-50 text-slate-700">
<BaseIcon path={mdiPencilOutline} size={20} />
</div>
<div className="min-w-0">
<p className="truncate text-[16px] font-semibold text-slate-900">
{values.name || 'Unnamed permission'}
</p>
<p className="mt-1 text-[13px] text-slate-500">Access capability</p>
</div>
</div>
</EntityAsideCard>
<BaseDivider />
<BaseButtons>
<BaseButton type="submit" color="info" label="Submit" />
<BaseButton type="reset" color="info" outline label="Reset" />
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/permissions/permissions-list')}/>
</BaseButtons>
</Form>
</Formik>
</CardBox>
<EntityAsideCard title="Actions">
{isPermissionLoading && <p className="text-[13px] text-slate-500">Loading permission</p>}
<div className="flex flex-wrap gap-2">
<Link
className={`${actionButtonClassName} border-slate-200 bg-white text-slate-700`}
href={typeof id === 'string' ? `/permissions/permissions-view/?id=${id}` : '/permissions/permissions-list'}
>
Cancel
</Link>
<button
className={`${actionButtonClassName} border-slate-900 bg-slate-900 text-white`}
disabled={isSubmitting || isPermissionLoading}
type="submit"
>
{isSubmitting ? 'Saving…' : 'Save changes'}
</button>
</div>
</EntityAsideCard>
</div>
</div>
</div>
</Form>
);
}}
</Formik>
</SectionMain>
</>
)
}
);
};
EditPermissionsPage.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated
permission={'UPDATE_PERMISSIONS'}
>
{page}
</LayoutAuthenticated>
)
}
return <LayoutAuthenticated permission="UPDATE_PERMISSIONS">{page}</LayoutAuthenticated>;
};
export default EditPermissionsPage
export default EditPermissionsPage;

View File

@ -1,125 +1,139 @@
import { mdiArrowLeft, mdiKeyVariant, mdiPencilOutline, mdiShieldOutline } from '@mdi/js';
import Head from 'next/head';
import Link from 'next/link';
import React, { ReactElement, useEffect } from 'react';
import Head from 'next/head'
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
import dayjs from "dayjs";
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
import {useRouter} from "next/router";
import { fetch } from '../../stores/permissions/permissionsSlice'
import {saveFile} from "../../helpers/fileSaver";
import dataFormatter from '../../helpers/dataFormatter';
import ImageField from "../../components/ImageField";
import LayoutAuthenticated from "../../layouts/Authenticated";
import {getPageTitle} from "../../config";
import SectionTitleLineWithButton from "../../components/SectionTitleLineWithButton";
import SectionMain from "../../components/SectionMain";
import CardBox from "../../components/CardBox";
import BaseButton from "../../components/BaseButton";
import BaseDivider from "../../components/BaseDivider";
import {mdiChartTimelineVariant} from "@mdi/js";
import {SwitchField} from "../../components/SwitchField";
import FormField from "../../components/FormField";
import BaseIcon from '../../components/BaseIcon';
import LoadingSpinner from '../../components/LoadingSpinner';
import {
actionButtonClassName,
EntityAsideCard,
EntityEmptyState,
EntityIntro,
EntitySection,
EntityValueCard,
} from '../../components/AdminEntity/PageKit';
import SectionMain from '../../components/SectionMain';
import { getPageTitle } from '../../config';
import LayoutAuthenticated from '../../layouts/Authenticated';
import { fetch } from '../../stores/permissions/permissionsSlice';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { useRouter } from 'next/router';
const PermissionsView = () => {
const router = useRouter()
const dispatch = useAppDispatch()
const { permissions } = useAppSelector((state) => state.permissions)
const router = useRouter();
const dispatch = useAppDispatch();
const { permissions, loading } = useAppSelector((state) => state.permissions);
const { id } = router.query;
const { id } = router.query;
function removeLastCharacter(str) {
console.log(str,`str`)
return str.slice(0, -1);
useEffect(() => {
if (typeof id !== 'string') {
return;
}
useEffect(() => {
dispatch(fetch({ id }));
}, [dispatch, id]);
dispatch(fetch({ id }));
}, [dispatch, id]);
const permission =
!Array.isArray(permissions) && permissions && typeof permissions === 'object' ? permissions : null;
return (
<>
<Head>
<title>{getPageTitle('View permissions')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={removeLastCharacter('View permissions')} main>
<BaseButton
color='info'
label='Edit'
return (
<>
<Head>
<title>{getPageTitle('Permission details')}</title>
</Head>
<SectionMain>
<div className="flex w-full flex-col gap-5">
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<Link
className="inline-flex items-center gap-2 text-[12px] font-medium text-slate-500"
href="/permissions/permissions-list"
>
<BaseIcon path={mdiArrowLeft} size={14} />
Back to permissions
</Link>
<p className="mt-4 text-[11px] font-medium uppercase tracking-[0.28em] text-slate-400">
Permission
</p>
<h1 className="mt-3 text-[2rem] font-semibold tracking-[-0.04em] text-slate-900">
{permission?.name || 'Permission details'}
</h1>
<p className="mt-3 max-w-2xl text-sm leading-6 text-slate-500">
Review the access capability this permission unlocks before wiring it into roles or user-specific
overrides.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Link
className={`${actionButtonClassName} border-slate-200 bg-white text-slate-700`}
href="/permissions/permissions-list"
>
Close
</Link>
<Link
className={`${actionButtonClassName} border-slate-900 bg-slate-900 text-white`}
href={`/permissions/permissions-edit/?id=${id}`}
/>
</SectionTitleLineWithButton>
<CardBox>
>
<BaseIcon className="mr-1" path={mdiPencilOutline} size={16} />
Edit permission
</Link>
</div>
</div>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Name</p>
<p>{permissions?.name}</p>
</div>
{loading && !permission && (
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-10">
<LoadingSpinner />
</div>
)}
{!loading && !permission && (
<EntityEmptyState
description="Try going back to the permissions list and opening this record again."
title="We could not load this permission."
/>
)}
{permission && (
<div className="grid gap-5 2xl:grid-cols-[minmax(0,1fr)_320px]">
<div className="space-y-5">
<EntitySection
description="The canonical permission key used across backend checks and admin screens."
icon={mdiShieldOutline}
title="Identity"
>
<div className="grid gap-4 md:grid-cols-2">
<EntityValueCard label="Name" value={permission.name || 'Untitled permission'} />
<EntityValueCard label="Type" value="Access capability" />
</div>
</EntitySection>
</div>
<BaseDivider />
<BaseButton
color='info'
label='Back'
onClick={() => router.push('/permissions/permissions-list')}
/>
</CardBox>
</SectionMain>
</>
);
<div className="space-y-5">
<EntityAsideCard title="At a glance">
<div className="space-y-3">
<div className="rounded-[10px] border border-slate-200 px-4 py-3">
<div className="flex items-center gap-2 text-slate-500">
<BaseIcon path={mdiKeyVariant} size={16} />
<p className="text-[12px] font-medium uppercase tracking-[0.16em]">Permission key</p>
</div>
<p className="mt-2 break-all text-[15px] font-medium text-slate-900">{permission.name}</p>
</div>
</div>
</EntityAsideCard>
</div>
</div>
)}
</div>
</SectionMain>
</>
);
};
PermissionsView.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated
permission={'READ_PERMISSIONS'}
>
{page}
</LayoutAuthenticated>
)
}
return <LayoutAuthenticated permission="READ_PERMISSIONS">{page}</LayoutAuthenticated>;
};
export default PermissionsView;
export default PermissionsView;

View File

@ -1,269 +1,166 @@
import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'
import Head from 'next/head'
import React, { ReactElement, useEffect, useState } from 'react'
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
import dayjs from "dayjs";
import CardBox from '../../components/CardBox'
import LayoutAuthenticated from '../../layouts/Authenticated'
import SectionMain from '../../components/SectionMain'
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
import { getPageTitle } from '../../config'
import { Field, Form, Formik } from 'formik'
import FormField from '../../components/FormField'
import BaseDivider from '../../components/BaseDivider'
import BaseButtons from '../../components/BaseButtons'
import BaseButton from '../../components/BaseButton'
import FormCheckRadio from '../../components/FormCheckRadio'
import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'
import FormFilePicker from '../../components/FormFilePicker'
import FormImagePicker from '../../components/FormImagePicker'
import { SelectField } from "../../components/SelectField";
import { SelectFieldMany } from "../../components/SelectFieldMany";
import { SwitchField } from '../../components/SwitchField'
import {RichTextField} from "../../components/RichTextField";
import { update, fetch } from '../../stores/roles/rolesSlice'
import { useAppDispatch, useAppSelector } from '../../stores/hooks'
import { useRouter } from 'next/router'
import {saveFile} from "../../helpers/fileSaver";
import dataFormatter from '../../helpers/dataFormatter';
import ImageField from "../../components/ImageField";
import { mdiArrowLeft, mdiKeyVariant, mdiPencilOutline, mdiShieldOutline } from '@mdi/js';
import Head from 'next/head';
import Link from 'next/link';
import React, { ReactElement, useEffect, useState } from 'react';
import { Field, Form, Formik } from 'formik';
import BaseIcon from '../../components/BaseIcon';
import {
actionButtonClassName,
EntityAsideCard,
EntityIntro,
EntitySection,
inputClassName,
} from '../../components/AdminEntity/PageKit';
import { SelectFieldMany } from '../../components/SelectFieldMany';
import SectionMain from '../../components/SectionMain';
import { getPageTitle } from '../../config';
import LayoutAuthenticated from '../../layouts/Authenticated';
import { fetch, update } from '../../stores/roles/rolesSlice';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { useRouter } from 'next/router';
const initialRoleValues = {
name: '',
permissions: [],
};
const EditRolesPage = () => {
const router = useRouter()
const dispatch = useAppDispatch()
const initVals = {
'name': '',
permissions: [],
}
const [initialValues, setInitialValues] = useState(initVals)
const { roles } = useAppSelector((state) => state.roles)
const { id } = router.query
const router = useRouter();
const dispatch = useAppDispatch();
const { roles, loading } = useAppSelector((state) => state.roles);
const { id } = router.query;
const [initialValues, setInitialValues] = useState(initialRoleValues);
useEffect(() => {
dispatch(fetch({ id: id }))
}, [id])
useEffect(() => {
if (typeof roles === 'object') {
setInitialValues(roles)
if (typeof id !== 'string') {
return;
}
}, [roles])
dispatch(fetch({ id }));
}, [dispatch, id]);
useEffect(() => {
if (typeof roles === 'object') {
const newInitialVal = {...initVals};
Object.keys(initVals).forEach(el => newInitialVal[el] = (roles)[el])
setInitialValues(newInitialVal);
}
}, [roles])
if (!roles || Array.isArray(roles) || typeof roles !== 'object') {
return;
}
const handleSubmit = async (data) => {
await dispatch(update({ id: id, data }))
await router.push('/roles/roles-list')
}
setInitialValues({
name: roles.name || '',
permissions: roles.permissions || [],
});
}, [roles]);
const handleSubmit = async (data: typeof initialRoleValues) => {
if (typeof id !== 'string') {
return;
}
await dispatch(update({ id, data }));
await router.push(`/roles/roles-view/?id=${id}`);
};
return (
<>
<Head>
<title>{getPageTitle('Edit roles')}</title>
<title>{getPageTitle('Edit role')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={'Edit roles'} main>
{''}
</SectionTitleLineWithButton>
<CardBox>
<Formik
enableReinitialize
initialValues={initialValues}
onSubmit={(values) => handleSubmit(values)}
>
<Form>
<Formik enableReinitialize initialValues={initialValues} onSubmit={(values) => handleSubmit(values)}>
{({ isSubmitting, values }) => {
const isRoleLoading = loading && !initialValues.name;
return (
<Form>
<div className="flex w-full flex-col gap-5">
<EntityIntro
backHref={typeof id === 'string' ? `/roles/roles-view/?id=${id}` : '/roles/roles-list'}
backLabel="Back to role"
description="Adjust the label and permission bundle without going through the old admin generator form."
kicker="Role"
title="Refine this role."
/>
<FormField
label="Name"
>
<Field
name="name"
placeholder="Name"
/>
</FormField>
<div className="grid gap-5 2xl:grid-cols-[minmax(0,1fr)_320px]">
<div className="space-y-5">
<EntitySection
description="Keep the role label human and obvious so workspace access stays easy to reason about."
icon={mdiShieldOutline}
title="Identity"
>
<div>
<label className="mb-2 block text-[13px] font-medium text-slate-700" htmlFor="name">
Role name
</label>
<Field className={inputClassName} id="name" name="name" />
</div>
</EntitySection>
<EntitySection
description="Pick the capabilities this role should inherit across workspace and admin actions."
icon={mdiKeyVariant}
title="Permissions"
>
<Field
component={SelectFieldMany}
id="permissions"
itemRef="permissions"
name="permissions"
options={values.permissions}
showField="name"
/>
</EntitySection>
</div>
<div className="space-y-5">
<EntityAsideCard title="Preview">
<div className="flex items-start gap-3">
<div className="inline-flex h-11 w-11 items-center justify-center rounded-[10px] border border-slate-200 bg-slate-50 text-slate-700">
<BaseIcon path={mdiPencilOutline} size={20} />
</div>
<div className="min-w-0">
<p className="truncate text-[16px] font-semibold text-slate-900">
{values.name || 'Unnamed role'}
</p>
<p className="mt-1 text-[13px] text-slate-500">
{Array.isArray(values.permissions) ? values.permissions.length : 0} permissions selected
</p>
</div>
</div>
</EntityAsideCard>
<FormField label='Permissions' labelFor='permissions'>
<Field
name='permissions'
id='permissions'
component={SelectFieldMany}
options={initialValues.permissions}
itemRef={'permissions'}
showField={'name'}
></Field>
</FormField>
<BaseDivider />
<BaseButtons>
<BaseButton type="submit" color="info" label="Submit" />
<BaseButton type="reset" color="info" outline label="Reset" />
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/roles/roles-list')}/>
</BaseButtons>
</Form>
</Formik>
</CardBox>
<EntityAsideCard title="Actions">
{isRoleLoading && <p className="text-[13px] text-slate-500">Loading role</p>}
<div className="flex flex-wrap gap-2">
<Link
className={`${actionButtonClassName} border-slate-200 bg-white text-slate-700`}
href={typeof id === 'string' ? `/roles/roles-view/?id=${id}` : '/roles/roles-list'}
>
Cancel
</Link>
<button
className={`${actionButtonClassName} border-slate-900 bg-slate-900 text-white`}
disabled={isSubmitting || isRoleLoading}
type="submit"
>
{isSubmitting ? 'Saving…' : 'Save changes'}
</button>
</div>
</EntityAsideCard>
</div>
</div>
</div>
</Form>
);
}}
</Formik>
</SectionMain>
</>
)
}
);
};
EditRolesPage.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated
permission={'UPDATE_ROLES'}
>
{page}
</LayoutAuthenticated>
)
}
return <LayoutAuthenticated permission="UPDATE_ROLES">{page}</LayoutAuthenticated>;
};
export default EditRolesPage
export default EditRolesPage;

View File

@ -1,298 +1,185 @@
import {
mdiAccountOutline,
mdiArrowLeft,
mdiKeyVariant,
mdiPencilOutline,
mdiShieldOutline,
} from '@mdi/js';
import Head from 'next/head';
import Link from 'next/link';
import React, { ReactElement, useEffect } from 'react';
import Head from 'next/head'
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
import dayjs from "dayjs";
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
import {useRouter} from "next/router";
import { fetch } from '../../stores/roles/rolesSlice'
import {saveFile} from "../../helpers/fileSaver";
import dataFormatter from '../../helpers/dataFormatter';
import ImageField from "../../components/ImageField";
import LayoutAuthenticated from "../../layouts/Authenticated";
import {getPageTitle} from "../../config";
import SectionTitleLineWithButton from "../../components/SectionTitleLineWithButton";
import SectionMain from "../../components/SectionMain";
import CardBox from "../../components/CardBox";
import BaseButton from "../../components/BaseButton";
import BaseDivider from "../../components/BaseDivider";
import {mdiChartTimelineVariant} from "@mdi/js";
import {SwitchField} from "../../components/SwitchField";
import FormField from "../../components/FormField";
import BaseIcon from '../../components/BaseIcon';
import LoadingSpinner from '../../components/LoadingSpinner';
import {
actionButtonClassName,
EntityAsideCard,
EntityEmptyState,
EntityLinkCard,
EntityValueCard,
} from '../../components/AdminEntity/PageKit';
import SectionMain from '../../components/SectionMain';
import { getPageTitle } from '../../config';
import LayoutAuthenticated from '../../layouts/Authenticated';
import { fetch } from '../../stores/roles/rolesSlice';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { useRouter } from 'next/router';
const RolesView = () => {
const router = useRouter()
const dispatch = useAppDispatch()
const { roles } = useAppSelector((state) => state.roles)
const router = useRouter();
const dispatch = useAppDispatch();
const { roles, loading } = useAppSelector((state) => state.roles);
const { id } = router.query;
const { id } = router.query;
function removeLastCharacter(str) {
console.log(str,`str`)
return str.slice(0, -1);
useEffect(() => {
if (typeof id !== 'string') {
return;
}
useEffect(() => {
dispatch(fetch({ id }));
}, [dispatch, id]);
dispatch(fetch({ id }));
}, [dispatch, id]);
const role = !Array.isArray(roles) && roles && typeof roles === 'object' ? roles : null;
const permissions = Array.isArray(role?.permissions) ? role.permissions : [];
const users = Array.isArray(role?.users_app_role) ? role.users_app_role : [];
return (
<>
<Head>
<title>{getPageTitle('View roles')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={removeLastCharacter('View roles')} main>
<BaseButton
color='info'
label='Edit'
return (
<>
<Head>
<title>{getPageTitle('Role details')}</title>
</Head>
<SectionMain>
<div className="flex w-full flex-col gap-5">
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<Link
className="inline-flex items-center gap-2 text-[12px] font-medium text-slate-500"
href="/roles/roles-list"
>
<BaseIcon path={mdiArrowLeft} size={14} />
Back to roles
</Link>
<p className="mt-4 text-[11px] font-medium uppercase tracking-[0.28em] text-slate-400">Role</p>
<h1 className="mt-3 text-[2rem] font-semibold tracking-[-0.04em] text-slate-900">
{role?.name || 'Role details'}
</h1>
<p className="mt-3 max-w-2xl text-sm leading-6 text-slate-500">
Review who inherits this role and which capabilities are bundled into it.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Link className={`${actionButtonClassName} border-slate-200 bg-white text-slate-700`} href="/roles/roles-list">
Close
</Link>
<Link
className={`${actionButtonClassName} border-slate-900 bg-slate-900 text-white`}
href={`/roles/roles-edit/?id=${id}`}
/>
</SectionTitleLineWithButton>
<CardBox>
>
<BaseIcon className="mr-1" path={mdiPencilOutline} size={16} />
Edit role
</Link>
</div>
</div>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Name</p>
<p>{roles?.name}</p>
{loading && !role && (
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-10">
<LoadingSpinner />
</div>
)}
{!loading && !role && (
<EntityEmptyState
description="Try going back to the roles list and opening this record again."
title="We could not load this role."
/>
)}
{role && (
<div className="grid gap-5 2xl:grid-cols-[minmax(0,1fr)_320px]">
<div className="space-y-5">
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-6">
<div className="mb-5 flex items-start gap-3">
<div className="mt-0.5 inline-flex h-10 w-10 items-center justify-center rounded-[10px] border border-slate-200 bg-slate-50 text-slate-600">
<BaseIcon path={mdiShieldOutline} size={20} />
</div>
<div>
<p className="text-[15px] font-medium text-slate-900">Identity</p>
<p className="mt-1 text-sm leading-6 text-slate-500">
The role label people inherit when they are assigned this access bundle.
</p>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<EntityValueCard label="Role name" value={role.name || 'Untitled role'} />
<EntityValueCard label="Permission count" value={permissions.length.toString()} />
</div>
</div>
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-6">
<div className="mb-5 flex items-start gap-3">
<div className="mt-0.5 inline-flex h-10 w-10 items-center justify-center rounded-[10px] border border-slate-200 bg-slate-50 text-slate-600">
<BaseIcon path={mdiKeyVariant} size={20} />
</div>
<div>
<p className="text-[15px] font-medium text-slate-900">Permissions</p>
<p className="mt-1 text-sm leading-6 text-slate-500">
Capabilities this role grants across the workspace and admin surface.
</p>
</div>
</div>
<div className="space-y-3">
{permissions.length > 0 ? (
permissions.map((item: any) => (
<EntityLinkCard
key={item.id}
description="Open the permission record for more detail."
href={`/permissions/permissions-view/?id=${item.id}`}
icon={mdiKeyVariant}
label="Permission"
value={item.name || 'Unnamed permission'}
/>
))
) : (
<p className="text-[13px] leading-6 text-slate-500">No permissions linked to this role yet.</p>
)}
</div>
</div>
</div>
<>
<p className={'block font-bold mb-2'}>Permissions</p>
<CardBox
className='mb-6 border border-gray-300 rounded overflow-hidden'
hasTable
>
<div className='overflow-x-auto'>
<table>
<thead>
<tr>
<th>Name</th>
</tr>
</thead>
<tbody>
{roles.permissions && Array.isArray(roles.permissions) &&
roles.permissions.map((item: any) => (
<tr key={item.id} onClick={() => router.push(`/permissions/permissions-view/?id=${item.id}`)}>
<td data-label="name">
{ item.name }
</td>
</tr>
))}
</tbody>
</table>
</div>
{!roles?.permissions?.length && <div className={'text-center py-4'}>No data</div>}
</CardBox>
</>
<>
<p className={'block font-bold mb-2'}>Users App Role</p>
<CardBox
className='mb-6 border border-gray-300 rounded overflow-hidden'
hasTable
>
<div className='overflow-x-auto'>
<table>
<thead>
<tr>
<th>First Name</th>
<th>Last Name</th>
<th>Phone Number</th>
<th>E-Mail</th>
<th>Disabled</th>
</tr>
</thead>
<tbody>
{roles.users_app_role && Array.isArray(roles.users_app_role) &&
roles.users_app_role.map((item: any) => (
<tr key={item.id} onClick={() => router.push(`/users/users-view/?id=${item.id}`)}>
<td data-label="firstName">
{ item.firstName }
</td>
<td data-label="lastName">
{ item.lastName }
</td>
<td data-label="phoneNumber">
{ item.phoneNumber }
</td>
<td data-label="email">
{ item.email }
</td>
<td data-label="disabled">
{ dataFormatter.booleanFormatter(item.disabled) }
</td>
</tr>
))}
</tbody>
</table>
</div>
{!roles?.users_app_role?.length && <div className={'text-center py-4'}>No data</div>}
</CardBox>
</>
<BaseDivider />
<BaseButton
color='info'
label='Back'
onClick={() => router.push('/roles/roles-list')}
/>
</CardBox>
</SectionMain>
</>
);
<div className="space-y-5">
<EntityAsideCard title="Assigned users">
<div className="space-y-3">
{users.length > 0 ? (
users.map((item: any) => (
<EntityLinkCard
key={item.id}
description={item.email || 'Open this user record.'}
href={`/users/users-view/?id=${item.id}`}
icon={mdiAccountOutline}
label="User"
value={[item.firstName, item.lastName].filter(Boolean).join(' ') || item.email || 'Unnamed user'}
/>
))
) : (
<p className="text-[13px] leading-6 text-slate-500">No users currently inherit this role.</p>
)}
</div>
</EntityAsideCard>
</div>
</div>
)}
</div>
</SectionMain>
</>
);
};
RolesView.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated
permission={'READ_ROLES'}
>
{page}
</LayoutAuthenticated>
)
}
return <LayoutAuthenticated permission="READ_ROLES">{page}</LayoutAuthenticated>;
};
export default RolesView;
export default RolesView;

View File

@ -1,5 +1,6 @@
import {
mdiAccountCircleOutline,
mdiCreditCardOutline,
mdiCogOutline,
mdiChevronRight,
mdiFileCodeOutline,
@ -20,6 +21,7 @@ function SettingsPage() {
const { currentUser } = useAppSelector((state) => state.auth);
const canAccessAdmin = hasPermission(currentUser, ADMIN_ENTRY_PERMISSIONS);
const canAccessApiDocs = hasPermission(currentUser, 'READ_API_DOCS');
const canManageStripe = hasPermission(currentUser, 'READ_USERS');
const apiDocsHref = process.env.NEXT_PUBLIC_BACK_API
? `${process.env.NEXT_PUBLIC_BACK_API.replace(/\/api$/, '')}/api-docs/`
: '/api-docs/';
@ -68,6 +70,50 @@ function SettingsPage() {
</div>
</Link>
<Link
className="group rounded-[12px] border border-slate-200 bg-white px-5 py-5"
href="/billing"
>
<div className="flex items-start justify-between gap-4">
<div>
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-[10px] border border-slate-200 bg-slate-50 text-slate-600">
<BaseIcon path={mdiCreditCardOutline} size={20} />
</div>
<h2 className="text-lg font-medium text-slate-900">Billing</h2>
<p className="mt-2 text-sm leading-6 text-slate-500">
Manage your plan, Stripe subscription, and renewal state.
</p>
<p className="mt-4 text-[12px] text-slate-400">Plans, renewals, and cancellations</p>
</div>
<div className="mt-0.5 text-slate-400">
<BaseIcon path={mdiChevronRight} size={18} />
</div>
</div>
</Link>
{canManageStripe && (
<Link
className="group rounded-[12px] border border-slate-200 bg-white px-5 py-5"
href="/stripe-settings"
>
<div className="flex items-start justify-between gap-4">
<div>
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-[10px] border border-slate-200 bg-slate-50 text-slate-600">
<BaseIcon path={mdiCreditCardOutline} size={20} />
</div>
<h2 className="text-lg font-medium text-slate-900">Stripe</h2>
<p className="mt-2 text-sm leading-6 text-slate-500">
Save Stripe keys, configure the webhook endpoint, and map live product and price IDs.
</p>
<p className="mt-4 text-[12px] text-slate-400">Secrets, webhooks, and plan mapping</p>
</div>
<div className="mt-0.5 text-slate-400">
<BaseIcon path={mdiChevronRight} size={18} />
</div>
</div>
</Link>
)}
{canAccessAdmin && (
<Link
className="group rounded-[12px] border border-slate-200 bg-white px-5 py-5"

View File

@ -0,0 +1,306 @@
import {
mdiCreditCardOutline,
mdiFileCodeOutline,
mdiKeyVariant,
mdiShieldOutline,
} from '@mdi/js';
import axios from 'axios';
import Head from 'next/head';
import Link from 'next/link';
import React, { ReactElement, useCallback, useEffect, useState } from 'react';
import { Field, Form, Formik } from 'formik';
import { toast } from 'react-toastify';
import {
actionButtonClassName,
EntityAsideCard,
EntityIntro,
EntitySection,
EntityValueCard,
inputClassName,
} from '../components/AdminEntity/PageKit';
import BaseIcon from '../components/BaseIcon';
import SectionMain from '../components/SectionMain';
import { getPageTitle } from '../config';
import LayoutAuthenticated from '../layouts/Authenticated';
import { StripeSettingsState } from '../lib/billingPlans';
const initialValues: StripeSettingsState = {
plans: [],
stripeSecretKey: '',
stripeSecretKeyPreview: '',
stripeSecretKeySource: 'missing',
stripeWebhookSecret: '',
stripeWebhookSecretPreview: '',
stripeWebhookSecretSource: 'missing',
webhookEndpoint: '',
webhookEvents: [],
};
function getErrorMessage(error: any) {
return error?.response?.data || error?.message || 'Something went wrong.';
}
function formatSourceLabel(source: string) {
if (source === 'saved') {
return 'Saved in workspace';
}
if (source === 'env') {
return 'Runtime env fallback';
}
return 'Missing';
}
function StripeSettingsPage() {
const [settings, setSettings] = useState(initialValues);
const [loading, setLoading] = useState(true);
const loadSettings = useCallback(async () => {
setLoading(true);
try {
const response = await axios.get('billing/stripe-settings');
setSettings(response.data.settings || initialValues);
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadSettings();
}, [loadSettings]);
const handleSubmit = async (values: StripeSettingsState) => {
const payload = {
plans: values.plans.map((plan) => ({
id: plan.id,
stripePriceId: plan.stripePriceId || '',
stripeProductId: plan.stripeProductId || '',
})),
stripeSecretKey: values.stripeSecretKey || '',
stripeWebhookSecret: values.stripeWebhookSecret || '',
};
try {
const response = await axios.put('billing/stripe-settings', {
data: payload,
});
setSettings(response.data.settings || initialValues);
toast.success('Stripe settings updated.');
} catch (error) {
toast.error(getErrorMessage(error));
throw error;
}
};
return (
<>
<Head>
<title>{getPageTitle('Stripe settings')}</title>
</Head>
<SectionMain>
<Formik enableReinitialize initialValues={settings} onSubmit={(values) => void handleSubmit(values)}>
{({ isSubmitting, values }) => (
<Form>
<div className="flex w-full flex-col gap-5">
<EntityIntro
backHref="/settings"
backLabel="Back to settings"
description="Configure Stripe secrets, wire the billing webhook, and replace mock price IDs with real Stripe prices for each plan."
kicker="Stripe"
title="Connect the workspace to Stripe."
/>
<div className="grid gap-5 2xl:grid-cols-[minmax(0,1fr)_320px]">
<div className="space-y-5">
<EntitySection
description="Save the Stripe secret key and webhook secret that billing should use for Checkout, subscriptions, and webhook verification."
icon={mdiKeyVariant}
title="Runtime keys"
>
<div className="grid gap-4">
<div>
<label className="mb-2 block text-[13px] font-medium text-slate-700" htmlFor="stripeSecretKey">
STRIPE_SECRET_KEY
</label>
<Field
className={inputClassName}
id="stripeSecretKey"
name="stripeSecretKey"
placeholder="sk_live_..."
/>
</div>
<div>
<label className="mb-2 block text-[13px] font-medium text-slate-700" htmlFor="stripeWebhookSecret">
STRIPE_WEBHOOK_SECRET
</label>
<Field
className={inputClassName}
id="stripeWebhookSecret"
name="stripeWebhookSecret"
placeholder="whsec_..."
/>
</div>
</div>
</EntitySection>
<EntitySection
description="Register this endpoint in Stripe and subscribe it to the exact events the backend already handles."
icon={mdiFileCodeOutline}
title="Webhook"
>
<div className="grid gap-4">
<EntityValueCard
label="Webhook endpoint"
value={
<code className="break-all rounded-[8px] bg-slate-50 px-2 py-1 text-[13px] text-slate-700">
{values.webhookEndpoint || '/api/billing/webhook'}
</code>
}
/>
<div className="rounded-[10px] border border-slate-200 px-4 py-4">
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-slate-400">
Events to register
</p>
<div className="mt-3 flex flex-wrap gap-2">
{values.webhookEvents.map((eventName) => (
<span
className="inline-flex items-center rounded-[999px] border border-slate-200 bg-slate-50 px-2.5 py-1 text-[12px] text-slate-700"
key={eventName}
>
{eventName}
</span>
))}
</div>
</div>
</div>
</EntitySection>
<EntitySection
description="Replace the mock Stripe IDs with the real Product and Price IDs created in your Stripe dashboard."
icon={mdiCreditCardOutline}
title="Plan mapping"
>
<div className="space-y-4">
{values.plans.map((plan, index) => (
<div className="rounded-[10px] border border-slate-200 px-4 py-4" key={plan.id || plan.slug || index}>
<div className="mb-4 flex flex-wrap items-start justify-between gap-2">
<div>
<p className="text-[15px] font-medium text-slate-900">{plan.name}</p>
<p className="mt-1 text-[13px] text-slate-500">{plan.slug}</p>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<label
className="mb-2 block text-[13px] font-medium text-slate-700"
htmlFor={`plans.${index}.stripeProductId`}
>
Stripe product ID
</label>
<Field
className={inputClassName}
id={`plans.${index}.stripeProductId`}
name={`plans.${index}.stripeProductId`}
placeholder="prod_..."
/>
</div>
<div>
<label
className="mb-2 block text-[13px] font-medium text-slate-700"
htmlFor={`plans.${index}.stripePriceId`}
>
Stripe price ID
</label>
<Field
className={inputClassName}
id={`plans.${index}.stripePriceId`}
name={`plans.${index}.stripePriceId`}
placeholder="price_..."
/>
</div>
</div>
</div>
))}
</div>
</EntitySection>
</div>
<div className="space-y-5">
<EntityAsideCard title="Current state">
<div className="space-y-3">
<EntityValueCard
label="Secret key source"
value={formatSourceLabel(values.stripeSecretKeySource)}
/>
<EntityValueCard
label="Webhook secret source"
value={formatSourceLabel(values.stripeWebhookSecretSource)}
/>
<EntityValueCard
label="Secret key preview"
value={values.stripeSecretKeyPreview || 'Not configured'}
/>
<EntityValueCard
label="Webhook secret preview"
value={values.stripeWebhookSecretPreview || 'Not configured'}
/>
</div>
</EntityAsideCard>
<EntityAsideCard title="What this enables">
<div className="space-y-3 text-[13px] leading-6 text-slate-500">
<div className="flex items-start gap-2">
<BaseIcon path={mdiShieldOutline} size={16} />
<p>Checkout runs through real Stripe Checkout instead of mock subscribe flows.</p>
</div>
<div className="flex items-start gap-2">
<BaseIcon path={mdiShieldOutline} size={16} />
<p>Cancellation uses Stripe <code>cancel_at_period_end</code> instead of a local fake state.</p>
</div>
<div className="flex items-start gap-2">
<BaseIcon path={mdiShieldOutline} size={16} />
<p>Webhook sync keeps local subscriptions updated from Stripe events.</p>
</div>
</div>
</EntityAsideCard>
<EntityAsideCard title="Actions">
{loading && <p className="text-[13px] text-slate-500">Loading Stripe settings</p>}
<div className="flex flex-wrap gap-2">
<Link
className={`${actionButtonClassName} border-slate-200 bg-white text-slate-700`}
href="/settings"
>
Cancel
</Link>
<button
className={`${actionButtonClassName} border-slate-900 bg-slate-900 text-white`}
disabled={isSubmitting || loading}
type="submit"
>
{isSubmitting ? 'Saving…' : 'Save Stripe settings'}
</button>
</div>
</EntityAsideCard>
</div>
</div>
</div>
</Form>
)}
</Formik>
</SectionMain>
</>
);
}
StripeSettingsPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated permission="READ_USERS">{page}</LayoutAuthenticated>;
};
export default StripeSettingsPage;

File diff suppressed because it is too large Load Diff

View File

@ -1,596 +1,212 @@
import {
mdiAccountOutline,
mdiArrowLeft,
mdiClockOutline,
mdiCurrencyUsd,
mdiMessageTextOutline,
mdiOpenInNew,
mdiPencilOutline,
mdiRobotOutline,
mdiTimelineTextOutline,
mdiWrenchOutline,
} from '@mdi/js';
import Head from 'next/head';
import Link from 'next/link';
import React, { ReactElement, useEffect } from 'react';
import Head from 'next/head'
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
import dayjs from "dayjs";
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
import {useRouter} from "next/router";
import { fetch } from '../../stores/usage_events/usage_eventsSlice'
import {saveFile} from "../../helpers/fileSaver";
import dataFormatter from '../../helpers/dataFormatter';
import ImageField from "../../components/ImageField";
import LayoutAuthenticated from "../../layouts/Authenticated";
import {getPageTitle} from "../../config";
import SectionTitleLineWithButton from "../../components/SectionTitleLineWithButton";
import SectionMain from "../../components/SectionMain";
import CardBox from "../../components/CardBox";
import BaseButton from "../../components/BaseButton";
import BaseDivider from "../../components/BaseDivider";
import {mdiChartTimelineVariant} from "@mdi/js";
import {SwitchField} from "../../components/SwitchField";
import FormField from "../../components/FormField";
import BaseIcon from '../../components/BaseIcon';
import LoadingSpinner from '../../components/LoadingSpinner';
import {
actionButtonClassName,
EntityAsideCard,
EntityEmptyState,
EntityLinkCard,
EntitySection,
EntityValueCard,
formatDateTime,
formatMoney,
formatName,
formatRole,
formatTokens,
} from '../../components/AdminEntity/PageKit';
import SectionMain from '../../components/SectionMain';
import { getPageTitle } from '../../config';
import LayoutAuthenticated from '../../layouts/Authenticated';
import { fetch } from '../../stores/usage_events/usage_eventsSlice';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { useRouter } from 'next/router';
const Usage_eventsView = () => {
const router = useRouter()
const dispatch = useAppDispatch()
const { usage_events } = useAppSelector((state) => state.usage_events)
const router = useRouter();
const dispatch = useAppDispatch();
const { usage_events, loading } = useAppSelector((state) => state.usage_events);
const { id } = router.query;
const { id } = router.query;
function removeLastCharacter(str) {
console.log(str,`str`)
return str.slice(0, -1);
useEffect(() => {
if (typeof id !== 'string') {
return;
}
useEffect(() => {
dispatch(fetch({ id }));
}, [dispatch, id]);
dispatch(fetch({ id }));
}, [dispatch, id]);
const usageEvent =
!Array.isArray(usage_events) && usage_events && typeof usage_events === 'object' ? usage_events : null;
return (
<>
<Head>
<title>{getPageTitle('View usage_events')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={removeLastCharacter('View usage_events')} main>
<BaseButton
color='info'
label='Edit'
return (
<>
<Head>
<title>{getPageTitle('Usage event details')}</title>
</Head>
<SectionMain>
<div className="flex w-full flex-col gap-5">
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<Link
className="inline-flex items-center gap-2 text-[12px] font-medium text-slate-500"
href="/usage_events/usage_events-list"
>
<BaseIcon path={mdiArrowLeft} size={14} />
Back to usage events
</Link>
<p className="mt-4 text-[11px] font-medium uppercase tracking-[0.28em] text-slate-400">
Usage event
</p>
<h1 className="mt-3 text-[2rem] font-semibold tracking-[-0.04em] text-slate-900">
{formatRole(usageEvent?.event_type)} event
</h1>
<p className="mt-3 max-w-3xl text-sm leading-6 text-slate-500">
Inspect token counts, model/provider metadata, and the linked records that produced this activity.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Link
className={`${actionButtonClassName} border-slate-200 bg-white text-slate-700`}
href="/usage_events/usage_events-list"
>
Close
</Link>
<Link
className={`${actionButtonClassName} border-slate-900 bg-slate-900 text-white`}
href={`/usage_events/usage_events-edit/?id=${id}`}
/>
</SectionTitleLineWithButton>
<CardBox>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>User</p>
<p>{usage_events?.user?.firstName ?? 'No data'}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Conversation</p>
<p>{usage_events?.conversation?.title ?? 'No data'}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Message</p>
<p>{usage_events?.message?.content ?? 'No data'}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Agent</p>
<p>{usage_events?.agent?.name ?? 'No data'}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Eventtype</p>
<p>{usage_events?.event_type ?? 'No data'}</p>
</div>
<FormField label='Occurredat'>
{usage_events.occurred_at ? <DatePicker
dateFormat="yyyy-MM-dd hh:mm"
showTimeSelect
selected={usage_events.occurred_at ?
new Date(
dayjs(usage_events.occurred_at).format('YYYY-MM-DD hh:mm'),
) : null
}
disabled
/> : <p>No Occurredat</p>}
</FormField>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Inputtokens</p>
<p>{usage_events?.input_tokens || 'No data'}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Outputtokens</p>
<p>{usage_events?.output_tokens || 'No data'}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Totaltokens</p>
<p>{usage_events?.total_tokens || 'No data'}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>CostUSD</p>
<p>{usage_events?.cost_usd || 'No data'}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Provider</p>
<p>{usage_events?.provider}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Model</p>
<p>{usage_events?.model}</p>
</div>
<FormField label='Multi Text' hasTextareaHeight>
<textarea className={'w-full'} disabled value={usage_events?.metadata_json} />
</FormField>
<BaseDivider />
<BaseButton
color='info'
label='Back'
onClick={() => router.push('/usage_events/usage_events-list')}
/>
</CardBox>
</SectionMain>
</>
);
>
<BaseIcon className="mr-1" path={mdiPencilOutline} size={16} />
Edit usage event
</Link>
</div>
</div>
</div>
{loading && !usageEvent && (
<div className="rounded-[12px] border border-slate-200 bg-white px-6 py-10">
<LoadingSpinner />
</div>
)}
{!loading && !usageEvent && (
<EntityEmptyState
description="Try going back to the usage events list and opening this record again."
title="We could not load this usage event."
/>
)}
{usageEvent && (
<div className="grid gap-5 2xl:grid-cols-[minmax(0,1fr)_320px]">
<div className="space-y-5">
<EntitySection
description="Core counters and provider details recorded for this activity entry."
icon={mdiTimelineTextOutline}
title="Overview"
>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<EntityValueCard label="Event type" value={formatRole(usageEvent.event_type)} />
<EntityValueCard label="Occurred" value={formatDateTime(usageEvent.occurred_at)} />
<EntityValueCard label="Provider" value={usageEvent.provider || 'No provider'} />
<EntityValueCard label="Model" value={usageEvent.model || 'No model'} />
<EntityValueCard label="Input tokens" value={formatTokens(usageEvent.input_tokens)} />
<EntityValueCard label="Output tokens" value={formatTokens(usageEvent.output_tokens)} />
<EntityValueCard label="Total tokens" value={formatTokens(usageEvent.total_tokens)} />
<EntityValueCard label="Cost" value={formatMoney(usageEvent.cost_usd)} />
</div>
</EntitySection>
<EntitySection
description="Raw structured metadata captured with the usage entry."
icon={mdiWrenchOutline}
title="Metadata"
>
<div className="rounded-[10px] border border-slate-200 bg-slate-50 px-4 py-4">
<pre className="whitespace-pre-wrap break-words text-[13px] leading-6 text-slate-700">
{usageEvent.metadata_json || 'No metadata saved.'}
</pre>
</div>
</EntitySection>
</div>
<div className="space-y-5">
<EntityAsideCard title="Linked records">
<div className="space-y-3">
<EntityLinkCard
description={usageEvent?.user?.email || 'Event owner'}
href={usageEvent?.user?.id ? `/users/users-view/?id=${usageEvent.user.id}` : undefined}
icon={mdiAccountOutline}
label="User"
value={formatName(usageEvent?.user)}
/>
<EntityLinkCard
description={usageEvent?.conversation?.status ? `${usageEvent.conversation.status} conversation` : 'Parent conversation'}
href={usageEvent?.conversation?.id ? `/conversations/conversations-view/?id=${usageEvent.conversation.id}` : undefined}
icon={mdiMessageTextOutline}
label="Conversation"
value={usageEvent?.conversation?.title || 'No conversation'}
/>
<EntityLinkCard
description="Source message"
href={usageEvent?.message?.id ? `/messages/messages-view/?id=${usageEvent.message.id}` : undefined}
icon={mdiOpenInNew}
label="Message"
value={usageEvent?.message?.content || 'No message'}
/>
<EntityLinkCard
description="Assigned agent"
href={usageEvent?.agent?.id ? `/agents/agents-view/?id=${usageEvent.agent.id}` : undefined}
icon={mdiRobotOutline}
label="Agent"
value={usageEvent?.agent?.name || 'No agent'}
/>
</div>
</EntityAsideCard>
<EntityAsideCard title="At a glance">
<div className="space-y-3">
<div className="rounded-[10px] border border-slate-200 px-4 py-3">
<div className="flex items-center gap-2 text-slate-500">
<BaseIcon path={mdiClockOutline} size={16} />
<p className="text-[12px] font-medium uppercase tracking-[0.16em]">Occurred</p>
</div>
<p className="mt-2 text-[15px] font-medium text-slate-900">{formatDateTime(usageEvent.occurred_at)}</p>
</div>
<div className="rounded-[10px] border border-slate-200 px-4 py-3">
<div className="flex items-center gap-2 text-slate-500">
<BaseIcon path={mdiCurrencyUsd} size={16} />
<p className="text-[12px] font-medium uppercase tracking-[0.16em]">Cost</p>
</div>
<p className="mt-2 text-[15px] font-medium text-slate-900">{formatMoney(usageEvent.cost_usd)}</p>
</div>
</div>
</EntityAsideCard>
</div>
</div>
)}
</div>
</SectionMain>
</>
);
};
Usage_eventsView.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated
permission={'READ_USAGE_EVENTS'}
>
{page}
</LayoutAuthenticated>
)
}
return <LayoutAuthenticated permission="READ_USAGE_EVENTS">{page}</LayoutAuthenticated>;
};
export default Usage_eventsView;
export default Usage_eventsView;

View File

@ -1,699 +1,304 @@
import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'
import Head from 'next/head'
import React, { ReactElement, useEffect, useState } from 'react'
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
import dayjs from "dayjs";
import {
mdiAccountOutline,
mdiArrowLeft,
mdiKeyVariant,
mdiPencilOutline,
mdiShieldOutline,
mdiUpload,
} from '@mdi/js';
import Head from 'next/head';
import Link from 'next/link';
import React, { ReactElement, useEffect, useState } from 'react';
import { Field, Form, Formik } from 'formik';
import CardBox from '../../components/CardBox'
import LayoutAuthenticated from '../../layouts/Authenticated'
import SectionMain from '../../components/SectionMain'
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
import { getPageTitle } from '../../config'
import BaseIcon from '../../components/BaseIcon';
import {
actionButtonClassName,
EntityAsideCard,
EntityIntro,
EntitySection,
formatName,
inputClassName,
} from '../../components/AdminEntity/PageKit';
import FormImagePicker from '../../components/FormImagePicker';
import { SelectField } from '../../components/SelectField';
import { SelectFieldMany } from '../../components/SelectFieldMany';
import { SwitchField } from '../../components/SwitchField';
import SectionMain from '../../components/SectionMain';
import { getPageTitle } from '../../config';
import LayoutAuthenticated from '../../layouts/Authenticated';
import { fetch, update } from '../../stores/users/usersSlice';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { useRouter } from 'next/router';
import { Field, Form, Formik } from 'formik'
import FormField from '../../components/FormField'
import BaseDivider from '../../components/BaseDivider'
import BaseButtons from '../../components/BaseButtons'
import BaseButton from '../../components/BaseButton'
import FormCheckRadio from '../../components/FormCheckRadio'
import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'
import FormFilePicker from '../../components/FormFilePicker'
import FormImagePicker from '../../components/FormImagePicker'
import { SelectField } from "../../components/SelectField";
import { SelectFieldMany } from "../../components/SelectFieldMany";
import { SwitchField } from '../../components/SwitchField'
import {RichTextField} from "../../components/RichTextField";
const initialUserValues = {
app_role: null,
avatar: [],
custom_permissions: [],
disabled: false,
email: '',
firstName: '',
lastName: '',
password: '',
phoneNumber: '',
};
import { update, fetch } from '../../stores/users/usersSlice'
import { useAppDispatch, useAppSelector } from '../../stores/hooks'
import { useRouter } from 'next/router'
import {saveFile} from "../../helpers/fileSaver";
import dataFormatter from '../../helpers/dataFormatter';
import ImageField from "../../components/ImageField";
function normalizeUserValues(user: any) {
return {
app_role: user?.app_role || null,
avatar: user?.avatar || [],
custom_permissions: user?.custom_permissions || [],
disabled: Boolean(user?.disabled),
email: user?.email || '',
firstName: user?.firstName || '',
lastName: user?.lastName || '',
password: '',
phoneNumber: user?.phoneNumber || '',
};
}
function getAvatarUrl(values: typeof initialUserValues) {
if (!Array.isArray(values.avatar) || values.avatar.length === 0) {
return '';
}
return values.avatar[0]?.publicUrl || '';
}
function getInitials(values: typeof initialUserValues) {
const fullName = [values.firstName, values.lastName].filter(Boolean).join(' ').trim();
if (!fullName) {
return 'U';
}
return fullName
.split(' ')
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() || '')
.join('');
}
const EditUsersPage = () => {
const router = useRouter()
const dispatch = useAppDispatch()
const initVals = {
'firstName': '',
'lastName': '',
'phoneNumber': '',
'email': '',
disabled: false,
avatar: [],
app_role: null,
custom_permissions: [],
password: ''
}
const [initialValues, setInitialValues] = useState(initVals)
const { users } = useAppSelector((state) => state.users)
const { id } = router.query
const router = useRouter();
const dispatch = useAppDispatch();
const { users, loading } = useAppSelector((state) => state.users);
const { id } = router.query;
const [initialValues, setInitialValues] = useState(initialUserValues);
useEffect(() => {
dispatch(fetch({ id: id }))
}, [id])
useEffect(() => {
if (typeof users === 'object') {
setInitialValues(users)
if (typeof id !== 'string') {
return;
}
}, [users])
dispatch(fetch({ id }));
}, [dispatch, id]);
useEffect(() => {
if (typeof users === 'object') {
const newInitialVal = {...initVals};
Object.keys(initVals).forEach(el => newInitialVal[el] = (users)[el])
setInitialValues(newInitialVal);
}
}, [users])
if (!users || Array.isArray(users) || typeof users !== 'object') {
return;
}
const handleSubmit = async (data) => {
await dispatch(update({ id: id, data }))
await router.push('/users/users-list')
}
setInitialValues(normalizeUserValues(users));
}, [users]);
const handleSubmit = async (data: typeof initialUserValues) => {
if (typeof id !== 'string') {
return;
}
await dispatch(update({ id, data }));
await router.push(`/users/users-view/?id=${id}`);
};
return (
<>
<Head>
<title>{getPageTitle('Edit users')}</title>
<title>{getPageTitle('Edit user')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={'Edit users'} main>
{''}
</SectionTitleLineWithButton>
<CardBox>
<Formik
enableReinitialize
initialValues={initialValues}
onSubmit={(values) => handleSubmit(values)}
>
<Form>
<FormField
label="First Name"
>
<Field
name="firstName"
placeholder="First Name"
/>
</FormField>
<FormField
label="Last Name"
>
<Field
name="lastName"
placeholder="Last Name"
/>
</FormField>
<FormField
label="Phone Number"
>
<Field
name="phoneNumber"
placeholder="Phone Number"
/>
</FormField>
<FormField
label="E-Mail"
>
<Field
name="email"
placeholder="E-Mail"
/>
</FormField>
<FormField label='Disabled' labelFor='disabled'>
<Field
name='disabled'
id='disabled'
component={SwitchField}
></Field>
</FormField>
<FormField>
<Field
label='Avatar'
color='info'
icon={mdiUpload}
path={'users/avatar'}
name='avatar'
id='avatar'
schema={{
size: undefined,
formats: undefined,
}}
component={FormImagePicker}
></Field>
</FormField>
<FormField label='App Role' labelFor='app_role'>
<Field
name='app_role'
id='app_role'
component={SelectField}
options={initialValues.app_role}
itemRef={'roles'}
showField={'name'}
></Field>
</FormField>
<FormField label='Custom Permissions' labelFor='custom_permissions'>
<Field
name='custom_permissions'
id='custom_permissions'
component={SelectFieldMany}
options={initialValues.custom_permissions}
itemRef={'permissions'}
showField={'name'}
></Field>
</FormField>
<FormField
label="Password"
>
<Field
name="password"
placeholder="password"
/>
</FormField>
<BaseDivider />
<BaseButtons>
<BaseButton type="submit" color="info" label="Submit" />
<BaseButton type="reset" color="info" outline label="Reset" />
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/users/users-list')}/>
</BaseButtons>
</Form>
</Formik>
</CardBox>
<Formik enableReinitialize initialValues={initialValues} onSubmit={(values) => handleSubmit(values)}>
{({ isSubmitting, values }) => {
const avatarUrl = getAvatarUrl(values);
const initials = getInitials(values);
const isUserLoading = loading && !initialValues.email && !initialValues.firstName;
return (
<Form>
<div className="flex w-full flex-col gap-5">
<EntityIntro
backHref={typeof id === 'string' ? `/users/users-view/?id=${id}` : '/users/users-list'}
backLabel="Back to user"
description="Update identity, avatar, role assignment, and custom access overrides in one clean editing surface."
kicker="User"
title="Refine this user."
/>
<div className="grid gap-5 2xl:grid-cols-[minmax(0,1fr)_320px]">
<div className="space-y-5">
<EntitySection
description="Basic identity fields people will see across the workspace."
icon={mdiAccountOutline}
title="Identity"
>
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className="mb-2 block text-[13px] font-medium text-slate-700" htmlFor="firstName">
First name
</label>
<Field className={inputClassName} id="firstName" name="firstName" />
</div>
<div>
<label className="mb-2 block text-[13px] font-medium text-slate-700" htmlFor="lastName">
Last name
</label>
<Field className={inputClassName} id="lastName" name="lastName" />
</div>
<div>
<label className="mb-2 block text-[13px] font-medium text-slate-700" htmlFor="phoneNumber">
Phone number
</label>
<Field className={inputClassName} id="phoneNumber" name="phoneNumber" />
</div>
<div>
<label className="mb-2 block text-[13px] font-medium text-slate-700" htmlFor="email">
Email
</label>
<Field className={inputClassName} id="email" name="email" type="email" />
</div>
</div>
</EntitySection>
<EntitySection
description="Keep the avatar current so the workspace and navbar reflect the right profile image."
icon={mdiUpload}
title="Avatar"
>
<Field
color="info"
component={FormImagePicker}
icon={mdiUpload}
id="avatar"
label="Avatar"
name="avatar"
path="users/avatar"
schema={{ formats: undefined, size: undefined }}
/>
</EntitySection>
<EntitySection
description="Control account status, role assignment, and any custom permission overrides."
icon={mdiShieldOutline}
title="Access"
>
<div className="grid gap-4">
<div className="rounded-[10px] border border-slate-200 px-4 py-3">
<label className="mb-3 block text-[13px] font-medium text-slate-700" htmlFor="disabled">
Disabled
</label>
<Field component={SwitchField} id="disabled" name="disabled" />
</div>
<div>
<label className="mb-2 block text-[13px] font-medium text-slate-700" htmlFor="app_role">
App role
</label>
<Field
component={SelectField}
id="app_role"
itemRef="roles"
name="app_role"
options={values.app_role}
showField="name"
/>
</div>
<div>
<label className="mb-2 block text-[13px] font-medium text-slate-700" htmlFor="custom_permissions">
Custom permissions
</label>
<Field
component={SelectFieldMany}
id="custom_permissions"
itemRef="permissions"
name="custom_permissions"
options={values.custom_permissions}
showField="name"
/>
</div>
</div>
</EntitySection>
<EntitySection
description="Set a new password only when you intentionally want to rotate credentials."
icon={mdiKeyVariant}
title="Security"
>
<div>
<label className="mb-2 block text-[13px] font-medium text-slate-700" htmlFor="password">
New password
</label>
<Field className={inputClassName} id="password" name="password" type="password" />
</div>
</EntitySection>
</div>
<div className="space-y-5">
<EntityAsideCard title="Preview">
<div className="flex items-start gap-3">
{avatarUrl ? (
<img
alt={formatName(values)}
className="h-14 w-14 rounded-[12px] border border-slate-200 object-cover"
src={avatarUrl}
/>
) : (
<div className="inline-flex h-14 w-14 items-center justify-center rounded-[12px] border border-slate-200 bg-slate-50 text-[18px] font-semibold text-slate-700">
{initials}
</div>
)}
<div className="min-w-0">
<p className="truncate text-[16px] font-semibold text-slate-900">
{formatName(values)}
</p>
<p className="mt-1 text-[13px] text-slate-500">{values.email || 'No email set'}</p>
<p className="mt-1 text-[13px] text-slate-500">
{values.app_role?.name || 'No role'} · {values.disabled ? 'Disabled' : 'Active'}
</p>
</div>
</div>
</EntityAsideCard>
<EntityAsideCard title="Actions">
{isUserLoading && <p className="text-[13px] text-slate-500">Loading user</p>}
<div className="flex flex-wrap gap-2">
<Link
className={`${actionButtonClassName} border-slate-200 bg-white text-slate-700`}
href={typeof id === 'string' ? `/users/users-view/?id=${id}` : '/users/users-list'}
>
Cancel
</Link>
<button
className={`${actionButtonClassName} border-slate-900 bg-slate-900 text-white`}
disabled={isSubmitting || isUserLoading}
type="submit"
>
{isSubmitting ? 'Saving…' : 'Save changes'}
</button>
</div>
</EntityAsideCard>
</div>
</div>
</div>
</Form>
);
}}
</Formik>
</SectionMain>
</>
)
}
);
};
EditUsersPage.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated
permission={'UPDATE_USERS'}
>
{page}
</LayoutAuthenticated>
)
}
return <LayoutAuthenticated permission="UPDATE_USERS">{page}</LayoutAuthenticated>;
};
export default EditUsersPage
export default EditUsersPage;

View File

@ -1,502 +1,266 @@
import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js'
import Head from 'next/head'
import React, { ReactElement } from 'react'
import CardBox from '../../components/CardBox'
import LayoutAuthenticated from '../../layouts/Authenticated'
import SectionMain from '../../components/SectionMain'
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
import { getPageTitle } from '../../config'
import {
mdiAccountOutline,
mdiKeyVariant,
mdiShieldOutline,
mdiUpload,
} from '@mdi/js';
import Head from 'next/head';
import Link from 'next/link';
import React, { ReactElement } from 'react';
import { Field, Form, Formik } from 'formik';
import { Field, Form, Formik } from 'formik'
import FormField from '../../components/FormField'
import BaseDivider from '../../components/BaseDivider'
import BaseButtons from '../../components/BaseButtons'
import BaseButton from '../../components/BaseButton'
import FormCheckRadio from '../../components/FormCheckRadio'
import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'
import FormFilePicker from '../../components/FormFilePicker'
import FormImagePicker from '../../components/FormImagePicker'
import { SwitchField } from '../../components/SwitchField'
import { SelectField } from '../../components/SelectField'
import { SelectFieldMany } from "../../components/SelectFieldMany";
import {RichTextField} from "../../components/RichTextField";
import { create } from '../../stores/users/usersSlice'
import { useAppDispatch } from '../../stores/hooks'
import { useRouter } from 'next/router'
import moment from 'moment';
import {
actionButtonClassName,
EntityAsideCard,
EntityIntro,
EntitySection,
formatName,
inputClassName,
} from '../../components/AdminEntity/PageKit';
import FormImagePicker from '../../components/FormImagePicker';
import SectionMain from '../../components/SectionMain';
import { SelectField } from '../../components/SelectField';
import { SelectFieldMany } from '../../components/SelectFieldMany';
import { SwitchField } from '../../components/SwitchField';
import { getPageTitle } from '../../config';
import LayoutAuthenticated from '../../layouts/Authenticated';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { create } from '../../stores/users/usersSlice';
import { useRouter } from 'next/router';
const initialValues = {
firstName: '',
lastName: '',
phoneNumber: '',
email: '',
disabled: false,
avatar: [],
app_role: '',
custom_permissions: [],
app_role: null,
avatar: [],
custom_permissions: [],
disabled: false,
email: '',
firstName: '',
lastName: '',
password: '',
phoneNumber: '',
};
function getAvatarUrl(values: typeof initialValues) {
if (!Array.isArray(values.avatar) || values.avatar.length === 0) {
return '';
}
return values.avatar[0]?.publicUrl || '';
}
function getInitials(values: typeof initialValues) {
const fullName = [values.firstName, values.lastName].filter(Boolean).join(' ').trim();
if (!fullName) {
return 'U';
}
return fullName
.split(' ')
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() || '')
.join('');
}
const UsersNew = () => {
const router = useRouter()
const dispatch = useAppDispatch()
const router = useRouter();
const dispatch = useAppDispatch();
const { loading } = useAppSelector((state) => state.users);
const handleSubmit = async (data: typeof initialValues) => {
await dispatch(create(data)).unwrap();
await router.push('/users/users-list');
};
const handleSubmit = async (data) => {
await dispatch(create(data))
await router.push('/users/users-list')
}
return (
<>
<Head>
<title>{getPageTitle('New Item')}</title>
<title>{getPageTitle('Create user')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main>
{''}
</SectionTitleLineWithButton>
<CardBox>
<Formik
initialValues={
initialValues
}
onSubmit={(values) => handleSubmit(values)}
>
<Form>
<FormField
label="First Name"
>
<Field
name="firstName"
placeholder="First Name"
/>
</FormField>
<FormField
label="Last Name"
>
<Field
name="lastName"
placeholder="Last Name"
/>
</FormField>
<FormField
label="Phone Number"
>
<Field
name="phoneNumber"
placeholder="Phone Number"
/>
</FormField>
<FormField
label="E-Mail"
>
<Field
name="email"
placeholder="E-Mail"
/>
</FormField>
<FormField label='Disabled' labelFor='disabled'>
<Field
name='disabled'
id='disabled'
component={SwitchField}
></Field>
</FormField>
<FormField>
<Field
label='Avatar'
color='info'
icon={mdiUpload}
path={'users/avatar'}
name='avatar'
id='avatar'
schema={{
size: undefined,
formats: undefined,
<Formik initialValues={initialValues} onSubmit={(values) => handleSubmit(values)}>
{({ isSubmitting, values }) => {
const avatarUrl = getAvatarUrl(values);
const initials = getInitials(values);
return (
<Form>
<div className="flex w-full flex-col gap-5">
<EntityIntro
backHref="/users/users-list"
backLabel="Back to users"
description="Set identity, access, avatar, and an initial password in one clean place before the user enters the workspace."
kicker="User"
title="Create a new user."
/>
<div className="grid gap-5 2xl:grid-cols-[minmax(0,1fr)_320px]">
<div className="space-y-5">
<EntitySection
description="Basic identity fields that appear across the workspace and admin area."
icon={mdiAccountOutline}
title="Identity"
>
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className="mb-2 block text-[13px] font-medium text-slate-700" htmlFor="firstName">
First name
</label>
<Field className={inputClassName} id="firstName" name="firstName" />
</div>
<div>
<label className="mb-2 block text-[13px] font-medium text-slate-700" htmlFor="lastName">
Last name
</label>
<Field className={inputClassName} id="lastName" name="lastName" />
</div>
<div>
<label className="mb-2 block text-[13px] font-medium text-slate-700" htmlFor="phoneNumber">
Phone number
</label>
<Field className={inputClassName} id="phoneNumber" name="phoneNumber" />
</div>
<div>
<label className="mb-2 block text-[13px] font-medium text-slate-700" htmlFor="email">
Email
</label>
<Field className={inputClassName} id="email" name="email" type="email" />
</div>
</div>
</EntitySection>
<EntitySection
description="Upload a profile image so the navbar, chat, and workspace feel personal from the start."
icon={mdiUpload}
title="Avatar"
>
<Field
color="info"
component={FormImagePicker}
icon={mdiUpload}
id="avatar"
label="Avatar"
name="avatar"
path="users/avatar"
schema={{ formats: undefined, size: undefined }}
/>
</EntitySection>
<EntitySection
description="Control whether the user is active, which role they inherit, and which custom overrides apply."
icon={mdiShieldOutline}
title="Access"
>
<div className="grid gap-4">
<div className="rounded-[10px] border border-slate-200 px-4 py-3">
<label className="mb-3 block text-[13px] font-medium text-slate-700" htmlFor="disabled">
Disabled
</label>
<Field component={SwitchField} id="disabled" name="disabled" />
</div>
<div>
<label className="mb-2 block text-[13px] font-medium text-slate-700" htmlFor="app_role">
App role
</label>
<Field
component={SelectField}
id="app_role"
itemRef="roles"
name="app_role"
options={values.app_role}
showField="name"
/>
</div>
<div>
<label className="mb-2 block text-[13px] font-medium text-slate-700" htmlFor="custom_permissions">
Custom permissions
</label>
<Field
component={SelectFieldMany}
id="custom_permissions"
itemRef="permissions"
name="custom_permissions"
options={values.custom_permissions}
showField="name"
/>
</div>
</div>
</EntitySection>
<EntitySection
description="Set the first password now so the account is ready to sign in immediately."
icon={mdiKeyVariant}
title="Security"
>
<div>
<label className="mb-2 block text-[13px] font-medium text-slate-700" htmlFor="password">
Initial password
</label>
<Field className={inputClassName} id="password" name="password" type="password" />
</div>
</EntitySection>
</div>
<div className="space-y-5">
<EntityAsideCard title="Preview">
<div className="flex items-start gap-3">
{avatarUrl ? (
<img
alt={formatName(values)}
className="h-14 w-14 rounded-[12px] border border-slate-200 object-cover"
src={avatarUrl}
/>
) : (
<div className="inline-flex h-14 w-14 items-center justify-center rounded-[12px] border border-slate-200 bg-slate-50 text-[18px] font-semibold text-slate-700">
{initials}
</div>
)}
<div className="min-w-0">
<p className="truncate text-[16px] font-semibold text-slate-900">
{formatName(values)}
</p>
<p className="mt-1 text-[13px] text-slate-500">{values.email || 'No email set yet'}</p>
<p className="mt-1 text-[13px] text-slate-500">
{values.app_role?.name || 'Default role'} · {values.disabled ? 'Disabled' : 'Active'}
</p>
</div>
</div>
</EntityAsideCard>
<EntityAsideCard title="Actions">
<p className="text-[13px] text-slate-500">
Save the account once identity, access, and password look right.
</p>
<div className="mt-3 flex flex-wrap gap-2">
<Link
className={`${actionButtonClassName} border-slate-200 bg-white text-slate-700`}
href="/users/users-list"
>
Cancel
</Link>
<button
className={`${actionButtonClassName} border-slate-900 bg-slate-900 text-white`}
disabled={isSubmitting || loading}
type="submit"
>
{isSubmitting || loading ? 'Creating…' : 'Create user'}
</button>
</div>
</EntityAsideCard>
</div>
</div>
</div>
</Form>
);
}}
component={FormImagePicker}
></Field>
</FormField>
<FormField label="App Role" labelFor="app_role">
<Field name="app_role" id="app_role" component={SelectField} options={[]} itemRef={'roles'}></Field>
</FormField>
<FormField label='Custom Permissions' labelFor='custom_permissions'>
<Field
name='custom_permissions'
id='custom_permissions'
itemRef={'permissions'}
options={[]}
component={SelectFieldMany}>
</Field>
</FormField>
<BaseDivider />
<BaseButtons>
<BaseButton type="submit" color="info" label="Submit" />
<BaseButton type="reset" color="info" outline label="Reset" />
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/users/users-list')}/>
</BaseButtons>
</Form>
</Formik>
</CardBox>
</Formik>
</SectionMain>
</>
)
}
);
};
UsersNew.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated
permission={'CREATE_USERS'}
>
{page}
</LayoutAuthenticated>
)
}
return <LayoutAuthenticated permission="CREATE_USERS">{page}</LayoutAuthenticated>;
};
export default UsersNew
export default UsersNew;

File diff suppressed because it is too large Load Diff