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 exportRoutes from './routes/export.routes';
|
||||
import bulkRoutes from './routes/bulk.routes';
|
||||
import projectsRoutes from './routes/projects.routes';
|
||||
import docsRoutes from './routes/docs.routes';
|
||||
import { legacyRateLimit, requestLogger, rateLimitErrorHandler } from './middleware/rate-limit.middleware';
|
||||
|
||||
@@ -45,8 +46,23 @@ app.use(compression());
|
||||
app.use(requestLogger({ redactionLevel: 'partial' }));
|
||||
|
||||
// CORS middleware
|
||||
const allowedOrigins = [
|
||||
'http://localhost:3000',
|
||||
'https://urltrackertool.com',
|
||||
process.env.CORS_ORIGIN
|
||||
].filter(Boolean);
|
||||
|
||||
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,
|
||||
optionsSuccessStatus: 200 // Some legacy browsers (IE11, various SmartTVs) choke on 204
|
||||
}));
|
||||
@@ -84,12 +100,14 @@ app.use('/v2/analyze', analysisRoutes);
|
||||
// Export routes (v2)
|
||||
app.use('/v2/export', exportRoutes);
|
||||
app.use('/v2/bulk', bulkRoutes);
|
||||
app.use('/v2/projects', projectsRoutes);
|
||||
|
||||
// Backward compatibility: keep /api/v2 routes as well
|
||||
app.use('/api/v2', trackingRoutes);
|
||||
app.use('/api/v2/analyze', analysisRoutes);
|
||||
app.use('/api/v2/export', exportRoutes);
|
||||
app.use('/api/v2/bulk', bulkRoutes);
|
||||
app.use('/api/v2/projects', projectsRoutes);
|
||||
|
||||
// Documentation routes
|
||||
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 rateLimit from 'express-rate-limit';
|
||||
import { RedirectTrackerService } from '../services/redirect-tracker.service';
|
||||
import { prisma } from '../lib/prisma';
|
||||
import { optionalAuth, requireAuth, AuthenticatedRequest } from '../middleware/auth.middleware';
|
||||
import { trackingRateLimit, addRateLimitStatus } from '../middleware/rate-limit.middleware';
|
||||
import { logger } from '../lib/logger';
|
||||
@@ -103,14 +104,65 @@ router.post('/track',
|
||||
// Set project ID based on authentication status
|
||||
if (!validatedData.projectId) {
|
||||
if (req.user) {
|
||||
// Authenticated user - use their default project
|
||||
// Authenticated user - get their default project
|
||||
const userMembership = req.user.memberships[0];
|
||||
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 {
|
||||
// Anonymous user - use the anonymous project
|
||||
validatedData.projectId = 'anonymous-project';
|
||||
// Anonymous user - create or find 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
|
||||
maxHops: z.number().min(1).max(20).default(10),
|
||||
timeout: z.number().min(1000).max(30000).default(15000),
|
||||
enableSSLAnalysis: z.boolean().default(false),
|
||||
enableSEOAnalysis: z.boolean().default(false),
|
||||
enableSecurityAnalysis: z.boolean().default(false),
|
||||
enableSSLAnalysis: z.boolean().default(true),
|
||||
enableSEOAnalysis: z.boolean().default(true),
|
||||
enableSecurityAnalysis: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export type TrackRequest = z.infer<typeof trackRequestSchema>;
|
||||
@@ -59,6 +59,11 @@ export interface CheckResult {
|
||||
redirectCount: number;
|
||||
loopDetected?: boolean;
|
||||
error?: string;
|
||||
analysis?: {
|
||||
ssl?: any;
|
||||
seo?: any;
|
||||
security?: any;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,6 +154,9 @@ export class RedirectTrackerService {
|
||||
// Perform enhanced analysis on final URL if enabled
|
||||
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 = {
|
||||
id: check.id,
|
||||
inputUrl,
|
||||
@@ -171,6 +179,7 @@ export class RedirectTrackerService {
|
||||
})),
|
||||
redirectCount,
|
||||
loopDetected,
|
||||
analysis: analysisData,
|
||||
};
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -26,6 +26,9 @@ import { BulkUploadPage } from './pages/BulkUploadPage';
|
||||
// Context providers
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
|
||||
// Analytics
|
||||
import { RouteTracker } from './components/Analytics/RouteTracker';
|
||||
|
||||
// Create React Query client
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -43,6 +46,7 @@ function App() {
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<RouteTracker />
|
||||
<Layout>
|
||||
<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 { useToast } from '@chakra-ui/react';
|
||||
import { authApi, AuthUser, LoginRequest, RegisterRequest } from '../services/api';
|
||||
import { trackUserAuth } from '../utils/analytics';
|
||||
|
||||
interface AuthContextType {
|
||||
user: AuthUser | null;
|
||||
@@ -109,6 +110,9 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
authApi.logout();
|
||||
setUser(null);
|
||||
|
||||
// Track logout
|
||||
trackUserAuth('logout');
|
||||
|
||||
toast({
|
||||
title: 'Logged out',
|
||||
description: 'You have been successfully logged out.',
|
||||
|
||||
@@ -42,6 +42,18 @@ import {
|
||||
TabPanels,
|
||||
Tab,
|
||||
TabPanel,
|
||||
Input,
|
||||
Textarea,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
@@ -58,13 +70,17 @@ import {
|
||||
} from 'react-icons/fi';
|
||||
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { trackingApi } from '../services/api';
|
||||
// import type { CheckResult } from '../services/api'; // For future use
|
||||
import { trackingApi, projectsApi, CreateProjectRequest } from '../services/api';
|
||||
|
||||
export function DashboardPage() {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const toast = useToast();
|
||||
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 borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
@@ -85,7 +101,7 @@ export function DashboardPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch recent checks
|
||||
// Fetch recent checks (only when authenticated)
|
||||
const {
|
||||
data: recentChecks = [],
|
||||
isLoading: isLoadingChecks,
|
||||
@@ -95,6 +111,20 @@ export function DashboardPage() {
|
||||
queryKey: ['recentChecks'],
|
||||
queryFn: () => trackingApi.getRecentChecks(20),
|
||||
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
|
||||
@@ -130,6 +160,7 @@ export function DashboardPage() {
|
||||
|
||||
const handleRefresh = () => {
|
||||
refetchChecks();
|
||||
refetchProjects();
|
||||
toast({
|
||||
title: 'Dashboard refreshed',
|
||||
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) {
|
||||
return (
|
||||
<Container maxW="6xl">
|
||||
@@ -455,21 +543,98 @@ export function DashboardPage() {
|
||||
<CardHeader>
|
||||
<HStack justify="space-between">
|
||||
<Heading as="h3" size="md">
|
||||
Project Management
|
||||
Your Projects
|
||||
</Heading>
|
||||
<Button size="sm" colorScheme="brand" leftIcon={<Icon as={FiPlus} />}>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="brand"
|
||||
leftIcon={<Icon as={FiPlus} />}
|
||||
onClick={onCreateModalOpen}
|
||||
>
|
||||
Create Project
|
||||
</Button>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<VStack py={8} spacing={4}>
|
||||
<Icon as={FiBarChart} size="3rem" color="gray.400" />
|
||||
<Text color="gray.600">Project management coming soon</Text>
|
||||
<Text fontSize="sm" color="gray.500" textAlign="center">
|
||||
Organize your checks into projects for better tracking and team collaboration.
|
||||
</Text>
|
||||
</VStack>
|
||||
{isLoadingProjects ? (
|
||||
<VStack py={8}>
|
||||
<Spinner size="lg" colorScheme="brand" />
|
||||
<Text color="gray.600">Loading projects...</Text>
|
||||
</VStack>
|
||||
) : projectsError ? (
|
||||
<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>
|
||||
</Card>
|
||||
</TabPanel>
|
||||
@@ -545,6 +710,51 @@ export function DashboardPage() {
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</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>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { trackUrlSubmission, trackAnalysisResult, trackError } from '../utils/analytics';
|
||||
import {
|
||||
Box,
|
||||
Heading,
|
||||
@@ -49,7 +50,7 @@ import {
|
||||
// Image,
|
||||
// Center,
|
||||
} 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 { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
@@ -90,7 +91,6 @@ export function HomePage() {
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [trackingResult, setTrackingResult] = useState<any>(null);
|
||||
const { isAuthenticated } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
@@ -124,6 +124,8 @@ export function HomePage() {
|
||||
// Tracking mutation
|
||||
const trackingMutation = useMutation({
|
||||
mutationFn: async (data: TrackRequestV2) => {
|
||||
// Track URL submission
|
||||
trackUrlSubmission(data.url, isAuthenticated);
|
||||
return await trackingApi.trackUrlV2(data);
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
@@ -136,12 +138,24 @@ export function HomePage() {
|
||||
isClosable: true,
|
||||
});
|
||||
|
||||
// Navigate to check detail page for authenticated users
|
||||
if (isAuthenticated) {
|
||||
navigate(`/check/${result.check.id}`);
|
||||
// Track analysis results
|
||||
const analysis = (result as any).check?.analysis;
|
||||
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) => {
|
||||
// Track errors
|
||||
trackError('tracking_failed', error.response?.data?.message || error.message || 'Unknown error');
|
||||
|
||||
toast({
|
||||
title: 'Tracking failed',
|
||||
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 {
|
||||
Box,
|
||||
Heading,
|
||||
Text,
|
||||
Container,
|
||||
VStack,
|
||||
Badge,
|
||||
HStack,
|
||||
Card,
|
||||
CardBody,
|
||||
Button,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
FormErrorMessage,
|
||||
InputGroup,
|
||||
InputRightElement,
|
||||
IconButton,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
Divider,
|
||||
useColorModeValue,
|
||||
} 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() {
|
||||
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 (
|
||||
<Container maxW="md">
|
||||
<Container maxW="md" py={12}>
|
||||
<VStack spacing={8}>
|
||||
<Box textAlign="center">
|
||||
<Heading as="h1" size="xl" mb={4}>
|
||||
@@ -27,40 +89,94 @@ export function LoginPage() {
|
||||
<Text color="gray.600">
|
||||
Access your redirect tracking dashboard
|
||||
</Text>
|
||||
<Badge colorScheme="yellow" mt={2}>
|
||||
Login UI coming in Phase 4
|
||||
</Badge>
|
||||
</Box>
|
||||
|
||||
<Card w="full">
|
||||
<CardBody textAlign="center" py={8}>
|
||||
<Text mb={6} color="gray.600">
|
||||
The authentication system is fully implemented in the backend API.
|
||||
The login form UI will be completed in the next phase.
|
||||
</Text>
|
||||
|
||||
<VStack spacing={4}>
|
||||
<Text fontSize="sm" fontWeight="medium">
|
||||
Backend Features Ready:
|
||||
</Text>
|
||||
<VStack fontSize="sm" color="gray.600">
|
||||
<Text>✅ User registration and login</Text>
|
||||
<Text>✅ JWT token authentication</Text>
|
||||
<Text>✅ Argon2 password hashing</Text>
|
||||
<Text>✅ Organization management</Text>
|
||||
<Card w="full" bg={cardBg} border="1px solid" borderColor={borderColor}>
|
||||
<CardBody p={8}>
|
||||
{errors.root && (
|
||||
<Alert status="error" mb={6} borderRadius="md">
|
||||
<AlertIcon />
|
||||
{errors.root.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<VStack spacing={6}>
|
||||
<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="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>
|
||||
</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>
|
||||
</CardBody>
|
||||
</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>
|
||||
</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 {
|
||||
Box,
|
||||
Heading,
|
||||
Text,
|
||||
Container,
|
||||
VStack,
|
||||
Badge,
|
||||
HStack,
|
||||
Card,
|
||||
CardBody,
|
||||
Button,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
FormErrorMessage,
|
||||
InputGroup,
|
||||
InputRightElement,
|
||||
IconButton,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
Divider,
|
||||
useColorModeValue,
|
||||
SimpleGrid,
|
||||
List,
|
||||
ListItem,
|
||||
ListIcon,
|
||||
} 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() {
|
||||
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 (
|
||||
<Container maxW="md">
|
||||
<Container maxW="lg" py={12}>
|
||||
<VStack spacing={8}>
|
||||
<Box textAlign="center">
|
||||
<Heading as="h1" size="xl" mb={4}>
|
||||
@@ -27,40 +122,190 @@ export function RegisterPage() {
|
||||
<Text color="gray.600">
|
||||
Get started with enhanced redirect tracking
|
||||
</Text>
|
||||
<Badge colorScheme="yellow" mt={2}>
|
||||
Registration UI coming in Phase 4
|
||||
</Badge>
|
||||
</Box>
|
||||
|
||||
<Card w="full">
|
||||
<CardBody textAlign="center" py={8}>
|
||||
<Text mb={6} color="gray.600">
|
||||
The user registration system is fully implemented in the backend API.
|
||||
The registration form UI will be completed in the next phase.
|
||||
</Text>
|
||||
|
||||
<VStack spacing={4}>
|
||||
<Text fontSize="sm" fontWeight="medium">
|
||||
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>
|
||||
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={8} w="full">
|
||||
{/* Registration Form */}
|
||||
<Card bg={cardBg} border="1px solid" borderColor={borderColor}>
|
||||
<CardBody p={8}>
|
||||
{errors.root && (
|
||||
<Alert status="error" mb={6} borderRadius="md">
|
||||
<AlertIcon />
|
||||
{errors.root.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<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/register</Text>
|
||||
</Text>
|
||||
</VStack>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<VStack spacing={6}>
|
||||
<FormControl isInvalid={!!errors.name}>
|
||||
<FormLabel>Full Name</FormLabel>
|
||||
<Input
|
||||
placeholder="Enter your full name"
|
||||
autoComplete="name"
|
||||
{...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>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import axios, { AxiosInstance, AxiosResponse } from 'axios';
|
||||
|
||||
// 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
|
||||
const api: AxiosInstance = axios.create({
|
||||
@@ -199,6 +199,61 @@ export interface SecurityAnalysisResult {
|
||||
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
|
||||
// ============================================================================
|
||||
@@ -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 = {
|
||||
async getHealth(): Promise<{
|
||||
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