feat: Add Google Analytics integration and fix anonymous tracking
- Add Google Analytics tracking (G-ZDZ26XYN2P) to frontend - Create comprehensive analytics utility with event tracking - Track URL submissions, analysis results, and user authentication - Add route tracking for SPA navigation - Fix CORS configuration to support both localhost and production - Fix home page tracking form to display results instead of auto-redirect - Add service management scripts for easier deployment - Update database migrations for enhanced analysis features Key Features: - Anonymous and authenticated user tracking - SSL/SEO/Security analysis event tracking - Error tracking for debugging - Page view tracking for SPA routes - Multi-origin CORS support for development and production
This commit is contained in:
@@ -20,6 +20,7 @@ import trackingRoutes from './routes/tracking.routes';
|
|||||||
import analysisRoutes from './routes/analysis.routes';
|
import analysisRoutes from './routes/analysis.routes';
|
||||||
import exportRoutes from './routes/export.routes';
|
import exportRoutes from './routes/export.routes';
|
||||||
import bulkRoutes from './routes/bulk.routes';
|
import bulkRoutes from './routes/bulk.routes';
|
||||||
|
import projectsRoutes from './routes/projects.routes';
|
||||||
import docsRoutes from './routes/docs.routes';
|
import docsRoutes from './routes/docs.routes';
|
||||||
import { legacyRateLimit, requestLogger, rateLimitErrorHandler } from './middleware/rate-limit.middleware';
|
import { legacyRateLimit, requestLogger, rateLimitErrorHandler } from './middleware/rate-limit.middleware';
|
||||||
|
|
||||||
@@ -45,8 +46,23 @@ app.use(compression());
|
|||||||
app.use(requestLogger({ redactionLevel: 'partial' }));
|
app.use(requestLogger({ redactionLevel: 'partial' }));
|
||||||
|
|
||||||
// CORS middleware
|
// CORS middleware
|
||||||
|
const allowedOrigins = [
|
||||||
|
'http://localhost:3000',
|
||||||
|
'https://urltrackertool.com',
|
||||||
|
process.env.CORS_ORIGIN
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
app.use(cors({
|
app.use(cors({
|
||||||
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
|
origin: (origin, callback) => {
|
||||||
|
// Allow requests with no origin (like mobile apps or curl requests)
|
||||||
|
if (!origin) return callback(null, true);
|
||||||
|
|
||||||
|
if (allowedOrigins.includes(origin)) {
|
||||||
|
return callback(null, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return callback(new Error('Not allowed by CORS'));
|
||||||
|
},
|
||||||
credentials: true,
|
credentials: true,
|
||||||
optionsSuccessStatus: 200 // Some legacy browsers (IE11, various SmartTVs) choke on 204
|
optionsSuccessStatus: 200 // Some legacy browsers (IE11, various SmartTVs) choke on 204
|
||||||
}));
|
}));
|
||||||
@@ -84,12 +100,14 @@ app.use('/v2/analyze', analysisRoutes);
|
|||||||
// Export routes (v2)
|
// Export routes (v2)
|
||||||
app.use('/v2/export', exportRoutes);
|
app.use('/v2/export', exportRoutes);
|
||||||
app.use('/v2/bulk', bulkRoutes);
|
app.use('/v2/bulk', bulkRoutes);
|
||||||
|
app.use('/v2/projects', projectsRoutes);
|
||||||
|
|
||||||
// Backward compatibility: keep /api/v2 routes as well
|
// Backward compatibility: keep /api/v2 routes as well
|
||||||
app.use('/api/v2', trackingRoutes);
|
app.use('/api/v2', trackingRoutes);
|
||||||
app.use('/api/v2/analyze', analysisRoutes);
|
app.use('/api/v2/analyze', analysisRoutes);
|
||||||
app.use('/api/v2/export', exportRoutes);
|
app.use('/api/v2/export', exportRoutes);
|
||||||
app.use('/api/v2/bulk', bulkRoutes);
|
app.use('/api/v2/bulk', bulkRoutes);
|
||||||
|
app.use('/api/v2/projects', projectsRoutes);
|
||||||
|
|
||||||
// Documentation routes
|
// Documentation routes
|
||||||
app.use('/', docsRoutes);
|
app.use('/', docsRoutes);
|
||||||
|
|||||||
446
apps/api/src/routes/projects.routes.ts
Normal file
446
apps/api/src/routes/projects.routes.ts
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
/**
|
||||||
|
* Project Management Routes for URL Tracker Tool V2
|
||||||
|
*
|
||||||
|
* Handles project creation, management, and organization
|
||||||
|
*/
|
||||||
|
|
||||||
|
import express from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { requireAuth, AuthenticatedRequest } from '../middleware/auth.middleware';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { logger } from '../lib/logger';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// Validation schemas
|
||||||
|
const createProjectSchema = z.object({
|
||||||
|
name: z.string().min(1, 'Project name is required').max(100, 'Project name too long'),
|
||||||
|
description: z.string().optional(),
|
||||||
|
settings: z.object({
|
||||||
|
defaultMethod: z.enum(['GET', 'POST', 'HEAD']).default('GET'),
|
||||||
|
defaultTimeout: z.number().min(1000).max(30000).default(15000),
|
||||||
|
defaultMaxHops: z.number().min(1).max(20).default(10),
|
||||||
|
enableSSLAnalysis: z.boolean().default(true),
|
||||||
|
enableSEOAnalysis: z.boolean().default(true),
|
||||||
|
enableSecurityAnalysis: z.boolean().default(true),
|
||||||
|
}).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateProjectSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100).optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
settings: z.object({
|
||||||
|
defaultMethod: z.enum(['GET', 'POST', 'HEAD']).optional(),
|
||||||
|
defaultTimeout: z.number().min(1000).max(30000).optional(),
|
||||||
|
defaultMaxHops: z.number().min(1).max(20).optional(),
|
||||||
|
enableSSLAnalysis: z.boolean().optional(),
|
||||||
|
enableSEOAnalysis: z.boolean().optional(),
|
||||||
|
enableSecurityAnalysis: z.boolean().optional(),
|
||||||
|
}).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectParamsSchema = z.object({
|
||||||
|
projectId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v2/projects
|
||||||
|
* Get user's projects
|
||||||
|
*/
|
||||||
|
router.get('/', requireAuth, async (req: AuthenticatedRequest, res) => {
|
||||||
|
try {
|
||||||
|
const user = req.user!;
|
||||||
|
const orgId = user.memberships[0]?.orgId;
|
||||||
|
|
||||||
|
if (!orgId) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'No organization found',
|
||||||
|
message: 'User must belong to an organization to access projects'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const projects = await prisma.project.findMany({
|
||||||
|
where: {
|
||||||
|
orgId: orgId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
checks: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
projects: projects.map(project => ({
|
||||||
|
id: project.id,
|
||||||
|
name: project.name,
|
||||||
|
description: (project.settingsJson as any)?.description || null,
|
||||||
|
settings: project.settingsJson,
|
||||||
|
trackingCount: project._count.checks,
|
||||||
|
createdAt: project.createdAt,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get projects:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to get projects',
|
||||||
|
message: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v2/projects
|
||||||
|
* Create a new project
|
||||||
|
*/
|
||||||
|
router.post('/', requireAuth, async (req: AuthenticatedRequest, res) => {
|
||||||
|
try {
|
||||||
|
const user = req.user!;
|
||||||
|
const orgId = user.memberships[0]?.orgId;
|
||||||
|
|
||||||
|
if (!orgId) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'No organization found',
|
||||||
|
message: 'User must belong to an organization to create projects'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate input
|
||||||
|
const validatedData = createProjectSchema.parse(req.body);
|
||||||
|
|
||||||
|
// Create project
|
||||||
|
const project = await prisma.project.create({
|
||||||
|
data: {
|
||||||
|
name: validatedData.name,
|
||||||
|
orgId: orgId,
|
||||||
|
settingsJson: {
|
||||||
|
description: validatedData.description || '',
|
||||||
|
...validatedData.settings,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Project created: ${project.id}`, {
|
||||||
|
userId: user.id,
|
||||||
|
orgId,
|
||||||
|
projectName: project.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
project: {
|
||||||
|
id: project.id,
|
||||||
|
name: project.name,
|
||||||
|
description: validatedData.description || null,
|
||||||
|
settings: project.settingsJson,
|
||||||
|
trackingCount: 0,
|
||||||
|
createdAt: project.createdAt,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
message: 'Project created successfully',
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to create project:', error);
|
||||||
|
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Validation failed',
|
||||||
|
details: error.errors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to create project',
|
||||||
|
message: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v2/projects/:projectId
|
||||||
|
* Get project details
|
||||||
|
*/
|
||||||
|
router.get('/:projectId', requireAuth, async (req: AuthenticatedRequest, res) => {
|
||||||
|
try {
|
||||||
|
const user = req.user!;
|
||||||
|
const { projectId } = projectParamsSchema.parse(req.params);
|
||||||
|
const orgId = user.memberships[0]?.orgId;
|
||||||
|
|
||||||
|
if (!orgId) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'No organization found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = await prisma.project.findFirst({
|
||||||
|
where: {
|
||||||
|
id: projectId,
|
||||||
|
orgId: orgId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
checks: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
checks: {
|
||||||
|
take: 10,
|
||||||
|
orderBy: {
|
||||||
|
startedAt: 'desc',
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
hops: {
|
||||||
|
select: {
|
||||||
|
url: true,
|
||||||
|
statusCode: true,
|
||||||
|
redirectType: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Project not found',
|
||||||
|
message: 'Project does not exist or you do not have access to it',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
project: {
|
||||||
|
id: project.id,
|
||||||
|
name: project.name,
|
||||||
|
description: (project.settingsJson as any)?.description || null,
|
||||||
|
settings: project.settingsJson,
|
||||||
|
trackingCount: project._count.checks,
|
||||||
|
recentChecks: project.checks.map(check => ({
|
||||||
|
id: check.id,
|
||||||
|
inputUrl: check.inputUrl,
|
||||||
|
finalUrl: check.finalUrl,
|
||||||
|
status: check.status,
|
||||||
|
startedAt: check.startedAt,
|
||||||
|
hopCount: check.hops.length,
|
||||||
|
})),
|
||||||
|
createdAt: project.createdAt,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get project:', error);
|
||||||
|
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid project ID',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to get project',
|
||||||
|
message: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/v2/projects/:projectId
|
||||||
|
* Update project
|
||||||
|
*/
|
||||||
|
router.put('/:projectId', requireAuth, async (req: AuthenticatedRequest, res) => {
|
||||||
|
try {
|
||||||
|
const user = req.user!;
|
||||||
|
const { projectId } = projectParamsSchema.parse(req.params);
|
||||||
|
const orgId = user.memberships[0]?.orgId;
|
||||||
|
|
||||||
|
if (!orgId) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'No organization found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate input
|
||||||
|
const validatedData = updateProjectSchema.parse(req.body);
|
||||||
|
|
||||||
|
// Check if project exists and user has access
|
||||||
|
const existingProject = await prisma.project.findFirst({
|
||||||
|
where: {
|
||||||
|
id: projectId,
|
||||||
|
orgId: orgId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingProject) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Project not found',
|
||||||
|
message: 'Project does not exist or you do not have access to it',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update project
|
||||||
|
const currentSettings = existingProject.settingsJson as any || {};
|
||||||
|
const project = await prisma.project.update({
|
||||||
|
where: {
|
||||||
|
id: projectId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
...(validatedData.name && { name: validatedData.name }),
|
||||||
|
settingsJson: {
|
||||||
|
...currentSettings,
|
||||||
|
...(validatedData.description !== undefined && { description: validatedData.description }),
|
||||||
|
...validatedData.settings,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Project updated: ${project.id}`, {
|
||||||
|
userId: user.id,
|
||||||
|
orgId,
|
||||||
|
changes: validatedData,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
project: {
|
||||||
|
id: project.id,
|
||||||
|
name: project.name,
|
||||||
|
description: (project.settingsJson as any)?.description || null,
|
||||||
|
settings: project.settingsJson,
|
||||||
|
createdAt: project.createdAt,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
message: 'Project updated successfully',
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to update project:', error);
|
||||||
|
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Validation failed',
|
||||||
|
details: error.errors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to update project',
|
||||||
|
message: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/v2/projects/:projectId
|
||||||
|
* Delete project
|
||||||
|
*/
|
||||||
|
router.delete('/:projectId', requireAuth, async (req: AuthenticatedRequest, res) => {
|
||||||
|
try {
|
||||||
|
const user = req.user!;
|
||||||
|
const { projectId } = projectParamsSchema.parse(req.params);
|
||||||
|
const orgId = user.memberships[0]?.orgId;
|
||||||
|
|
||||||
|
if (!orgId) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'No organization found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if project exists and user has access
|
||||||
|
const existingProject = await prisma.project.findFirst({
|
||||||
|
where: {
|
||||||
|
id: projectId,
|
||||||
|
orgId: orgId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
checks: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingProject) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Project not found',
|
||||||
|
message: 'Project does not exist or you do not have access to it',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent deletion of projects with tracking data
|
||||||
|
if (existingProject._count.checks > 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Cannot delete project with tracking data',
|
||||||
|
message: `This project has ${existingProject._count.checks} tracking records. Please archive or move the data first.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete project
|
||||||
|
await prisma.project.delete({
|
||||||
|
where: {
|
||||||
|
id: projectId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Project deleted: ${projectId}`, {
|
||||||
|
userId: user.id,
|
||||||
|
orgId,
|
||||||
|
projectName: existingProject.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Project deleted successfully',
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to delete project:', error);
|
||||||
|
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid project ID',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to delete project',
|
||||||
|
message: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -8,6 +8,7 @@ import express from 'express';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import rateLimit from 'express-rate-limit';
|
import rateLimit from 'express-rate-limit';
|
||||||
import { RedirectTrackerService } from '../services/redirect-tracker.service';
|
import { RedirectTrackerService } from '../services/redirect-tracker.service';
|
||||||
|
import { prisma } from '../lib/prisma';
|
||||||
import { optionalAuth, requireAuth, AuthenticatedRequest } from '../middleware/auth.middleware';
|
import { optionalAuth, requireAuth, AuthenticatedRequest } from '../middleware/auth.middleware';
|
||||||
import { trackingRateLimit, addRateLimitStatus } from '../middleware/rate-limit.middleware';
|
import { trackingRateLimit, addRateLimitStatus } from '../middleware/rate-limit.middleware';
|
||||||
import { logger } from '../lib/logger';
|
import { logger } from '../lib/logger';
|
||||||
@@ -103,14 +104,65 @@ router.post('/track',
|
|||||||
// Set project ID based on authentication status
|
// Set project ID based on authentication status
|
||||||
if (!validatedData.projectId) {
|
if (!validatedData.projectId) {
|
||||||
if (req.user) {
|
if (req.user) {
|
||||||
// Authenticated user - use their default project
|
// Authenticated user - get their default project
|
||||||
const userMembership = req.user.memberships[0];
|
const userMembership = req.user.memberships[0];
|
||||||
if (userMembership) {
|
if (userMembership) {
|
||||||
validatedData.projectId = 'default-project'; // Placeholder
|
// Find the user's default project
|
||||||
|
const defaultProject = await prisma.project.findFirst({
|
||||||
|
where: {
|
||||||
|
orgId: userMembership.orgId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (defaultProject) {
|
||||||
|
validatedData.projectId = defaultProject.id;
|
||||||
|
} else {
|
||||||
|
// Create a default project if none exists
|
||||||
|
const newProject = await prisma.project.create({
|
||||||
|
data: {
|
||||||
|
name: 'Default Project',
|
||||||
|
orgId: userMembership.orgId,
|
||||||
|
settingsJson: {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
validatedData.projectId = newProject.id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Anonymous user - use the anonymous project
|
// Anonymous user - create or find anonymous project
|
||||||
validatedData.projectId = 'anonymous-project';
|
let anonymousProject = await prisma.project.findFirst({
|
||||||
|
where: {
|
||||||
|
name: 'Anonymous Tracking'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!anonymousProject) {
|
||||||
|
// Need to create anonymous organization first
|
||||||
|
let anonymousOrg = await prisma.organization.findFirst({
|
||||||
|
where: {
|
||||||
|
name: 'Anonymous Users'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!anonymousOrg) {
|
||||||
|
anonymousOrg = await prisma.organization.create({
|
||||||
|
data: {
|
||||||
|
name: 'Anonymous Users',
|
||||||
|
plan: 'free'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
anonymousProject = await prisma.project.create({
|
||||||
|
data: {
|
||||||
|
name: 'Anonymous Tracking',
|
||||||
|
orgId: anonymousOrg.id,
|
||||||
|
settingsJson: {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
validatedData.projectId = anonymousProject.id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ const trackRequestSchema = z.object({
|
|||||||
followJS: z.boolean().default(false), // Future: JavaScript redirects
|
followJS: z.boolean().default(false), // Future: JavaScript redirects
|
||||||
maxHops: z.number().min(1).max(20).default(10),
|
maxHops: z.number().min(1).max(20).default(10),
|
||||||
timeout: z.number().min(1000).max(30000).default(15000),
|
timeout: z.number().min(1000).max(30000).default(15000),
|
||||||
enableSSLAnalysis: z.boolean().default(false),
|
enableSSLAnalysis: z.boolean().default(true),
|
||||||
enableSEOAnalysis: z.boolean().default(false),
|
enableSEOAnalysis: z.boolean().default(true),
|
||||||
enableSecurityAnalysis: z.boolean().default(false),
|
enableSecurityAnalysis: z.boolean().default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TrackRequest = z.infer<typeof trackRequestSchema>;
|
export type TrackRequest = z.infer<typeof trackRequestSchema>;
|
||||||
@@ -59,6 +59,11 @@ export interface CheckResult {
|
|||||||
redirectCount: number;
|
redirectCount: number;
|
||||||
loopDetected?: boolean;
|
loopDetected?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
analysis?: {
|
||||||
|
ssl?: any;
|
||||||
|
seo?: any;
|
||||||
|
security?: any;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -149,6 +154,9 @@ export class RedirectTrackerService {
|
|||||||
// Perform enhanced analysis on final URL if enabled
|
// Perform enhanced analysis on final URL if enabled
|
||||||
await this.performEnhancedAnalysis(check.id, finalUrl || inputUrl, hops.map(h => h.url), validatedRequest);
|
await this.performEnhancedAnalysis(check.id, finalUrl || inputUrl, hops.map(h => h.url), validatedRequest);
|
||||||
|
|
||||||
|
// Fetch analysis results from database
|
||||||
|
const analysisData = await this.getAnalysisResults(check.id);
|
||||||
|
|
||||||
const result: CheckResult = {
|
const result: CheckResult = {
|
||||||
id: check.id,
|
id: check.id,
|
||||||
inputUrl,
|
inputUrl,
|
||||||
@@ -171,6 +179,7 @@ export class RedirectTrackerService {
|
|||||||
})),
|
})),
|
||||||
redirectCount,
|
redirectCount,
|
||||||
loopDetected,
|
loopDetected,
|
||||||
|
analysis: analysisData,
|
||||||
};
|
};
|
||||||
|
|
||||||
logger.info(`Enhanced redirect tracking completed: ${inputUrl}`, {
|
logger.info(`Enhanced redirect tracking completed: ${inputUrl}`, {
|
||||||
@@ -608,4 +617,42 @@ export class RedirectTrackerService {
|
|||||||
// Don't throw - analysis failure shouldn't break the main tracking
|
// Don't throw - analysis failure shouldn't break the main tracking
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch analysis results from database for a check
|
||||||
|
*/
|
||||||
|
private async getAnalysisResults(checkId: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
const [sslInspections, seoFlags, securityFlags] = await Promise.all([
|
||||||
|
prisma.sslInspection.findMany({
|
||||||
|
where: { checkId }
|
||||||
|
}),
|
||||||
|
prisma.seoFlags.findMany({
|
||||||
|
where: { checkId }
|
||||||
|
}),
|
||||||
|
prisma.securityFlags.findMany({
|
||||||
|
where: { checkId }
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
const analysis: any = {};
|
||||||
|
|
||||||
|
if (sslInspections.length > 0) {
|
||||||
|
analysis.ssl = sslInspections[0]; // Take the first (should be only one)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seoFlags.length > 0) {
|
||||||
|
analysis.seo = seoFlags[0]; // Take the first (should be only one)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (securityFlags.length > 0) {
|
||||||
|
analysis.security = securityFlags[0]; // Take the first (should be only one)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(analysis).length > 0 ? analysis : null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to fetch analysis results for check ${checkId}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,16 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>URL Tracker Tool V2</title>
|
<title>URL Tracker Tool V2</title>
|
||||||
|
|
||||||
|
<!-- Google tag (gtag.js) -->
|
||||||
|
<script async src="https://www.googletagmanager.com/gtag/js?id=G-ZDZ26XYN2P"></script>
|
||||||
|
<script>
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
function gtag(){dataLayer.push(arguments);}
|
||||||
|
gtag('js', new Date());
|
||||||
|
|
||||||
|
gtag('config', 'G-ZDZ26XYN2P');
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ import { BulkUploadPage } from './pages/BulkUploadPage';
|
|||||||
// Context providers
|
// Context providers
|
||||||
import { AuthProvider } from './contexts/AuthContext';
|
import { AuthProvider } from './contexts/AuthContext';
|
||||||
|
|
||||||
|
// Analytics
|
||||||
|
import { RouteTracker } from './components/Analytics/RouteTracker';
|
||||||
|
|
||||||
// Create React Query client
|
// Create React Query client
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -43,6 +46,7 @@ function App() {
|
|||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<Router>
|
<Router>
|
||||||
|
<RouteTracker />
|
||||||
<Layout>
|
<Layout>
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Public routes */}
|
{/* Public routes */}
|
||||||
|
|||||||
20
apps/web/src/components/Analytics/RouteTracker.tsx
Normal file
20
apps/web/src/components/Analytics/RouteTracker.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Route Tracker Component
|
||||||
|
*
|
||||||
|
* Tracks page views for Google Analytics in SPA navigation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { trackPageView } from '../../utils/analytics';
|
||||||
|
|
||||||
|
export function RouteTracker() {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Track page view on route change
|
||||||
|
trackPageView(location.pathname + location.search, document.title);
|
||||||
|
}, [location]);
|
||||||
|
|
||||||
|
return null; // This component doesn't render anything
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
|
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
|
||||||
import { useToast } from '@chakra-ui/react';
|
import { useToast } from '@chakra-ui/react';
|
||||||
import { authApi, AuthUser, LoginRequest, RegisterRequest } from '../services/api';
|
import { authApi, AuthUser, LoginRequest, RegisterRequest } from '../services/api';
|
||||||
|
import { trackUserAuth } from '../utils/analytics';
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
user: AuthUser | null;
|
user: AuthUser | null;
|
||||||
@@ -109,6 +110,9 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
|||||||
authApi.logout();
|
authApi.logout();
|
||||||
setUser(null);
|
setUser(null);
|
||||||
|
|
||||||
|
// Track logout
|
||||||
|
trackUserAuth('logout');
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Logged out',
|
title: 'Logged out',
|
||||||
description: 'You have been successfully logged out.',
|
description: 'You have been successfully logged out.',
|
||||||
|
|||||||
@@ -42,6 +42,18 @@ import {
|
|||||||
TabPanels,
|
TabPanels,
|
||||||
Tab,
|
Tab,
|
||||||
TabPanel,
|
TabPanel,
|
||||||
|
Input,
|
||||||
|
Textarea,
|
||||||
|
FormControl,
|
||||||
|
FormLabel,
|
||||||
|
Modal,
|
||||||
|
ModalOverlay,
|
||||||
|
ModalContent,
|
||||||
|
ModalHeader,
|
||||||
|
ModalFooter,
|
||||||
|
ModalBody,
|
||||||
|
ModalCloseButton,
|
||||||
|
useDisclosure,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { Link as RouterLink } from 'react-router-dom';
|
import { Link as RouterLink } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
@@ -58,13 +70,17 @@ import {
|
|||||||
} from 'react-icons/fi';
|
} from 'react-icons/fi';
|
||||||
|
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { trackingApi } from '../services/api';
|
import { trackingApi, projectsApi, CreateProjectRequest } from '../services/api';
|
||||||
// import type { CheckResult } from '../services/api'; // For future use
|
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const { isAuthenticated, user } = useAuth();
|
const { isAuthenticated, user } = useAuth();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [selectedPeriod, setSelectedPeriod] = useState('7d');
|
const [selectedPeriod, setSelectedPeriod] = useState('7d');
|
||||||
|
const [newProjectName, setNewProjectName] = useState('');
|
||||||
|
const [newProjectDescription, setNewProjectDescription] = useState('');
|
||||||
|
const [isCreatingProject, setIsCreatingProject] = useState(false);
|
||||||
|
|
||||||
|
const { isOpen: isCreateModalOpen, onOpen: onCreateModalOpen, onClose: onCreateModalClose } = useDisclosure();
|
||||||
|
|
||||||
const cardBg = useColorModeValue('white', 'gray.800');
|
const cardBg = useColorModeValue('white', 'gray.800');
|
||||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||||
@@ -85,7 +101,7 @@ export function DashboardPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch recent checks
|
// Fetch recent checks (only when authenticated)
|
||||||
const {
|
const {
|
||||||
data: recentChecks = [],
|
data: recentChecks = [],
|
||||||
isLoading: isLoadingChecks,
|
isLoading: isLoadingChecks,
|
||||||
@@ -95,6 +111,20 @@ export function DashboardPage() {
|
|||||||
queryKey: ['recentChecks'],
|
queryKey: ['recentChecks'],
|
||||||
queryFn: () => trackingApi.getRecentChecks(20),
|
queryFn: () => trackingApi.getRecentChecks(20),
|
||||||
refetchInterval: 30000, // Auto-refresh every 30 seconds
|
refetchInterval: 30000, // Auto-refresh every 30 seconds
|
||||||
|
enabled: isAuthenticated, // Only run when authenticated
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch projects (only when authenticated)
|
||||||
|
const {
|
||||||
|
data: projects = [],
|
||||||
|
isLoading: isLoadingProjects,
|
||||||
|
error: projectsError,
|
||||||
|
refetch: refetchProjects
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['projects'],
|
||||||
|
queryFn: () => projectsApi.getProjects(),
|
||||||
|
refetchInterval: 60000, // Auto-refresh every minute
|
||||||
|
enabled: isAuthenticated, // Only run when authenticated
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate dashboard metrics
|
// Calculate dashboard metrics
|
||||||
@@ -130,6 +160,7 @@ export function DashboardPage() {
|
|||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
refetchChecks();
|
refetchChecks();
|
||||||
|
refetchProjects();
|
||||||
toast({
|
toast({
|
||||||
title: 'Dashboard refreshed',
|
title: 'Dashboard refreshed',
|
||||||
status: 'success',
|
status: 'success',
|
||||||
@@ -138,6 +169,63 @@ export function DashboardPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCreateProject = async () => {
|
||||||
|
if (!newProjectName.trim()) {
|
||||||
|
toast({
|
||||||
|
title: 'Project name required',
|
||||||
|
status: 'error',
|
||||||
|
duration: 3000,
|
||||||
|
isClosable: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsCreatingProject(true);
|
||||||
|
|
||||||
|
const projectData: CreateProjectRequest = {
|
||||||
|
name: newProjectName.trim(),
|
||||||
|
description: newProjectDescription.trim() || undefined,
|
||||||
|
settings: {
|
||||||
|
defaultMethod: 'GET',
|
||||||
|
defaultTimeout: 15000,
|
||||||
|
defaultMaxHops: 10,
|
||||||
|
enableSSLAnalysis: true,
|
||||||
|
enableSEOAnalysis: true,
|
||||||
|
enableSecurityAnalysis: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await projectsApi.createProject(projectData);
|
||||||
|
|
||||||
|
// Reset form and close modal
|
||||||
|
setNewProjectName('');
|
||||||
|
setNewProjectDescription('');
|
||||||
|
onCreateModalClose();
|
||||||
|
|
||||||
|
// Refresh projects
|
||||||
|
refetchProjects();
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Project created successfully',
|
||||||
|
description: `${projectData.name} is ready for tracking`,
|
||||||
|
status: 'success',
|
||||||
|
duration: 3000,
|
||||||
|
isClosable: true,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to create project',
|
||||||
|
description: error.response?.data?.message || 'Please try again',
|
||||||
|
status: 'error',
|
||||||
|
duration: 5000,
|
||||||
|
isClosable: true,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsCreatingProject(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (checksError) {
|
if (checksError) {
|
||||||
return (
|
return (
|
||||||
<Container maxW="6xl">
|
<Container maxW="6xl">
|
||||||
@@ -455,21 +543,98 @@ export function DashboardPage() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<HStack justify="space-between">
|
<HStack justify="space-between">
|
||||||
<Heading as="h3" size="md">
|
<Heading as="h3" size="md">
|
||||||
Project Management
|
Your Projects
|
||||||
</Heading>
|
</Heading>
|
||||||
<Button size="sm" colorScheme="brand" leftIcon={<Icon as={FiPlus} />}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
colorScheme="brand"
|
||||||
|
leftIcon={<Icon as={FiPlus} />}
|
||||||
|
onClick={onCreateModalOpen}
|
||||||
|
>
|
||||||
Create Project
|
Create Project
|
||||||
</Button>
|
</Button>
|
||||||
</HStack>
|
</HStack>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<VStack py={8} spacing={4}>
|
{isLoadingProjects ? (
|
||||||
<Icon as={FiBarChart} size="3rem" color="gray.400" />
|
<VStack py={8}>
|
||||||
<Text color="gray.600">Project management coming soon</Text>
|
<Spinner size="lg" colorScheme="brand" />
|
||||||
<Text fontSize="sm" color="gray.500" textAlign="center">
|
<Text color="gray.600">Loading projects...</Text>
|
||||||
Organize your checks into projects for better tracking and team collaboration.
|
</VStack>
|
||||||
</Text>
|
) : projectsError ? (
|
||||||
</VStack>
|
<VStack py={8} spacing={4}>
|
||||||
|
<Alert status="error">
|
||||||
|
<AlertIcon />
|
||||||
|
Failed to load projects
|
||||||
|
</Alert>
|
||||||
|
<Button size="sm" onClick={() => refetchProjects()}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</VStack>
|
||||||
|
) : projects.length === 0 ? (
|
||||||
|
<VStack py={8} spacing={4}>
|
||||||
|
<Icon as={FiBarChart} size="3rem" color="gray.400" />
|
||||||
|
<Text color="gray.600">No projects yet</Text>
|
||||||
|
<Text fontSize="sm" color="gray.500" textAlign="center">
|
||||||
|
Create your first project to organize your URL tracking
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
colorScheme="brand"
|
||||||
|
size="sm"
|
||||||
|
onClick={onCreateModalOpen}
|
||||||
|
leftIcon={<Icon as={FiPlus} />}
|
||||||
|
>
|
||||||
|
Create First Project
|
||||||
|
</Button>
|
||||||
|
</VStack>
|
||||||
|
) : (
|
||||||
|
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
|
||||||
|
{projects.map((project) => (
|
||||||
|
<Card key={project.id} bg={cardBg} border="1px solid" borderColor={borderColor}>
|
||||||
|
<CardBody>
|
||||||
|
<VStack align="start" spacing={3}>
|
||||||
|
<HStack justify="space-between" w="full">
|
||||||
|
<Heading as="h4" size="sm" noOfLines={1}>
|
||||||
|
{project.name}
|
||||||
|
</Heading>
|
||||||
|
<Badge colorScheme="blue" variant="subtle">
|
||||||
|
{project.trackingCount} checks
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{project.description && (
|
||||||
|
<Text fontSize="sm" color="gray.600" noOfLines={2}>
|
||||||
|
{project.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<HStack justify="space-between" w="full">
|
||||||
|
<Text fontSize="xs" color="gray.500">
|
||||||
|
Created {formatTimeAgo(project.createdAt)}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="ghost"
|
||||||
|
leftIcon={<Icon as={FiEye} />}
|
||||||
|
onClick={() => {
|
||||||
|
// Navigate to project details (future feature)
|
||||||
|
toast({
|
||||||
|
title: 'Project details',
|
||||||
|
description: 'Project detail view coming soon',
|
||||||
|
status: 'info',
|
||||||
|
duration: 2000,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
)}
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
@@ -545,6 +710,51 @@ export function DashboardPage() {
|
|||||||
</TabPanel>
|
</TabPanel>
|
||||||
</TabPanels>
|
</TabPanels>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Create Project Modal */}
|
||||||
|
<Modal isOpen={isCreateModalOpen} onClose={onCreateModalClose}>
|
||||||
|
<ModalOverlay />
|
||||||
|
<ModalContent>
|
||||||
|
<ModalHeader>Create New Project</ModalHeader>
|
||||||
|
<ModalCloseButton />
|
||||||
|
<ModalBody>
|
||||||
|
<VStack spacing={4}>
|
||||||
|
<FormControl isRequired>
|
||||||
|
<FormLabel>Project Name</FormLabel>
|
||||||
|
<Input
|
||||||
|
placeholder="Enter project name"
|
||||||
|
value={newProjectName}
|
||||||
|
onChange={(e) => setNewProjectName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<FormLabel>Description</FormLabel>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Describe your project (optional)"
|
||||||
|
value={newProjectDescription}
|
||||||
|
onChange={(e) => setNewProjectDescription(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</VStack>
|
||||||
|
</ModalBody>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
<Button variant="ghost" mr={3} onClick={onCreateModalClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
colorScheme="brand"
|
||||||
|
onClick={handleCreateProject}
|
||||||
|
isLoading={isCreatingProject}
|
||||||
|
loadingText="Creating..."
|
||||||
|
>
|
||||||
|
Create Project
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
</VStack>
|
</VStack>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { trackUrlSubmission, trackAnalysisResult, trackError } from '../utils/analytics';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Heading,
|
Heading,
|
||||||
@@ -49,7 +50,7 @@ import {
|
|||||||
// Image,
|
// Image,
|
||||||
// Center,
|
// Center,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { Link as RouterLink, useNavigate } from 'react-router-dom';
|
import { Link as RouterLink } from 'react-router-dom';
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
@@ -90,7 +91,6 @@ export function HomePage() {
|
|||||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
const [trackingResult, setTrackingResult] = useState<any>(null);
|
const [trackingResult, setTrackingResult] = useState<any>(null);
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
const navigate = useNavigate();
|
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const cardBg = useColorModeValue('white', 'gray.800');
|
const cardBg = useColorModeValue('white', 'gray.800');
|
||||||
@@ -124,6 +124,8 @@ export function HomePage() {
|
|||||||
// Tracking mutation
|
// Tracking mutation
|
||||||
const trackingMutation = useMutation({
|
const trackingMutation = useMutation({
|
||||||
mutationFn: async (data: TrackRequestV2) => {
|
mutationFn: async (data: TrackRequestV2) => {
|
||||||
|
// Track URL submission
|
||||||
|
trackUrlSubmission(data.url, isAuthenticated);
|
||||||
return await trackingApi.trackUrlV2(data);
|
return await trackingApi.trackUrlV2(data);
|
||||||
},
|
},
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
@@ -136,12 +138,24 @@ export function HomePage() {
|
|||||||
isClosable: true,
|
isClosable: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Navigate to check detail page for authenticated users
|
// Track analysis results
|
||||||
if (isAuthenticated) {
|
const analysis = (result as any).check?.analysis;
|
||||||
navigate(`/check/${result.check.id}`);
|
if (analysis) {
|
||||||
|
trackAnalysisResult(
|
||||||
|
(result as any).check.redirectCount || 0,
|
||||||
|
analysis.ssl?.warningsJson?.length > 0 || false,
|
||||||
|
!analysis.seo?.robotsTxtStatus || analysis.seo?.noindex || false,
|
||||||
|
analysis.security?.mixedContent === 'PRESENT' || analysis.security?.safeBrowsingStatus !== 'safe' || false
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Don't auto-navigate on home page to allow users to see results
|
||||||
|
// They can manually navigate to their dashboard to see saved results
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
|
// Track errors
|
||||||
|
trackError('tracking_failed', error.response?.data?.message || error.message || 'Unknown error');
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Tracking failed',
|
title: 'Tracking failed',
|
||||||
description: error.response?.data?.message || 'An error occurred',
|
description: error.response?.data?.message || 'An error occurred',
|
||||||
|
|||||||
@@ -1,24 +1,86 @@
|
|||||||
/**
|
/**
|
||||||
* Login Page - Placeholder for Phase 4
|
* Login Page - User Authentication
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { trackUserAuth } from '../utils/analytics';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Heading,
|
Heading,
|
||||||
Text,
|
Text,
|
||||||
Container,
|
Container,
|
||||||
VStack,
|
VStack,
|
||||||
Badge,
|
HStack,
|
||||||
Card,
|
Card,
|
||||||
CardBody,
|
CardBody,
|
||||||
Button,
|
Button,
|
||||||
|
FormControl,
|
||||||
|
FormLabel,
|
||||||
|
Input,
|
||||||
|
FormErrorMessage,
|
||||||
|
InputGroup,
|
||||||
|
InputRightElement,
|
||||||
|
IconButton,
|
||||||
|
Alert,
|
||||||
|
AlertIcon,
|
||||||
|
Divider,
|
||||||
|
useColorModeValue,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { Link as RouterLink } from 'react-router-dom';
|
import { Link as RouterLink, useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { FiEye, FiEyeOff } from 'react-icons/fi';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string().email('Invalid email address'),
|
||||||
|
password: z.string().min(8, 'Password must be at least 8 characters'),
|
||||||
|
});
|
||||||
|
|
||||||
|
type LoginFormData = z.infer<typeof loginSchema>;
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const { login } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const cardBg = useColorModeValue('white', 'gray.800');
|
||||||
|
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
setError,
|
||||||
|
} = useForm<LoginFormData>({
|
||||||
|
resolver: zodResolver(loginSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: LoginFormData) => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
await login(data);
|
||||||
|
|
||||||
|
// Track successful login
|
||||||
|
trackUserAuth('login');
|
||||||
|
|
||||||
|
// Redirect to intended page or dashboard
|
||||||
|
const from = location.state?.from?.pathname || '/dashboard';
|
||||||
|
navigate(from, { replace: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
setError('root', {
|
||||||
|
message: error.response?.data?.message || 'Login failed. Please try again.',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container maxW="md">
|
<Container maxW="md" py={12}>
|
||||||
<VStack spacing={8}>
|
<VStack spacing={8}>
|
||||||
<Box textAlign="center">
|
<Box textAlign="center">
|
||||||
<Heading as="h1" size="xl" mb={4}>
|
<Heading as="h1" size="xl" mb={4}>
|
||||||
@@ -27,40 +89,94 @@ export function LoginPage() {
|
|||||||
<Text color="gray.600">
|
<Text color="gray.600">
|
||||||
Access your redirect tracking dashboard
|
Access your redirect tracking dashboard
|
||||||
</Text>
|
</Text>
|
||||||
<Badge colorScheme="yellow" mt={2}>
|
|
||||||
Login UI coming in Phase 4
|
|
||||||
</Badge>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Card w="full">
|
<Card w="full" bg={cardBg} border="1px solid" borderColor={borderColor}>
|
||||||
<CardBody textAlign="center" py={8}>
|
<CardBody p={8}>
|
||||||
<Text mb={6} color="gray.600">
|
{errors.root && (
|
||||||
The authentication system is fully implemented in the backend API.
|
<Alert status="error" mb={6} borderRadius="md">
|
||||||
The login form UI will be completed in the next phase.
|
<AlertIcon />
|
||||||
</Text>
|
{errors.root.message}
|
||||||
|
</Alert>
|
||||||
<VStack spacing={4}>
|
)}
|
||||||
<Text fontSize="sm" fontWeight="medium">
|
|
||||||
Backend Features Ready:
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
</Text>
|
<VStack spacing={6}>
|
||||||
<VStack fontSize="sm" color="gray.600">
|
<FormControl isInvalid={!!errors.email}>
|
||||||
<Text>✅ User registration and login</Text>
|
<FormLabel>Email</FormLabel>
|
||||||
<Text>✅ JWT token authentication</Text>
|
<Input
|
||||||
<Text>✅ Argon2 password hashing</Text>
|
type="email"
|
||||||
<Text>✅ Organization management</Text>
|
placeholder="Enter your email"
|
||||||
|
autoComplete="email"
|
||||||
|
{...register('email')}
|
||||||
|
/>
|
||||||
|
<FormErrorMessage>{errors.email?.message}</FormErrorMessage>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl isInvalid={!!errors.password}>
|
||||||
|
<FormLabel>Password</FormLabel>
|
||||||
|
<InputGroup>
|
||||||
|
<Input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
{...register('password')}
|
||||||
|
/>
|
||||||
|
<InputRightElement>
|
||||||
|
<IconButton
|
||||||
|
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||||
|
icon={showPassword ? <FiEyeOff /> : <FiEye />}
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
/>
|
||||||
|
</InputRightElement>
|
||||||
|
</InputGroup>
|
||||||
|
<FormErrorMessage>{errors.password?.message}</FormErrorMessage>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
colorScheme="brand"
|
||||||
|
size="lg"
|
||||||
|
w="full"
|
||||||
|
isLoading={isLoading}
|
||||||
|
loadingText="Signing in..."
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</Button>
|
||||||
</VStack>
|
</VStack>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<Divider my={6} />
|
||||||
|
|
||||||
|
<VStack spacing={4}>
|
||||||
|
<HStack>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
Don't have an account?
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
as={RouterLink}
|
||||||
|
to="/register"
|
||||||
|
variant="link"
|
||||||
|
colorScheme="brand"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Sign up
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
as={RouterLink}
|
||||||
|
to="/track"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
w="full"
|
||||||
|
>
|
||||||
|
Continue as Guest
|
||||||
|
</Button>
|
||||||
</VStack>
|
</VStack>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<VStack>
|
|
||||||
<Button as={RouterLink} to="/track" colorScheme="brand">
|
|
||||||
Try Anonymous Tracking
|
|
||||||
</Button>
|
|
||||||
<Text fontSize="sm" color="gray.600">
|
|
||||||
or test the API directly at <Text as="span" fontFamily="mono">/api/v1/auth/login</Text>
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
</VStack>
|
</VStack>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,24 +1,119 @@
|
|||||||
/**
|
/**
|
||||||
* Register Page - Placeholder for Phase 4
|
* Register Page - User Registration
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { trackUserAuth } from '../utils/analytics';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Heading,
|
Heading,
|
||||||
Text,
|
Text,
|
||||||
Container,
|
Container,
|
||||||
VStack,
|
VStack,
|
||||||
Badge,
|
HStack,
|
||||||
Card,
|
Card,
|
||||||
CardBody,
|
CardBody,
|
||||||
Button,
|
Button,
|
||||||
|
FormControl,
|
||||||
|
FormLabel,
|
||||||
|
Input,
|
||||||
|
FormErrorMessage,
|
||||||
|
InputGroup,
|
||||||
|
InputRightElement,
|
||||||
|
IconButton,
|
||||||
|
Alert,
|
||||||
|
AlertIcon,
|
||||||
|
Divider,
|
||||||
|
useColorModeValue,
|
||||||
|
SimpleGrid,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListIcon,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { Link as RouterLink } from 'react-router-dom';
|
import { Link as RouterLink, useNavigate } from 'react-router-dom';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { FiEye, FiEyeOff, FiCheck } from 'react-icons/fi';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
|
const registerSchema = z.object({
|
||||||
|
name: z.string().min(2, 'Name must be at least 2 characters'),
|
||||||
|
email: z.string().email('Invalid email address'),
|
||||||
|
password: z.string()
|
||||||
|
.min(8, 'Password must be at least 8 characters')
|
||||||
|
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
|
||||||
|
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
|
||||||
|
.regex(/[0-9]/, 'Password must contain at least one number')
|
||||||
|
.regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character'),
|
||||||
|
confirmPassword: z.string(),
|
||||||
|
}).refine((data) => data.password === data.confirmPassword, {
|
||||||
|
message: "Passwords don't match",
|
||||||
|
path: ["confirmPassword"],
|
||||||
|
});
|
||||||
|
|
||||||
|
type RegisterFormData = z.infer<typeof registerSchema>;
|
||||||
|
|
||||||
export function RegisterPage() {
|
export function RegisterPage() {
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const { register: registerUser } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const cardBg = useColorModeValue('white', 'gray.800');
|
||||||
|
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
setError,
|
||||||
|
watch,
|
||||||
|
} = useForm<RegisterFormData>({
|
||||||
|
resolver: zodResolver(registerSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const password = watch('password');
|
||||||
|
|
||||||
|
const onSubmit = async (data: RegisterFormData) => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
await registerUser({
|
||||||
|
name: data.name,
|
||||||
|
email: data.email,
|
||||||
|
password: data.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track successful registration
|
||||||
|
trackUserAuth('register');
|
||||||
|
|
||||||
|
// Navigate to dashboard after successful registration
|
||||||
|
navigate('/dashboard', { replace: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
setError('root', {
|
||||||
|
message: error.response?.data?.message || 'Registration failed. Please try again.',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPasswordValidationColor = (valid: boolean) => {
|
||||||
|
if (!password) return 'gray.500';
|
||||||
|
return valid ? 'green.500' : 'red.500';
|
||||||
|
};
|
||||||
|
|
||||||
|
const passwordValidations = [
|
||||||
|
{ label: 'At least 8 characters', valid: password?.length >= 8 },
|
||||||
|
{ label: 'One uppercase letter', valid: /[A-Z]/.test(password || '') },
|
||||||
|
{ label: 'One lowercase letter', valid: /[a-z]/.test(password || '') },
|
||||||
|
{ label: 'One number', valid: /[0-9]/.test(password || '') },
|
||||||
|
{ label: 'One special character', valid: /[^A-Za-z0-9]/.test(password || '') },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container maxW="md">
|
<Container maxW="lg" py={12}>
|
||||||
<VStack spacing={8}>
|
<VStack spacing={8}>
|
||||||
<Box textAlign="center">
|
<Box textAlign="center">
|
||||||
<Heading as="h1" size="xl" mb={4}>
|
<Heading as="h1" size="xl" mb={4}>
|
||||||
@@ -27,40 +122,190 @@ export function RegisterPage() {
|
|||||||
<Text color="gray.600">
|
<Text color="gray.600">
|
||||||
Get started with enhanced redirect tracking
|
Get started with enhanced redirect tracking
|
||||||
</Text>
|
</Text>
|
||||||
<Badge colorScheme="yellow" mt={2}>
|
|
||||||
Registration UI coming in Phase 4
|
|
||||||
</Badge>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Card w="full">
|
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={8} w="full">
|
||||||
<CardBody textAlign="center" py={8}>
|
{/* Registration Form */}
|
||||||
<Text mb={6} color="gray.600">
|
<Card bg={cardBg} border="1px solid" borderColor={borderColor}>
|
||||||
The user registration system is fully implemented in the backend API.
|
<CardBody p={8}>
|
||||||
The registration form UI will be completed in the next phase.
|
{errors.root && (
|
||||||
</Text>
|
<Alert status="error" mb={6} borderRadius="md">
|
||||||
|
<AlertIcon />
|
||||||
<VStack spacing={4}>
|
{errors.root.message}
|
||||||
<Text fontSize="sm" fontWeight="medium">
|
</Alert>
|
||||||
Account Benefits:
|
)}
|
||||||
</Text>
|
|
||||||
<VStack fontSize="sm" color="gray.600">
|
|
||||||
<Text>🚀 Higher rate limits (200/hour)</Text>
|
|
||||||
<Text>💾 Saved tracking history</Text>
|
|
||||||
<Text>📊 Analysis dashboards</Text>
|
|
||||||
<Text>🏢 Organization management</Text>
|
|
||||||
</VStack>
|
|
||||||
</VStack>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<VStack>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<Button as={RouterLink} to="/track" colorScheme="brand">
|
<VStack spacing={6}>
|
||||||
Try Anonymous Tracking
|
<FormControl isInvalid={!!errors.name}>
|
||||||
</Button>
|
<FormLabel>Full Name</FormLabel>
|
||||||
<Text fontSize="sm" color="gray.600">
|
<Input
|
||||||
or test the API directly at <Text as="span" fontFamily="mono">/api/v1/auth/register</Text>
|
placeholder="Enter your full name"
|
||||||
</Text>
|
autoComplete="name"
|
||||||
</VStack>
|
{...register('name')}
|
||||||
|
/>
|
||||||
|
<FormErrorMessage>{errors.name?.message}</FormErrorMessage>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl isInvalid={!!errors.email}>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder="Enter your email"
|
||||||
|
autoComplete="email"
|
||||||
|
{...register('email')}
|
||||||
|
/>
|
||||||
|
<FormErrorMessage>{errors.email?.message}</FormErrorMessage>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl isInvalid={!!errors.password}>
|
||||||
|
<FormLabel>Password</FormLabel>
|
||||||
|
<InputGroup>
|
||||||
|
<Input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
placeholder="Create a strong password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
{...register('password')}
|
||||||
|
/>
|
||||||
|
<InputRightElement>
|
||||||
|
<IconButton
|
||||||
|
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||||
|
icon={showPassword ? <FiEyeOff /> : <FiEye />}
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
/>
|
||||||
|
</InputRightElement>
|
||||||
|
</InputGroup>
|
||||||
|
<FormErrorMessage>{errors.password?.message}</FormErrorMessage>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl isInvalid={!!errors.confirmPassword}>
|
||||||
|
<FormLabel>Confirm Password</FormLabel>
|
||||||
|
<InputGroup>
|
||||||
|
<Input
|
||||||
|
type={showConfirmPassword ? 'text' : 'password'}
|
||||||
|
placeholder="Confirm your password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
{...register('confirmPassword')}
|
||||||
|
/>
|
||||||
|
<InputRightElement>
|
||||||
|
<IconButton
|
||||||
|
aria-label={showConfirmPassword ? 'Hide password' : 'Show password'}
|
||||||
|
icon={showConfirmPassword ? <FiEyeOff /> : <FiEye />}
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||||
|
/>
|
||||||
|
</InputRightElement>
|
||||||
|
</InputGroup>
|
||||||
|
<FormErrorMessage>{errors.confirmPassword?.message}</FormErrorMessage>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
colorScheme="brand"
|
||||||
|
size="lg"
|
||||||
|
w="full"
|
||||||
|
isLoading={isLoading}
|
||||||
|
loadingText="Creating account..."
|
||||||
|
>
|
||||||
|
Create Account
|
||||||
|
</Button>
|
||||||
|
</VStack>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<Divider my={6} />
|
||||||
|
|
||||||
|
<VStack spacing={4}>
|
||||||
|
<HStack>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
Already have an account?
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
as={RouterLink}
|
||||||
|
to="/login"
|
||||||
|
variant="link"
|
||||||
|
colorScheme="brand"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
as={RouterLink}
|
||||||
|
to="/track"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
w="full"
|
||||||
|
>
|
||||||
|
Continue as Guest
|
||||||
|
</Button>
|
||||||
|
</VStack>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Benefits & Password Requirements */}
|
||||||
|
<VStack spacing={6} align="stretch">
|
||||||
|
{/* Account Benefits */}
|
||||||
|
<Card bg={cardBg} border="1px solid" borderColor={borderColor}>
|
||||||
|
<CardBody>
|
||||||
|
<Heading size="md" mb={4}>Account Benefits</Heading>
|
||||||
|
<List spacing={2}>
|
||||||
|
<ListItem>
|
||||||
|
<ListIcon as={FiCheck} color="green.500" />
|
||||||
|
Higher rate limits (1000/hour)
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<ListIcon as={FiCheck} color="green.500" />
|
||||||
|
Saved tracking history
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<ListIcon as={FiCheck} color="green.500" />
|
||||||
|
Analysis dashboards
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<ListIcon as={FiCheck} color="green.500" />
|
||||||
|
Organization management
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<ListIcon as={FiCheck} color="green.500" />
|
||||||
|
API key access
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<ListIcon as={FiCheck} color="green.500" />
|
||||||
|
Bulk URL processing
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Password Requirements */}
|
||||||
|
{password && (
|
||||||
|
<Card bg={cardBg} border="1px solid" borderColor={borderColor}>
|
||||||
|
<CardBody>
|
||||||
|
<Heading size="md" mb={4}>Password Requirements</Heading>
|
||||||
|
<List spacing={2}>
|
||||||
|
{passwordValidations.map((validation, index) => (
|
||||||
|
<ListItem key={index}>
|
||||||
|
<ListIcon
|
||||||
|
as={FiCheck}
|
||||||
|
color={getPasswordValidationColor(validation.valid)}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
as="span"
|
||||||
|
color={getPasswordValidationColor(validation.valid)}
|
||||||
|
fontSize="sm"
|
||||||
|
>
|
||||||
|
{validation.label}
|
||||||
|
</Text>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</SimpleGrid>
|
||||||
</VStack>
|
</VStack>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
import axios, { AxiosInstance, AxiosResponse } from 'axios';
|
import axios, { AxiosInstance, AxiosResponse } from 'axios';
|
||||||
|
|
||||||
// Base configuration
|
// Base configuration
|
||||||
const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || 'http://localhost:3333';
|
const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || 'http://localhost:3334';
|
||||||
|
|
||||||
// Create axios instance
|
// Create axios instance
|
||||||
const api: AxiosInstance = axios.create({
|
const api: AxiosInstance = axios.create({
|
||||||
@@ -199,6 +199,61 @@ export interface SecurityAnalysisResult {
|
|||||||
securityScore: number;
|
securityScore: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Project Management Types
|
||||||
|
export interface Project {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
settings: {
|
||||||
|
description?: string;
|
||||||
|
defaultMethod?: 'GET' | 'POST' | 'HEAD';
|
||||||
|
defaultTimeout?: number;
|
||||||
|
defaultMaxHops?: number;
|
||||||
|
enableSSLAnalysis?: boolean;
|
||||||
|
enableSEOAnalysis?: boolean;
|
||||||
|
enableSecurityAnalysis?: boolean;
|
||||||
|
};
|
||||||
|
trackingCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectDetails extends Project {
|
||||||
|
recentChecks: Array<{
|
||||||
|
id: string;
|
||||||
|
inputUrl: string;
|
||||||
|
finalUrl: string | null;
|
||||||
|
status: string;
|
||||||
|
startedAt: string;
|
||||||
|
hopCount: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateProjectRequest {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
settings?: {
|
||||||
|
defaultMethod?: 'GET' | 'POST' | 'HEAD';
|
||||||
|
defaultTimeout?: number;
|
||||||
|
defaultMaxHops?: number;
|
||||||
|
enableSSLAnalysis?: boolean;
|
||||||
|
enableSEOAnalysis?: boolean;
|
||||||
|
enableSecurityAnalysis?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateProjectRequest {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
settings?: {
|
||||||
|
defaultMethod?: 'GET' | 'POST' | 'HEAD';
|
||||||
|
defaultTimeout?: number;
|
||||||
|
defaultMaxHops?: number;
|
||||||
|
enableSSLAnalysis?: boolean;
|
||||||
|
enableSEOAnalysis?: boolean;
|
||||||
|
enableSecurityAnalysis?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// API SERVICES
|
// API SERVICES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -300,6 +355,32 @@ export const analysisApi = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const projectsApi = {
|
||||||
|
async getProjects(): Promise<Project[]> {
|
||||||
|
const response: AxiosResponse<{ data: { projects: Project[] } }> = await api.get('/v2/projects');
|
||||||
|
return response.data.data.projects;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getProject(projectId: string): Promise<ProjectDetails> {
|
||||||
|
const response: AxiosResponse<{ data: { project: ProjectDetails } }> = await api.get(`/v2/projects/${projectId}`);
|
||||||
|
return response.data.data.project;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createProject(projectData: CreateProjectRequest): Promise<Project> {
|
||||||
|
const response: AxiosResponse<{ data: { project: Project } }> = await api.post('/v2/projects', projectData);
|
||||||
|
return response.data.data.project;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateProject(projectId: string, updates: UpdateProjectRequest): Promise<Project> {
|
||||||
|
const response: AxiosResponse<{ data: { project: Project } }> = await api.put(`/v2/projects/${projectId}`, updates);
|
||||||
|
return response.data.data.project;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteProject(projectId: string): Promise<void> {
|
||||||
|
await api.delete(`/v2/projects/${projectId}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const healthApi = {
|
export const healthApi = {
|
||||||
async getHealth(): Promise<{
|
async getHealth(): Promise<{
|
||||||
status: string;
|
status: string;
|
||||||
|
|||||||
110
apps/web/src/utils/analytics.ts
Normal file
110
apps/web/src/utils/analytics.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* Google Analytics Utility Functions
|
||||||
|
*
|
||||||
|
* Provides type-safe Google Analytics event tracking
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Extend the Window interface to include gtag
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
gtag: (
|
||||||
|
command: 'config' | 'event' | 'js' | 'set',
|
||||||
|
targetId: string | Date,
|
||||||
|
config?: Record<string, any>
|
||||||
|
) => void;
|
||||||
|
dataLayer: any[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track a custom event in Google Analytics
|
||||||
|
*/
|
||||||
|
export const trackEvent = (
|
||||||
|
eventName: string,
|
||||||
|
parameters?: {
|
||||||
|
event_category?: string;
|
||||||
|
event_label?: string;
|
||||||
|
value?: number;
|
||||||
|
custom_parameters?: Record<string, any>;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
if (typeof window !== 'undefined' && window.gtag) {
|
||||||
|
window.gtag('event', eventName, {
|
||||||
|
event_category: parameters?.event_category,
|
||||||
|
event_label: parameters?.event_label,
|
||||||
|
value: parameters?.value,
|
||||||
|
...parameters?.custom_parameters,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track URL tracking events
|
||||||
|
*/
|
||||||
|
export const trackUrlSubmission = (url: string, isAuthenticated: boolean) => {
|
||||||
|
trackEvent('url_track', {
|
||||||
|
event_category: 'tracking',
|
||||||
|
event_label: isAuthenticated ? 'authenticated' : 'anonymous',
|
||||||
|
custom_parameters: {
|
||||||
|
url_domain: new URL(url).hostname,
|
||||||
|
user_type: isAuthenticated ? 'authenticated' : 'anonymous',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track user authentication events
|
||||||
|
*/
|
||||||
|
export const trackUserAuth = (action: 'login' | 'register' | 'logout') => {
|
||||||
|
trackEvent('user_auth', {
|
||||||
|
event_category: 'authentication',
|
||||||
|
event_label: action,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track page views (for SPA navigation)
|
||||||
|
*/
|
||||||
|
export const trackPageView = (pagePath: string, pageTitle?: string) => {
|
||||||
|
if (typeof window !== 'undefined' && window.gtag) {
|
||||||
|
window.gtag('config', 'G-ZDZ26XYN2P', {
|
||||||
|
page_path: pagePath,
|
||||||
|
page_title: pageTitle,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track analysis results
|
||||||
|
*/
|
||||||
|
export const trackAnalysisResult = (
|
||||||
|
redirectCount: number,
|
||||||
|
hasSSLIssues: boolean,
|
||||||
|
hasSEOIssues: boolean,
|
||||||
|
hasSecurityIssues: boolean
|
||||||
|
) => {
|
||||||
|
trackEvent('analysis_complete', {
|
||||||
|
event_category: 'analysis',
|
||||||
|
event_label: 'redirect_analysis',
|
||||||
|
value: redirectCount,
|
||||||
|
custom_parameters: {
|
||||||
|
redirect_count: redirectCount,
|
||||||
|
ssl_issues: hasSSLIssues,
|
||||||
|
seo_issues: hasSEOIssues,
|
||||||
|
security_issues: hasSecurityIssues,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track errors
|
||||||
|
*/
|
||||||
|
export const trackError = (errorType: string, errorMessage: string) => {
|
||||||
|
trackEvent('error', {
|
||||||
|
event_category: 'error',
|
||||||
|
event_label: errorType,
|
||||||
|
custom_parameters: {
|
||||||
|
error_message: errorMessage,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "Role" AS ENUM ('OWNER', 'ADMIN', 'MEMBER');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "CheckStatus" AS ENUM ('OK', 'ERROR', 'TIMEOUT', 'LOOP');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "RedirectType" AS ENUM ('HTTP_301', 'HTTP_302', 'HTTP_307', 'HTTP_308', 'META_REFRESH', 'JS', 'FINAL', 'OTHER');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "MixedContent" AS ENUM ('NONE', 'PRESENT', 'FINAL_TO_HTTP');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "JobStatus" AS ENUM ('PENDING', 'QUEUED', 'RUNNING', 'COMPLETED', 'FAILED', 'CANCELLED', 'ERROR');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "users" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"password_hash" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"last_login_at" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "organizations" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"plan" TEXT NOT NULL DEFAULT 'free',
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "organizations_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "org_memberships" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"org_id" TEXT NOT NULL,
|
||||||
|
"user_id" TEXT NOT NULL,
|
||||||
|
"role" "Role" NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "org_memberships_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "projects" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"org_id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"settings_json" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "projects_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "checks" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"project_id" TEXT NOT NULL,
|
||||||
|
"input_url" TEXT NOT NULL,
|
||||||
|
"method" TEXT NOT NULL DEFAULT 'GET',
|
||||||
|
"headers_json" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"user_agent" TEXT,
|
||||||
|
"started_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
"finished_at" TIMESTAMP(3),
|
||||||
|
"status" "CheckStatus" NOT NULL,
|
||||||
|
"final_url" TEXT,
|
||||||
|
"total_time_ms" INTEGER,
|
||||||
|
"report_id" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "checks_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "hops" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"check_id" TEXT NOT NULL,
|
||||||
|
"hop_index" INTEGER NOT NULL,
|
||||||
|
"url" TEXT NOT NULL,
|
||||||
|
"scheme" TEXT,
|
||||||
|
"status_code" INTEGER,
|
||||||
|
"redirect_type" "RedirectType" NOT NULL,
|
||||||
|
"latency_ms" INTEGER,
|
||||||
|
"content_type" TEXT,
|
||||||
|
"reason" TEXT,
|
||||||
|
"response_headers_json" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
|
||||||
|
CONSTRAINT "hops_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ssl_inspections" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"check_id" TEXT NOT NULL,
|
||||||
|
"host" TEXT NOT NULL,
|
||||||
|
"valid_from" TIMESTAMP(3),
|
||||||
|
"valid_to" TIMESTAMP(3),
|
||||||
|
"days_to_expiry" INTEGER,
|
||||||
|
"issuer" TEXT,
|
||||||
|
"protocol" TEXT,
|
||||||
|
"warnings_json" JSONB NOT NULL DEFAULT '[]',
|
||||||
|
|
||||||
|
CONSTRAINT "ssl_inspections_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "seo_flags" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"check_id" TEXT NOT NULL,
|
||||||
|
"robots_txt_status" TEXT,
|
||||||
|
"robots_txt_rules_json" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"meta_robots" TEXT,
|
||||||
|
"canonical_url" TEXT,
|
||||||
|
"sitemap_present" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"noindex" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"nofollow" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
CONSTRAINT "seo_flags_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "security_flags" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"check_id" TEXT NOT NULL,
|
||||||
|
"safe_browsing_status" TEXT,
|
||||||
|
"mixed_content" "MixedContent" NOT NULL DEFAULT 'NONE',
|
||||||
|
"https_to_http" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
CONSTRAINT "security_flags_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "reports" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"check_id" TEXT NOT NULL,
|
||||||
|
"markdown_path" TEXT,
|
||||||
|
"pdf_path" TEXT,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "reports_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "bulk_jobs" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"user_id" TEXT NOT NULL,
|
||||||
|
"organization_id" TEXT,
|
||||||
|
"project_id" TEXT NOT NULL,
|
||||||
|
"upload_path" TEXT NOT NULL,
|
||||||
|
"status" "JobStatus" NOT NULL,
|
||||||
|
"total_urls" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"processed_urls" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"successful_urls" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"failed_urls" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"config_json" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"urls_json" JSONB,
|
||||||
|
"results_json" JSONB,
|
||||||
|
"progress_json" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"started_at" TIMESTAMP(3),
|
||||||
|
"finished_at" TIMESTAMP(3),
|
||||||
|
"completed_at" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "bulk_jobs_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "api_keys" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"org_id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"token_hash" TEXT NOT NULL,
|
||||||
|
"perms_json" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"rate_limit_quota" INTEGER NOT NULL DEFAULT 1000,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "api_keys_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "audit_logs" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"org_id" TEXT NOT NULL,
|
||||||
|
"actor_user_id" TEXT,
|
||||||
|
"action" TEXT NOT NULL,
|
||||||
|
"entity" TEXT NOT NULL,
|
||||||
|
"entity_id" TEXT NOT NULL,
|
||||||
|
"meta_json" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "org_memberships_org_id_user_id_key" ON "org_memberships"("org_id", "user_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "checks_project_id_started_at_idx" ON "checks"("project_id", "started_at" DESC);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "hops_check_id_hop_index_idx" ON "hops"("check_id", "hop_index");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "seo_flags_check_id_key" ON "seo_flags"("check_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "security_flags_check_id_key" ON "security_flags"("check_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "api_keys_token_hash_key" ON "api_keys"("token_hash");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "api_keys_token_hash_idx" ON "api_keys"("token_hash");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "org_memberships" ADD CONSTRAINT "org_memberships_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "org_memberships" ADD CONSTRAINT "org_memberships_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "projects" ADD CONSTRAINT "projects_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "checks" ADD CONSTRAINT "checks_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "hops" ADD CONSTRAINT "hops_check_id_fkey" FOREIGN KEY ("check_id") REFERENCES "checks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ssl_inspections" ADD CONSTRAINT "ssl_inspections_check_id_fkey" FOREIGN KEY ("check_id") REFERENCES "checks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "seo_flags" ADD CONSTRAINT "seo_flags_check_id_fkey" FOREIGN KEY ("check_id") REFERENCES "checks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "security_flags" ADD CONSTRAINT "security_flags_check_id_fkey" FOREIGN KEY ("check_id") REFERENCES "checks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "reports" ADD CONSTRAINT "reports_check_id_fkey" FOREIGN KEY ("check_id") REFERENCES "checks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "bulk_jobs" ADD CONSTRAINT "bulk_jobs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "bulk_jobs" ADD CONSTRAINT "bulk_jobs_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "bulk_jobs" ADD CONSTRAINT "bulk_jobs_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_actor_user_id_fkey" FOREIGN KEY ("actor_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
3
packages/database/prisma/migrations/migration_lock.toml
Normal file
3
packages/database/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (i.e. Git)
|
||||||
|
provider = "postgresql"
|
||||||
69
service-control.sh
Executable file
69
service-control.sh
Executable file
@@ -0,0 +1,69 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# URL Tracker Tool V2 - Service Control Script
|
||||||
|
# Usage: ./service-control.sh [start|stop|restart|status|logs|enable|disable]
|
||||||
|
|
||||||
|
SERVICE_NAME="catch-redirect"
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
start)
|
||||||
|
echo "🚀 Starting URL Tracker Tool V2 service..."
|
||||||
|
systemctl start $SERVICE_NAME
|
||||||
|
sleep 3
|
||||||
|
systemctl status $SERVICE_NAME --no-pager
|
||||||
|
;;
|
||||||
|
stop)
|
||||||
|
echo "🛑 Stopping URL Tracker Tool V2 service..."
|
||||||
|
systemctl stop $SERVICE_NAME
|
||||||
|
echo "Service stopped."
|
||||||
|
;;
|
||||||
|
restart)
|
||||||
|
echo "🔄 Restarting URL Tracker Tool V2 service..."
|
||||||
|
systemctl restart $SERVICE_NAME
|
||||||
|
sleep 3
|
||||||
|
systemctl status $SERVICE_NAME --no-pager
|
||||||
|
;;
|
||||||
|
status)
|
||||||
|
echo "📊 URL Tracker Tool V2 service status:"
|
||||||
|
systemctl status $SERVICE_NAME --no-pager
|
||||||
|
echo ""
|
||||||
|
echo "🌐 Service Health Check:"
|
||||||
|
echo "API (port 3334): $(curl -s http://localhost:3334/health | jq -r '.status' 2>/dev/null || echo "Not responding")"
|
||||||
|
echo "Frontend (port 3000): $(curl -s http://localhost:3000 >/dev/null && echo "Running" || echo "Not responding")"
|
||||||
|
;;
|
||||||
|
logs)
|
||||||
|
echo "📝 Recent service logs:"
|
||||||
|
echo "=== Main Service Log ==="
|
||||||
|
tail -20 /var/log/catch-redirect.log
|
||||||
|
echo ""
|
||||||
|
echo "=== API Log ==="
|
||||||
|
tail -10 /var/log/catch-redirect-api.log 2>/dev/null || echo "No API logs yet"
|
||||||
|
echo ""
|
||||||
|
echo "=== Frontend Log ==="
|
||||||
|
tail -10 /var/log/catch-redirect-frontend.log 2>/dev/null || echo "No frontend logs yet"
|
||||||
|
;;
|
||||||
|
enable)
|
||||||
|
echo "⚡ Enabling URL Tracker Tool V2 service for auto-start..."
|
||||||
|
systemctl enable $SERVICE_NAME
|
||||||
|
echo "Service will now start automatically on boot."
|
||||||
|
;;
|
||||||
|
disable)
|
||||||
|
echo "❌ Disabling URL Tracker Tool V2 service auto-start..."
|
||||||
|
systemctl disable $SERVICE_NAME
|
||||||
|
echo "Service will no longer start automatically on boot."
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "URL Tracker Tool V2 - Service Control"
|
||||||
|
echo "Usage: $0 [start|stop|restart|status|logs|enable|disable]"
|
||||||
|
echo ""
|
||||||
|
echo "Commands:"
|
||||||
|
echo " start - Start the service"
|
||||||
|
echo " stop - Stop the service"
|
||||||
|
echo " restart - Restart the service"
|
||||||
|
echo " status - Show service status and health check"
|
||||||
|
echo " logs - Show recent service logs"
|
||||||
|
echo " enable - Enable auto-start on boot"
|
||||||
|
echo " disable - Disable auto-start on boot"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
68
start-services.sh
Executable file
68
start-services.sh
Executable file
@@ -0,0 +1,68 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# URL Tracker Tool V2 - Service Startup Script
|
||||||
|
# This script starts both API and Frontend services
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
export NODE_ENV="production"
|
||||||
|
export PORT=3334
|
||||||
|
export CORS_ORIGIN="https://urltrackertool.com"
|
||||||
|
export DATABASE_URL="postgresql://postgres:postgres@localhost:5432/redirect_intelligence_v2?schema=public"
|
||||||
|
export JWT_SECRET="J9k2L7mP4nQ8vX3w6Z9c2F5h8K1m4P7r0T3w6Y9b2E5g8J1l4O7q0S3v6Z9c2F5h"
|
||||||
|
|
||||||
|
# Change to app directory
|
||||||
|
cd /root/catch_redirect
|
||||||
|
|
||||||
|
# Log startup
|
||||||
|
echo "$(date): Starting URL Tracker Tool V2 services..." >> /var/log/catch-redirect.log
|
||||||
|
|
||||||
|
# Kill any existing processes
|
||||||
|
pkill -f "node dist/index.js" || true
|
||||||
|
pkill -f "npx serve" || true
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
# Start API service
|
||||||
|
echo "$(date): Starting API service on port $PORT..." >> /var/log/catch-redirect.log
|
||||||
|
cd /root/catch_redirect/apps/api
|
||||||
|
nohup node dist/index.js >> /var/log/catch-redirect-api.log 2>&1 &
|
||||||
|
API_PID=$!
|
||||||
|
echo "$(date): API service started with PID $API_PID" >> /var/log/catch-redirect.log
|
||||||
|
|
||||||
|
# Wait for API to be ready
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
# Start Frontend service
|
||||||
|
echo "$(date): Starting Frontend service on port 3000..." >> /var/log/catch-redirect.log
|
||||||
|
cd /root/catch_redirect/apps/web
|
||||||
|
nohup serve dist -l 3000 >> /var/log/catch-redirect-frontend.log 2>&1 &
|
||||||
|
FRONTEND_PID=$!
|
||||||
|
echo "$(date): Frontend service started with PID $FRONTEND_PID" >> /var/log/catch-redirect.log
|
||||||
|
|
||||||
|
# Save PIDs for monitoring
|
||||||
|
echo "$API_PID" > /var/run/catch-redirect-api.pid
|
||||||
|
echo "$FRONTEND_PID" > /var/run/catch-redirect-frontend.pid
|
||||||
|
|
||||||
|
# Wait and monitor
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
# Check if services are running
|
||||||
|
if curl -s http://localhost:3334/health > /dev/null; then
|
||||||
|
echo "$(date): API service health check passed" >> /var/log/catch-redirect.log
|
||||||
|
else
|
||||||
|
echo "$(date): ERROR - API service health check failed" >> /var/log/catch-redirect.log
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if curl -s http://localhost:3000 > /dev/null; then
|
||||||
|
echo "$(date): Frontend service health check passed" >> /var/log/catch-redirect.log
|
||||||
|
else
|
||||||
|
echo "$(date): ERROR - Frontend service health check failed" >> /var/log/catch-redirect.log
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$(date): All services started successfully" >> /var/log/catch-redirect.log
|
||||||
|
|
||||||
|
# Keep the script running to maintain the service
|
||||||
|
wait
|
||||||
28
stop-services.sh
Executable file
28
stop-services.sh
Executable file
@@ -0,0 +1,28 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# URL Tracker Tool V2 - Service Stop Script
|
||||||
|
# This script stops both API and Frontend services cleanly
|
||||||
|
|
||||||
|
echo "$(date): Stopping URL Tracker Tool V2 services..." >> /var/log/catch-redirect.log
|
||||||
|
|
||||||
|
# Stop API service
|
||||||
|
if [ -f /var/run/catch-redirect-api.pid ]; then
|
||||||
|
API_PID=$(cat /var/run/catch-redirect-api.pid)
|
||||||
|
echo "$(date): Stopping API service (PID: $API_PID)..." >> /var/log/catch-redirect.log
|
||||||
|
kill $API_PID 2>/dev/null || true
|
||||||
|
rm -f /var/run/catch-redirect-api.pid
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Stop Frontend service
|
||||||
|
if [ -f /var/run/catch-redirect-frontend.pid ]; then
|
||||||
|
FRONTEND_PID=$(cat /var/run/catch-redirect-frontend.pid)
|
||||||
|
echo "$(date): Stopping Frontend service (PID: $FRONTEND_PID)..." >> /var/log/catch-redirect.log
|
||||||
|
kill $FRONTEND_PID 2>/dev/null || true
|
||||||
|
rm -f /var/run/catch-redirect-frontend.pid
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Force kill any remaining processes
|
||||||
|
pkill -f "node dist/index.js" || true
|
||||||
|
pkill -f "serve dist" || true
|
||||||
|
|
||||||
|
echo "$(date): All services stopped" >> /var/log/catch-redirect.log
|
||||||
Reference in New Issue
Block a user