7
This commit is contained in:
parent
eb455b41dd
commit
83cdb092cd
@ -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"
|
||||
|
||||
183
backend/src/db/migrations/20260515000000-create-subscriptions.js
Normal file
183
backend/src/db/migrations/20260515000000-create-subscriptions.js
Normal 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
46
backend/src/db/models/billing_settings.js
Normal file
46
backend/src/db/models/billing_settings.js
Normal 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;
|
||||
};
|
||||
88
backend/src/db/models/subscription_plans.js
Normal file
88
backend/src/db/models/subscription_plans.js
Normal 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;
|
||||
};
|
||||
73
backend/src/db/models/subscriptions.js
Normal file
73
backend/src/db/models/subscriptions.js
Normal 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;
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
88
backend/src/db/seeders/20260515010000-subscription-plans.js
Normal file
88
backend/src/db/seeders/20260515010000-subscription-plans.js
Normal 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, {});
|
||||
},
|
||||
};
|
||||
@ -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',
|
||||
|
||||
73
backend/src/routes/billing.js
Normal file
73
backend/src/routes/billing.js
Normal 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;
|
||||
20
backend/src/routes/billingWebhook.js
Normal file
20
backend/src/routes/billingWebhook.js
Normal 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;
|
||||
857
backend/src/services/billing.js
Normal file
857
backend/src/services/billing.js
Normal 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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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',
|
||||
});
|
||||
|
||||
|
||||
1341
backend/yarn.lock
1341
backend/yarn.lock
File diff suppressed because it is too large
Load Diff
240
frontend/src/components/AdminEntity/PageKit.tsx
Normal file
240
frontend/src/components/AdminEntity/PageKit.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
222
frontend/src/components/Agents/AgentFormSections.tsx
Normal file
222
frontend/src/components/Agents/AgentFormSections.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 ${
|
||||
|
||||
122
frontend/src/components/Billing/PricingPlanCard.tsx
Normal file
122
frontend/src/components/Billing/PricingPlanCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 mode—pages 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;
|
||||
@ -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>
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
@ -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}
|
||||
|
||||
128
frontend/src/lib/billingPlans.ts
Normal file
128
frontend/src/lib/billingPlans.ts
Normal 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));
|
||||
}
|
||||
@ -80,6 +80,11 @@ const menuAside: MenuAsideItem[] = [
|
||||
label: 'Settings',
|
||||
icon: icon.mdiAccountCircle,
|
||||
},
|
||||
{
|
||||
href: '/billing',
|
||||
label: 'Billing',
|
||||
icon: icon.mdiCreditCardOutline,
|
||||
},
|
||||
{
|
||||
label: 'Admin',
|
||||
icon: icon.mdiCogOutline,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
@ -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;
|
||||
|
||||
274
frontend/src/pages/billing.tsx
Normal file
274
frontend/src/pages/billing.tsx
Normal 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
@ -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
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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"
|
||||
|
||||
306
frontend/src/pages/stripe-settings.tsx
Normal file
306
frontend/src/pages/stripe-settings.tsx
Normal 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
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user