✅ Backend Implementation: - Add Azure AD JWT token validation middleware - Create hybrid authentication system supporting both Azure AD and password auth - Implement auto-provisioning for new Azure AD users - Add admin controls to toggle password authentication - Update all API routes to use hybrid authentication - Add database fields for authentication (password, lastLoginAt) - Create comprehensive auth routes with validation endpoints ✅ Frontend Implementation: - Install and configure Azure MSAL browser library - Create Azure AD authentication service with popup/redirect support - Build hybrid authentication service managing both auth methods - Update Login.vue with modern dual-authentication UI - Implement dynamic password auth toggle based on admin settings - Update App.vue for proper session management and validation - Modify API service to handle both token types ✅ Security Features: - Azure AD tenant validation (Oliver Agency) - Role-based access control with auto-admin assignment - JWT token validation for both auth methods - Automatic user provisioning with proper defaults - Session validation and automatic logout on token expiry ✅ Admin Features: - Toggle password authentication on/off - Manage users from both authentication methods - Full role and agent access control - Azure AD user auto-provisioning as regular users ✅ Configuration: - Azure AD: Tenant e519c2e6-bc6d-4fdf-8d9c-923c2f002385 - Client ID: 9079054c-9620-4757-a256-23413042f1ef - Development redirect URI support - Fallback password authentication for testing 🔧 Technical Stack: - Azure MSAL Browser & Node libraries - JWT token validation and hybrid middleware - Database schema updates with migrations - Vue.js integration with MSAL - Express.js hybrid authentication routes 🚀 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
493 lines
No EOL
16 KiB
JavaScript
493 lines
No EOL
16 KiB
JavaScript
const express = require('express');
|
|
const multer = require('multer');
|
|
const { v4: uuidv4 } = require('uuid');
|
|
const { Assistant, Conversation, Message, User } = require('../models');
|
|
const openaiService = require('../utils/openai');
|
|
const responsesService = require('../utils/responses');
|
|
const { chatLimiter } = require('../middleware/rateLimiter');
|
|
const { hybridAuthenticate } = require('../middleware/azureAuth');
|
|
|
|
const router = express.Router();
|
|
|
|
// Configure multer for file uploads (store in memory)
|
|
const upload = multer({
|
|
storage: multer.memoryStorage(),
|
|
limits: {
|
|
fileSize: 10 * 1024 * 1024, // 10MB max file size
|
|
},
|
|
fileFilter: (req, file, cb) => {
|
|
// Accept images, PDFs, and text files
|
|
const allowedTypes = [
|
|
'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp',
|
|
'application/pdf', 'text/plain', 'application/msword',
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
|
];
|
|
|
|
if (allowedTypes.includes(file.mimetype)) {
|
|
cb(null, true);
|
|
} else {
|
|
cb(new Error('Invalid file type. Only images, PDFs, and text files are allowed.'));
|
|
}
|
|
}
|
|
});
|
|
|
|
router.use(chatLimiter);
|
|
|
|
// Helper function to generate title for first conversation
|
|
async function generateTitleIfNeeded(conversation, userMessage, assistantResponse, agentName) {
|
|
try {
|
|
// Check if this conversation needs a title (first exchange)
|
|
const messageCount = await Message.count({
|
|
where: { conversationId: conversation.id }
|
|
});
|
|
|
|
if (messageCount === 2 && !conversation.title) {
|
|
console.log(`Generating title for conversation ${conversation.id}`);
|
|
const title = await openaiService.generateConversationTitle(
|
|
userMessage,
|
|
assistantResponse,
|
|
agentName
|
|
);
|
|
|
|
await conversation.update({ title });
|
|
console.log(`Updated conversation ${conversation.id} with title: "${title}"`);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error generating conversation title:', error);
|
|
// Don't throw - title generation failure shouldn't break chat
|
|
}
|
|
}
|
|
|
|
router.post('/completions', hybridAuthenticate, upload.array('files', 5), async (req, res, next) => {
|
|
try {
|
|
// Handle form data parsing when files are present
|
|
let messages, assistantKey, conversationId, userId, stream;
|
|
|
|
if (req.files && req.files.length > 0) {
|
|
// Parse JSON fields from multipart form data
|
|
console.log('Processing request with files:', req.files.length);
|
|
console.log('Form data fields:', Object.keys(req.body));
|
|
|
|
messages = req.body.messages ? JSON.parse(req.body.messages) : undefined;
|
|
assistantKey = req.body.assistantKey || 'creator-bot-push-the-boundaries-of-technology';
|
|
conversationId = req.body.conversationId && req.body.conversationId !== 'null' ? req.body.conversationId : null;
|
|
userId = req.body.userId;
|
|
stream = req.body.stream === 'true';
|
|
|
|
console.log('Parsed data:', { assistantKey, conversationId, userId, stream, messageCount: messages?.length });
|
|
} else {
|
|
// Use regular JSON body when no files
|
|
({
|
|
messages,
|
|
assistantKey = 'creator-bot-push-the-boundaries-of-technology',
|
|
conversationId,
|
|
userId,
|
|
stream = false
|
|
} = req.body);
|
|
}
|
|
|
|
// Ensure we have a valid UUID for userId
|
|
if (!userId || typeof userId !== 'string' || !userId.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)) {
|
|
userId = uuidv4(); // Generate a proper UUID
|
|
console.log(`Generated new userId: ${userId}`);
|
|
}
|
|
|
|
// Ensure user exists or create one
|
|
let user = await User.findByPk(userId);
|
|
if (!user) {
|
|
user = await User.create({
|
|
id: userId,
|
|
email: `user-${userId}@temp.com`,
|
|
name: `User ${userId.substring(0, 8)}`,
|
|
preferences: { theme: 'light', notifications: true, defaultAssistant: assistantKey }
|
|
});
|
|
console.log(`Created new user: ${user.id}`);
|
|
}
|
|
|
|
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
|
return res.status(400).json({
|
|
error: 'Validation Error',
|
|
message: 'Messages array is required and cannot be empty'
|
|
});
|
|
}
|
|
|
|
const agent = await Assistant.findOne({
|
|
where: { key: assistantKey, isActive: true }
|
|
});
|
|
|
|
if (!agent) {
|
|
return res.status(404).json({
|
|
error: 'Agent Not Found',
|
|
message: `Agent with key '${assistantKey}' not found or inactive`
|
|
});
|
|
}
|
|
|
|
let conversation;
|
|
let conversationHistory = [];
|
|
|
|
if (conversationId) {
|
|
conversation = await Conversation.findByPk(conversationId);
|
|
if (!conversation) {
|
|
return res.status(404).json({
|
|
error: 'Conversation Not Found',
|
|
message: 'Specified conversation not found'
|
|
});
|
|
}
|
|
|
|
// Load existing conversation history for context
|
|
const existingMessages = await Message.findAll({
|
|
where: { conversationId },
|
|
order: [['createdAt', 'ASC']]
|
|
});
|
|
|
|
conversationHistory = existingMessages.map(msg => ({
|
|
role: msg.role,
|
|
content: msg.content
|
|
}));
|
|
|
|
console.log(`Loaded ${conversationHistory.length} existing messages for conversation ${conversationId}`);
|
|
} else {
|
|
conversation = await Conversation.create({
|
|
id: uuidv4(),
|
|
userId,
|
|
assistantId: agent.id,
|
|
status: 'active',
|
|
lastMessageAt: new Date(),
|
|
});
|
|
}
|
|
|
|
// Build complete message history: system prompt + conversation history + new messages
|
|
const formattedMessages = [
|
|
openaiService.buildSystemMessage(agent.systemPrompt),
|
|
...conversationHistory,
|
|
...openaiService.formatMessagesForAPI(messages)
|
|
];
|
|
|
|
// Handle file uploads for responses agents
|
|
let processedFiles = [];
|
|
if (req.files && req.files.length > 0 && agent.agentType === 'responses') {
|
|
try {
|
|
console.log(`Processing ${req.files.length} files for Responses API`);
|
|
processedFiles = await responsesService.processFiles(req.files);
|
|
} catch (error) {
|
|
console.error('File processing error:', error);
|
|
return res.status(400).json({
|
|
error: 'File Processing Error',
|
|
message: error.message
|
|
});
|
|
}
|
|
} else if (req.files && req.files.length > 0 && agent.agentType === 'chat') {
|
|
return res.status(400).json({
|
|
error: 'File Upload Not Supported',
|
|
message: 'File uploads are only supported for responses agents'
|
|
});
|
|
}
|
|
|
|
console.log(`Total messages for API: ${formattedMessages.length} (${conversationHistory.length} history + ${messages.length} new + 1 system)`);
|
|
console.log(`Agent type: ${agent.agentType} (${agent.agentType === 'responses' ? 'Responses API' : 'Chat Completions API'})`);
|
|
console.log(`Agent tools config:`, {
|
|
agentType: agent.agentType,
|
|
webSearchEnabled: agent.webSearchEnabled,
|
|
fileSearchEnabled: agent.fileSearchEnabled,
|
|
codeInterpreterEnabled: agent.codeInterpreterEnabled
|
|
});
|
|
console.log(`Processed files: ${processedFiles.length}`);
|
|
|
|
if (stream) {
|
|
res.setHeader('Content-Type', 'text/event-stream');
|
|
res.setHeader('Cache-Control', 'no-cache');
|
|
res.setHeader('Connection', 'keep-alive');
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
|
|
let fullResponse = '';
|
|
|
|
if (agent.agentType === 'responses') {
|
|
// Use Responses API for streaming
|
|
const userInput = messages[messages.length - 1].content;
|
|
|
|
await responsesService.createStreamingResponse(
|
|
userInput,
|
|
agent,
|
|
conversation.id,
|
|
userId,
|
|
(data) => {
|
|
if (data.content) {
|
|
fullResponse += data.content;
|
|
res.write(`data: ${JSON.stringify({ content: data.content, done: false })}\n\n`);
|
|
}
|
|
if (data.done) {
|
|
fullResponse = data.fullResponse || fullResponse;
|
|
}
|
|
},
|
|
{ attachments: processedFiles }
|
|
);
|
|
} else {
|
|
// Use Chat Completions API for streaming
|
|
const streamResponse = await openaiService.createStreamingResponse(
|
|
formattedMessages,
|
|
{
|
|
model: agent.model,
|
|
temperature: agent.temperature,
|
|
maxTokens: agent.maxTokens,
|
|
reasoningEffort: agent.reasoningEffort,
|
|
}
|
|
);
|
|
|
|
for await (const chunk of streamResponse) {
|
|
const delta = chunk.choices[0]?.delta?.content || '';
|
|
if (delta) {
|
|
fullResponse += delta;
|
|
res.write(`data: ${JSON.stringify({ content: delta, done: false })}\n\n`);
|
|
}
|
|
}
|
|
}
|
|
|
|
await Message.create({
|
|
conversationId: conversation.id,
|
|
role: 'user',
|
|
content: messages[messages.length - 1].content,
|
|
});
|
|
|
|
await Message.create({
|
|
conversationId: conversation.id,
|
|
role: 'assistant',
|
|
content: fullResponse,
|
|
model: agent.model,
|
|
metadata: {
|
|
assistantKey,
|
|
agentType: agent.agentType,
|
|
toolsEnabled: agent.agentType === 'responses' ? {
|
|
webSearch: agent.webSearchEnabled,
|
|
fileSearch: agent.fileSearchEnabled,
|
|
codeInterpreter: agent.codeInterpreterEnabled
|
|
} : {}
|
|
},
|
|
});
|
|
|
|
await conversation.update({ lastMessageAt: new Date() });
|
|
|
|
// Generate title for first conversation
|
|
await generateTitleIfNeeded(
|
|
conversation,
|
|
messages[messages.length - 1].content,
|
|
fullResponse,
|
|
agent.name
|
|
);
|
|
|
|
res.write(`data: ${JSON.stringify({
|
|
content: '',
|
|
done: true,
|
|
conversationId: conversation.id,
|
|
totalTokens: null
|
|
})}\n\n`);
|
|
res.end();
|
|
|
|
} else {
|
|
let response;
|
|
let assistantResponse;
|
|
|
|
if (agent.agentType === 'responses') {
|
|
// Use Responses API for non-streaming
|
|
const userInput = messages[messages.length - 1].content;
|
|
response = await responsesService.createResponse(
|
|
userInput,
|
|
agent,
|
|
conversation.id,
|
|
userId,
|
|
{ attachments: processedFiles }
|
|
);
|
|
|
|
// Extract text from Responses API format
|
|
const messageOutput = response.output?.find(item => item.type === 'message');
|
|
const textContent = messageOutput?.content?.find(content => content.type === 'output_text');
|
|
assistantResponse = textContent?.text || response.output_text || '';
|
|
} else {
|
|
// Use Chat Completions API for non-streaming
|
|
response = await openaiService.createResponse(
|
|
formattedMessages,
|
|
{
|
|
model: agent.model,
|
|
temperature: agent.temperature,
|
|
maxTokens: agent.maxTokens,
|
|
reasoningEffort: agent.reasoningEffort,
|
|
}
|
|
);
|
|
assistantResponse = response.choices[0].message.content;
|
|
}
|
|
|
|
await Message.create({
|
|
conversationId: conversation.id,
|
|
role: 'user',
|
|
content: messages[messages.length - 1].content,
|
|
});
|
|
|
|
await Message.create({
|
|
conversationId: conversation.id,
|
|
role: 'assistant',
|
|
content: assistantResponse,
|
|
model: agent.model,
|
|
tokenCount: response.usage?.output_tokens || response.usage?.completion_tokens,
|
|
finishReason: response.status === 'completed' ? 'stop' : response.status,
|
|
metadata: {
|
|
assistantKey,
|
|
agentType: agent.agentType,
|
|
responseId: agent.agentType === 'responses' ? response.id : undefined,
|
|
toolsEnabled: agent.agentType === 'responses' ? {
|
|
webSearch: agent.webSearchEnabled,
|
|
fileSearch: agent.fileSearchEnabled,
|
|
codeInterpreter: agent.codeInterpreterEnabled
|
|
} : {}
|
|
},
|
|
});
|
|
|
|
await conversation.update({ lastMessageAt: new Date() });
|
|
|
|
// Generate title for first conversation
|
|
await generateTitleIfNeeded(
|
|
conversation,
|
|
messages[messages.length - 1].content,
|
|
assistantResponse,
|
|
agent.name
|
|
);
|
|
|
|
res.json({
|
|
response: assistantResponse,
|
|
conversationId: conversation.id,
|
|
usage: response.usage,
|
|
model: agent.model,
|
|
finishReason: agent.agentType === 'responses' ? response.status : response.choices[0]?.finish_reason,
|
|
});
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Chat completion error:', error);
|
|
|
|
if (error.code === 'insufficient_quota') {
|
|
return res.status(402).json({
|
|
error: 'Quota Exceeded',
|
|
message: 'OpenAI API quota exceeded. Please check your billing.'
|
|
});
|
|
}
|
|
|
|
if (error.code === 'rate_limit_exceeded') {
|
|
return res.status(429).json({
|
|
error: 'Rate Limit Exceeded',
|
|
message: 'OpenAI API rate limit exceeded. Please try again later.'
|
|
});
|
|
}
|
|
|
|
error.code = 'OPENAI_API_ERROR';
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
router.get('/conversations', async (req, res, next) => {
|
|
try {
|
|
const { userId, limit = 20, offset = 0 } = req.query;
|
|
|
|
const whereClause = {};
|
|
if (userId) {
|
|
whereClause.userId = userId;
|
|
}
|
|
|
|
const conversations = await Conversation.findAll({
|
|
where: { ...whereClause, status: 'active' },
|
|
include: [
|
|
{
|
|
model: Assistant,
|
|
as: 'assistant',
|
|
attributes: ['key', 'name', 'description']
|
|
}
|
|
],
|
|
order: [['lastMessageAt', 'DESC']],
|
|
limit: parseInt(limit),
|
|
offset: parseInt(offset),
|
|
});
|
|
|
|
res.json({
|
|
conversations: conversations.map(conv => ({
|
|
id: conv.id,
|
|
title: conv.title,
|
|
lastMessageAt: conv.lastMessageAt,
|
|
createdAt: conv.createdAt,
|
|
assistant: {
|
|
key: conv.assistant.key,
|
|
name: conv.assistant.name,
|
|
description: conv.assistant.description
|
|
}
|
|
})),
|
|
total: conversations.length
|
|
});
|
|
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
router.get('/conversations/:conversationId/messages', async (req, res, next) => {
|
|
try {
|
|
const { conversationId } = req.params;
|
|
const { limit = 50, offset = 0 } = req.query;
|
|
|
|
const conversation = await Conversation.findByPk(conversationId);
|
|
if (!conversation) {
|
|
return res.status(404).json({
|
|
error: 'Conversation Not Found',
|
|
message: 'Specified conversation not found'
|
|
});
|
|
}
|
|
|
|
const messages = await Message.findAll({
|
|
where: { conversationId },
|
|
order: [['createdAt', 'ASC']],
|
|
limit: parseInt(limit),
|
|
offset: parseInt(offset),
|
|
});
|
|
|
|
res.json({
|
|
messages: messages.map(msg => ({
|
|
id: msg.id,
|
|
role: msg.role,
|
|
content: msg.content,
|
|
createdAt: msg.createdAt,
|
|
metadata: msg.metadata,
|
|
})),
|
|
conversationId,
|
|
total: messages.length,
|
|
});
|
|
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
router.delete('/conversations/:conversationId', async (req, res, next) => {
|
|
try {
|
|
const { conversationId } = req.params;
|
|
|
|
const conversation = await Conversation.findByPk(conversationId);
|
|
if (!conversation) {
|
|
return res.status(404).json({
|
|
error: 'Conversation Not Found',
|
|
message: 'Specified conversation not found'
|
|
});
|
|
}
|
|
|
|
// Soft delete - change status to 'deleted' but preserve data
|
|
await conversation.update({
|
|
status: 'deleted',
|
|
updatedAt: new Date()
|
|
});
|
|
|
|
res.json({
|
|
message: 'Conversation deleted successfully',
|
|
conversationId: conversation.id
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('Error deleting conversation:', error);
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
module.exports = router; |