Compare commits
28 Commits
feature/sh
...
feature/on
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12e8c9beaa | ||
|
|
f9b1ec5038 | ||
|
|
3fa1a5429e | ||
|
|
a58761e8d7 | ||
|
|
56f1c5e894 | ||
|
|
6e997e62f1 | ||
|
|
2c5ca8adec | ||
|
|
3e573ae27f | ||
|
|
5b29f7502c | ||
|
|
d32a279949 | ||
|
|
ed6e5cd334 | ||
|
|
cf1bf95388 | ||
|
|
5830277ba9 | ||
|
|
aa13c87e87 | ||
|
|
83c3647f66 | ||
|
|
927613c09d | ||
|
|
24ee6b0b6c | ||
|
|
13d8b92cf3 | ||
|
|
4b2db351c4 | ||
|
|
334adec9f2 | ||
|
|
9a54daae55 | ||
|
|
7cd5f84c58 | ||
|
|
470ddbe8e7 | ||
|
|
c63578b35b | ||
|
|
b5792df69f | ||
|
|
00f2e2937d | ||
|
|
0d1773eb74 | ||
|
|
ed1c57dbb8 |
1
.github/workflows/docker-build.yml
vendored
1
.github/workflows/docker-build.yml
vendored
@@ -8,6 +8,7 @@ on:
|
||||
- "apps/api/**"
|
||||
- "apps/worker/**"
|
||||
- "apps/public/**"
|
||||
- "apps/start/**"
|
||||
- "packages/**"
|
||||
- "!packages/sdks/**"
|
||||
- "**Dockerfile"
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"start": "dotenv -e ../../.env node dist/index.js",
|
||||
"build": "rm -rf dist && tsdown",
|
||||
"gen:bots": "jiti scripts/get-bots.ts && biome format --write src/bots/bots.ts",
|
||||
"test:manage": "jiti scripts/test-manage-api.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
340
apps/api/scripts/test-manage-api.ts
Normal file
340
apps/api/scripts/test-manage-api.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* One-off script to test all /manage/ API endpoints
|
||||
*
|
||||
* Usage:
|
||||
* pnpm test:manage
|
||||
* or
|
||||
* pnpm jiti scripts/test-manage-api.ts
|
||||
*
|
||||
* Set API_URL environment variable to test against a different server:
|
||||
* API_URL=http://localhost:3000 pnpm test:manage
|
||||
*/
|
||||
|
||||
const CLIENT_ID = process.env.CLIENT_ID!;
|
||||
const CLIENT_SECRET = process.env.CLIENT_SECRET!;
|
||||
const API_BASE_URL = process.env.API_URL || 'http://localhost:3333';
|
||||
|
||||
if (!CLIENT_ID || !CLIENT_SECRET) {
|
||||
console.error('CLIENT_ID and CLIENT_SECRET must be set');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
interface TestResult {
|
||||
name: string;
|
||||
method: string;
|
||||
url: string;
|
||||
status: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
const results: TestResult[] = [];
|
||||
|
||||
async function makeRequest(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: any,
|
||||
): Promise<TestResult> {
|
||||
const url = `${API_BASE_URL}${path}`;
|
||||
const headers: Record<string, string> = {
|
||||
'openpanel-client-id': CLIENT_ID,
|
||||
'openpanel-client-secret': CLIENT_SECRET,
|
||||
};
|
||||
|
||||
// Only set Content-Type if there's a body
|
||||
if (body) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
return {
|
||||
name: `${method} ${path}`,
|
||||
method,
|
||||
url,
|
||||
status: response.status,
|
||||
success: response.ok,
|
||||
error: response.ok ? undefined : data.message || 'Request failed',
|
||||
data: response.ok ? data : undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
name: `${method} ${path}`,
|
||||
method,
|
||||
url,
|
||||
status: 0,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function testProjects() {
|
||||
console.log('\n📁 Testing Projects endpoints...\n');
|
||||
|
||||
// Create project
|
||||
const createResult = await makeRequest('POST', '/manage/projects', {
|
||||
name: `Test Project ${Date.now()}`,
|
||||
domain: 'https://example.com',
|
||||
cors: ['https://example.com', 'https://www.example.com'],
|
||||
crossDomain: false,
|
||||
types: ['website'],
|
||||
});
|
||||
results.push(createResult);
|
||||
console.log(
|
||||
`✓ POST /manage/projects: ${createResult.success ? '✅' : '❌'} ${createResult.status}`,
|
||||
);
|
||||
if (createResult.error) console.log(` Error: ${createResult.error}`);
|
||||
|
||||
const projectId = createResult.data?.data?.id;
|
||||
const clientId = createResult.data?.data?.client?.id;
|
||||
const clientSecret = createResult.data?.data?.client?.secret;
|
||||
|
||||
if (projectId) {
|
||||
console.log(` Created project: ${projectId}`);
|
||||
if (clientId) console.log(` Created client: ${clientId}`);
|
||||
if (clientSecret) console.log(` Client secret: ${clientSecret}`);
|
||||
}
|
||||
|
||||
// List projects
|
||||
const listResult = await makeRequest('GET', '/manage/projects');
|
||||
results.push(listResult);
|
||||
console.log(
|
||||
`✓ GET /manage/projects: ${listResult.success ? '✅' : '❌'} ${listResult.status}`,
|
||||
);
|
||||
if (listResult.data?.data?.length) {
|
||||
console.log(` Found ${listResult.data.data.length} projects`);
|
||||
}
|
||||
|
||||
if (projectId) {
|
||||
// Get project
|
||||
const getResult = await makeRequest('GET', `/manage/projects/${projectId}`);
|
||||
results.push(getResult);
|
||||
console.log(
|
||||
`✓ GET /manage/projects/:id: ${getResult.success ? '✅' : '❌'} ${getResult.status}`,
|
||||
);
|
||||
|
||||
// Update project
|
||||
const updateResult = await makeRequest(
|
||||
'PATCH',
|
||||
`/manage/projects/${projectId}`,
|
||||
{
|
||||
name: 'Updated Test Project',
|
||||
crossDomain: true,
|
||||
},
|
||||
);
|
||||
results.push(updateResult);
|
||||
console.log(
|
||||
`✓ PATCH /manage/projects/:id: ${updateResult.success ? '✅' : '❌'} ${updateResult.status}`,
|
||||
);
|
||||
|
||||
// Delete project (soft delete)
|
||||
const deleteResult = await makeRequest(
|
||||
'DELETE',
|
||||
`/manage/projects/${projectId}`,
|
||||
);
|
||||
results.push(deleteResult);
|
||||
console.log(
|
||||
`✓ DELETE /manage/projects/:id: ${deleteResult.success ? '✅' : '❌'} ${deleteResult.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { projectId, clientId };
|
||||
}
|
||||
|
||||
async function testClients(projectId?: string) {
|
||||
console.log('\n🔑 Testing Clients endpoints...\n');
|
||||
|
||||
// Create client
|
||||
const createResult = await makeRequest('POST', '/manage/clients', {
|
||||
name: `Test Client ${Date.now()}`,
|
||||
projectId: projectId || undefined,
|
||||
type: 'read',
|
||||
});
|
||||
results.push(createResult);
|
||||
console.log(
|
||||
`✓ POST /manage/clients: ${createResult.success ? '✅' : '❌'} ${createResult.status}`,
|
||||
);
|
||||
if (createResult.error) console.log(` Error: ${createResult.error}`);
|
||||
|
||||
const clientId = createResult.data?.data?.id;
|
||||
const clientSecret = createResult.data?.data?.secret;
|
||||
|
||||
if (clientId) {
|
||||
console.log(` Created client: ${clientId}`);
|
||||
if (clientSecret) console.log(` Client secret: ${clientSecret}`);
|
||||
}
|
||||
|
||||
// List clients
|
||||
const listResult = await makeRequest(
|
||||
'GET',
|
||||
projectId ? `/manage/clients?projectId=${projectId}` : '/manage/clients',
|
||||
);
|
||||
results.push(listResult);
|
||||
console.log(
|
||||
`✓ GET /manage/clients: ${listResult.success ? '✅' : '❌'} ${listResult.status}`,
|
||||
);
|
||||
if (listResult.data?.data?.length) {
|
||||
console.log(` Found ${listResult.data.data.length} clients`);
|
||||
}
|
||||
|
||||
if (clientId) {
|
||||
// Get client
|
||||
const getResult = await makeRequest('GET', `/manage/clients/${clientId}`);
|
||||
results.push(getResult);
|
||||
console.log(
|
||||
`✓ GET /manage/clients/:id: ${getResult.success ? '✅' : '❌'} ${getResult.status}`,
|
||||
);
|
||||
|
||||
// Update client
|
||||
const updateResult = await makeRequest(
|
||||
'PATCH',
|
||||
`/manage/clients/${clientId}`,
|
||||
{
|
||||
name: 'Updated Test Client',
|
||||
},
|
||||
);
|
||||
results.push(updateResult);
|
||||
console.log(
|
||||
`✓ PATCH /manage/clients/:id: ${updateResult.success ? '✅' : '❌'} ${updateResult.status}`,
|
||||
);
|
||||
|
||||
// Delete client
|
||||
const deleteResult = await makeRequest(
|
||||
'DELETE',
|
||||
`/manage/clients/${clientId}`,
|
||||
);
|
||||
results.push(deleteResult);
|
||||
console.log(
|
||||
`✓ DELETE /manage/clients/:id: ${deleteResult.success ? '✅' : '❌'} ${deleteResult.status}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function testReferences(projectId?: string) {
|
||||
console.log('\n📚 Testing References endpoints...\n');
|
||||
|
||||
if (!projectId) {
|
||||
console.log(' ⚠️ Skipping references tests - no project ID available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create reference
|
||||
const createResult = await makeRequest('POST', '/manage/references', {
|
||||
projectId,
|
||||
title: `Test Reference ${Date.now()}`,
|
||||
description: 'This is a test reference',
|
||||
datetime: new Date().toISOString(),
|
||||
});
|
||||
results.push(createResult);
|
||||
console.log(
|
||||
`✓ POST /manage/references: ${createResult.success ? '✅' : '❌'} ${createResult.status}`,
|
||||
);
|
||||
if (createResult.error) console.log(` Error: ${createResult.error}`);
|
||||
|
||||
const referenceId = createResult.data?.data?.id;
|
||||
|
||||
if (referenceId) {
|
||||
console.log(` Created reference: ${referenceId}`);
|
||||
}
|
||||
|
||||
// List references
|
||||
const listResult = await makeRequest(
|
||||
'GET',
|
||||
`/manage/references?projectId=${projectId}`,
|
||||
);
|
||||
results.push(listResult);
|
||||
console.log(
|
||||
`✓ GET /manage/references: ${listResult.success ? '✅' : '❌'} ${listResult.status}`,
|
||||
);
|
||||
if (listResult.data?.data?.length) {
|
||||
console.log(` Found ${listResult.data.data.length} references`);
|
||||
}
|
||||
|
||||
if (referenceId) {
|
||||
// Get reference
|
||||
const getResult = await makeRequest(
|
||||
'GET',
|
||||
`/manage/references/${referenceId}`,
|
||||
);
|
||||
results.push(getResult);
|
||||
console.log(
|
||||
`✓ GET /manage/references/:id: ${getResult.success ? '✅' : '❌'} ${getResult.status}`,
|
||||
);
|
||||
|
||||
// Update reference
|
||||
const updateResult = await makeRequest(
|
||||
'PATCH',
|
||||
`/manage/references/${referenceId}`,
|
||||
{
|
||||
title: 'Updated Test Reference',
|
||||
description: 'Updated description',
|
||||
datetime: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
results.push(updateResult);
|
||||
console.log(
|
||||
`✓ PATCH /manage/references/:id: ${updateResult.success ? '✅' : '❌'} ${updateResult.status}`,
|
||||
);
|
||||
|
||||
// Delete reference
|
||||
const deleteResult = await makeRequest(
|
||||
'DELETE',
|
||||
`/manage/references/${referenceId}`,
|
||||
);
|
||||
results.push(deleteResult);
|
||||
console.log(
|
||||
`✓ DELETE /manage/references/:id: ${deleteResult.success ? '✅' : '❌'} ${deleteResult.status}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('🚀 Testing Manage API Endpoints\n');
|
||||
console.log(`API Base URL: ${API_BASE_URL}`);
|
||||
console.log(`Client ID: ${CLIENT_ID}\n`);
|
||||
|
||||
try {
|
||||
// Test projects first (creates a project we can use for other tests)
|
||||
const { projectId } = await testProjects();
|
||||
|
||||
// Test clients
|
||||
await testClients(projectId);
|
||||
|
||||
// Test references (requires a project)
|
||||
await testReferences(projectId);
|
||||
|
||||
// Summary
|
||||
console.log(`\n${'='.repeat(60)}`);
|
||||
console.log('📊 Test Summary\n');
|
||||
const successful = results.filter((r) => r.success).length;
|
||||
const failed = results.filter((r) => !r.success).length;
|
||||
|
||||
console.log(`Total tests: ${results.length}`);
|
||||
console.log(`✅ Successful: ${successful}`);
|
||||
console.log(`❌ Failed: ${failed}\n`);
|
||||
|
||||
if (failed > 0) {
|
||||
console.log('Failed tests:');
|
||||
results
|
||||
.filter((r) => !r.success)
|
||||
.forEach((r) => {
|
||||
console.log(` ❌ ${r.name} (${r.status})`);
|
||||
if (r.error) console.log(` Error: ${r.error}`);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fatal error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
649
apps/api/src/controllers/manage.controller.ts
Normal file
649
apps/api/src/controllers/manage.controller.ts
Normal file
@@ -0,0 +1,649 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { HttpError } from '@/utils/errors';
|
||||
import { stripTrailingSlash } from '@openpanel/common';
|
||||
import { hashPassword } from '@openpanel/common/server';
|
||||
import {
|
||||
db,
|
||||
getClientByIdCached,
|
||||
getId,
|
||||
getProjectByIdCached,
|
||||
} from '@openpanel/db';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Validation schemas
|
||||
const zCreateProject = z.object({
|
||||
name: z.string().min(1),
|
||||
domain: z.string().url().or(z.literal('')).or(z.null()).optional(),
|
||||
cors: z.array(z.string()).default([]),
|
||||
crossDomain: z.boolean().optional().default(false),
|
||||
types: z
|
||||
.array(z.enum(['website', 'app', 'backend']))
|
||||
.optional()
|
||||
.default([]),
|
||||
});
|
||||
|
||||
const zUpdateProject = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
domain: z.string().url().or(z.literal('')).or(z.null()).optional(),
|
||||
cors: z.array(z.string()).optional(),
|
||||
crossDomain: z.boolean().optional(),
|
||||
allowUnsafeRevenueTracking: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const zCreateClient = z.object({
|
||||
name: z.string().min(1),
|
||||
projectId: z.string().optional(),
|
||||
type: z.enum(['read', 'write', 'root']).optional().default('write'),
|
||||
});
|
||||
|
||||
const zUpdateClient = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
const zCreateReference = z.object({
|
||||
projectId: z.string(),
|
||||
title: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
datetime: z.string(),
|
||||
});
|
||||
|
||||
const zUpdateReference = z.object({
|
||||
title: z.string().min(1).optional(),
|
||||
description: z.string().optional(),
|
||||
datetime: z.string().optional(),
|
||||
});
|
||||
|
||||
// Projects CRUD
|
||||
export async function listProjects(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const projects = await db.project.findMany({
|
||||
where: {
|
||||
organizationId: request.client!.organizationId,
|
||||
deleteAt: null,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
reply.send({ data: projects });
|
||||
}
|
||||
|
||||
export async function getProject(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const project = await db.project.findFirst({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new HttpError('Project not found', { status: 404 });
|
||||
}
|
||||
|
||||
reply.send({ data: project });
|
||||
}
|
||||
|
||||
export async function createProject(
|
||||
request: FastifyRequest<{ Body: z.infer<typeof zCreateProject> }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const parsed = zCreateProject.safeParse(request.body);
|
||||
|
||||
if (parsed.success === false) {
|
||||
return reply.status(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid request body',
|
||||
details: parsed.error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
const { name, domain, cors, crossDomain, types } = parsed.data;
|
||||
|
||||
// Generate a default client secret
|
||||
const secret = `sec_${crypto.randomBytes(10).toString('hex')}`;
|
||||
const clientData = {
|
||||
organizationId: request.client!.organizationId,
|
||||
name: 'First client',
|
||||
type: 'write' as const,
|
||||
secret: await hashPassword(secret),
|
||||
};
|
||||
|
||||
const project = await db.project.create({
|
||||
data: {
|
||||
id: await getId('project', name),
|
||||
organizationId: request.client!.organizationId,
|
||||
name,
|
||||
domain: domain ? stripTrailingSlash(domain) : null,
|
||||
cors: cors.map((c) => stripTrailingSlash(c)),
|
||||
crossDomain: crossDomain ?? false,
|
||||
allowUnsafeRevenueTracking: false,
|
||||
filters: [],
|
||||
types,
|
||||
clients: {
|
||||
create: clientData,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
clients: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Clear cache
|
||||
await Promise.all([
|
||||
getProjectByIdCached.clear(project.id),
|
||||
project.clients.map((client) => {
|
||||
getClientByIdCached.clear(client.id);
|
||||
}),
|
||||
]);
|
||||
|
||||
reply.send({
|
||||
data: {
|
||||
...project,
|
||||
client: project.clients[0]
|
||||
? {
|
||||
id: project.clients[0].id,
|
||||
secret,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateProject(
|
||||
request: FastifyRequest<{
|
||||
Params: { id: string };
|
||||
Body: z.infer<typeof zUpdateProject>;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const parsed = zUpdateProject.safeParse(request.body);
|
||||
|
||||
if (parsed.success === false) {
|
||||
return reply.status(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid request body',
|
||||
details: parsed.error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
// Verify project exists and belongs to organization
|
||||
const existing = await db.project.findFirst({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
include: {
|
||||
clients: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new HttpError('Project not found', { status: 404 });
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
if (parsed.data.name !== undefined) {
|
||||
updateData.name = parsed.data.name;
|
||||
}
|
||||
if (parsed.data.domain !== undefined) {
|
||||
updateData.domain = parsed.data.domain
|
||||
? stripTrailingSlash(parsed.data.domain)
|
||||
: null;
|
||||
}
|
||||
if (parsed.data.cors !== undefined) {
|
||||
updateData.cors = parsed.data.cors.map((c) => stripTrailingSlash(c));
|
||||
}
|
||||
if (parsed.data.crossDomain !== undefined) {
|
||||
updateData.crossDomain = parsed.data.crossDomain;
|
||||
}
|
||||
if (parsed.data.allowUnsafeRevenueTracking !== undefined) {
|
||||
updateData.allowUnsafeRevenueTracking =
|
||||
parsed.data.allowUnsafeRevenueTracking;
|
||||
}
|
||||
|
||||
const project = await db.project.update({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
// Clear cache
|
||||
await Promise.all([
|
||||
getProjectByIdCached.clear(project.id),
|
||||
existing.clients.map((client) => {
|
||||
getClientByIdCached.clear(client.id);
|
||||
}),
|
||||
]);
|
||||
|
||||
reply.send({ data: project });
|
||||
}
|
||||
|
||||
export async function deleteProject(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const project = await db.project.findFirst({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new HttpError('Project not found', { status: 404 });
|
||||
}
|
||||
|
||||
await db.project.update({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
data: {
|
||||
deleteAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
await getProjectByIdCached.clear(request.params.id);
|
||||
|
||||
reply.send({ success: true });
|
||||
}
|
||||
|
||||
// Clients CRUD
|
||||
export async function listClients(
|
||||
request: FastifyRequest<{ Querystring: { projectId?: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const where: any = {
|
||||
organizationId: request.client!.organizationId,
|
||||
};
|
||||
|
||||
if (request.query.projectId) {
|
||||
// Verify project belongs to organization
|
||||
const project = await db.project.findFirst({
|
||||
where: {
|
||||
id: request.query.projectId,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new HttpError('Project not found', { status: 404 });
|
||||
}
|
||||
|
||||
where.projectId = request.query.projectId;
|
||||
}
|
||||
|
||||
const clients = await db.client.findMany({
|
||||
where,
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
reply.send({ data: clients });
|
||||
}
|
||||
|
||||
export async function getClient(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const client = await db.client.findFirst({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
throw new HttpError('Client not found', { status: 404 });
|
||||
}
|
||||
|
||||
reply.send({ data: client });
|
||||
}
|
||||
|
||||
export async function createClient(
|
||||
request: FastifyRequest<{ Body: z.infer<typeof zCreateClient> }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const parsed = zCreateClient.safeParse(request.body);
|
||||
|
||||
if (parsed.success === false) {
|
||||
return reply.status(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid request body',
|
||||
details: parsed.error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
const { name, projectId, type } = parsed.data;
|
||||
|
||||
// If projectId is provided, verify it belongs to organization
|
||||
if (projectId) {
|
||||
const project = await db.project.findFirst({
|
||||
where: {
|
||||
id: projectId,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new HttpError('Project not found', { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
// Generate secret
|
||||
const secret = `sec_${crypto.randomBytes(10).toString('hex')}`;
|
||||
|
||||
const client = await db.client.create({
|
||||
data: {
|
||||
organizationId: request.client!.organizationId,
|
||||
projectId: projectId || null,
|
||||
name,
|
||||
type: type || 'write',
|
||||
secret: await hashPassword(secret),
|
||||
},
|
||||
});
|
||||
|
||||
await getClientByIdCached.clear(client.id);
|
||||
|
||||
reply.send({
|
||||
data: {
|
||||
...client,
|
||||
secret, // Return plain secret only once
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateClient(
|
||||
request: FastifyRequest<{
|
||||
Params: { id: string };
|
||||
Body: z.infer<typeof zUpdateClient>;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const parsed = zUpdateClient.safeParse(request.body);
|
||||
|
||||
if (parsed.success === false) {
|
||||
return reply.status(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid request body',
|
||||
details: parsed.error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
// Verify client exists and belongs to organization
|
||||
const existing = await db.client.findFirst({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new HttpError('Client not found', { status: 404 });
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
if (parsed.data.name !== undefined) {
|
||||
updateData.name = parsed.data.name;
|
||||
}
|
||||
|
||||
const client = await db.client.update({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
await getClientByIdCached.clear(client.id);
|
||||
|
||||
reply.send({ data: client });
|
||||
}
|
||||
|
||||
export async function deleteClient(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const client = await db.client.findFirst({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
throw new HttpError('Client not found', { status: 404 });
|
||||
}
|
||||
|
||||
await db.client.delete({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
});
|
||||
|
||||
await getClientByIdCached.clear(request.params.id);
|
||||
|
||||
reply.send({ success: true });
|
||||
}
|
||||
|
||||
// References CRUD
|
||||
export async function listReferences(
|
||||
request: FastifyRequest<{ Querystring: { projectId?: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const where: any = {};
|
||||
|
||||
if (request.query.projectId) {
|
||||
// Verify project belongs to organization
|
||||
const project = await db.project.findFirst({
|
||||
where: {
|
||||
id: request.query.projectId,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new HttpError('Project not found', { status: 404 });
|
||||
}
|
||||
|
||||
where.projectId = request.query.projectId;
|
||||
} else {
|
||||
// If no projectId, get all projects in org and filter references
|
||||
const projects = await db.project.findMany({
|
||||
where: {
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
where.projectId = {
|
||||
in: projects.map((p) => p.id),
|
||||
};
|
||||
}
|
||||
|
||||
const references = await db.reference.findMany({
|
||||
where,
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
reply.send({ data: references });
|
||||
}
|
||||
|
||||
export async function getReference(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const reference = await db.reference.findUnique({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
organizationId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!reference) {
|
||||
throw new HttpError('Reference not found', { status: 404 });
|
||||
}
|
||||
|
||||
if (reference.project.organizationId !== request.client!.organizationId) {
|
||||
throw new HttpError('Reference not found', { status: 404 });
|
||||
}
|
||||
|
||||
reply.send({ data: reference });
|
||||
}
|
||||
|
||||
export async function createReference(
|
||||
request: FastifyRequest<{ Body: z.infer<typeof zCreateReference> }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const parsed = zCreateReference.safeParse(request.body);
|
||||
|
||||
if (parsed.success === false) {
|
||||
return reply.status(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid request body',
|
||||
details: parsed.error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
const { projectId, title, description, datetime } = parsed.data;
|
||||
|
||||
// Verify project belongs to organization
|
||||
const project = await db.project.findFirst({
|
||||
where: {
|
||||
id: projectId,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new HttpError('Project not found', { status: 404 });
|
||||
}
|
||||
|
||||
const reference = await db.reference.create({
|
||||
data: {
|
||||
projectId,
|
||||
title,
|
||||
description: description || null,
|
||||
date: new Date(datetime),
|
||||
},
|
||||
});
|
||||
|
||||
reply.send({ data: reference });
|
||||
}
|
||||
|
||||
export async function updateReference(
|
||||
request: FastifyRequest<{
|
||||
Params: { id: string };
|
||||
Body: z.infer<typeof zUpdateReference>;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const parsed = zUpdateReference.safeParse(request.body);
|
||||
|
||||
if (parsed.success === false) {
|
||||
return reply.status(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid request body',
|
||||
details: parsed.error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
// Verify reference exists and belongs to organization
|
||||
const existing = await db.reference.findUnique({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
organizationId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new HttpError('Reference not found', { status: 404 });
|
||||
}
|
||||
|
||||
if (existing.project.organizationId !== request.client!.organizationId) {
|
||||
throw new HttpError('Reference not found', { status: 404 });
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
if (parsed.data.title !== undefined) {
|
||||
updateData.title = parsed.data.title;
|
||||
}
|
||||
if (parsed.data.description !== undefined) {
|
||||
updateData.description = parsed.data.description ?? null;
|
||||
}
|
||||
if (parsed.data.datetime !== undefined) {
|
||||
updateData.date = new Date(parsed.data.datetime);
|
||||
}
|
||||
|
||||
const reference = await db.reference.update({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
reply.send({ data: reference });
|
||||
}
|
||||
|
||||
export async function deleteReference(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const reference = await db.reference.findUnique({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
organizationId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!reference) {
|
||||
throw new HttpError('Reference not found', { status: 404 });
|
||||
}
|
||||
|
||||
if (reference.project.organizationId !== request.client!.organizationId) {
|
||||
throw new HttpError('Reference not found', { status: 404 });
|
||||
}
|
||||
|
||||
await db.reference.delete({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
});
|
||||
|
||||
reply.send({ success: true });
|
||||
}
|
||||
@@ -49,7 +49,7 @@ function getIdentity(body: ITrackHandlerPayload): IIdentifyPayload | undefined {
|
||||
identity ||
|
||||
(body.payload.profileId
|
||||
? {
|
||||
profileId: body.payload.profileId,
|
||||
profileId: String(body.payload.profileId),
|
||||
}
|
||||
: undefined)
|
||||
);
|
||||
|
||||
@@ -44,6 +44,6 @@ export async function isBotHook(
|
||||
}
|
||||
}
|
||||
|
||||
return reply.status(202).send('OK');
|
||||
return reply.status(202).send();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import exportRouter from './routes/export.router';
|
||||
import importRouter from './routes/import.router';
|
||||
import insightsRouter from './routes/insights.router';
|
||||
import liveRouter from './routes/live.router';
|
||||
import manageRouter from './routes/manage.router';
|
||||
import miscRouter from './routes/misc.router';
|
||||
import oauthRouter from './routes/oauth-callback.router';
|
||||
import profileRouter from './routes/profile.router';
|
||||
@@ -143,7 +144,7 @@ const startServer = async () => {
|
||||
instance.addHook('onRequest', async (req) => {
|
||||
if (req.cookies?.session) {
|
||||
try {
|
||||
const sessionId = decodeSessionToken(req.cookies.session);
|
||||
const sessionId = decodeSessionToken(req.cookies?.session);
|
||||
const session = await runWithAlsSession(sessionId, () =>
|
||||
validateSessionToken(req.cookies.session),
|
||||
);
|
||||
@@ -151,6 +152,15 @@ const startServer = async () => {
|
||||
} catch (e) {
|
||||
req.session = EMPTY_SESSION;
|
||||
}
|
||||
} else if (process.env.DEMO_USER_ID) {
|
||||
try {
|
||||
const session = await runWithAlsSession('1', () =>
|
||||
validateSessionToken(null),
|
||||
);
|
||||
req.session = session;
|
||||
} catch (e) {
|
||||
req.session = EMPTY_SESSION;
|
||||
}
|
||||
} else {
|
||||
req.session = EMPTY_SESSION;
|
||||
}
|
||||
@@ -194,6 +204,7 @@ const startServer = async () => {
|
||||
instance.register(importRouter, { prefix: '/import' });
|
||||
instance.register(insightsRouter, { prefix: '/insights' });
|
||||
instance.register(trackRouter, { prefix: '/track' });
|
||||
instance.register(manageRouter, { prefix: '/manage' });
|
||||
// Keep existing endpoints for backward compatibility
|
||||
instance.get('/healthcheck', healthcheck);
|
||||
// New Kubernetes-style health endpoints
|
||||
|
||||
132
apps/api/src/routes/manage.router.ts
Normal file
132
apps/api/src/routes/manage.router.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import * as controller from '@/controllers/manage.controller';
|
||||
import { validateManageRequest } from '@/utils/auth';
|
||||
import { activateRateLimiter } from '@/utils/rate-limiter';
|
||||
import { Prisma } from '@openpanel/db';
|
||||
import type { FastifyPluginCallback, FastifyRequest } from 'fastify';
|
||||
|
||||
const manageRouter: FastifyPluginCallback = async (fastify) => {
|
||||
await activateRateLimiter({
|
||||
fastify,
|
||||
max: 20,
|
||||
timeWindow: '10 seconds',
|
||||
});
|
||||
|
||||
fastify.addHook('preHandler', async (req: FastifyRequest, reply) => {
|
||||
try {
|
||||
const client = await validateManageRequest(req.headers);
|
||||
req.client = client;
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
return reply.status(401).send({
|
||||
error: 'Unauthorized',
|
||||
message: 'Client ID seems to be malformed',
|
||||
});
|
||||
}
|
||||
|
||||
if (e instanceof Error) {
|
||||
return reply
|
||||
.status(401)
|
||||
.send({ error: 'Unauthorized', message: e.message });
|
||||
}
|
||||
|
||||
return reply
|
||||
.status(401)
|
||||
.send({ error: 'Unauthorized', message: 'Unexpected error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Projects routes
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/projects',
|
||||
handler: controller.listProjects,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/projects/:id',
|
||||
handler: controller.getProject,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'POST',
|
||||
url: '/projects',
|
||||
handler: controller.createProject,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'PATCH',
|
||||
url: '/projects/:id',
|
||||
handler: controller.updateProject,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'DELETE',
|
||||
url: '/projects/:id',
|
||||
handler: controller.deleteProject,
|
||||
});
|
||||
|
||||
// Clients routes
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/clients',
|
||||
handler: controller.listClients,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/clients/:id',
|
||||
handler: controller.getClient,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'POST',
|
||||
url: '/clients',
|
||||
handler: controller.createClient,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'PATCH',
|
||||
url: '/clients/:id',
|
||||
handler: controller.updateClient,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'DELETE',
|
||||
url: '/clients/:id',
|
||||
handler: controller.deleteClient,
|
||||
});
|
||||
|
||||
// References routes
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/references',
|
||||
handler: controller.listReferences,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/references/:id',
|
||||
handler: controller.getReference,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'POST',
|
||||
url: '/references',
|
||||
handler: controller.createReference,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'PATCH',
|
||||
url: '/references/:id',
|
||||
handler: controller.updateReference,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'DELETE',
|
||||
url: '/references/:id',
|
||||
handler: controller.deleteReference,
|
||||
});
|
||||
};
|
||||
|
||||
export default manageRouter;
|
||||
@@ -236,3 +236,40 @@ export async function validateImportRequest(
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function validateManageRequest(
|
||||
headers: RawRequestDefaultExpression['headers'],
|
||||
): Promise<IServiceClientWithProject> {
|
||||
const clientId = headers['openpanel-client-id'] as string;
|
||||
const clientSecret = (headers['openpanel-client-secret'] as string) || '';
|
||||
|
||||
if (
|
||||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(
|
||||
clientId,
|
||||
)
|
||||
) {
|
||||
throw new Error('Manage: Client ID must be a valid UUIDv4');
|
||||
}
|
||||
|
||||
const client = await getClientByIdCached(clientId);
|
||||
|
||||
if (!client) {
|
||||
throw new Error('Manage: Invalid client id');
|
||||
}
|
||||
|
||||
if (!client.secret) {
|
||||
throw new Error('Manage: Client has no secret');
|
||||
}
|
||||
|
||||
if (client.type !== ClientType.root) {
|
||||
throw new Error(
|
||||
'Manage: Only root clients are allowed to manage resources',
|
||||
);
|
||||
}
|
||||
|
||||
if (!(await verifyPassword(clientSecret, client.secret))) {
|
||||
throw new Error('Manage: Invalid client secret');
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
@@ -7,11 +7,12 @@ description: Learn how to authenticate with the OpenPanel API using client crede
|
||||
|
||||
To authenticate with the OpenPanel API, you need to use your `clientId` and `clientSecret`. Different API endpoints may require different access levels:
|
||||
|
||||
- **Track API**: Default client works with `track` mode
|
||||
- **Track API**: Default client works with `write` mode
|
||||
- **Export API**: Requires `read` or `root` mode
|
||||
- **Insights API**: Requires `read` or `root` mode
|
||||
- **Manage API**: Requires `root` mode only
|
||||
|
||||
The default client does not have access to the Export or Insights APIs.
|
||||
The default client (created with a project) has `write` mode and does not have access to the Export, Insights, or Manage APIs. You'll need to create additional clients with appropriate access levels.
|
||||
|
||||
## Headers
|
||||
|
||||
@@ -48,15 +49,29 @@ If authentication fails, you'll receive a `401 Unauthorized` response:
|
||||
|
||||
Common authentication errors:
|
||||
- Invalid client ID or secret
|
||||
- Client doesn't have required permissions
|
||||
- Malformed client ID
|
||||
- Client doesn't have required permissions (e.g., trying to access Manage API with a non-root client)
|
||||
- Malformed client ID (must be a valid UUIDv4)
|
||||
- Client type mismatch (e.g., `write` client trying to access Export API)
|
||||
|
||||
## Client Types
|
||||
|
||||
OpenPanel supports three client types with different access levels:
|
||||
|
||||
| Type | Description | Access |
|
||||
|------|-------------|--------|
|
||||
| `write` | Write access | Track API only |
|
||||
| `read` | Read-only access | Export API, Insights API |
|
||||
| `root` | Full access | All APIs including Manage API |
|
||||
|
||||
**Note**: Root clients have organization-wide access and can manage all resources. Use root clients carefully and store their credentials securely.
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
The API implements rate limiting to prevent abuse. Rate limits vary by endpoint:
|
||||
|
||||
- **Track API**: Higher limits for event tracking
|
||||
- **Export/Insights APIs**: Lower limits for data retrieval
|
||||
- **Export/Insights APIs**: 100 requests per 10 seconds
|
||||
- **Manage API**: 20 requests per 10 seconds
|
||||
|
||||
If you exceed the rate limit, you'll receive a `429 Too Many Requests` response. Implement exponential backoff for retries.
|
||||
|
||||
|
||||
332
apps/public/content/docs/api/manage/clients.mdx
Normal file
332
apps/public/content/docs/api/manage/clients.mdx
Normal file
@@ -0,0 +1,332 @@
|
||||
---
|
||||
title: Clients
|
||||
description: Manage API clients for your OpenPanel projects. Create, read, update, and delete clients with different access levels.
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
To authenticate with the Clients API, you need to use your `clientId` and `clientSecret` from a root client. Root clients have organization-wide access.
|
||||
|
||||
For detailed authentication information, see the [Authentication](/docs/api/authentication) guide.
|
||||
|
||||
Include the following headers with your requests:
|
||||
- `openpanel-client-id`: Your OpenPanel root client ID
|
||||
- `openpanel-client-secret`: Your OpenPanel root client secret
|
||||
|
||||
## Base URL
|
||||
|
||||
All Clients API requests should be made to:
|
||||
|
||||
```
|
||||
https://api.openpanel.dev/manage/clients
|
||||
```
|
||||
|
||||
## Client Types
|
||||
|
||||
OpenPanel supports three client types with different access levels:
|
||||
|
||||
| Type | Description | Use Case |
|
||||
|------|-------------|----------|
|
||||
| `read` | Read-only access | Export data, view insights, read-only operations |
|
||||
| `write` | Write access | Track events, send data to OpenPanel |
|
||||
| `root` | Full access | Manage resources, access Manage API |
|
||||
|
||||
**Note**: Only `root` clients can access the Manage API.
|
||||
|
||||
## Endpoints
|
||||
|
||||
### List Clients
|
||||
|
||||
Retrieve all clients in your organization, optionally filtered by project.
|
||||
|
||||
```
|
||||
GET /manage/clients
|
||||
```
|
||||
|
||||
#### Query Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `projectId` | string | Optional. Filter clients by project ID |
|
||||
|
||||
#### Example Request
|
||||
|
||||
```bash
|
||||
# List all clients
|
||||
curl 'https://api.openpanel.dev/manage/clients' \
|
||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
|
||||
|
||||
# List clients for a specific project
|
||||
curl 'https://api.openpanel.dev/manage/clients?projectId=my-project' \
|
||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
|
||||
```
|
||||
|
||||
#### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "fa0c2780-55f2-4d9e-bea0-da2e02c7b1a9",
|
||||
"name": "First client",
|
||||
"type": "write",
|
||||
"projectId": "my-project",
|
||||
"organizationId": "org_123",
|
||||
"ignoreCorsAndSecret": false,
|
||||
"createdAt": "2024-01-15T10:30:00.000Z",
|
||||
"updatedAt": "2024-01-15T10:30:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "b8904453-863d-4e04-8ebc-8abae30ffb1a",
|
||||
"name": "Read-only Client",
|
||||
"type": "read",
|
||||
"projectId": "my-project",
|
||||
"organizationId": "org_123",
|
||||
"ignoreCorsAndSecret": false,
|
||||
"createdAt": "2024-01-15T11:00:00.000Z",
|
||||
"updatedAt": "2024-01-15T11:00:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: Client secrets are never returned in list or get responses for security reasons.
|
||||
|
||||
### Get Client
|
||||
|
||||
Retrieve a specific client by ID.
|
||||
|
||||
```
|
||||
GET /manage/clients/{id}
|
||||
```
|
||||
|
||||
#### Path Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `id` | string | The ID of the client (UUID) |
|
||||
|
||||
#### Example Request
|
||||
|
||||
```bash
|
||||
curl 'https://api.openpanel.dev/manage/clients/fa0c2780-55f2-4d9e-bea0-da2e02c7b1a9' \
|
||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
|
||||
```
|
||||
|
||||
#### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": "fa0c2780-55f2-4d9e-bea0-da2e02c7b1a9",
|
||||
"name": "First client",
|
||||
"type": "write",
|
||||
"projectId": "my-project",
|
||||
"organizationId": "org_123",
|
||||
"ignoreCorsAndSecret": false,
|
||||
"createdAt": "2024-01-15T10:30:00.000Z",
|
||||
"updatedAt": "2024-01-15T10:30:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Create Client
|
||||
|
||||
Create a new API client. A secure secret is automatically generated and returned once.
|
||||
|
||||
```
|
||||
POST /manage/clients
|
||||
```
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `name` | string | Yes | Client name (minimum 1 character) |
|
||||
| `projectId` | string | No | Associate client with a specific project |
|
||||
| `type` | string | No | Client type: `read`, `write`, or `root` (default: `write`) |
|
||||
|
||||
#### Example Request
|
||||
|
||||
```bash
|
||||
curl -X POST 'https://api.openpanel.dev/manage/clients' \
|
||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "My API Client",
|
||||
"projectId": "my-project",
|
||||
"type": "read"
|
||||
}'
|
||||
```
|
||||
|
||||
#### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": "b8904453-863d-4e04-8ebc-8abae30ffb1a",
|
||||
"name": "My API Client",
|
||||
"type": "read",
|
||||
"projectId": "my-project",
|
||||
"organizationId": "org_123",
|
||||
"ignoreCorsAndSecret": false,
|
||||
"createdAt": "2024-01-15T11:00:00.000Z",
|
||||
"updatedAt": "2024-01-15T11:00:00.000Z",
|
||||
"secret": "sec_b2521ca283bf903b46b3"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Important**: The `secret` field is only returned once when the client is created. Store it securely immediately. You cannot retrieve the secret later - if lost, you'll need to delete and recreate the client.
|
||||
|
||||
### Update Client
|
||||
|
||||
Update an existing client's name.
|
||||
|
||||
```
|
||||
PATCH /manage/clients/{id}
|
||||
```
|
||||
|
||||
#### Path Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `id` | string | The ID of the client (UUID) |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `name` | string | No | New client name (minimum 1 character) |
|
||||
|
||||
**Note**: Currently, only the `name` field can be updated. To change the client type or project association, delete and recreate the client.
|
||||
|
||||
#### Example Request
|
||||
|
||||
```bash
|
||||
curl -X PATCH 'https://api.openpanel.dev/manage/clients/b8904453-863d-4e04-8ebc-8abae30ffb1a' \
|
||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "Updated Client Name"
|
||||
}'
|
||||
```
|
||||
|
||||
#### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": "b8904453-863d-4e04-8ebc-8abae30ffb1a",
|
||||
"name": "Updated Client Name",
|
||||
"type": "read",
|
||||
"projectId": "my-project",
|
||||
"organizationId": "org_123",
|
||||
"ignoreCorsAndSecret": false,
|
||||
"createdAt": "2024-01-15T11:00:00.000Z",
|
||||
"updatedAt": "2024-01-15T11:30:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Delete Client
|
||||
|
||||
Permanently delete a client. This action cannot be undone.
|
||||
|
||||
```
|
||||
DELETE /manage/clients/{id}
|
||||
```
|
||||
|
||||
#### Path Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `id` | string | The ID of the client (UUID) |
|
||||
|
||||
#### Example Request
|
||||
|
||||
```bash
|
||||
curl -X DELETE 'https://api.openpanel.dev/manage/clients/b8904453-863d-4e04-8ebc-8abae30ffb1a' \
|
||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
|
||||
```
|
||||
|
||||
#### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
**Warning**: Deleting a client is permanent. Any applications using this client will immediately lose access. Make sure to update your applications before deleting a client.
|
||||
|
||||
## Error Handling
|
||||
|
||||
The API uses standard HTTP response codes. Common error responses:
|
||||
|
||||
### 400 Bad Request
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Bad Request",
|
||||
"message": "Invalid request body",
|
||||
"details": [
|
||||
{
|
||||
"path": ["name"],
|
||||
"message": "String must contain at least 1 character(s)"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 401 Unauthorized
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": "Manage: Only root clients are allowed to manage resources"
|
||||
}
|
||||
```
|
||||
|
||||
### 404 Not Found
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Not Found",
|
||||
"message": "Client not found"
|
||||
}
|
||||
```
|
||||
|
||||
### 429 Too Many Requests
|
||||
|
||||
Rate limiting response includes headers indicating your rate limit status.
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
The Clients API implements rate limiting:
|
||||
- **20 requests per 10 seconds** per client
|
||||
- Rate limit headers included in responses
|
||||
- Implement exponential backoff for retries
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. **Store Secrets Securely**: Client secrets are only shown once on creation. Store them in secure credential management systems
|
||||
2. **Use Appropriate Client Types**: Use the minimum required access level for each use case
|
||||
3. **Rotate Secrets Regularly**: Delete old clients and create new ones to rotate secrets
|
||||
4. **Never Expose Secrets**: Never commit client secrets to version control or expose them in client-side code
|
||||
5. **Monitor Client Usage**: Regularly review and remove unused clients
|
||||
|
||||
## Notes
|
||||
|
||||
- Client IDs are UUIDs (Universally Unique Identifiers)
|
||||
- Client secrets are automatically generated with the format `sec_` followed by random hex characters
|
||||
- Secrets are hashed using argon2 before storage
|
||||
- Clients can be associated with a project or exist at the organization level
|
||||
- Clients are scoped to your organization - you can only manage clients in your organization
|
||||
- The `ignoreCorsAndSecret` field is an advanced setting that bypasses CORS and secret validation (use with caution)
|
||||
140
apps/public/content/docs/api/manage/index.mdx
Normal file
140
apps/public/content/docs/api/manage/index.mdx
Normal file
@@ -0,0 +1,140 @@
|
||||
---
|
||||
title: Manage API Overview
|
||||
description: Programmatically manage projects, clients, and references in your OpenPanel organization using the Manage API.
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The Manage API provides programmatic access to manage your OpenPanel resources including projects, clients, and references. This API is designed for automation, infrastructure-as-code, and administrative tasks.
|
||||
|
||||
## Authentication
|
||||
|
||||
The Manage API requires a **root client** for authentication. Root clients have organization-wide access and can manage all resources within their organization.
|
||||
|
||||
To authenticate with the Manage API, you need:
|
||||
- A client with `type: 'root'`
|
||||
- Your `clientId` and `clientSecret`
|
||||
|
||||
For detailed authentication information, see the [Authentication](/docs/api/authentication) guide.
|
||||
|
||||
Include the following headers with your requests:
|
||||
- `openpanel-client-id`: Your OpenPanel root client ID
|
||||
- `openpanel-client-secret`: Your OpenPanel root client secret
|
||||
|
||||
## Base URL
|
||||
|
||||
All Manage API requests should be made to:
|
||||
|
||||
```
|
||||
https://api.openpanel.dev/manage
|
||||
```
|
||||
|
||||
## Available Resources
|
||||
|
||||
The Manage API provides CRUD operations for three resource types:
|
||||
|
||||
### Projects
|
||||
|
||||
Manage your analytics projects programmatically:
|
||||
- **[Projects Documentation](/docs/api/manage/projects)** - Create, read, update, and delete projects
|
||||
- Automatically creates a default write client when creating a project
|
||||
- Supports project configuration including domains, CORS settings, and project types
|
||||
|
||||
### Clients
|
||||
|
||||
Manage API clients for your projects:
|
||||
- **[Clients Documentation](/docs/api/manage/clients)** - Create, read, update, and delete clients
|
||||
- Supports different client types: `read`, `write`, and `root`
|
||||
- Auto-generates secure secrets on creation (returned once)
|
||||
|
||||
### References
|
||||
|
||||
Manage reference points for your analytics:
|
||||
- **[References Documentation](/docs/api/manage/references)** - Create, read, update, and delete references
|
||||
- Useful for marking important dates or events in your analytics timeline
|
||||
- Can be filtered by project
|
||||
|
||||
## Common Features
|
||||
|
||||
All endpoints share these common characteristics:
|
||||
|
||||
### Organization Scope
|
||||
|
||||
All operations are scoped to your organization. You can only manage resources that belong to your organization.
|
||||
|
||||
### Response Format
|
||||
|
||||
Successful responses follow this structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
// Resource data
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For list endpoints:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
// Array of resources
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
The API uses standard HTTP response codes:
|
||||
|
||||
- `200 OK` - Request successful
|
||||
- `400 Bad Request` - Invalid request parameters
|
||||
- `401 Unauthorized` - Authentication failed
|
||||
- `404 Not Found` - Resource not found
|
||||
- `429 Too Many Requests` - Rate limit exceeded
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
The Manage API implements rate limiting:
|
||||
- **20 requests per 10 seconds** per client
|
||||
- Rate limit headers included in responses
|
||||
- Implement exponential backoff for retries
|
||||
|
||||
## Use Cases
|
||||
|
||||
The Manage API is ideal for:
|
||||
|
||||
- **Infrastructure as Code**: Manage OpenPanel resources alongside your application infrastructure
|
||||
- **Automation**: Automatically create projects and clients for new deployments
|
||||
- **Bulk Operations**: Programmatically manage multiple resources
|
||||
- **CI/CD Integration**: Set up projects and clients as part of your deployment pipeline
|
||||
- **Administrative Tools**: Build custom admin interfaces
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. **Root Clients Only**: Only root clients can access the Manage API
|
||||
2. **Store Credentials Securely**: Never expose root client credentials in client-side code
|
||||
3. **Use HTTPS**: Always use HTTPS for API requests
|
||||
4. **Rotate Credentials**: Regularly rotate your root client credentials
|
||||
5. **Limit Access**: Restrict root client creation to trusted administrators
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. **Create a Root Client**: Use the dashboard to create a root client in your organization
|
||||
2. **Store Credentials**: Securely store your root client ID and secret
|
||||
3. **Make Your First Request**: Start with listing projects to verify authentication
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
curl 'https://api.openpanel.dev/manage/projects' \
|
||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Read the [Projects documentation](/docs/api/manage/projects) to manage projects
|
||||
- Read the [Clients documentation](/docs/api/manage/clients) to manage API clients
|
||||
- Read the [References documentation](/docs/api/manage/references) to manage reference points
|
||||
5
apps/public/content/docs/api/manage/meta.json
Normal file
5
apps/public/content/docs/api/manage/meta.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"title": "Manage",
|
||||
"pages": ["projects", "clients", "references"],
|
||||
"defaultOpen": false
|
||||
}
|
||||
327
apps/public/content/docs/api/manage/projects.mdx
Normal file
327
apps/public/content/docs/api/manage/projects.mdx
Normal file
@@ -0,0 +1,327 @@
|
||||
---
|
||||
title: Projects
|
||||
description: Manage your OpenPanel projects programmatically. Create, read, update, and delete projects using the Manage API.
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
To authenticate with the Projects API, you need to use your `clientId` and `clientSecret` from a root client. Root clients have organization-wide access.
|
||||
|
||||
For detailed authentication information, see the [Authentication](/docs/api/authentication) guide.
|
||||
|
||||
Include the following headers with your requests:
|
||||
- `openpanel-client-id`: Your OpenPanel root client ID
|
||||
- `openpanel-client-secret`: Your OpenPanel root client secret
|
||||
|
||||
## Base URL
|
||||
|
||||
All Projects API requests should be made to:
|
||||
|
||||
```
|
||||
https://api.openpanel.dev/manage/projects
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
### List Projects
|
||||
|
||||
Retrieve all projects in your organization.
|
||||
|
||||
```
|
||||
GET /manage/projects
|
||||
```
|
||||
|
||||
#### Example Request
|
||||
|
||||
```bash
|
||||
curl 'https://api.openpanel.dev/manage/projects' \
|
||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
|
||||
```
|
||||
|
||||
#### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "my-project",
|
||||
"name": "My Project",
|
||||
"organizationId": "org_123",
|
||||
"domain": "https://example.com",
|
||||
"cors": ["https://example.com", "https://www.example.com"],
|
||||
"crossDomain": false,
|
||||
"allowUnsafeRevenueTracking": false,
|
||||
"filters": [],
|
||||
"types": ["website"],
|
||||
"eventsCount": 0,
|
||||
"createdAt": "2024-01-15T10:30:00.000Z",
|
||||
"updatedAt": "2024-01-15T10:30:00.000Z",
|
||||
"deleteAt": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Get Project
|
||||
|
||||
Retrieve a specific project by ID.
|
||||
|
||||
```
|
||||
GET /manage/projects/{id}
|
||||
```
|
||||
|
||||
#### Path Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `id` | string | The ID of the project |
|
||||
|
||||
#### Example Request
|
||||
|
||||
```bash
|
||||
curl 'https://api.openpanel.dev/manage/projects/my-project' \
|
||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
|
||||
```
|
||||
|
||||
#### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": "my-project",
|
||||
"name": "My Project",
|
||||
"organizationId": "org_123",
|
||||
"domain": "https://example.com",
|
||||
"cors": ["https://example.com"],
|
||||
"crossDomain": false,
|
||||
"allowUnsafeRevenueTracking": false,
|
||||
"filters": [],
|
||||
"types": ["website"],
|
||||
"eventsCount": 0,
|
||||
"createdAt": "2024-01-15T10:30:00.000Z",
|
||||
"updatedAt": "2024-01-15T10:30:00.000Z",
|
||||
"deleteAt": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Create Project
|
||||
|
||||
Create a new project in your organization. A default write client is automatically created with the project.
|
||||
|
||||
```
|
||||
POST /manage/projects
|
||||
```
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `name` | string | Yes | Project name (minimum 1 character) |
|
||||
| `domain` | string \| null | No | Primary domain for the project (URL format or empty string) |
|
||||
| `cors` | string[] | No | Array of allowed CORS origins (default: `[]`) |
|
||||
| `crossDomain` | boolean | No | Enable cross-domain tracking (default: `false`) |
|
||||
| `types` | string[] | No | Project types: `website`, `app`, `backend` (default: `[]`) |
|
||||
|
||||
#### Project Types
|
||||
|
||||
- `website`: Web-based project
|
||||
- `app`: Mobile application
|
||||
- `backend`: Backend/server-side project
|
||||
|
||||
#### Example Request
|
||||
|
||||
```bash
|
||||
curl -X POST 'https://api.openpanel.dev/manage/projects' \
|
||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "My New Project",
|
||||
"domain": "https://example.com",
|
||||
"cors": ["https://example.com", "https://www.example.com"],
|
||||
"crossDomain": false,
|
||||
"types": ["website"]
|
||||
}'
|
||||
```
|
||||
|
||||
#### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": "my-new-project",
|
||||
"name": "My New Project",
|
||||
"organizationId": "org_123",
|
||||
"domain": "https://example.com",
|
||||
"cors": ["https://example.com", "https://www.example.com"],
|
||||
"crossDomain": false,
|
||||
"allowUnsafeRevenueTracking": false,
|
||||
"filters": [],
|
||||
"types": ["website"],
|
||||
"eventsCount": 0,
|
||||
"createdAt": "2024-01-15T10:30:00.000Z",
|
||||
"updatedAt": "2024-01-15T10:30:00.000Z",
|
||||
"deleteAt": null,
|
||||
"client": {
|
||||
"id": "fa0c2780-55f2-4d9e-bea0-da2e02c7b1a9",
|
||||
"secret": "sec_6c8ae85a092d6c66b242"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Important**: The `client.secret` is only returned once when the project is created. Store it securely immediately.
|
||||
|
||||
### Update Project
|
||||
|
||||
Update an existing project's configuration.
|
||||
|
||||
```
|
||||
PATCH /manage/projects/{id}
|
||||
```
|
||||
|
||||
#### Path Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `id` | string | The ID of the project |
|
||||
|
||||
#### Request Body
|
||||
|
||||
All fields are optional. Only include fields you want to update.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `name` | string | Project name (minimum 1 character) |
|
||||
| `domain` | string \| null | Primary domain (URL format, empty string, or null) |
|
||||
| `cors` | string[] | Array of allowed CORS origins |
|
||||
| `crossDomain` | boolean | Enable cross-domain tracking |
|
||||
| `allowUnsafeRevenueTracking` | boolean | Allow revenue tracking without client secret |
|
||||
|
||||
#### Example Request
|
||||
|
||||
```bash
|
||||
curl -X PATCH 'https://api.openpanel.dev/manage/projects/my-project' \
|
||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "Updated Project Name",
|
||||
"crossDomain": true,
|
||||
"allowUnsafeRevenueTracking": false
|
||||
}'
|
||||
```
|
||||
|
||||
#### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": "my-project",
|
||||
"name": "Updated Project Name",
|
||||
"organizationId": "org_123",
|
||||
"domain": "https://example.com",
|
||||
"cors": ["https://example.com"],
|
||||
"crossDomain": true,
|
||||
"allowUnsafeRevenueTracking": false,
|
||||
"filters": [],
|
||||
"types": ["website"],
|
||||
"eventsCount": 0,
|
||||
"createdAt": "2024-01-15T10:30:00.000Z",
|
||||
"updatedAt": "2024-01-15T11:00:00.000Z",
|
||||
"deleteAt": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Delete Project
|
||||
|
||||
Soft delete a project. The project will be scheduled for deletion after 24 hours.
|
||||
|
||||
```
|
||||
DELETE /manage/projects/{id}
|
||||
```
|
||||
|
||||
#### Path Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `id` | string | The ID of the project |
|
||||
|
||||
#### Example Request
|
||||
|
||||
```bash
|
||||
curl -X DELETE 'https://api.openpanel.dev/manage/projects/my-project' \
|
||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
|
||||
```
|
||||
|
||||
#### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: Projects are soft-deleted. The `deleteAt` field is set to 24 hours in the future. You can cancel deletion by updating the project before the deletion time.
|
||||
|
||||
## Error Handling
|
||||
|
||||
The API uses standard HTTP response codes. Common error responses:
|
||||
|
||||
### 400 Bad Request
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Bad Request",
|
||||
"message": "Invalid request body",
|
||||
"details": [
|
||||
{
|
||||
"path": ["name"],
|
||||
"message": "String must contain at least 1 character(s)"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 401 Unauthorized
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": "Manage: Only root clients are allowed to manage resources"
|
||||
}
|
||||
```
|
||||
|
||||
### 404 Not Found
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Not Found",
|
||||
"message": "Project not found"
|
||||
}
|
||||
```
|
||||
|
||||
### 429 Too Many Requests
|
||||
|
||||
Rate limiting response includes headers indicating your rate limit status.
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
The Projects API implements rate limiting:
|
||||
- **20 requests per 10 seconds** per client
|
||||
- Rate limit headers included in responses
|
||||
- Implement exponential backoff for retries
|
||||
|
||||
## Notes
|
||||
|
||||
- Project IDs are automatically generated from the project name using a slug format
|
||||
- If a project ID already exists, a numeric suffix is added
|
||||
- CORS domains are automatically normalized (trailing slashes removed)
|
||||
- The default client created with a project has `type: 'write'`
|
||||
- Projects are scoped to your organization - you can only manage projects in your organization
|
||||
- Soft-deleted projects are excluded from list endpoints
|
||||
344
apps/public/content/docs/api/manage/references.mdx
Normal file
344
apps/public/content/docs/api/manage/references.mdx
Normal file
@@ -0,0 +1,344 @@
|
||||
---
|
||||
title: References
|
||||
description: Manage reference points for your OpenPanel projects. References are useful for marking important dates or events in your analytics timeline.
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
To authenticate with the References API, you need to use your `clientId` and `clientSecret` from a root client. Root clients have organization-wide access.
|
||||
|
||||
For detailed authentication information, see the [Authentication](/docs/api/authentication) guide.
|
||||
|
||||
Include the following headers with your requests:
|
||||
- `openpanel-client-id`: Your OpenPanel root client ID
|
||||
- `openpanel-client-secret`: Your OpenPanel root client secret
|
||||
|
||||
## Base URL
|
||||
|
||||
All References API requests should be made to:
|
||||
|
||||
```
|
||||
https://api.openpanel.dev/manage/references
|
||||
```
|
||||
|
||||
## What are References?
|
||||
|
||||
References are markers you can add to your analytics timeline to track important events such as:
|
||||
- Product launches
|
||||
- Marketing campaign start dates
|
||||
- Feature releases
|
||||
- Website redesigns
|
||||
- Major announcements
|
||||
|
||||
References appear in your analytics charts and help you correlate changes in metrics with specific events.
|
||||
|
||||
## Endpoints
|
||||
|
||||
### List References
|
||||
|
||||
Retrieve all references in your organization, optionally filtered by project.
|
||||
|
||||
```
|
||||
GET /manage/references
|
||||
```
|
||||
|
||||
#### Query Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `projectId` | string | Optional. Filter references by project ID |
|
||||
|
||||
#### Example Request
|
||||
|
||||
```bash
|
||||
# List all references
|
||||
curl 'https://api.openpanel.dev/manage/references' \
|
||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
|
||||
|
||||
# List references for a specific project
|
||||
curl 'https://api.openpanel.dev/manage/references?projectId=my-project' \
|
||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
|
||||
```
|
||||
|
||||
#### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "1af09627-2dd7-4b37-9e5c-6ffa7e450e85",
|
||||
"title": "Product Launch",
|
||||
"description": "Version 2.0 released",
|
||||
"date": "2024-01-15T10:00:00.000Z",
|
||||
"projectId": "my-project",
|
||||
"createdAt": "2024-01-10T08:00:00.000Z",
|
||||
"updatedAt": "2024-01-10T08:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "2bf19738-3ee8-4c48-af6d-7ggb8f561f96",
|
||||
"title": "Marketing Campaign Start",
|
||||
"description": "Q1 2024 campaign launched",
|
||||
"date": "2024-01-20T09:00:00.000Z",
|
||||
"projectId": "my-project",
|
||||
"createdAt": "2024-01-18T10:00:00.000Z",
|
||||
"updatedAt": "2024-01-18T10:00:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Get Reference
|
||||
|
||||
Retrieve a specific reference by ID.
|
||||
|
||||
```
|
||||
GET /manage/references/{id}
|
||||
```
|
||||
|
||||
#### Path Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `id` | string | The ID of the reference (UUID) |
|
||||
|
||||
#### Example Request
|
||||
|
||||
```bash
|
||||
curl 'https://api.openpanel.dev/manage/references/1af09627-2dd7-4b37-9e5c-6ffa7e450e85' \
|
||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
|
||||
```
|
||||
|
||||
#### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": "1af09627-2dd7-4b37-9e5c-6ffa7e450e85",
|
||||
"title": "Product Launch",
|
||||
"description": "Version 2.0 released",
|
||||
"date": "2024-01-15T10:00:00.000Z",
|
||||
"projectId": "my-project",
|
||||
"createdAt": "2024-01-10T08:00:00.000Z",
|
||||
"updatedAt": "2024-01-10T08:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Create Reference
|
||||
|
||||
Create a new reference point for a project.
|
||||
|
||||
```
|
||||
POST /manage/references
|
||||
```
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `projectId` | string | Yes | The ID of the project this reference belongs to |
|
||||
| `title` | string | Yes | Reference title (minimum 1 character) |
|
||||
| `description` | string | No | Optional description or notes |
|
||||
| `datetime` | string | Yes | Date and time for the reference (ISO 8601 format) |
|
||||
|
||||
#### Example Request
|
||||
|
||||
```bash
|
||||
curl -X POST 'https://api.openpanel.dev/manage/references' \
|
||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"projectId": "my-project",
|
||||
"title": "Product Launch",
|
||||
"description": "Version 2.0 released with new features",
|
||||
"datetime": "2024-01-15T10:00:00.000Z"
|
||||
}'
|
||||
```
|
||||
|
||||
#### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": "1af09627-2dd7-4b37-9e5c-6ffa7e450e85",
|
||||
"title": "Product Launch",
|
||||
"description": "Version 2.0 released with new features",
|
||||
"date": "2024-01-15T10:00:00.000Z",
|
||||
"projectId": "my-project",
|
||||
"createdAt": "2024-01-10T08:00:00.000Z",
|
||||
"updatedAt": "2024-01-10T08:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: The `date` field in the response is parsed from the `datetime` string you provided.
|
||||
|
||||
### Update Reference
|
||||
|
||||
Update an existing reference.
|
||||
|
||||
```
|
||||
PATCH /manage/references/{id}
|
||||
```
|
||||
|
||||
#### Path Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `id` | string | The ID of the reference (UUID) |
|
||||
|
||||
#### Request Body
|
||||
|
||||
All fields are optional. Only include fields you want to update.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `title` | string | Reference title (minimum 1 character) |
|
||||
| `description` | string \| null | Description or notes (set to `null` to clear) |
|
||||
| `datetime` | string | Date and time for the reference (ISO 8601 format) |
|
||||
|
||||
#### Example Request
|
||||
|
||||
```bash
|
||||
curl -X PATCH 'https://api.openpanel.dev/manage/references/1af09627-2dd7-4b37-9e5c-6ffa7e450e85' \
|
||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"title": "Product Launch v2.1",
|
||||
"description": "Updated: Version 2.1 released with bug fixes",
|
||||
"datetime": "2024-01-15T10:00:00.000Z"
|
||||
}'
|
||||
```
|
||||
|
||||
#### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": "1af09627-2dd7-4b37-9e5c-6ffa7e450e85",
|
||||
"title": "Product Launch v2.1",
|
||||
"description": "Updated: Version 2.1 released with bug fixes",
|
||||
"date": "2024-01-15T10:00:00.000Z",
|
||||
"projectId": "my-project",
|
||||
"createdAt": "2024-01-10T08:00:00.000Z",
|
||||
"updatedAt": "2024-01-10T09:30:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Delete Reference
|
||||
|
||||
Permanently delete a reference. This action cannot be undone.
|
||||
|
||||
```
|
||||
DELETE /manage/references/{id}
|
||||
```
|
||||
|
||||
#### Path Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `id` | string | The ID of the reference (UUID) |
|
||||
|
||||
#### Example Request
|
||||
|
||||
```bash
|
||||
curl -X DELETE 'https://api.openpanel.dev/manage/references/1af09627-2dd7-4b37-9e5c-6ffa7e450e85' \
|
||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
|
||||
```
|
||||
|
||||
#### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
The API uses standard HTTP response codes. Common error responses:
|
||||
|
||||
### 400 Bad Request
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Bad Request",
|
||||
"message": "Invalid request body",
|
||||
"details": [
|
||||
{
|
||||
"path": ["title"],
|
||||
"message": "String must contain at least 1 character(s)"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 401 Unauthorized
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": "Manage: Only root clients are allowed to manage resources"
|
||||
}
|
||||
```
|
||||
|
||||
### 404 Not Found
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Not Found",
|
||||
"message": "Reference not found"
|
||||
}
|
||||
```
|
||||
|
||||
This error can occur if:
|
||||
- The reference ID doesn't exist
|
||||
- The reference belongs to a different organization
|
||||
|
||||
### 429 Too Many Requests
|
||||
|
||||
Rate limiting response includes headers indicating your rate limit status.
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
The References API implements rate limiting:
|
||||
- **20 requests per 10 seconds** per client
|
||||
- Rate limit headers included in responses
|
||||
- Implement exponential backoff for retries
|
||||
|
||||
## Date Format
|
||||
|
||||
References use ISO 8601 date format. Examples:
|
||||
|
||||
- `2024-01-15T10:00:00.000Z` - UTC timezone
|
||||
- `2024-01-15T10:00:00-05:00` - Eastern Time (UTC-5)
|
||||
- `2024-01-15` - Date only (time defaults to 00:00:00)
|
||||
|
||||
The `datetime` field in requests is converted to a `date` field in responses, stored as a timestamp.
|
||||
|
||||
## Use Cases
|
||||
|
||||
References are useful for:
|
||||
|
||||
- **Product Launches**: Mark when new versions or features are released
|
||||
- **Marketing Campaigns**: Track campaign start and end dates
|
||||
- **Website Changes**: Note when major redesigns or updates occur
|
||||
- **Business Events**: Record important business milestones
|
||||
- **A/B Testing**: Mark when experiments start or end
|
||||
- **Seasonal Events**: Track holidays, sales periods, or seasonal changes
|
||||
|
||||
## Notes
|
||||
|
||||
- Reference IDs are UUIDs (Universally Unique Identifiers)
|
||||
- References are scoped to projects - each reference belongs to a specific project
|
||||
- References are scoped to your organization - you can only manage references for projects in your organization
|
||||
- The `description` field is optional and can be set to `null` to clear it
|
||||
- References appear in analytics charts to help correlate metrics with events
|
||||
- When filtering by `projectId`, the project must exist and belong to your organization
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"title": "API",
|
||||
"pages": ["track", "export", "insights"]
|
||||
"pages": ["track", "export", "insights", "manage"]
|
||||
}
|
||||
|
||||
@@ -175,6 +175,48 @@ COOKIE_SECRET=your-random-secret-here
|
||||
Never use the default value in production! Always generate a unique secret.
|
||||
</Callout>
|
||||
|
||||
### COOKIE_TLDS
|
||||
|
||||
**Type**: `string` (comma-separated)
|
||||
**Required**: No
|
||||
**Default**: None
|
||||
|
||||
Custom multi-part TLDs for cookie domain handling. Use this when deploying on domains with public suffixes that aren't recognized by default (e.g., `.my.id`, `.web.id`, `.co.id`).
|
||||
|
||||
**Example**:
|
||||
```bash
|
||||
# For domains like abc.my.id
|
||||
COOKIE_TLDS=my.id
|
||||
|
||||
# Multiple TLDs
|
||||
COOKIE_TLDS=my.id,web.id,co.id
|
||||
```
|
||||
|
||||
<Callout>
|
||||
This is required when using domain suffixes that are public suffixes (like `.co.uk`). Without this, the browser will reject authentication cookies. Common examples include Indonesian domains (`.my.id`, `.web.id`, `.co.id`).
|
||||
</Callout>
|
||||
|
||||
### CUSTOM_COOKIE_DOMAIN
|
||||
|
||||
**Type**: `string`
|
||||
**Required**: No
|
||||
**Default**: None
|
||||
|
||||
Override the automatic cookie domain detection and set a specific domain for authentication cookies. Useful when proxying the API through your main domain or when you need precise control over cookie scope.
|
||||
|
||||
**Example**:
|
||||
```bash
|
||||
# Set cookies only on the main domain
|
||||
CUSTOM_COOKIE_DOMAIN=.example.com
|
||||
|
||||
# Set cookies on a specific subdomain
|
||||
CUSTOM_COOKIE_DOMAIN=.app.example.com
|
||||
```
|
||||
|
||||
<Callout>
|
||||
When set, this completely bypasses the automatic domain parsing logic. The cookie will always be set as secure. Include a leading dot (`.`) to allow the cookie to be shared across subdomains.
|
||||
</Callout>
|
||||
|
||||
### DEMO_USER_ID
|
||||
|
||||
**Type**: `string`
|
||||
|
||||
388
apps/public/src/app/(content)/open-source/page.tsx
Normal file
388
apps/public/src/app/(content)/open-source/page.tsx
Normal file
@@ -0,0 +1,388 @@
|
||||
import { CtaBanner } from '@/app/(home)/_sections/cta-banner';
|
||||
import { HeroContainer } from '@/app/(home)/_sections/hero';
|
||||
import { FaqItem, Faqs } from '@/components/faq';
|
||||
import { FeatureCard } from '@/components/feature-card';
|
||||
import { Section, SectionHeader } from '@/components/section';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { url } from '@/lib/layout.shared';
|
||||
import { getOgImageUrl, getPageMetadata } from '@/lib/metadata';
|
||||
import {
|
||||
BarChartIcon,
|
||||
CheckIcon,
|
||||
CodeIcon,
|
||||
GlobeIcon,
|
||||
HeartHandshakeIcon,
|
||||
LinkIcon,
|
||||
MailIcon,
|
||||
MessageSquareIcon,
|
||||
SparklesIcon,
|
||||
UsersIcon,
|
||||
ZapIcon,
|
||||
} from 'lucide-react';
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import Script from 'next/script';
|
||||
|
||||
export const metadata: Metadata = getPageMetadata({
|
||||
title: 'Free Analytics for Open Source Projects | OpenPanel OSS Program',
|
||||
description:
|
||||
"Get free web and product analytics for your open source project. Track up to 2.5M events/month. Apply to OpenPanel's open source program today.",
|
||||
url: url('/open-source'),
|
||||
image: getOgImageUrl('/open-source'),
|
||||
});
|
||||
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
name: 'Free Analytics for Open Source Projects | OpenPanel OSS Program',
|
||||
description:
|
||||
"Get free web and product analytics for your open source project. Track up to 2.5M events/month. Apply to OpenPanel's open source program today.",
|
||||
url: url('/open-source'),
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: 'OpenPanel',
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: url('/logo.png'),
|
||||
},
|
||||
},
|
||||
mainEntity: {
|
||||
'@type': 'Offer',
|
||||
name: 'Free Analytics for Open Source Projects',
|
||||
description:
|
||||
'Free analytics service for open source projects up to 2.5M events per month',
|
||||
price: '0',
|
||||
priceCurrency: 'USD',
|
||||
},
|
||||
};
|
||||
|
||||
export default function OpenSourcePage() {
|
||||
return (
|
||||
<div>
|
||||
<Script
|
||||
id="open-source-schema"
|
||||
strategy="beforeInteractive"
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<HeroContainer>
|
||||
<div className="col center-center flex-1">
|
||||
<SectionHeader
|
||||
as="h1"
|
||||
align="center"
|
||||
className="flex-1"
|
||||
title={
|
||||
<>
|
||||
Free Analytics for
|
||||
<br />
|
||||
Open Source Projects
|
||||
</>
|
||||
}
|
||||
description="Track your users, understand adoption, and grow your project - all without cost. Get free analytics for your open source project with up to 2.5M events per month."
|
||||
/>
|
||||
<div className="col gap-4 justify-center items-center mt-8">
|
||||
<Button size="lg" asChild>
|
||||
<Link href="mailto:oss@openpanel.dev">
|
||||
Apply for Free Access
|
||||
<MailIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Up to 2.5M events/month • No credit card required
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</HeroContainer>
|
||||
|
||||
<div className="container">
|
||||
<div className="col gap-16">
|
||||
{/* What You Get Section */}
|
||||
<Section className="my-0">
|
||||
<SectionHeader
|
||||
title="What you get"
|
||||
description="Everything you need to understand your users and grow your open source project."
|
||||
/>
|
||||
<div className="grid md:grid-cols-2 gap-6 mt-8">
|
||||
<FeatureCard
|
||||
title="2.5 Million Events/Month"
|
||||
description="More than enough for most open source projects. Track page views, user actions, and custom events without worrying about limits."
|
||||
icon={BarChartIcon}
|
||||
/>
|
||||
<FeatureCard
|
||||
title="Full Feature Access"
|
||||
description="Same powerful capabilities as paid plans. Funnels, retention analysis, custom dashboards, and real-time analytics."
|
||||
icon={ZapIcon}
|
||||
/>
|
||||
<FeatureCard
|
||||
title="Unlimited Team Members"
|
||||
description="Invite your entire contributor team. Collaborate with maintainers and core contributors on understanding your project's growth."
|
||||
icon={UsersIcon}
|
||||
/>
|
||||
<FeatureCard
|
||||
title="Priority Support"
|
||||
description="Dedicated help for open source maintainers. Get faster responses and priority assistance when you need it."
|
||||
icon={MessageSquareIcon}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Why We Do This Section */}
|
||||
<Section className="my-0">
|
||||
<SectionHeader
|
||||
title="Why we do this"
|
||||
description="OpenPanel is built by and for the open source community. We believe in giving back."
|
||||
/>
|
||||
<div className="col gap-6 mt-8">
|
||||
<p className="text-muted-foreground">
|
||||
We started OpenPanel because we believed analytics tools
|
||||
shouldn't be complicated or locked behind expensive enterprise
|
||||
subscriptions. As an open source project ourselves, we
|
||||
understand the challenges of building and growing a project
|
||||
without the resources of big corporations.
|
||||
</p>
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<FeatureCard
|
||||
title="Built for OSS"
|
||||
description="OpenPanel is open source. We know what it's like to build in the open."
|
||||
icon={CodeIcon}
|
||||
/>
|
||||
<FeatureCard
|
||||
title="No Barriers"
|
||||
description="Analytics shouldn't be a barrier to understanding your users. We're removing that barrier."
|
||||
icon={HeartHandshakeIcon}
|
||||
/>
|
||||
<FeatureCard
|
||||
title="Giving Back"
|
||||
description="We're giving back to the projects that inspire us and the community that supports us."
|
||||
icon={SparklesIcon}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* What We Ask In Return Section */}
|
||||
<Section className="my-0">
|
||||
<SectionHeader
|
||||
title="What we ask in return"
|
||||
description="We keep it simple. Just a small way to help us grow and support more projects."
|
||||
/>
|
||||
<div className="row gap-6 mt-8">
|
||||
<div className="col gap-6">
|
||||
<FeatureCard
|
||||
title="Backlink to OpenPanel"
|
||||
description="A simple link on your website or README helps others discover OpenPanel. It's a win-win for the community."
|
||||
icon={LinkIcon}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Example: "Analytics powered by{' '}
|
||||
<Link
|
||||
href="https://openpanel.dev"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
OpenPanel
|
||||
</Link>
|
||||
"
|
||||
</p>
|
||||
</FeatureCard>
|
||||
<FeatureCard
|
||||
title="Display a Widget"
|
||||
description="Showcase your visitor count with our real-time analytics widget. It's completely optional but helps spread the word."
|
||||
icon={GlobeIcon}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Display real-time visitor counts, page views, or other
|
||||
metrics on your project's website.
|
||||
</p>
|
||||
</FeatureCard>
|
||||
<p className="text-muted-foreground">
|
||||
That's it. No complicated requirements, no hidden fees, no
|
||||
catch. We just want to help open source projects succeed.
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center text-xs text-muted-foreground">
|
||||
<iframe
|
||||
title="Realtime Widget"
|
||||
src="https://dashboard.openpanel.dev/widget/realtime?shareId=26wVGY"
|
||||
width="300"
|
||||
height="400"
|
||||
className="rounded-xl border mb-2"
|
||||
/>
|
||||
Analytics from{' '}
|
||||
<a className="underline" href="https://openpanel.dev">
|
||||
OpenPanel.dev
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Eligibility Criteria Section */}
|
||||
<Section className="my-0">
|
||||
<SectionHeader
|
||||
title="Eligibility criteria"
|
||||
description="We want to support legitimate open source projects that are making a difference."
|
||||
/>
|
||||
<div className="col gap-4 mt-8">
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="flex gap-3">
|
||||
<CheckIcon className="size-5 text-primary mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<h3 className="font-semibold mb-1">OSI-Approved License</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your project must use an OSI-approved open source license
|
||||
(MIT, Apache, GPL, etc.)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<CheckIcon className="size-5 text-primary mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<h3 className="font-semibold mb-1">Public Repository</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your code must be publicly available on GitHub, GitLab, or
|
||||
similar platforms
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<CheckIcon className="size-5 text-primary mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<h3 className="font-semibold mb-1">Active Development</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Show evidence of active development and a growing
|
||||
community
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<CheckIcon className="size-5 text-primary mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<h3 className="font-semibold mb-1">
|
||||
Non-Commercial Primary Purpose
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The primary purpose should be non-commercial, though
|
||||
commercial OSS projects may be considered
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* How to Apply Section */}
|
||||
<Section className="my-0">
|
||||
<SectionHeader
|
||||
title="How to apply"
|
||||
description="Getting started is simple. Just send us an email with a few details about your project."
|
||||
/>
|
||||
<div className="col gap-6 mt-8">
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
<div className="col gap-3">
|
||||
<div className="size-10 rounded-full bg-primary/10 center-center text-primary font-semibold">
|
||||
1
|
||||
</div>
|
||||
<h3 className="font-semibold">Send us an email</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Reach out to{' '}
|
||||
<Link
|
||||
href="mailto:oss@openpanel.dev"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
oss@openpanel.dev
|
||||
</Link>{' '}
|
||||
with your project details
|
||||
</p>
|
||||
</div>
|
||||
<div className="col gap-3">
|
||||
<div className="size-10 rounded-full bg-primary/10 center-center text-primary font-semibold">
|
||||
2
|
||||
</div>
|
||||
<h3 className="font-semibold">Include project info</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Share your project URL, license type, and a brief
|
||||
description of what you're building
|
||||
</p>
|
||||
</div>
|
||||
<div className="col gap-3">
|
||||
<div className="size-10 rounded-full bg-primary/10 center-center text-primary font-semibold">
|
||||
3
|
||||
</div>
|
||||
<h3 className="font-semibold">We'll review</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
We'll evaluate your project and respond within a few
|
||||
business days
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Button size="lg" asChild>
|
||||
<Link href="mailto:oss@openpanel.dev?subject=Open Source Program Application">
|
||||
Apply Now
|
||||
<MailIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* FAQ Section */}
|
||||
<Section className="my-0">
|
||||
<SectionHeader
|
||||
title="Frequently asked questions"
|
||||
description="Everything you need to know about our open source program."
|
||||
/>
|
||||
<div className="mt-8">
|
||||
<Faqs>
|
||||
<FaqItem question="What counts as an open-source project?">
|
||||
We consider any project with an OSI-approved open source
|
||||
license (MIT, Apache, GPL, BSD, etc.) that is publicly
|
||||
available and actively maintained. The project should have a
|
||||
non-commercial primary purpose, though we may consider
|
||||
commercial open source projects on a case-by-case basis.
|
||||
</FaqItem>
|
||||
<FaqItem question="What happens if I exceed 2.5M events per month?">
|
||||
We understand that successful projects grow. If you
|
||||
consistently exceed 2.5M events, we'll reach out to discuss
|
||||
options. We're flexible and want to support your success. In
|
||||
most cases, we can work out a solution that works for both of
|
||||
us.
|
||||
</FaqItem>
|
||||
<FaqItem question="Can commercial open source projects apply?">
|
||||
Yes, we consider commercial open source projects on a
|
||||
case-by-case basis. If your project is open source but has
|
||||
commercial offerings, please mention this in your application
|
||||
and we'll evaluate accordingly.
|
||||
</FaqItem>
|
||||
<FaqItem question="How long does the free access last?">
|
||||
As long as your project remains eligible and active, your free
|
||||
access continues. We review projects periodically to ensure
|
||||
they still meet our criteria, but we're committed to
|
||||
supporting projects long-term.
|
||||
</FaqItem>
|
||||
<FaqItem question="Do I need to display the widget?">
|
||||
No, displaying the widget is completely optional. We only
|
||||
require a backlink to OpenPanel on your website or README. The
|
||||
widget is just a nice way to showcase your analytics if you
|
||||
want to.
|
||||
</FaqItem>
|
||||
<FaqItem question="What if my project is very small or just starting?">
|
||||
We welcome projects of all sizes! Whether you're just getting
|
||||
started or have a large community, if you meet our eligibility
|
||||
criteria, we'd love to help. Small projects often benefit the
|
||||
most from understanding their users early on.
|
||||
</FaqItem>
|
||||
</Faqs>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<CtaBanner
|
||||
title="Ready to get free analytics for your open source project?"
|
||||
description="Join other open source projects using OpenPanel to understand their users and grow their communities. Apply today and get started in minutes."
|
||||
ctaText="Apply for Free Access"
|
||||
ctaLink="mailto:oss@openpanel.dev?subject=Open Source Program Application"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,13 @@ async function getOgData(
|
||||
'Support OpenPanel and get exclusive perks like latest Docker images, prioritized support, and early access to new features.',
|
||||
};
|
||||
}
|
||||
case 'open-source': {
|
||||
return {
|
||||
title: 'Free analytics for open source projects',
|
||||
description:
|
||||
"Get free web and product analytics for your open source project. Track up to 2.5M events/month. Apply to OpenPanel's open source program today.",
|
||||
};
|
||||
}
|
||||
case 'pricing': {
|
||||
return {
|
||||
title: 'Pricing',
|
||||
|
||||
@@ -25,6 +25,10 @@ export async function Footer() {
|
||||
{ title: 'About', url: '/about' },
|
||||
{ title: 'Contact', url: '/contact' },
|
||||
{ title: 'Become a supporter', url: '/supporter' },
|
||||
{
|
||||
title: 'Free analytics for open source projects',
|
||||
url: '/open-source',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { type ISignInShare, zSignInShare } from '@openpanel/validation';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { LogoSquare } from '../logo';
|
||||
import { PublicPageCard } from '../public-page-card';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
|
||||
@@ -43,54 +43,27 @@ export function ShareEnterPassword({
|
||||
});
|
||||
});
|
||||
|
||||
const typeLabel =
|
||||
shareType === 'dashboard'
|
||||
? 'Dashboard'
|
||||
: shareType === 'report'
|
||||
? 'Report'
|
||||
: 'Overview';
|
||||
|
||||
return (
|
||||
<div className="center-center h-screen w-screen p-4 col">
|
||||
<div className="bg-background p-6 rounded-lg max-w-md w-full text-left">
|
||||
<div className="col mt-1 flex-1 gap-2">
|
||||
<LogoSquare className="size-12 mb-4" />
|
||||
<div className="text-xl font-semibold">
|
||||
{shareType === 'dashboard'
|
||||
? 'Dashboard is locked'
|
||||
: shareType === 'report'
|
||||
? 'Report is locked'
|
||||
: 'Overview is locked'}
|
||||
</div>
|
||||
<div className="text-lg text-muted-foreground leading-normal">
|
||||
Please enter correct password to access this{' '}
|
||||
{shareType === 'dashboard'
|
||||
? 'dashboard'
|
||||
: shareType === 'report'
|
||||
? 'report'
|
||||
: 'overview'}
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={onSubmit} className="col gap-4 mt-6">
|
||||
<Input
|
||||
{...form.register('password')}
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
size="large"
|
||||
/>
|
||||
<Button type="submit">Get access</Button>
|
||||
</form>
|
||||
</div>
|
||||
<div className="p-6 text-sm max-w-sm col gap-0.5">
|
||||
<p>
|
||||
Powered by{' '}
|
||||
<a href="https://openpanel.dev" className="font-medium">
|
||||
OpenPanel.dev
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
The best web and product analytics tool out there (our honest
|
||||
opinion).
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://dashboard.openpanel.dev/onboarding">
|
||||
Try it for free today!
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<PublicPageCard
|
||||
title={`${typeLabel} is locked`}
|
||||
description={`Please enter correct password to access this ${typeLabel.toLowerCase()}`}
|
||||
>
|
||||
<form onSubmit={onSubmit} className="col gap-4">
|
||||
<Input
|
||||
{...form.register('password')}
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
size="large"
|
||||
/>
|
||||
<Button type="submit">Get access</Button>
|
||||
</form>
|
||||
</PublicPageCard>
|
||||
);
|
||||
}
|
||||
|
||||
51
apps/start/src/components/public-page-card.tsx
Normal file
51
apps/start/src/components/public-page-card.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { LoginNavbar } from './login-navbar';
|
||||
import { LogoSquare } from './logo';
|
||||
|
||||
interface PublicPageCardProps {
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
children?: ReactNode;
|
||||
showFooter?: boolean;
|
||||
}
|
||||
|
||||
export function PublicPageCard({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
showFooter = true,
|
||||
}: PublicPageCardProps) {
|
||||
return (
|
||||
<div>
|
||||
<LoginNavbar />
|
||||
<div className="center-center h-screen w-screen p-4 col">
|
||||
<div className="bg-background p-6 rounded-lg max-w-md w-full text-left">
|
||||
<div className="col mt-1 flex-1 gap-2">
|
||||
<LogoSquare className="size-12 mb-4" />
|
||||
<div className="text-xl font-semibold">{title}</div>
|
||||
{description && (
|
||||
<div className="text-lg text-muted-foreground leading-normal">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!!children && <div className="mt-6">{children}</div>}
|
||||
</div>
|
||||
{showFooter && (
|
||||
<div className="p-6 text-sm max-w-sm col gap-1 text-muted-foreground">
|
||||
<p>
|
||||
Powered by{' '}
|
||||
<a href="https://openpanel.dev" className="font-medium">
|
||||
OpenPanel.dev
|
||||
</a>
|
||||
{' · '}
|
||||
<a href="https://dashboard.openpanel.dev/onboarding">
|
||||
Try it for free today!
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -61,9 +61,14 @@ export function Chart({ data }: Props) {
|
||||
range,
|
||||
series: reportSeries,
|
||||
breakdowns,
|
||||
options: reportOptions,
|
||||
},
|
||||
options: { hideXAxis, hideYAxis },
|
||||
} = useReportChartContext();
|
||||
|
||||
const histogramOptions =
|
||||
reportOptions?.type === 'histogram' ? reportOptions : undefined;
|
||||
const isStacked = histogramOptions?.stacked ?? false;
|
||||
const trpc = useTRPC();
|
||||
const references = useQuery(
|
||||
trpc.reference.getChartReferences.queryOptions(
|
||||
@@ -155,68 +160,70 @@ export function Chart({ data }: Props) {
|
||||
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={rechartData}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
className="stroke-def-200"
|
||||
/>
|
||||
<Tooltip
|
||||
content={<ReportChartTooltip.Tooltip />}
|
||||
cursor={<BarHover />}
|
||||
/>
|
||||
<YAxis {...yAxisProps} />
|
||||
<XAxis {...xAxisProps} scale={'auto'} type="category" />
|
||||
{previous
|
||||
? series.map((serie) => {
|
||||
return (
|
||||
<Bar
|
||||
key={`${serie.id}:prev`}
|
||||
name={`${serie.id}:prev`}
|
||||
dataKey={`${serie.id}:prev:count`}
|
||||
fill={getChartColor(serie.index)}
|
||||
fillOpacity={0.3}
|
||||
radius={5}
|
||||
/>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
{series.map((serie) => {
|
||||
return (
|
||||
<Bar
|
||||
key={serie.id}
|
||||
name={serie.id}
|
||||
dataKey={`${serie.id}:count`}
|
||||
fill={getChartColor(serie.index)}
|
||||
radius={5}
|
||||
fillOpacity={1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{references.data?.map((ref) => (
|
||||
<ReferenceLine
|
||||
key={ref.id}
|
||||
x={ref.date.getTime()}
|
||||
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
|
||||
strokeDasharray={'3 3'}
|
||||
label={{
|
||||
value: ref.title,
|
||||
position: 'centerTop',
|
||||
fill: '#334155',
|
||||
fontSize: 12,
|
||||
}}
|
||||
fontSize={10}
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
className="stroke-def-200"
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
{isEditMode && (
|
||||
<ReportTable
|
||||
data={data}
|
||||
visibleSeries={series}
|
||||
setVisibleSeries={setVisibleSeries}
|
||||
/>
|
||||
)}
|
||||
<Tooltip
|
||||
content={<ReportChartTooltip.Tooltip />}
|
||||
cursor={<BarHover />}
|
||||
/>
|
||||
<YAxis {...yAxisProps} />
|
||||
<XAxis {...xAxisProps} scale={'auto'} type="category" />
|
||||
{previous
|
||||
? series.map((serie) => {
|
||||
return (
|
||||
<Bar
|
||||
key={`${serie.id}:prev`}
|
||||
name={`${serie.id}:prev`}
|
||||
dataKey={`${serie.id}:prev:count`}
|
||||
fill={getChartColor(serie.index)}
|
||||
fillOpacity={0.3}
|
||||
radius={5}
|
||||
stackId={isStacked ? 'prev' : undefined}
|
||||
/>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
{series.map((serie) => {
|
||||
return (
|
||||
<Bar
|
||||
key={serie.id}
|
||||
name={serie.id}
|
||||
dataKey={`${serie.id}:count`}
|
||||
fill={getChartColor(serie.index)}
|
||||
radius={isStacked ? 0 : 4}
|
||||
fillOpacity={1}
|
||||
stackId={isStacked ? 'current' : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{references.data?.map((ref) => (
|
||||
<ReferenceLine
|
||||
key={ref.id}
|
||||
x={ref.date.getTime()}
|
||||
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
|
||||
strokeDasharray={'3 3'}
|
||||
label={{
|
||||
value: ref.title,
|
||||
position: 'centerTop',
|
||||
fill: '#334155',
|
||||
fontSize: 12,
|
||||
}}
|
||||
fontSize={10}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
{isEditMode && (
|
||||
<ReportTable
|
||||
data={data}
|
||||
visibleSeries={series}
|
||||
setVisibleSeries={setVisibleSeries}
|
||||
/>
|
||||
)}
|
||||
</ChartClickMenu>
|
||||
</ReportChartTooltip.TooltipProvider>
|
||||
);
|
||||
|
||||
@@ -361,6 +361,17 @@ export const reportSlice = createSlice({
|
||||
state.options.include = action.payload;
|
||||
}
|
||||
},
|
||||
changeStacked(state, action: PayloadAction<boolean>) {
|
||||
state.dirty = true;
|
||||
if (!state.options || state.options.type !== 'histogram') {
|
||||
state.options = {
|
||||
type: 'histogram',
|
||||
stacked: action.payload,
|
||||
};
|
||||
} else {
|
||||
state.options.stacked = action.payload;
|
||||
}
|
||||
},
|
||||
reorderEvents(
|
||||
state,
|
||||
action: PayloadAction<{ fromIndex: number; toIndex: number }>,
|
||||
@@ -406,6 +417,7 @@ export const {
|
||||
changeSankeySteps,
|
||||
changeSankeyExclude,
|
||||
changeSankeyInclude,
|
||||
changeStacked,
|
||||
reorderEvents,
|
||||
} = reportSlice.actions;
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
changeSankeyInclude,
|
||||
changeSankeyMode,
|
||||
changeSankeySteps,
|
||||
changeStacked,
|
||||
changeUnit,
|
||||
} from '../reportSlice';
|
||||
|
||||
@@ -25,14 +26,17 @@ export function ReportSettings() {
|
||||
const previous = useSelector((state) => state.report.previous);
|
||||
const unit = useSelector((state) => state.report.unit);
|
||||
const options = useSelector((state) => state.report.options);
|
||||
|
||||
|
||||
const retentionOptions = options?.type === 'retention' ? options : undefined;
|
||||
const criteria = retentionOptions?.criteria ?? 'on_or_after';
|
||||
|
||||
|
||||
const funnelOptions = options?.type === 'funnel' ? options : undefined;
|
||||
const funnelGroup = funnelOptions?.funnelGroup;
|
||||
const funnelWindow = funnelOptions?.funnelWindow;
|
||||
|
||||
const histogramOptions = options?.type === 'histogram' ? options : undefined;
|
||||
const stacked = histogramOptions?.stacked ?? false;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { projectId } = useAppParams();
|
||||
const eventNames = useEventNames({ projectId });
|
||||
@@ -61,6 +65,10 @@ export function ReportSettings() {
|
||||
fields.push('sankeyInclude');
|
||||
}
|
||||
|
||||
if (chartType === 'histogram') {
|
||||
fields.push('stacked');
|
||||
}
|
||||
|
||||
return fields;
|
||||
}, [chartType]);
|
||||
|
||||
@@ -259,6 +267,15 @@ export function ReportSettings() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{fields.includes('stacked') && (
|
||||
<Label className="flex items-center justify-between mb-0">
|
||||
<span className="whitespace-nowrap">Stack series</span>
|
||||
<Switch
|
||||
checked={stacked}
|
||||
onCheckedChange={(val) => dispatch(changeStacked(!!val))}
|
||||
/>
|
||||
</Label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,11 +7,12 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAppContext } from '@/hooks/use-app-context';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { pushModal } from '@/modals';
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { Link, useNavigate } from '@tanstack/react-router';
|
||||
import { Link, useNavigate, useRouteContext } from '@tanstack/react-router';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { ChevronDownIcon, PlusIcon } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
@@ -28,6 +29,8 @@ export default function SidebarOrganizationMenu({
|
||||
}: {
|
||||
organization: RouterOutputs['organization']['list'][number];
|
||||
}) {
|
||||
const { isSelfHosted } = useAppContext();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link
|
||||
@@ -52,21 +55,23 @@ export default function SidebarOrganizationMenu({
|
||||
<CogIcon size={20} />
|
||||
<div className="flex-1">Settings</div>
|
||||
</Link>
|
||||
<Link
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md px-3 py-2 font-medium transition-all hover:bg-def-200 text-[13px]',
|
||||
)}
|
||||
activeOptions={{ exact: true }}
|
||||
to="/$organizationId/billing"
|
||||
from="/$organizationId"
|
||||
>
|
||||
<CreditCardIcon size={20} />
|
||||
<div className="flex-1">Billing</div>
|
||||
{organization?.isTrial && <Badge>Trial</Badge>}
|
||||
{organization?.isExpired && <Badge>Expired</Badge>}
|
||||
{organization?.isWillBeCanceled && <Badge>Canceled</Badge>}
|
||||
{organization?.isCanceled && <Badge>Canceled</Badge>}
|
||||
</Link>
|
||||
{!isSelfHosted && (
|
||||
<Link
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md px-3 py-2 font-medium transition-all hover:bg-def-200 text-[13px]',
|
||||
)}
|
||||
activeOptions={{ exact: true }}
|
||||
to="/$organizationId/billing"
|
||||
from="/$organizationId"
|
||||
>
|
||||
<CreditCardIcon size={20} />
|
||||
<div className="flex-1">Billing</div>
|
||||
{organization?.isTrial && <Badge>Trial</Badge>}
|
||||
{organization?.isExpired && <Badge>Expired</Badge>}
|
||||
{organization?.isWillBeCanceled && <Badge>Canceled</Badge>}
|
||||
{organization?.isCanceled && <Badge>Canceled</Badge>}
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md px-3 py-2 font-medium transition-all hover:bg-def-200 text-[13px]',
|
||||
|
||||
@@ -35,7 +35,7 @@ const setCookieFn = createServerFn({ method: 'POST' })
|
||||
});
|
||||
|
||||
// Called in __root.tsx beforeLoad hook to get cookies from the server
|
||||
// And recieved with useRouteContext in the client
|
||||
// And received with useRouteContext in the client
|
||||
export const getCookiesFn = createServerFn({ method: 'GET' }).handler(() =>
|
||||
pick(VALID_COOKIES, getCookies()),
|
||||
);
|
||||
|
||||
@@ -102,7 +102,7 @@ export default function CreateInvite() {
|
||||
<div>
|
||||
<SheetTitle>Invite a user</SheetTitle>
|
||||
<SheetDescription>
|
||||
Invite users to your organization. They will recieve an email
|
||||
Invite users to your organization. They will receive an email
|
||||
will instructions.
|
||||
</SheetDescription>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as UnsubscribeRouteImport } from './routes/unsubscribe'
|
||||
import { Route as StepsRouteImport } from './routes/_steps'
|
||||
import { Route as PublicRouteImport } from './routes/_public'
|
||||
import { Route as LoginRouteImport } from './routes/_login'
|
||||
@@ -36,6 +37,7 @@ import { Route as AppOrganizationIdProjectIdRouteImport } from './routes/_app.$o
|
||||
import { Route as AppOrganizationIdProjectIdIndexRouteImport } from './routes/_app.$organizationId.$projectId.index'
|
||||
import { Route as StepsOnboardingProjectIdVerifyRouteImport } from './routes/_steps.onboarding.$projectId.verify'
|
||||
import { Route as StepsOnboardingProjectIdConnectRouteImport } from './routes/_steps.onboarding.$projectId.connect'
|
||||
import { Route as AppOrganizationIdProfileTabsRouteImport } from './routes/_app.$organizationId.profile._tabs'
|
||||
import { Route as AppOrganizationIdMembersTabsRouteImport } from './routes/_app.$organizationId.members._tabs'
|
||||
import { Route as AppOrganizationIdIntegrationsTabsRouteImport } from './routes/_app.$organizationId.integrations._tabs'
|
||||
import { Route as AppOrganizationIdProjectIdSessionsRouteImport } from './routes/_app.$organizationId.$projectId.sessions'
|
||||
@@ -46,8 +48,10 @@ import { Route as AppOrganizationIdProjectIdPagesRouteImport } from './routes/_a
|
||||
import { Route as AppOrganizationIdProjectIdInsightsRouteImport } from './routes/_app.$organizationId.$projectId.insights'
|
||||
import { Route as AppOrganizationIdProjectIdDashboardsRouteImport } from './routes/_app.$organizationId.$projectId.dashboards'
|
||||
import { Route as AppOrganizationIdProjectIdChatRouteImport } from './routes/_app.$organizationId.$projectId.chat'
|
||||
import { Route as AppOrganizationIdProfileTabsIndexRouteImport } from './routes/_app.$organizationId.profile._tabs.index'
|
||||
import { Route as AppOrganizationIdMembersTabsIndexRouteImport } from './routes/_app.$organizationId.members._tabs.index'
|
||||
import { Route as AppOrganizationIdIntegrationsTabsIndexRouteImport } from './routes/_app.$organizationId.integrations._tabs.index'
|
||||
import { Route as AppOrganizationIdProfileTabsEmailPreferencesRouteImport } from './routes/_app.$organizationId.profile._tabs.email-preferences'
|
||||
import { Route as AppOrganizationIdMembersTabsMembersRouteImport } from './routes/_app.$organizationId.members._tabs.members'
|
||||
import { Route as AppOrganizationIdMembersTabsInvitationsRouteImport } from './routes/_app.$organizationId.members._tabs.invitations'
|
||||
import { Route as AppOrganizationIdIntegrationsTabsInstalledRouteImport } from './routes/_app.$organizationId.integrations._tabs.installed'
|
||||
@@ -80,6 +84,9 @@ import { Route as AppOrganizationIdProjectIdEventsTabsConversionsRouteImport } f
|
||||
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.index'
|
||||
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.events'
|
||||
|
||||
const AppOrganizationIdProfileRouteImport = createFileRoute(
|
||||
'/_app/$organizationId/profile',
|
||||
)()
|
||||
const AppOrganizationIdMembersRouteImport = createFileRoute(
|
||||
'/_app/$organizationId/members',
|
||||
)()
|
||||
@@ -102,6 +109,11 @@ const AppOrganizationIdProjectIdProfilesProfileIdRouteImport = createFileRoute(
|
||||
'/_app/$organizationId/$projectId/profiles/$profileId',
|
||||
)()
|
||||
|
||||
const UnsubscribeRoute = UnsubscribeRouteImport.update({
|
||||
id: '/unsubscribe',
|
||||
path: '/unsubscribe',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const StepsRoute = StepsRouteImport.update({
|
||||
id: '/_steps',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
@@ -168,6 +180,12 @@ const AppOrganizationIdRoute = AppOrganizationIdRouteImport.update({
|
||||
path: '/$organizationId',
|
||||
getParentRoute: () => AppRoute,
|
||||
} as any)
|
||||
const AppOrganizationIdProfileRoute =
|
||||
AppOrganizationIdProfileRouteImport.update({
|
||||
id: '/profile',
|
||||
path: '/profile',
|
||||
getParentRoute: () => AppOrganizationIdRoute,
|
||||
} as any)
|
||||
const AppOrganizationIdMembersRoute =
|
||||
AppOrganizationIdMembersRouteImport.update({
|
||||
id: '/members',
|
||||
@@ -265,6 +283,11 @@ const StepsOnboardingProjectIdConnectRoute =
|
||||
path: '/onboarding/$projectId/connect',
|
||||
getParentRoute: () => StepsRoute,
|
||||
} as any)
|
||||
const AppOrganizationIdProfileTabsRoute =
|
||||
AppOrganizationIdProfileTabsRouteImport.update({
|
||||
id: '/_tabs',
|
||||
getParentRoute: () => AppOrganizationIdProfileRoute,
|
||||
} as any)
|
||||
const AppOrganizationIdMembersTabsRoute =
|
||||
AppOrganizationIdMembersTabsRouteImport.update({
|
||||
id: '/_tabs',
|
||||
@@ -329,6 +352,12 @@ const AppOrganizationIdProjectIdProfilesProfileIdRoute =
|
||||
path: '/$profileId',
|
||||
getParentRoute: () => AppOrganizationIdProjectIdProfilesRoute,
|
||||
} as any)
|
||||
const AppOrganizationIdProfileTabsIndexRoute =
|
||||
AppOrganizationIdProfileTabsIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => AppOrganizationIdProfileTabsRoute,
|
||||
} as any)
|
||||
const AppOrganizationIdMembersTabsIndexRoute =
|
||||
AppOrganizationIdMembersTabsIndexRouteImport.update({
|
||||
id: '/',
|
||||
@@ -341,6 +370,12 @@ const AppOrganizationIdIntegrationsTabsIndexRoute =
|
||||
path: '/',
|
||||
getParentRoute: () => AppOrganizationIdIntegrationsTabsRoute,
|
||||
} as any)
|
||||
const AppOrganizationIdProfileTabsEmailPreferencesRoute =
|
||||
AppOrganizationIdProfileTabsEmailPreferencesRouteImport.update({
|
||||
id: '/email-preferences',
|
||||
path: '/email-preferences',
|
||||
getParentRoute: () => AppOrganizationIdProfileTabsRoute,
|
||||
} as any)
|
||||
const AppOrganizationIdMembersTabsMembersRoute =
|
||||
AppOrganizationIdMembersTabsMembersRouteImport.update({
|
||||
id: '/members',
|
||||
@@ -525,6 +560,7 @@ const AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute =
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/unsubscribe': typeof UnsubscribeRoute
|
||||
'/$organizationId': typeof AppOrganizationIdRouteWithChildren
|
||||
'/login': typeof LoginLoginRoute
|
||||
'/reset-password': typeof LoginResetPasswordRoute
|
||||
@@ -552,6 +588,7 @@ export interface FileRoutesByFullPath {
|
||||
'/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute
|
||||
'/$organizationId/integrations': typeof AppOrganizationIdIntegrationsTabsRouteWithChildren
|
||||
'/$organizationId/members': typeof AppOrganizationIdMembersTabsRouteWithChildren
|
||||
'/$organizationId/profile': typeof AppOrganizationIdProfileTabsRouteWithChildren
|
||||
'/onboarding/$projectId/connect': typeof StepsOnboardingProjectIdConnectRoute
|
||||
'/onboarding/$projectId/verify': typeof StepsOnboardingProjectIdVerifyRoute
|
||||
'/$organizationId/$projectId/': typeof AppOrganizationIdProjectIdIndexRoute
|
||||
@@ -566,8 +603,10 @@ export interface FileRoutesByFullPath {
|
||||
'/$organizationId/integrations/installed': typeof AppOrganizationIdIntegrationsTabsInstalledRoute
|
||||
'/$organizationId/members/invitations': typeof AppOrganizationIdMembersTabsInvitationsRoute
|
||||
'/$organizationId/members/members': typeof AppOrganizationIdMembersTabsMembersRoute
|
||||
'/$organizationId/profile/email-preferences': typeof AppOrganizationIdProfileTabsEmailPreferencesRoute
|
||||
'/$organizationId/integrations/': typeof AppOrganizationIdIntegrationsTabsIndexRoute
|
||||
'/$organizationId/members/': typeof AppOrganizationIdMembersTabsIndexRoute
|
||||
'/$organizationId/profile/': typeof AppOrganizationIdProfileTabsIndexRoute
|
||||
'/$organizationId/$projectId/events/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute
|
||||
'/$organizationId/$projectId/events/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute
|
||||
'/$organizationId/$projectId/events/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute
|
||||
@@ -591,6 +630,7 @@ export interface FileRoutesByFullPath {
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/unsubscribe': typeof UnsubscribeRoute
|
||||
'/login': typeof LoginLoginRoute
|
||||
'/reset-password': typeof LoginResetPasswordRoute
|
||||
'/onboarding': typeof PublicOnboardingRoute
|
||||
@@ -616,6 +656,7 @@ export interface FileRoutesByTo {
|
||||
'/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute
|
||||
'/$organizationId/integrations': typeof AppOrganizationIdIntegrationsTabsIndexRoute
|
||||
'/$organizationId/members': typeof AppOrganizationIdMembersTabsIndexRoute
|
||||
'/$organizationId/profile': typeof AppOrganizationIdProfileTabsIndexRoute
|
||||
'/onboarding/$projectId/connect': typeof StepsOnboardingProjectIdConnectRoute
|
||||
'/onboarding/$projectId/verify': typeof StepsOnboardingProjectIdVerifyRoute
|
||||
'/$organizationId/$projectId': typeof AppOrganizationIdProjectIdIndexRoute
|
||||
@@ -630,6 +671,7 @@ export interface FileRoutesByTo {
|
||||
'/$organizationId/integrations/installed': typeof AppOrganizationIdIntegrationsTabsInstalledRoute
|
||||
'/$organizationId/members/invitations': typeof AppOrganizationIdMembersTabsInvitationsRoute
|
||||
'/$organizationId/members/members': typeof AppOrganizationIdMembersTabsMembersRoute
|
||||
'/$organizationId/profile/email-preferences': typeof AppOrganizationIdProfileTabsEmailPreferencesRoute
|
||||
'/$organizationId/$projectId/events/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute
|
||||
'/$organizationId/$projectId/events/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute
|
||||
'/$organizationId/$projectId/events/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute
|
||||
@@ -653,6 +695,7 @@ export interface FileRoutesById {
|
||||
'/_login': typeof LoginRouteWithChildren
|
||||
'/_public': typeof PublicRouteWithChildren
|
||||
'/_steps': typeof StepsRouteWithChildren
|
||||
'/unsubscribe': typeof UnsubscribeRoute
|
||||
'/_app/$organizationId': typeof AppOrganizationIdRouteWithChildren
|
||||
'/_login/login': typeof LoginLoginRoute
|
||||
'/_login/reset-password': typeof LoginResetPasswordRoute
|
||||
@@ -682,6 +725,8 @@ export interface FileRoutesById {
|
||||
'/_app/$organizationId/integrations/_tabs': typeof AppOrganizationIdIntegrationsTabsRouteWithChildren
|
||||
'/_app/$organizationId/members': typeof AppOrganizationIdMembersRouteWithChildren
|
||||
'/_app/$organizationId/members/_tabs': typeof AppOrganizationIdMembersTabsRouteWithChildren
|
||||
'/_app/$organizationId/profile': typeof AppOrganizationIdProfileRouteWithChildren
|
||||
'/_app/$organizationId/profile/_tabs': typeof AppOrganizationIdProfileTabsRouteWithChildren
|
||||
'/_steps/onboarding/$projectId/connect': typeof StepsOnboardingProjectIdConnectRoute
|
||||
'/_steps/onboarding/$projectId/verify': typeof StepsOnboardingProjectIdVerifyRoute
|
||||
'/_app/$organizationId/$projectId/': typeof AppOrganizationIdProjectIdIndexRoute
|
||||
@@ -700,8 +745,10 @@ export interface FileRoutesById {
|
||||
'/_app/$organizationId/integrations/_tabs/installed': typeof AppOrganizationIdIntegrationsTabsInstalledRoute
|
||||
'/_app/$organizationId/members/_tabs/invitations': typeof AppOrganizationIdMembersTabsInvitationsRoute
|
||||
'/_app/$organizationId/members/_tabs/members': typeof AppOrganizationIdMembersTabsMembersRoute
|
||||
'/_app/$organizationId/profile/_tabs/email-preferences': typeof AppOrganizationIdProfileTabsEmailPreferencesRoute
|
||||
'/_app/$organizationId/integrations/_tabs/': typeof AppOrganizationIdIntegrationsTabsIndexRoute
|
||||
'/_app/$organizationId/members/_tabs/': typeof AppOrganizationIdMembersTabsIndexRoute
|
||||
'/_app/$organizationId/profile/_tabs/': typeof AppOrganizationIdProfileTabsIndexRoute
|
||||
'/_app/$organizationId/$projectId/events/_tabs/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute
|
||||
'/_app/$organizationId/$projectId/events/_tabs/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute
|
||||
'/_app/$organizationId/$projectId/events/_tabs/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute
|
||||
@@ -728,6 +775,7 @@ export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/unsubscribe'
|
||||
| '/$organizationId'
|
||||
| '/login'
|
||||
| '/reset-password'
|
||||
@@ -755,6 +803,7 @@ export interface FileRouteTypes {
|
||||
| '/$organizationId/$projectId/sessions'
|
||||
| '/$organizationId/integrations'
|
||||
| '/$organizationId/members'
|
||||
| '/$organizationId/profile'
|
||||
| '/onboarding/$projectId/connect'
|
||||
| '/onboarding/$projectId/verify'
|
||||
| '/$organizationId/$projectId/'
|
||||
@@ -769,8 +818,10 @@ export interface FileRouteTypes {
|
||||
| '/$organizationId/integrations/installed'
|
||||
| '/$organizationId/members/invitations'
|
||||
| '/$organizationId/members/members'
|
||||
| '/$organizationId/profile/email-preferences'
|
||||
| '/$organizationId/integrations/'
|
||||
| '/$organizationId/members/'
|
||||
| '/$organizationId/profile/'
|
||||
| '/$organizationId/$projectId/events/conversions'
|
||||
| '/$organizationId/$projectId/events/events'
|
||||
| '/$organizationId/$projectId/events/stats'
|
||||
@@ -794,6 +845,7 @@ export interface FileRouteTypes {
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| '/unsubscribe'
|
||||
| '/login'
|
||||
| '/reset-password'
|
||||
| '/onboarding'
|
||||
@@ -819,6 +871,7 @@ export interface FileRouteTypes {
|
||||
| '/$organizationId/$projectId/sessions'
|
||||
| '/$organizationId/integrations'
|
||||
| '/$organizationId/members'
|
||||
| '/$organizationId/profile'
|
||||
| '/onboarding/$projectId/connect'
|
||||
| '/onboarding/$projectId/verify'
|
||||
| '/$organizationId/$projectId'
|
||||
@@ -833,6 +886,7 @@ export interface FileRouteTypes {
|
||||
| '/$organizationId/integrations/installed'
|
||||
| '/$organizationId/members/invitations'
|
||||
| '/$organizationId/members/members'
|
||||
| '/$organizationId/profile/email-preferences'
|
||||
| '/$organizationId/$projectId/events/conversions'
|
||||
| '/$organizationId/$projectId/events/events'
|
||||
| '/$organizationId/$projectId/events/stats'
|
||||
@@ -855,6 +909,7 @@ export interface FileRouteTypes {
|
||||
| '/_login'
|
||||
| '/_public'
|
||||
| '/_steps'
|
||||
| '/unsubscribe'
|
||||
| '/_app/$organizationId'
|
||||
| '/_login/login'
|
||||
| '/_login/reset-password'
|
||||
@@ -884,6 +939,8 @@ export interface FileRouteTypes {
|
||||
| '/_app/$organizationId/integrations/_tabs'
|
||||
| '/_app/$organizationId/members'
|
||||
| '/_app/$organizationId/members/_tabs'
|
||||
| '/_app/$organizationId/profile'
|
||||
| '/_app/$organizationId/profile/_tabs'
|
||||
| '/_steps/onboarding/$projectId/connect'
|
||||
| '/_steps/onboarding/$projectId/verify'
|
||||
| '/_app/$organizationId/$projectId/'
|
||||
@@ -902,8 +959,10 @@ export interface FileRouteTypes {
|
||||
| '/_app/$organizationId/integrations/_tabs/installed'
|
||||
| '/_app/$organizationId/members/_tabs/invitations'
|
||||
| '/_app/$organizationId/members/_tabs/members'
|
||||
| '/_app/$organizationId/profile/_tabs/email-preferences'
|
||||
| '/_app/$organizationId/integrations/_tabs/'
|
||||
| '/_app/$organizationId/members/_tabs/'
|
||||
| '/_app/$organizationId/profile/_tabs/'
|
||||
| '/_app/$organizationId/$projectId/events/_tabs/conversions'
|
||||
| '/_app/$organizationId/$projectId/events/_tabs/events'
|
||||
| '/_app/$organizationId/$projectId/events/_tabs/stats'
|
||||
@@ -933,6 +992,7 @@ export interface RootRouteChildren {
|
||||
LoginRoute: typeof LoginRouteWithChildren
|
||||
PublicRoute: typeof PublicRouteWithChildren
|
||||
StepsRoute: typeof StepsRouteWithChildren
|
||||
UnsubscribeRoute: typeof UnsubscribeRoute
|
||||
ApiConfigRoute: typeof ApiConfigRoute
|
||||
ApiHealthcheckRoute: typeof ApiHealthcheckRoute
|
||||
WidgetCounterRoute: typeof WidgetCounterRoute
|
||||
@@ -945,6 +1005,13 @@ export interface RootRouteChildren {
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/unsubscribe': {
|
||||
id: '/unsubscribe'
|
||||
path: '/unsubscribe'
|
||||
fullPath: '/unsubscribe'
|
||||
preLoaderRoute: typeof UnsubscribeRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_steps': {
|
||||
id: '/_steps'
|
||||
path: ''
|
||||
@@ -1043,6 +1110,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AppOrganizationIdRouteImport
|
||||
parentRoute: typeof AppRoute
|
||||
}
|
||||
'/_app/$organizationId/profile': {
|
||||
id: '/_app/$organizationId/profile'
|
||||
path: '/profile'
|
||||
fullPath: '/$organizationId/profile'
|
||||
preLoaderRoute: typeof AppOrganizationIdProfileRouteImport
|
||||
parentRoute: typeof AppOrganizationIdRoute
|
||||
}
|
||||
'/_app/$organizationId/members': {
|
||||
id: '/_app/$organizationId/members'
|
||||
path: '/members'
|
||||
@@ -1162,6 +1236,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof StepsOnboardingProjectIdConnectRouteImport
|
||||
parentRoute: typeof StepsRoute
|
||||
}
|
||||
'/_app/$organizationId/profile/_tabs': {
|
||||
id: '/_app/$organizationId/profile/_tabs'
|
||||
path: '/profile'
|
||||
fullPath: '/$organizationId/profile'
|
||||
preLoaderRoute: typeof AppOrganizationIdProfileTabsRouteImport
|
||||
parentRoute: typeof AppOrganizationIdProfileRoute
|
||||
}
|
||||
'/_app/$organizationId/members/_tabs': {
|
||||
id: '/_app/$organizationId/members/_tabs'
|
||||
path: '/members'
|
||||
@@ -1239,6 +1320,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdRouteImport
|
||||
parentRoute: typeof AppOrganizationIdProjectIdProfilesRoute
|
||||
}
|
||||
'/_app/$organizationId/profile/_tabs/': {
|
||||
id: '/_app/$organizationId/profile/_tabs/'
|
||||
path: '/'
|
||||
fullPath: '/$organizationId/profile/'
|
||||
preLoaderRoute: typeof AppOrganizationIdProfileTabsIndexRouteImport
|
||||
parentRoute: typeof AppOrganizationIdProfileTabsRoute
|
||||
}
|
||||
'/_app/$organizationId/members/_tabs/': {
|
||||
id: '/_app/$organizationId/members/_tabs/'
|
||||
path: '/'
|
||||
@@ -1253,6 +1341,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AppOrganizationIdIntegrationsTabsIndexRouteImport
|
||||
parentRoute: typeof AppOrganizationIdIntegrationsTabsRoute
|
||||
}
|
||||
'/_app/$organizationId/profile/_tabs/email-preferences': {
|
||||
id: '/_app/$organizationId/profile/_tabs/email-preferences'
|
||||
path: '/email-preferences'
|
||||
fullPath: '/$organizationId/profile/email-preferences'
|
||||
preLoaderRoute: typeof AppOrganizationIdProfileTabsEmailPreferencesRouteImport
|
||||
parentRoute: typeof AppOrganizationIdProfileTabsRoute
|
||||
}
|
||||
'/_app/$organizationId/members/_tabs/members': {
|
||||
id: '/_app/$organizationId/members/_tabs/members'
|
||||
path: '/members'
|
||||
@@ -1797,6 +1892,39 @@ const AppOrganizationIdMembersRouteWithChildren =
|
||||
AppOrganizationIdMembersRouteChildren,
|
||||
)
|
||||
|
||||
interface AppOrganizationIdProfileTabsRouteChildren {
|
||||
AppOrganizationIdProfileTabsEmailPreferencesRoute: typeof AppOrganizationIdProfileTabsEmailPreferencesRoute
|
||||
AppOrganizationIdProfileTabsIndexRoute: typeof AppOrganizationIdProfileTabsIndexRoute
|
||||
}
|
||||
|
||||
const AppOrganizationIdProfileTabsRouteChildren: AppOrganizationIdProfileTabsRouteChildren =
|
||||
{
|
||||
AppOrganizationIdProfileTabsEmailPreferencesRoute:
|
||||
AppOrganizationIdProfileTabsEmailPreferencesRoute,
|
||||
AppOrganizationIdProfileTabsIndexRoute:
|
||||
AppOrganizationIdProfileTabsIndexRoute,
|
||||
}
|
||||
|
||||
const AppOrganizationIdProfileTabsRouteWithChildren =
|
||||
AppOrganizationIdProfileTabsRoute._addFileChildren(
|
||||
AppOrganizationIdProfileTabsRouteChildren,
|
||||
)
|
||||
|
||||
interface AppOrganizationIdProfileRouteChildren {
|
||||
AppOrganizationIdProfileTabsRoute: typeof AppOrganizationIdProfileTabsRouteWithChildren
|
||||
}
|
||||
|
||||
const AppOrganizationIdProfileRouteChildren: AppOrganizationIdProfileRouteChildren =
|
||||
{
|
||||
AppOrganizationIdProfileTabsRoute:
|
||||
AppOrganizationIdProfileTabsRouteWithChildren,
|
||||
}
|
||||
|
||||
const AppOrganizationIdProfileRouteWithChildren =
|
||||
AppOrganizationIdProfileRoute._addFileChildren(
|
||||
AppOrganizationIdProfileRouteChildren,
|
||||
)
|
||||
|
||||
interface AppOrganizationIdRouteChildren {
|
||||
AppOrganizationIdProjectIdRoute: typeof AppOrganizationIdProjectIdRouteWithChildren
|
||||
AppOrganizationIdBillingRoute: typeof AppOrganizationIdBillingRoute
|
||||
@@ -1804,6 +1932,7 @@ interface AppOrganizationIdRouteChildren {
|
||||
AppOrganizationIdIndexRoute: typeof AppOrganizationIdIndexRoute
|
||||
AppOrganizationIdIntegrationsRoute: typeof AppOrganizationIdIntegrationsRouteWithChildren
|
||||
AppOrganizationIdMembersRoute: typeof AppOrganizationIdMembersRouteWithChildren
|
||||
AppOrganizationIdProfileRoute: typeof AppOrganizationIdProfileRouteWithChildren
|
||||
}
|
||||
|
||||
const AppOrganizationIdRouteChildren: AppOrganizationIdRouteChildren = {
|
||||
@@ -1814,6 +1943,7 @@ const AppOrganizationIdRouteChildren: AppOrganizationIdRouteChildren = {
|
||||
AppOrganizationIdIntegrationsRoute:
|
||||
AppOrganizationIdIntegrationsRouteWithChildren,
|
||||
AppOrganizationIdMembersRoute: AppOrganizationIdMembersRouteWithChildren,
|
||||
AppOrganizationIdProfileRoute: AppOrganizationIdProfileRouteWithChildren,
|
||||
}
|
||||
|
||||
const AppOrganizationIdRouteWithChildren =
|
||||
@@ -1872,6 +2002,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
LoginRoute: LoginRouteWithChildren,
|
||||
PublicRoute: PublicRouteWithChildren,
|
||||
StepsRoute: StepsRouteWithChildren,
|
||||
UnsubscribeRoute: UnsubscribeRoute,
|
||||
ApiConfigRoute: ApiConfigRoute,
|
||||
ApiHealthcheckRoute: ApiHealthcheckRoute,
|
||||
WidgetCounterRoute: WidgetCounterRoute,
|
||||
|
||||
@@ -86,31 +86,27 @@ function Component() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{realtimeWidget && (
|
||||
<RealtimeWidgetSection
|
||||
widget={realtimeWidget as any}
|
||||
dashboardUrl={dashboardUrl}
|
||||
isToggling={toggleMutation.isPending}
|
||||
isUpdatingOptions={updateOptionsMutation.isPending}
|
||||
onToggle={(enabled) => handleToggle('realtime', enabled)}
|
||||
onUpdateOptions={(options) =>
|
||||
updateOptionsMutation.mutate({
|
||||
projectId,
|
||||
organizationId,
|
||||
options,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<RealtimeWidgetSection
|
||||
widget={realtimeWidget as any}
|
||||
dashboardUrl={dashboardUrl}
|
||||
isToggling={toggleMutation.isPending}
|
||||
isUpdatingOptions={updateOptionsMutation.isPending}
|
||||
onToggle={(enabled) => handleToggle('realtime', enabled)}
|
||||
onUpdateOptions={(options) =>
|
||||
updateOptionsMutation.mutate({
|
||||
projectId,
|
||||
organizationId,
|
||||
options,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
{counterWidget && (
|
||||
<CounterWidgetSection
|
||||
widget={counterWidget}
|
||||
dashboardUrl={dashboardUrl}
|
||||
isToggling={toggleMutation.isPending}
|
||||
onToggle={(enabled) => handleToggle('counter', enabled)}
|
||||
/>
|
||||
)}
|
||||
<CounterWidgetSection
|
||||
widget={counterWidget as any}
|
||||
dashboardUrl={dashboardUrl}
|
||||
isToggling={toggleMutation.isPending}
|
||||
onToggle={(enabled) => handleToggle('counter', enabled)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -152,11 +148,15 @@ function RealtimeWidgetSection({
|
||||
countries: true,
|
||||
paths: false,
|
||||
};
|
||||
|
||||
const [options, setOptions] = useState<IRealtimeWidgetOptions>(
|
||||
(widget?.options as IRealtimeWidgetOptions) || defaultOptions,
|
||||
);
|
||||
|
||||
// Create a checksum based on URL and current options to force iframe reload
|
||||
const widgetChecksum = widgetUrl
|
||||
? btoa(JSON.stringify(Object.values(options)))
|
||||
: null;
|
||||
|
||||
// Update local options when widget data changes
|
||||
useEffect(() => {
|
||||
if (widget?.options) {
|
||||
@@ -254,7 +254,8 @@ function RealtimeWidgetSection({
|
||||
<h3 className="text-sm font-medium">Preview</h3>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<iframe
|
||||
src={widgetUrl!}
|
||||
key={widgetChecksum}
|
||||
src={`${widgetUrl}&checksum=${widgetChecksum}`}
|
||||
width="100%"
|
||||
height="600"
|
||||
className="border-0"
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { WithLabel } from '@/components/forms/input-with-label';
|
||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { handleError } from '@/integrations/trpc/react';
|
||||
import { emailCategories } from '@openpanel/constants';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { SaveIcon } from 'lucide-react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
const validator = z.object({
|
||||
categories: z.record(z.string(), z.boolean()),
|
||||
});
|
||||
|
||||
type IForm = z.infer<typeof validator>;
|
||||
|
||||
/**
|
||||
* Build explicit boolean values for every key in emailCategories.
|
||||
* Uses saved preferences when available, falling back to true (opted-in).
|
||||
*/
|
||||
function buildCategoryDefaults(
|
||||
savedPreferences?: Record<string, boolean>,
|
||||
): Record<string, boolean> {
|
||||
return Object.keys(emailCategories).reduce(
|
||||
(acc, category) => {
|
||||
acc[category] = savedPreferences?.[category] ?? true;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, boolean>,
|
||||
);
|
||||
}
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/profile/_tabs/email-preferences',
|
||||
)({
|
||||
component: Component,
|
||||
pendingComponent: FullPageLoadingState,
|
||||
});
|
||||
|
||||
function Component() {
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const preferencesQuery = useSuspenseQuery(
|
||||
trpc.email.getPreferences.queryOptions(),
|
||||
);
|
||||
|
||||
const { control, handleSubmit, formState, reset } = useForm<IForm>({
|
||||
defaultValues: {
|
||||
categories: buildCategoryDefaults(preferencesQuery.data),
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = useMutation(
|
||||
trpc.email.updatePreferences.mutationOptions({
|
||||
onSuccess: async () => {
|
||||
toast('Email preferences updated', {
|
||||
description: 'Your email preferences have been saved.',
|
||||
});
|
||||
await queryClient.invalidateQueries(
|
||||
trpc.email.getPreferences.pathFilter(),
|
||||
);
|
||||
// Reset form with fresh data after refetch
|
||||
const freshData = await queryClient.fetchQuery(
|
||||
trpc.email.getPreferences.queryOptions(),
|
||||
);
|
||||
reset({
|
||||
categories: buildCategoryDefaults(freshData),
|
||||
});
|
||||
},
|
||||
onError: handleError,
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit((values) => {
|
||||
mutation.mutate(values);
|
||||
})}
|
||||
>
|
||||
<Widget className="max-w-screen-md w-full">
|
||||
<WidgetHead>
|
||||
<span className="title">Email Preferences</span>
|
||||
</WidgetHead>
|
||||
<WidgetBody className="gap-4 col">
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Choose which types of emails you want to receive. Uncheck a category
|
||||
to stop receiving those emails.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{Object.entries(emailCategories).map(([category, label]) => (
|
||||
<Controller
|
||||
key={category}
|
||||
name={`categories.${category}`}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-4 rounded-md border border-border hover:bg-def-200 transition-colors">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{label}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{category === 'onboarding' &&
|
||||
'Get started tips and guidance emails'}
|
||||
{category === 'billing' &&
|
||||
'Subscription updates and payment reminders'}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={mutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
type="submit"
|
||||
disabled={!formState.isDirty || mutation.isPending}
|
||||
className="self-end mt-4"
|
||||
icon={SaveIcon}
|
||||
loading={mutation.isPending}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { InputWithLabel } from '@/components/forms/input-with-label';
|
||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
import { PageContainer } from '@/components/page-container';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
|
||||
import { handleError, useTRPC } from '@/integrations/trpc/react';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { SaveIcon } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
const validator = z.object({
|
||||
firstName: z.string(),
|
||||
lastName: z.string(),
|
||||
});
|
||||
|
||||
type IForm = z.infer<typeof validator>;
|
||||
|
||||
export const Route = createFileRoute('/_app/$organizationId/profile/_tabs/')({
|
||||
component: Component,
|
||||
pendingComponent: FullPageLoadingState,
|
||||
});
|
||||
|
||||
function Component() {
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const session = useSuspenseQuery(trpc.auth.session.queryOptions());
|
||||
const user = session.data?.user;
|
||||
|
||||
const { register, handleSubmit, formState, reset } = useForm<IForm>({
|
||||
defaultValues: {
|
||||
firstName: user?.firstName ?? '',
|
||||
lastName: user?.lastName ?? '',
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = useMutation(
|
||||
trpc.user.update.mutationOptions({
|
||||
onSuccess: (data) => {
|
||||
toast('Profile updated', {
|
||||
description: 'Your profile has been updated.',
|
||||
});
|
||||
queryClient.invalidateQueries(trpc.auth.session.pathFilter());
|
||||
reset({
|
||||
firstName: data.firstName ?? '',
|
||||
lastName: data.lastName ?? '',
|
||||
});
|
||||
},
|
||||
onError: handleError,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit((values) => {
|
||||
mutation.mutate(values);
|
||||
})}
|
||||
>
|
||||
<Widget className="max-w-screen-md w-full">
|
||||
<WidgetHead>
|
||||
<span className="title">Profile</span>
|
||||
</WidgetHead>
|
||||
<WidgetBody className="gap-4 col">
|
||||
<InputWithLabel
|
||||
label="First name"
|
||||
{...register('firstName')}
|
||||
defaultValue={user.firstName ?? ''}
|
||||
/>
|
||||
<InputWithLabel
|
||||
label="Last name"
|
||||
{...register('lastName')}
|
||||
defaultValue={user.lastName ?? ''}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
type="submit"
|
||||
disabled={!formState.isDirty || mutation.isPending}
|
||||
className="self-end"
|
||||
icon={SaveIcon}
|
||||
loading={mutation.isPending}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
55
apps/start/src/routes/_app.$organizationId.profile._tabs.tsx
Normal file
55
apps/start/src/routes/_app.$organizationId.profile._tabs.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
import { PageContainer } from '@/components/page-container';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
|
||||
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { usePageTabs } from '@/hooks/use-page-tabs';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { getProfileName } from '@/utils/getters';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { Outlet, createFileRoute, useRouter } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/_app/$organizationId/profile/_tabs')({
|
||||
component: Component,
|
||||
pendingComponent: FullPageLoadingState,
|
||||
});
|
||||
|
||||
function Component() {
|
||||
const router = useRouter();
|
||||
const { activeTab, tabs } = usePageTabs([
|
||||
{
|
||||
id: '/$organizationId/profile',
|
||||
label: 'Profile',
|
||||
},
|
||||
{ id: 'email-preferences', label: 'Email preferences' },
|
||||
]);
|
||||
|
||||
const handleTabChange = (tabId: string) => {
|
||||
router.navigate({
|
||||
from: Route.fullPath,
|
||||
to: tabId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader title={'Your profile'} />
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
className="mt-2 mb-8"
|
||||
>
|
||||
<TabsList>
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger key={tab.id} value={tab.id}>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Outlet />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
89
apps/start/src/routes/unsubscribe.tsx
Normal file
89
apps/start/src/routes/unsubscribe.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
import { PublicPageCard } from '@/components/public-page-card';
|
||||
import { Button, LinkButton } from '@/components/ui/button';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { emailCategories } from '@openpanel/constants';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { createFileRoute, useSearch } from '@tanstack/react-router';
|
||||
import { useState } from 'react';
|
||||
import { z } from 'zod';
|
||||
|
||||
const unsubscribeSearchSchema = z.object({
|
||||
email: z.string().email(),
|
||||
category: z.string(),
|
||||
token: z.string(),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute('/unsubscribe')({
|
||||
component: RouteComponent,
|
||||
validateSearch: unsubscribeSearchSchema,
|
||||
pendingComponent: FullPageLoadingState,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const search = useSearch({ from: '/unsubscribe' });
|
||||
const { email, category, token } = search;
|
||||
const trpc = useTRPC();
|
||||
const [isUnsubscribing, setIsUnsubscribing] = useState(false);
|
||||
const [isSuccess, setIsSuccess] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const unsubscribeMutation = useMutation(
|
||||
trpc.email.unsubscribe.mutationOptions({
|
||||
onSuccess: () => {
|
||||
setIsSuccess(true);
|
||||
setIsUnsubscribing(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err.message || 'Failed to unsubscribe');
|
||||
setIsUnsubscribing(false);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const handleUnsubscribe = () => {
|
||||
setIsUnsubscribing(true);
|
||||
setError(null);
|
||||
unsubscribeMutation.mutate({ email, category, token });
|
||||
};
|
||||
|
||||
const categoryName =
|
||||
emailCategories[category as keyof typeof emailCategories] || category;
|
||||
|
||||
if (isSuccess) {
|
||||
return (
|
||||
<PublicPageCard
|
||||
title="Unsubscribed"
|
||||
description={`You've been unsubscribed from ${categoryName} emails. You won't receive any more ${categoryName.toLowerCase()} emails from
|
||||
us.`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PublicPageCard
|
||||
title="Unsubscribe"
|
||||
description={
|
||||
<>
|
||||
Unsubscribe from {categoryName} emails? You'll stop receiving{' '}
|
||||
{categoryName.toLowerCase()} emails sent to
|
||||
<span className="">{email}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="col gap-3">
|
||||
{error && (
|
||||
<div className="bg-destructive/10 text-destructive px-4 py-3 rounded-md text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<Button onClick={handleUnsubscribe} disabled={isUnsubscribing}>
|
||||
{isUnsubscribing ? 'Unsubscribing...' : 'Confirm Unsubscribe'}
|
||||
</Button>
|
||||
<LinkButton href="/" variant="ghost">
|
||||
Cancel
|
||||
</LinkButton>
|
||||
</div>
|
||||
</PublicPageCard>
|
||||
);
|
||||
}
|
||||
@@ -119,7 +119,7 @@ function RealtimeWidget({ shareId, data, limit, color }: RealtimeWidgetProps) {
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-col bg-background text-foreground">
|
||||
{/* Header with live counter */}
|
||||
<div className="border-b p-6 pb-3">
|
||||
<div className="p-6 pb-3">
|
||||
<div className="flex items-center justify-between w-full h-4">
|
||||
<div className="flex items-center gap-3 w-full">
|
||||
<Ping />
|
||||
@@ -171,10 +171,10 @@ function RealtimeWidget({ shareId, data, limit, color }: RealtimeWidgetProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-6 overflow-auto p-6 hide-scrollbar">
|
||||
{/* Histogram */}
|
||||
{/* Countries and Referrers */}
|
||||
{(data.countries.length > 0 || data.referrers.length > 0) && (
|
||||
{(data.countries.length > 0 ||
|
||||
data.referrers.length > 0 ||
|
||||
data.paths.length > 0) && (
|
||||
<div className="flex flex-1 flex-col gap-6 overflow-auto p-6 hide-scrollbar border-t">
|
||||
<div className={cn('grid grid-cols-1 gap-6', grids)}>
|
||||
{/* Countries */}
|
||||
{data.countries.length > 0 && (
|
||||
@@ -296,8 +296,8 @@ function RealtimeWidget({ shareId, data, limit, color }: RealtimeWidgetProps) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,9 +20,11 @@
|
||||
"@openpanel/json": "workspace:*",
|
||||
"@openpanel/logger": "workspace:*",
|
||||
"@openpanel/importer": "workspace:*",
|
||||
"@openpanel/payments": "workspace:*",
|
||||
"@openpanel/queue": "workspace:*",
|
||||
"@openpanel/redis": "workspace:*",
|
||||
"bullmq": "^5.63.0",
|
||||
"date-fns": "^3.3.1",
|
||||
"express": "^4.18.2",
|
||||
"groupmq": "catalog:",
|
||||
"prom-client": "^15.1.3",
|
||||
|
||||
@@ -39,6 +39,11 @@ export async function bootCron() {
|
||||
type: 'insightsDaily',
|
||||
pattern: '0 2 * * *',
|
||||
},
|
||||
{
|
||||
name: 'onboarding',
|
||||
type: 'onboarding',
|
||||
pattern: '0 * * * *',
|
||||
},
|
||||
];
|
||||
|
||||
if (process.env.SELF_HOSTED && process.env.NODE_ENV === 'production') {
|
||||
|
||||
@@ -281,10 +281,20 @@ export async function bootWorkers() {
|
||||
eventName: string,
|
||||
evtOrExitCodeOrError: number | string | Error,
|
||||
) {
|
||||
logger.info('Starting graceful shutdown', {
|
||||
code: evtOrExitCodeOrError,
|
||||
eventName,
|
||||
});
|
||||
// Log the actual error details for unhandled rejections/exceptions
|
||||
if (evtOrExitCodeOrError instanceof Error) {
|
||||
logger.error('Unhandled error triggered shutdown', {
|
||||
eventName,
|
||||
message: evtOrExitCodeOrError.message,
|
||||
stack: evtOrExitCodeOrError.stack,
|
||||
name: evtOrExitCodeOrError.name,
|
||||
});
|
||||
} else {
|
||||
logger.info('Starting graceful shutdown', {
|
||||
code: evtOrExitCodeOrError,
|
||||
eventName,
|
||||
});
|
||||
}
|
||||
try {
|
||||
const time = performance.now();
|
||||
|
||||
|
||||
@@ -30,7 +30,10 @@ async function start() {
|
||||
const PORT = Number.parseInt(process.env.WORKER_PORT || '3000', 10);
|
||||
const app = express();
|
||||
|
||||
if (process.env.DISABLE_BULLBOARD === undefined) {
|
||||
if (
|
||||
process.env.DISABLE_BULLBOARD !== '1' &&
|
||||
process.env.DISABLE_BULLBOARD !== 'true'
|
||||
) {
|
||||
const serverAdapter = new ExpressAdapter();
|
||||
serverAdapter.setBasePath('/');
|
||||
createBullBoard({
|
||||
|
||||
276
apps/worker/src/jobs/cron.onboarding.ts
Normal file
276
apps/worker/src/jobs/cron.onboarding.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import type { Job } from 'bullmq';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
|
||||
import { db } from '@openpanel/db';
|
||||
import {
|
||||
type EmailData,
|
||||
type EmailTemplate,
|
||||
sendEmail,
|
||||
} from '@openpanel/email';
|
||||
import type { CronQueuePayload } from '@openpanel/queue';
|
||||
|
||||
import { getRecommendedPlan } from '@openpanel/payments';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
// Types for the onboarding email system
|
||||
const orgQuery = {
|
||||
include: {
|
||||
createdBy: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
deletedAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
type OrgWithCreator = Awaited<
|
||||
ReturnType<typeof db.organization.findMany<typeof orgQuery>>
|
||||
>[number];
|
||||
|
||||
type OnboardingContext = {
|
||||
org: OrgWithCreator;
|
||||
user: NonNullable<OrgWithCreator['createdBy']>;
|
||||
};
|
||||
|
||||
type OnboardingEmail<T extends EmailTemplate = EmailTemplate> = {
|
||||
day: number;
|
||||
template: T;
|
||||
shouldSend?: (ctx: OnboardingContext) => Promise<boolean | 'complete'>;
|
||||
data: (ctx: OnboardingContext) => EmailData<T>;
|
||||
};
|
||||
|
||||
// Helper to create type-safe email entries with correlated template/data types
|
||||
function email<T extends EmailTemplate>(config: OnboardingEmail<T>) {
|
||||
return config;
|
||||
}
|
||||
|
||||
const getters = {
|
||||
firstName: (ctx: OnboardingContext) => ctx.user.firstName || undefined,
|
||||
organizationName: (ctx: OnboardingContext) => ctx.org.name,
|
||||
dashboardUrl: (ctx: OnboardingContext) => {
|
||||
return `${process.env.DASHBOARD_URL}/${ctx.org.id}`;
|
||||
},
|
||||
billingUrl: (ctx: OnboardingContext) => {
|
||||
return `${process.env.DASHBOARD_URL}/${ctx.org.id}/billing`;
|
||||
},
|
||||
recommendedPlan: (ctx: OnboardingContext) => {
|
||||
return getRecommendedPlan(
|
||||
ctx.org.subscriptionPeriodEventsCount,
|
||||
(plan) =>
|
||||
`${plan.formattedEvents} events per month for ${plan.formattedPrice}`,
|
||||
);
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Declarative email schedule - easy to add, remove, or reorder
|
||||
const ONBOARDING_EMAILS = [
|
||||
email({
|
||||
day: 0,
|
||||
template: 'onboarding-welcome',
|
||||
data: (ctx) => ({
|
||||
firstName: getters.firstName(ctx),
|
||||
dashboardUrl: getters.dashboardUrl(ctx),
|
||||
}),
|
||||
}),
|
||||
email({
|
||||
day: 2,
|
||||
template: 'onboarding-what-to-track',
|
||||
data: (ctx) => ({
|
||||
firstName: getters.firstName(ctx),
|
||||
}),
|
||||
}),
|
||||
email({
|
||||
day: 6,
|
||||
template: 'onboarding-dashboards',
|
||||
data: (ctx) => ({
|
||||
firstName: getters.firstName(ctx),
|
||||
dashboardUrl: getters.dashboardUrl(ctx),
|
||||
}),
|
||||
}),
|
||||
email({
|
||||
day: 14,
|
||||
template: 'onboarding-feature-request',
|
||||
data: (ctx) => ({
|
||||
firstName: getters.firstName(ctx),
|
||||
}),
|
||||
}),
|
||||
email({
|
||||
day: 26,
|
||||
template: 'onboarding-trial-ending',
|
||||
shouldSend: async ({ org }) => {
|
||||
if (org.subscriptionStatus === 'active') {
|
||||
return 'complete';
|
||||
}
|
||||
return true;
|
||||
},
|
||||
data: (ctx) => {
|
||||
return {
|
||||
firstName: getters.firstName(ctx),
|
||||
organizationName: getters.organizationName(ctx),
|
||||
billingUrl: getters.billingUrl(ctx),
|
||||
recommendedPlan: getters.recommendedPlan(ctx),
|
||||
};
|
||||
},
|
||||
}),
|
||||
email({
|
||||
day: 30,
|
||||
template: 'onboarding-trial-ended',
|
||||
shouldSend: async ({ org }) => {
|
||||
if (org.subscriptionStatus === 'active') {
|
||||
return 'complete';
|
||||
}
|
||||
return true;
|
||||
},
|
||||
data: (ctx) => {
|
||||
return {
|
||||
firstName: getters.firstName(ctx),
|
||||
billingUrl: getters.billingUrl(ctx),
|
||||
recommendedPlan: getters.recommendedPlan(ctx),
|
||||
};
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
export async function onboardingJob(job: Job<CronQueuePayload>) {
|
||||
if (process.env.SELF_HOSTED === 'true') {
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info('Starting onboarding email job');
|
||||
|
||||
// Fetch organizations that are in onboarding (not completed)
|
||||
const orgs = await db.organization.findMany({
|
||||
where: {
|
||||
OR: [{ onboarding: null }, { onboarding: { notIn: ['completed'] } }],
|
||||
deleteAt: null,
|
||||
createdBy: {
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
...orgQuery,
|
||||
});
|
||||
|
||||
logger.info(`Found ${orgs.length} organizations in onboarding`);
|
||||
|
||||
let emailsSent = 0;
|
||||
let orgsCompleted = 0;
|
||||
let orgsSkipped = 0;
|
||||
|
||||
for (const org of orgs) {
|
||||
// Skip if no creator or creator is deleted
|
||||
if (!org.createdBy || org.createdBy.deletedAt) {
|
||||
orgsSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const user = org.createdBy;
|
||||
const daysSinceOrgCreation = differenceInDays(new Date(), org.createdAt);
|
||||
|
||||
// Find the next email to send
|
||||
// If org.onboarding is null or empty string, they haven't received any email yet
|
||||
const lastSentIndex = org.onboarding
|
||||
? ONBOARDING_EMAILS.findIndex((e) => e.template === org.onboarding)
|
||||
: -1;
|
||||
const nextEmailIndex = lastSentIndex + 1;
|
||||
|
||||
// No more emails to send
|
||||
if (nextEmailIndex >= ONBOARDING_EMAILS.length) {
|
||||
await db.organization.update({
|
||||
where: { id: org.id },
|
||||
data: { onboarding: 'completed' },
|
||||
});
|
||||
orgsCompleted++;
|
||||
logger.info(
|
||||
`Completed onboarding for organization ${org.id} (all emails sent)`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextEmail = ONBOARDING_EMAILS[nextEmailIndex];
|
||||
if (!nextEmail) {
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Checking if enough days have passed for organization ${org.id}`,
|
||||
{
|
||||
daysSinceOrgCreation,
|
||||
nextEmailDay: nextEmail.day,
|
||||
orgCreatedAt: org.createdAt,
|
||||
today: new Date(),
|
||||
},
|
||||
);
|
||||
// Check if enough days have passed
|
||||
if (daysSinceOrgCreation < nextEmail.day) {
|
||||
orgsSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check shouldSend callback if defined
|
||||
if (nextEmail.shouldSend) {
|
||||
const result = await nextEmail.shouldSend({ org, user });
|
||||
|
||||
if (result === 'complete') {
|
||||
await db.organization.update({
|
||||
where: { id: org.id },
|
||||
data: { onboarding: 'completed' },
|
||||
});
|
||||
orgsCompleted++;
|
||||
logger.info(
|
||||
`Completed onboarding for organization ${org.id} (shouldSend returned complete)`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result === false) {
|
||||
orgsSkipped++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const emailData = nextEmail.data({ org, user });
|
||||
|
||||
await sendEmail(nextEmail.template, {
|
||||
to: user.email,
|
||||
data: emailData as never,
|
||||
});
|
||||
|
||||
// Update onboarding to the template name we just sent
|
||||
await db.organization.update({
|
||||
where: { id: org.id },
|
||||
data: { onboarding: nextEmail.template },
|
||||
});
|
||||
|
||||
emailsSent++;
|
||||
logger.info(
|
||||
`Sent onboarding email "${nextEmail.template}" to organization ${org.id} (user ${user.id})`,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to send onboarding email to organization ${org.id}`,
|
||||
{
|
||||
error,
|
||||
template: nextEmail.template,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Completed onboarding email job', {
|
||||
totalOrgs: orgs.length,
|
||||
emailsSent,
|
||||
orgsCompleted,
|
||||
orgsSkipped,
|
||||
});
|
||||
|
||||
return {
|
||||
totalOrgs: orgs.length,
|
||||
emailsSent,
|
||||
orgsCompleted,
|
||||
orgsSkipped,
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { eventBuffer, profileBuffer, sessionBuffer } from '@openpanel/db';
|
||||
import type { CronQueuePayload } from '@openpanel/queue';
|
||||
|
||||
import { jobdeleteProjects } from './cron.delete-projects';
|
||||
import { onboardingJob } from './cron.onboarding';
|
||||
import { ping } from './cron.ping';
|
||||
import { salt } from './cron.salt';
|
||||
import { insightsDailyJob } from './insights';
|
||||
@@ -31,5 +32,8 @@ export async function cronJob(job: Job<CronQueuePayload>) {
|
||||
case 'insightsDaily': {
|
||||
return await insightsDailyJob(job);
|
||||
}
|
||||
case 'onboarding': {
|
||||
return await onboardingJob(job);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,19 +12,6 @@ services:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
|
||||
op-df:
|
||||
image: docker.dragonflydb.io/dragonflydb/dragonfly:latest
|
||||
container_name: op-df
|
||||
restart: always
|
||||
ports:
|
||||
- "6380:6379"
|
||||
ulimits:
|
||||
memlock: -1
|
||||
nofile: 65535
|
||||
command:
|
||||
- "--cluster_mode=emulated"
|
||||
- "--lock_on_hashtags"
|
||||
|
||||
op-kv:
|
||||
image: redis:7.2.5-alpine
|
||||
restart: always
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { parseCookieDomain } from './parse-cookie-domain';
|
||||
|
||||
describe('parseCookieDomain', () => {
|
||||
@@ -399,4 +399,100 @@ describe('parseCookieDomain', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom multi-part TLDs via COOKIE_TLDS', () => {
|
||||
const originalEnv = process.env.COOKIE_TLDS;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset the environment variable before each test
|
||||
delete process.env.COOKIE_TLDS;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original value
|
||||
if (originalEnv !== undefined) {
|
||||
process.env.COOKIE_TLDS = originalEnv;
|
||||
} else {
|
||||
delete process.env.COOKIE_TLDS;
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle my.id domains when COOKIE_TLDS includes my.id', () => {
|
||||
process.env.COOKIE_TLDS = 'my.id';
|
||||
expect(parseCookieDomain('https://abc.my.id')).toEqual({
|
||||
domain: '.abc.my.id',
|
||||
secure: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle subdomains of my.id domains correctly', () => {
|
||||
process.env.COOKIE_TLDS = 'my.id';
|
||||
expect(parseCookieDomain('https://api.abc.my.id')).toEqual({
|
||||
domain: '.abc.my.id',
|
||||
secure: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle multiple custom TLDs', () => {
|
||||
process.env.COOKIE_TLDS = 'my.id,web.id,co.id';
|
||||
|
||||
expect(parseCookieDomain('https://abc.my.id')).toEqual({
|
||||
domain: '.abc.my.id',
|
||||
secure: true,
|
||||
});
|
||||
|
||||
expect(parseCookieDomain('https://abc.web.id')).toEqual({
|
||||
domain: '.abc.web.id',
|
||||
secure: true,
|
||||
});
|
||||
|
||||
expect(parseCookieDomain('https://abc.co.id')).toEqual({
|
||||
domain: '.abc.co.id',
|
||||
secure: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle custom TLDs with extra whitespace', () => {
|
||||
process.env.COOKIE_TLDS = ' my.id , web.id ';
|
||||
expect(parseCookieDomain('https://abc.my.id')).toEqual({
|
||||
domain: '.abc.my.id',
|
||||
secure: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle case-insensitive custom TLDs', () => {
|
||||
process.env.COOKIE_TLDS = 'MY.ID';
|
||||
expect(parseCookieDomain('https://abc.my.id')).toEqual({
|
||||
domain: '.abc.my.id',
|
||||
secure: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not affect domains when env variable is empty', () => {
|
||||
process.env.COOKIE_TLDS = '';
|
||||
// Without the custom TLD, my.id is treated as a regular TLD
|
||||
expect(parseCookieDomain('https://abc.my.id')).toEqual({
|
||||
domain: '.my.id',
|
||||
secure: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not affect domains when env variable is not set', () => {
|
||||
delete process.env.COOKIE_TLDS;
|
||||
// Without the custom TLD, my.id is treated as a regular TLD
|
||||
expect(parseCookieDomain('https://abc.my.id')).toEqual({
|
||||
domain: '.my.id',
|
||||
secure: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should still work with built-in multi-part TLDs when custom TLDs are set', () => {
|
||||
process.env.COOKIE_TLDS = 'my.id';
|
||||
// Built-in TLDs should still work
|
||||
expect(parseCookieDomain('https://example.co.uk')).toEqual({
|
||||
domain: '.example.co.uk',
|
||||
secure: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,34 @@ const MULTI_PART_TLDS = [
|
||||
/go\.\w{2}$/,
|
||||
];
|
||||
|
||||
function getCustomMultiPartTLDs(): string[] {
|
||||
const envValue = process.env.COOKIE_TLDS || '';
|
||||
if (!envValue.trim()) {
|
||||
return [];
|
||||
}
|
||||
return envValue
|
||||
.split(',')
|
||||
.map((tld) => tld.trim().toLowerCase())
|
||||
.filter((tld) => tld.length > 0);
|
||||
}
|
||||
|
||||
function isMultiPartTLD(potentialTLD: string): boolean {
|
||||
if (MULTI_PART_TLDS.some((pattern) => pattern.test(potentialTLD))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const customTLDs = getCustomMultiPartTLDs();
|
||||
return customTLDs.includes(potentialTLD.toLowerCase());
|
||||
}
|
||||
|
||||
export const parseCookieDomain = (url: string) => {
|
||||
if (process.env.CUSTOM_COOKIE_DOMAIN) {
|
||||
return {
|
||||
domain: process.env.CUSTOM_COOKIE_DOMAIN,
|
||||
secure: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
return {
|
||||
domain: undefined,
|
||||
@@ -36,7 +63,7 @@ export const parseCookieDomain = (url: string) => {
|
||||
// Handle multi-part TLDs like co.uk, com.au, etc.
|
||||
if (parts.length >= 3) {
|
||||
const potentialTLD = parts.slice(-2).join('.');
|
||||
if (MULTI_PART_TLDS.some((tld) => tld.test(potentialTLD))) {
|
||||
if (isMultiPartTLD(potentialTLD)) {
|
||||
// For domains like example.co.uk or subdomain.example.co.uk
|
||||
// Use the last 3 parts: .example.co.uk
|
||||
return {
|
||||
|
||||
@@ -3,10 +3,7 @@ import { differenceInDays, isSameDay, isSameMonth } from 'date-fns';
|
||||
export const DEFAULT_ASPECT_RATIO = 0.5625;
|
||||
export const NOT_SET_VALUE = '(not set)';
|
||||
|
||||
export const RESERVED_EVENT_NAMES = [
|
||||
'session_start',
|
||||
'session_end',
|
||||
] as const;
|
||||
export const RESERVED_EVENT_NAMES = ['session_start', 'session_end'] as const;
|
||||
|
||||
export const timeWindows = {
|
||||
'30min': {
|
||||
@@ -508,6 +505,12 @@ export function getCountry(code?: string) {
|
||||
return countries[code as keyof typeof countries];
|
||||
}
|
||||
|
||||
export const emailCategories = {
|
||||
onboarding: 'Onboarding',
|
||||
} as const;
|
||||
|
||||
export type EmailCategory = keyof typeof emailCategories;
|
||||
|
||||
export const chartColors = [
|
||||
{ main: '#2563EB', translucent: 'rgba(37, 99, 235, 0.1)' },
|
||||
{ main: '#ff7557', translucent: 'rgba(255, 117, 87, 0.1)' },
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."organizations"
|
||||
ADD COLUMN "onboarding" TEXT NOT NULL DEFAULT 'completed';
|
||||
@@ -0,0 +1,12 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "public"."email_unsubscribes" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"email" TEXT NOT NULL,
|
||||
"category" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "email_unsubscribes_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "email_unsubscribes_email_category_key" ON "public"."email_unsubscribes"("email", "category");
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."organizations" ALTER COLUMN "onboarding" DROP NOT NULL;
|
||||
@@ -62,6 +62,7 @@ model Organization {
|
||||
integrations Integration[]
|
||||
invites Invite[]
|
||||
timezone String?
|
||||
onboarding String? @default("completed")
|
||||
|
||||
// Subscription
|
||||
subscriptionId String?
|
||||
@@ -610,3 +611,13 @@ model InsightEvent {
|
||||
@@index([insightId, createdAt])
|
||||
@@map("insight_events")
|
||||
}
|
||||
|
||||
model EmailUnsubscribe {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
email String
|
||||
category String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([email, category])
|
||||
@@map("email_unsubscribes")
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@openpanel/db": "workspace:*",
|
||||
"@react-email/components": "^0.5.6",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
|
||||
24
packages/email/src/components/button.tsx
Normal file
24
packages/email/src/components/button.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Button as EmailButton } from '@react-email/components';
|
||||
import type * as React from 'react';
|
||||
|
||||
export function Button({
|
||||
href,
|
||||
children,
|
||||
style,
|
||||
}: { href: string; children: React.ReactNode; style?: React.CSSProperties }) {
|
||||
return (
|
||||
<EmailButton
|
||||
href={href}
|
||||
style={{
|
||||
backgroundColor: '#000',
|
||||
borderRadius: '6px',
|
||||
color: '#fff',
|
||||
padding: '12px 20px',
|
||||
textDecoration: 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</EmailButton>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import React from 'react';
|
||||
|
||||
const baseUrl = 'https://openpanel.dev';
|
||||
|
||||
export function Footer() {
|
||||
export function Footer({ unsubscribeUrl }: { unsubscribeUrl?: string }) {
|
||||
return (
|
||||
<>
|
||||
<Hr />
|
||||
@@ -71,15 +71,17 @@ export function Footer() {
|
||||
</Text>
|
||||
</Row>
|
||||
|
||||
{/* <Row>
|
||||
<Link
|
||||
className="text-[#707070] text-[14px]"
|
||||
href="https://dashboard.openpanel.dev/settings/notifications"
|
||||
title="Unsubscribe"
|
||||
>
|
||||
Notification preferences
|
||||
</Link>
|
||||
</Row> */}
|
||||
{unsubscribeUrl && (
|
||||
<Row>
|
||||
<Link
|
||||
className="text-[#707070] text-[14px]"
|
||||
href={unsubscribeUrl}
|
||||
title="Unsubscribe"
|
||||
>
|
||||
Notification preferences
|
||||
</Link>
|
||||
</Row>
|
||||
)}
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -7,15 +7,15 @@ import {
|
||||
Section,
|
||||
Tailwind,
|
||||
} from '@react-email/components';
|
||||
// biome-ignore lint/style/useImportType: resend needs React
|
||||
import React from 'react';
|
||||
import type React from 'react';
|
||||
import { Footer } from './footer';
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
unsubscribeUrl?: string;
|
||||
};
|
||||
|
||||
export function Layout({ children }: Props) {
|
||||
export function Layout({ children, unsubscribeUrl }: Props) {
|
||||
return (
|
||||
<Html>
|
||||
<Tailwind>
|
||||
@@ -57,7 +57,7 @@ export function Layout({ children }: Props) {
|
||||
/>
|
||||
</Section>
|
||||
<Section className="p-6">{children}</Section>
|
||||
<Footer />
|
||||
<Footer unsubscribeUrl={unsubscribeUrl} />
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
|
||||
13
packages/email/src/components/list.tsx
Normal file
13
packages/email/src/components/list.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Text } from '@react-email/components';
|
||||
|
||||
export function List({ items }: { items: React.ReactNode[] }) {
|
||||
return (
|
||||
<ul style={{ paddingLeft: 20 }}>
|
||||
{items.map((node, index) => (
|
||||
<li key={index.toString()}>
|
||||
<Text style={{ marginBottom: 2, marginTop: 2 }}>{node}</Text>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -13,9 +13,10 @@ export default EmailInvite;
|
||||
export function EmailInvite({
|
||||
organizationName = 'Acme Co',
|
||||
url = 'https://openpanel.dev',
|
||||
}: Props) {
|
||||
unsubscribeUrl,
|
||||
}: Props & { unsubscribeUrl?: string }) {
|
||||
return (
|
||||
<Layout>
|
||||
<Layout unsubscribeUrl={unsubscribeUrl}>
|
||||
<Text>You've been invited to join {organizationName}!</Text>
|
||||
<Text>
|
||||
If you don't have an account yet, click the button below to create one
|
||||
|
||||
@@ -9,9 +9,12 @@ export const zEmailResetPassword = z.object({
|
||||
|
||||
export type Props = z.infer<typeof zEmailResetPassword>;
|
||||
export default EmailResetPassword;
|
||||
export function EmailResetPassword({ url = 'https://openpanel.dev' }: Props) {
|
||||
export function EmailResetPassword({
|
||||
url = 'https://openpanel.dev',
|
||||
unsubscribeUrl,
|
||||
}: Props & { unsubscribeUrl?: string }) {
|
||||
return (
|
||||
<Layout>
|
||||
<Layout unsubscribeUrl={unsubscribeUrl}>
|
||||
<Text>
|
||||
You have requested to reset your password. Follow the link below to
|
||||
reset your password:
|
||||
|
||||
@@ -3,6 +3,22 @@ import { EmailInvite, zEmailInvite } from './email-invite';
|
||||
import EmailResetPassword, {
|
||||
zEmailResetPassword,
|
||||
} from './email-reset-password';
|
||||
import OnboardingDashboards, {
|
||||
zOnboardingDashboards,
|
||||
} from './onboarding-dashboards';
|
||||
import OnboardingFeatureRequest, {
|
||||
zOnboardingFeatureRequest,
|
||||
} from './onboarding-feature-request';
|
||||
import OnboardingTrialEnded, {
|
||||
zOnboardingTrialEnded,
|
||||
} from './onboarding-trial-ended';
|
||||
import OnboardingTrialEnding, {
|
||||
zOnboardingTrialEnding,
|
||||
} from './onboarding-trial-ending';
|
||||
import OnboardingWelcome, { zOnboardingWelcome } from './onboarding-welcome';
|
||||
import OnboardingWhatToTrack, {
|
||||
zOnboardingWhatToTrack,
|
||||
} from './onboarding-what-to-track';
|
||||
import TrailEndingSoon, { zTrailEndingSoon } from './trial-ending-soon';
|
||||
|
||||
export const templates = {
|
||||
@@ -24,6 +40,40 @@ export const templates = {
|
||||
Component: TrailEndingSoon,
|
||||
schema: zTrailEndingSoon,
|
||||
},
|
||||
'onboarding-welcome': {
|
||||
subject: () => "You're in",
|
||||
Component: OnboardingWelcome,
|
||||
schema: zOnboardingWelcome,
|
||||
category: 'onboarding' as const,
|
||||
},
|
||||
'onboarding-what-to-track': {
|
||||
subject: () => "What's actually worth tracking",
|
||||
Component: OnboardingWhatToTrack,
|
||||
schema: zOnboardingWhatToTrack,
|
||||
category: 'onboarding' as const,
|
||||
},
|
||||
'onboarding-dashboards': {
|
||||
subject: () => 'The part most people skip',
|
||||
Component: OnboardingDashboards,
|
||||
schema: zOnboardingDashboards,
|
||||
category: 'onboarding' as const,
|
||||
},
|
||||
'onboarding-feature-request': {
|
||||
subject: () => 'One provider to rule them all',
|
||||
Component: OnboardingFeatureRequest,
|
||||
schema: zOnboardingFeatureRequest,
|
||||
category: 'onboarding' as const,
|
||||
},
|
||||
'onboarding-trial-ending': {
|
||||
subject: () => 'Your trial ends in a few days',
|
||||
Component: OnboardingTrialEnding,
|
||||
schema: zOnboardingTrialEnding,
|
||||
},
|
||||
'onboarding-trial-ended': {
|
||||
subject: () => 'Your trial has ended',
|
||||
Component: OnboardingTrialEnded,
|
||||
schema: zOnboardingTrialEnded,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type Templates = typeof templates;
|
||||
|
||||
65
packages/email/src/emails/onboarding-dashboards.tsx
Normal file
65
packages/email/src/emails/onboarding-dashboards.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Link, Text } from '@react-email/components';
|
||||
import React from 'react';
|
||||
import { z } from 'zod';
|
||||
import { Layout } from '../components/layout';
|
||||
import { List } from '../components/list';
|
||||
|
||||
export const zOnboardingDashboards = z.object({
|
||||
firstName: z.string().optional(),
|
||||
dashboardUrl: z.string(),
|
||||
});
|
||||
|
||||
export type Props = z.infer<typeof zOnboardingDashboards>;
|
||||
export default OnboardingDashboards;
|
||||
export function OnboardingDashboards({
|
||||
firstName,
|
||||
dashboardUrl = 'https://dashboard.openpanel.dev',
|
||||
unsubscribeUrl,
|
||||
}: Props & { unsubscribeUrl?: string }) {
|
||||
const newUrl = new URL(dashboardUrl);
|
||||
newUrl.searchParams.set('utm_source', 'email');
|
||||
newUrl.searchParams.set('utm_medium', 'email');
|
||||
newUrl.searchParams.set('utm_campaign', 'onboarding-dashboards');
|
||||
|
||||
return (
|
||||
<Layout unsubscribeUrl={unsubscribeUrl}>
|
||||
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
|
||||
<Text>
|
||||
Tracking events is the easy part. The value comes from actually looking
|
||||
at them.
|
||||
</Text>
|
||||
<Text>
|
||||
If you haven't yet, try building a simple dashboard. Pick one thing you
|
||||
care about and visualize it. Could be:
|
||||
</Text>
|
||||
<List
|
||||
items={[
|
||||
'How many people sign up and then actually do something',
|
||||
'Where users drop off in a flow (funnel)',
|
||||
'Which pages lead to conversions (entry page → CTA)',
|
||||
]}
|
||||
/>
|
||||
<Text>
|
||||
This is usually when people go from "I have analytics" to "I understand
|
||||
what's happening." It's a different feeling.
|
||||
</Text>
|
||||
<Text>Takes maybe 10 minutes to set up. Worth it.</Text>
|
||||
<Text>
|
||||
Best regards,
|
||||
<br />
|
||||
Carl
|
||||
</Text>
|
||||
<span style={{ margin: '0 -20px', display: 'block' }}>
|
||||
<img
|
||||
src="https://openpanel.dev/_next/image?url=%2Fscreenshots%2Fdashboard-dark.webp&w=3840&q=75"
|
||||
alt="Dashboard"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
borderRadius: '5px',
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
42
packages/email/src/emails/onboarding-feature-request.tsx
Normal file
42
packages/email/src/emails/onboarding-feature-request.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Link, Text } from '@react-email/components';
|
||||
import React from 'react';
|
||||
import { z } from 'zod';
|
||||
import { Layout } from '../components/layout';
|
||||
|
||||
export const zOnboardingFeatureRequest = z.object({
|
||||
firstName: z.string().optional(),
|
||||
});
|
||||
|
||||
export type Props = z.infer<typeof zOnboardingFeatureRequest>;
|
||||
export default OnboardingFeatureRequest;
|
||||
export function OnboardingFeatureRequest({
|
||||
firstName,
|
||||
unsubscribeUrl,
|
||||
}: Props & { unsubscribeUrl?: string }) {
|
||||
return (
|
||||
<Layout unsubscribeUrl={unsubscribeUrl}>
|
||||
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
|
||||
<Text>
|
||||
OpenPanel aims to be the one stop shop for all your analytics needs.
|
||||
</Text>
|
||||
<Text>
|
||||
We have already in a very short time become one of the most popular
|
||||
open-source analytics platforms out there and we're working hard to add
|
||||
more features to make it the best analytics platform.
|
||||
</Text>
|
||||
<Text>
|
||||
Do you feel like you're missing a feature that's important to you? If
|
||||
that's the case, please reply here or go to our feedback board and add
|
||||
your request there.
|
||||
</Text>
|
||||
<Text>
|
||||
<Link href={'https://feedback.openpanel.dev'}>Feedback board</Link>
|
||||
</Text>
|
||||
<Text>
|
||||
Best regards,
|
||||
<br />
|
||||
Carl
|
||||
</Text>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
56
packages/email/src/emails/onboarding-trial-ended.tsx
Normal file
56
packages/email/src/emails/onboarding-trial-ended.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Text } from '@react-email/components';
|
||||
import React from 'react';
|
||||
import { z } from 'zod';
|
||||
import { Button } from '../components/button';
|
||||
import { Layout } from '../components/layout';
|
||||
|
||||
export const zOnboardingTrialEnded = z.object({
|
||||
firstName: z.string().optional(),
|
||||
billingUrl: z.string(),
|
||||
recommendedPlan: z.string().optional(),
|
||||
});
|
||||
|
||||
export type Props = z.infer<typeof zOnboardingTrialEnded>;
|
||||
export default OnboardingTrialEnded;
|
||||
export function OnboardingTrialEnded({
|
||||
firstName,
|
||||
billingUrl = 'https://dashboard.openpanel.dev',
|
||||
recommendedPlan,
|
||||
unsubscribeUrl,
|
||||
}: Props & { unsubscribeUrl?: string }) {
|
||||
const newUrl = new URL(billingUrl);
|
||||
newUrl.searchParams.set('utm_source', 'email');
|
||||
newUrl.searchParams.set('utm_medium', 'email');
|
||||
newUrl.searchParams.set('utm_campaign', 'onboarding-trial-ended');
|
||||
|
||||
return (
|
||||
<Layout unsubscribeUrl={unsubscribeUrl}>
|
||||
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
|
||||
<Text>Your OpenPanel trial has ended.</Text>
|
||||
<Text>
|
||||
Your tracking is still running in the background, but you won't be able
|
||||
to see any new data until you upgrade. All your dashboards, reports, and
|
||||
event history are still there waiting for you.
|
||||
</Text>
|
||||
<Text>
|
||||
Important: If you don't upgrade within 30 days, your workspace and
|
||||
projects will be permanently deleted.
|
||||
</Text>
|
||||
<Text>
|
||||
To keep your data and continue using OpenPanel, upgrade to a paid plan.{' '}
|
||||
{recommendedPlan
|
||||
? `Based on your usage we recommend upgrading to the ${recommendedPlan}`
|
||||
: 'Plans start at $2.50/month'}
|
||||
.
|
||||
</Text>
|
||||
<Text>
|
||||
If you have any questions or something's holding you back, just reply to
|
||||
this email.
|
||||
</Text>
|
||||
<Text>
|
||||
<Button href={newUrl.toString()}>Upgrade Now</Button>
|
||||
</Text>
|
||||
<Text>Carl</Text>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
57
packages/email/src/emails/onboarding-trial-ending.tsx
Normal file
57
packages/email/src/emails/onboarding-trial-ending.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Text } from '@react-email/components';
|
||||
import React from 'react';
|
||||
import { z } from 'zod';
|
||||
import { Button } from '../components/button';
|
||||
import { Layout } from '../components/layout';
|
||||
|
||||
export const zOnboardingTrialEnding = z.object({
|
||||
firstName: z.string().optional(),
|
||||
organizationName: z.string(),
|
||||
billingUrl: z.string(),
|
||||
recommendedPlan: z.string().optional(),
|
||||
});
|
||||
|
||||
export type Props = z.infer<typeof zOnboardingTrialEnding>;
|
||||
export default OnboardingTrialEnding;
|
||||
export function OnboardingTrialEnding({
|
||||
firstName,
|
||||
organizationName = 'your organization',
|
||||
billingUrl = 'https://dashboard.openpanel.dev',
|
||||
recommendedPlan,
|
||||
unsubscribeUrl,
|
||||
}: Props & { unsubscribeUrl?: string }) {
|
||||
const newUrl = new URL(billingUrl);
|
||||
newUrl.searchParams.set('utm_source', 'email');
|
||||
newUrl.searchParams.set('utm_medium', 'email');
|
||||
newUrl.searchParams.set('utm_campaign', 'onboarding-trial-ending');
|
||||
|
||||
return (
|
||||
<Layout unsubscribeUrl={unsubscribeUrl}>
|
||||
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
|
||||
<Text>Quick heads up: your OpenPanel trial ends soon.</Text>
|
||||
<Text>
|
||||
Your tracking will keep working, but you won't be able to see new data
|
||||
until you upgrade. Everything you've built so far (dashboards, reports,
|
||||
event history) stays intact.
|
||||
</Text>
|
||||
<Text>
|
||||
To continue using OpenPanel, you'll need to upgrade to a paid plan.{' '}
|
||||
{recommendedPlan
|
||||
? `Based on your usage we recommend upgrading to the ${recommendedPlan} plan`
|
||||
: 'Plans start at $2.50/month'}
|
||||
.
|
||||
</Text>
|
||||
<Text>
|
||||
If something's holding you back, I'd like to hear about it. Just reply.
|
||||
</Text>
|
||||
<Text>
|
||||
Your project will receive events for the next 30 days, if you haven't
|
||||
upgraded by then we'll remove your workspace and projects.
|
||||
</Text>
|
||||
<Text>
|
||||
<Button href={newUrl.toString()}>Upgrade Now</Button>
|
||||
</Text>
|
||||
<Text>Carl</Text>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
55
packages/email/src/emails/onboarding-welcome.tsx
Normal file
55
packages/email/src/emails/onboarding-welcome.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Heading, Link, Text } from '@react-email/components';
|
||||
import React from 'react';
|
||||
import { z } from 'zod';
|
||||
import { Layout } from '../components/layout';
|
||||
import { List } from '../components/list';
|
||||
|
||||
export const zOnboardingWelcome = z.object({
|
||||
firstName: z.string().optional(),
|
||||
dashboardUrl: z.string(),
|
||||
});
|
||||
|
||||
export type Props = z.infer<typeof zOnboardingWelcome>;
|
||||
export default OnboardingWelcome;
|
||||
export function OnboardingWelcome({
|
||||
firstName,
|
||||
unsubscribeUrl,
|
||||
}: Props & { unsubscribeUrl?: string }) {
|
||||
return (
|
||||
<Layout unsubscribeUrl={unsubscribeUrl}>
|
||||
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
|
||||
<Text>Thanks for trying OpenPanel.</Text>
|
||||
<Text>
|
||||
We built OpenPanel because most analytics tools are either too
|
||||
expensive, too complicated, or both. OpenPanel is different.
|
||||
</Text>
|
||||
<Text>
|
||||
We hope you find OpenPanel useful and if you have any questions,
|
||||
regarding tracking or how to import your existing events, just reach
|
||||
out. We're here to help.
|
||||
</Text>
|
||||
<Text>To get started, you can:</Text>
|
||||
<List
|
||||
items={[
|
||||
<Link
|
||||
key=""
|
||||
href={'https://openpanel.dev/docs/get-started/install-openpanel'}
|
||||
>
|
||||
Install tracking script
|
||||
</Link>,
|
||||
<Link
|
||||
key=""
|
||||
href={'https://openpanel.dev/docs/get-started/track-events'}
|
||||
>
|
||||
Start tracking your events
|
||||
</Link>,
|
||||
]}
|
||||
/>
|
||||
<Text>
|
||||
Best regards,
|
||||
<br />
|
||||
Carl
|
||||
</Text>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
46
packages/email/src/emails/onboarding-what-to-track.tsx
Normal file
46
packages/email/src/emails/onboarding-what-to-track.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Text } from '@react-email/components';
|
||||
import React from 'react';
|
||||
import { z } from 'zod';
|
||||
import { Layout } from '../components/layout';
|
||||
import { List } from '../components/list';
|
||||
|
||||
export const zOnboardingWhatToTrack = z.object({
|
||||
firstName: z.string().optional(),
|
||||
});
|
||||
|
||||
export type Props = z.infer<typeof zOnboardingWhatToTrack>;
|
||||
export default OnboardingWhatToTrack;
|
||||
export function OnboardingWhatToTrack({
|
||||
firstName,
|
||||
unsubscribeUrl,
|
||||
}: Props & { unsubscribeUrl?: string }) {
|
||||
return (
|
||||
<Layout unsubscribeUrl={unsubscribeUrl}>
|
||||
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
|
||||
<Text>
|
||||
Tracking can be overwhelming at first, and that's why its important to
|
||||
focus on what's matters. For most products, that's something like:
|
||||
</Text>
|
||||
<List
|
||||
items={[
|
||||
'Find good funnels to track (onboarding or checkout)',
|
||||
'Conversions (how many clicks your hero CTA)',
|
||||
'What did the user do after clicking the CTA',
|
||||
]}
|
||||
/>
|
||||
<Text>
|
||||
Start small and incrementally add more events as you go is usually the
|
||||
best approach.
|
||||
</Text>
|
||||
<Text>
|
||||
If you're not sure whether something's worth tracking, or have any
|
||||
questions, just reply here.
|
||||
</Text>
|
||||
<Text>
|
||||
Best regards,
|
||||
<br />
|
||||
Carl
|
||||
</Text>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,8 @@ export default TrailEndingSoon;
|
||||
export function TrailEndingSoon({
|
||||
organizationName = 'Acme Co',
|
||||
url = 'https://openpanel.dev',
|
||||
}: Props) {
|
||||
unsubscribeUrl,
|
||||
}: Props & { unsubscribeUrl?: string }) {
|
||||
const newUrl = new URL(url);
|
||||
newUrl.searchParams.set('utm_source', 'email');
|
||||
newUrl.searchParams.set('utm_medium', 'email');
|
||||
|
||||
@@ -2,48 +2,81 @@ import React from 'react';
|
||||
import { Resend } from 'resend';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { db } from '@openpanel/db';
|
||||
import { type TemplateKey, type Templates, templates } from './emails';
|
||||
import { getUnsubscribeUrl } from './unsubscribe';
|
||||
|
||||
export * from './unsubscribe';
|
||||
|
||||
const FROM = process.env.EMAIL_SENDER ?? 'hello@openpanel.dev';
|
||||
|
||||
export type EmailData<T extends TemplateKey> = z.infer<Templates[T]['schema']>;
|
||||
export type EmailTemplate = keyof Templates;
|
||||
|
||||
export async function sendEmail<T extends TemplateKey>(
|
||||
template: T,
|
||||
templateKey: T,
|
||||
options: {
|
||||
to: string | string[];
|
||||
to: string;
|
||||
data: z.infer<Templates[T]['schema']>;
|
||||
},
|
||||
) {
|
||||
const { to, data } = options;
|
||||
const { subject, Component, schema } = templates[template];
|
||||
const props = schema.safeParse(data);
|
||||
const template = templates[templateKey];
|
||||
const props = template.schema.safeParse(data);
|
||||
|
||||
if (!props.success) {
|
||||
console.error('Failed to parse data', props.error);
|
||||
return null;
|
||||
}
|
||||
|
||||
if ('category' in template && template.category) {
|
||||
const unsubscribed = await db.emailUnsubscribe.findUnique({
|
||||
where: {
|
||||
email_category: {
|
||||
email: to,
|
||||
category: template.category,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (unsubscribed) {
|
||||
console.log(
|
||||
`Skipping email to ${to} - unsubscribed from ${template.category}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
console.log('No RESEND_API_KEY found, here is the data');
|
||||
console.log(data);
|
||||
console.log('Template:', template);
|
||||
console.log('Subject: ', template.subject(props.data as any));
|
||||
console.log('To: ', to);
|
||||
console.log('Data: ', JSON.stringify(data, null, 2));
|
||||
return null;
|
||||
}
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if ('category' in template && template.category) {
|
||||
const unsubscribeUrl = getUnsubscribeUrl(to, template.category);
|
||||
(props.data as any).unsubscribeUrl = unsubscribeUrl;
|
||||
headers['List-Unsubscribe'] = `<${unsubscribeUrl}>`;
|
||||
headers['List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click';
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await resend.emails.send({
|
||||
from: FROM,
|
||||
to,
|
||||
// @ts-expect-error - TODO: fix this
|
||||
subject: subject(props.data),
|
||||
// @ts-expect-error - TODO: fix this
|
||||
react: <Component {...props.data} />,
|
||||
subject: template.subject(props.data as any),
|
||||
react: <template.Component {...(props.data as any)} />,
|
||||
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
||||
});
|
||||
|
||||
if (res.error) {
|
||||
throw new Error(res.error.message);
|
||||
}
|
||||
|
||||
return res;
|
||||
} catch (error) {
|
||||
console.error('Failed to send email', error);
|
||||
|
||||
39
packages/email/src/unsubscribe.ts
Normal file
39
packages/email/src/unsubscribe.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
|
||||
const SECRET =
|
||||
process.env.UNSUBSCRIBE_SECRET ||
|
||||
process.env.COOKIE_SECRET ||
|
||||
process.env.SECRET ||
|
||||
'default-secret-change-in-production';
|
||||
|
||||
export function generateUnsubscribeToken(email: string, category: string): string {
|
||||
const data = `${email}:${category}`;
|
||||
return createHmac('sha256', SECRET).update(data).digest('hex');
|
||||
}
|
||||
|
||||
export function verifyUnsubscribeToken(
|
||||
email: string,
|
||||
category: string,
|
||||
token: string,
|
||||
): boolean {
|
||||
const expectedToken = generateUnsubscribeToken(email, category);
|
||||
const tokenBuffer = Buffer.from(token, 'hex');
|
||||
const expectedBuffer = Buffer.from(expectedToken, 'hex');
|
||||
|
||||
// Handle length mismatch safely to avoid timing leaks
|
||||
if (tokenBuffer.length !== expectedBuffer.length) {
|
||||
// Compare against zero-filled buffer of same length as token to maintain constant time
|
||||
const zeroBuffer = Buffer.alloc(tokenBuffer.length);
|
||||
timingSafeEqual(tokenBuffer, zeroBuffer);
|
||||
return false;
|
||||
}
|
||||
|
||||
return timingSafeEqual(tokenBuffer, expectedBuffer);
|
||||
}
|
||||
|
||||
export function getUnsubscribeUrl(email: string, category: string): string {
|
||||
const token = generateUnsubscribeToken(email, category);
|
||||
const params = new URLSearchParams({ email, category, token });
|
||||
const dashboardUrl = process.env.DASHBOARD_URL || 'http://localhost:3000';
|
||||
return `${dashboardUrl}/unsubscribe?${params.toString()}`;
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
export type { ProductPrice } from '@polar-sh/sdk/models/components/productprice.js';
|
||||
|
||||
function formatEventsCount(events: number) {
|
||||
return new Intl.NumberFormat('en-gb', {
|
||||
notation: 'compact',
|
||||
}).format(events);
|
||||
}
|
||||
|
||||
export type IPrice = {
|
||||
price: number;
|
||||
events: number;
|
||||
@@ -39,3 +45,29 @@ export const FREE_PRODUCT_IDS = [
|
||||
'a18b4bee-d3db-4404-be6f-fba2f042d9ed', // Prod
|
||||
'036efa2a-b3b4-4c75-b24a-9cac6bb8893b', // Sandbox
|
||||
];
|
||||
|
||||
export function getRecommendedPlan<T>(
|
||||
monthlyEvents: number | undefined | null,
|
||||
cb: (
|
||||
options: {
|
||||
formattedEvents: string;
|
||||
formattedPrice: string;
|
||||
} & IPrice,
|
||||
) => T,
|
||||
): T | undefined {
|
||||
if (!monthlyEvents) {
|
||||
return undefined;
|
||||
}
|
||||
const price = PRICING.find((price) => price.events >= monthlyEvents);
|
||||
if (!price) {
|
||||
return undefined;
|
||||
}
|
||||
return cb({
|
||||
...price,
|
||||
formattedEvents: formatEventsCount(price.events),
|
||||
formattedPrice: Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(price.price),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -115,6 +115,10 @@ export type CronQueuePayloadInsightsDaily = {
|
||||
type: 'insightsDaily';
|
||||
payload: undefined;
|
||||
};
|
||||
export type CronQueuePayloadOnboarding = {
|
||||
type: 'onboarding';
|
||||
payload: undefined;
|
||||
};
|
||||
export type CronQueuePayload =
|
||||
| CronQueuePayloadSalt
|
||||
| CronQueuePayloadFlushEvents
|
||||
@@ -122,7 +126,8 @@ export type CronQueuePayload =
|
||||
| CronQueuePayloadFlushProfiles
|
||||
| CronQueuePayloadPing
|
||||
| CronQueuePayloadProject
|
||||
| CronQueuePayloadInsightsDaily;
|
||||
| CronQueuePayloadInsightsDaily
|
||||
| CronQueuePayloadOnboarding;
|
||||
|
||||
export type MiscQueuePayloadTrialEndingSoon = {
|
||||
type: 'trialEndingSoon';
|
||||
@@ -254,18 +259,3 @@ export const insightsQueue = new Queue<InsightsQueuePayloadProject>(
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export function addTrialEndingSoonJob(organizationId: string, delay: number) {
|
||||
return miscQueue.add(
|
||||
'misc',
|
||||
{
|
||||
type: 'trialEndingSoon',
|
||||
payload: {
|
||||
organizationId,
|
||||
},
|
||||
},
|
||||
{
|
||||
delay,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { chartRouter } from './routers/chart';
|
||||
import { chatRouter } from './routers/chat';
|
||||
import { clientRouter } from './routers/client';
|
||||
import { dashboardRouter } from './routers/dashboard';
|
||||
import { emailRouter } from './routers/email';
|
||||
import { eventRouter } from './routers/event';
|
||||
import { importRouter } from './routers/import';
|
||||
import { insightRouter } from './routers/insight';
|
||||
@@ -51,6 +52,7 @@ export const appRouter = createTRPCRouter({
|
||||
chat: chatRouter,
|
||||
insight: insightRouter,
|
||||
widget: widgetRouter,
|
||||
email: emailRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
@@ -353,7 +353,6 @@ export const authRouter = createTRPCRouter({
|
||||
.input(zSignInShare)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { password, shareId, shareType = 'overview' } = input;
|
||||
|
||||
let share: { password: string | null; public: boolean } | null = null;
|
||||
let cookieName = '';
|
||||
|
||||
|
||||
114
packages/trpc/src/routers/email.ts
Normal file
114
packages/trpc/src/routers/email.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { emailCategories } from '@openpanel/constants';
|
||||
import { db } from '@openpanel/db';
|
||||
import { verifyUnsubscribeToken } from '@openpanel/email';
|
||||
import { z } from 'zod';
|
||||
import { TRPCBadRequestError } from '../errors';
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
|
||||
|
||||
export const emailRouter = createTRPCRouter({
|
||||
unsubscribe: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
email: z.string().email(),
|
||||
category: z.string(),
|
||||
token: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
const { email, category, token } = input;
|
||||
|
||||
// Verify token
|
||||
if (!verifyUnsubscribeToken(email, category, token)) {
|
||||
throw TRPCBadRequestError('Invalid unsubscribe link');
|
||||
}
|
||||
|
||||
// Upsert the unsubscribe record
|
||||
await db.emailUnsubscribe.upsert({
|
||||
where: {
|
||||
email_category: {
|
||||
email,
|
||||
category,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
email,
|
||||
category,
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
getPreferences: protectedProcedure.query(async ({ ctx }) => {
|
||||
if (!ctx.session.userId || !ctx.session.user?.email) {
|
||||
throw new Error('User not authenticated');
|
||||
}
|
||||
|
||||
const email = ctx.session.user.email;
|
||||
|
||||
// Get all unsubscribe records for this user
|
||||
const unsubscribes = await db.emailUnsubscribe.findMany({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
select: {
|
||||
category: true,
|
||||
},
|
||||
});
|
||||
|
||||
const unsubscribedCategories = new Set(unsubscribes.map((u) => u.category));
|
||||
|
||||
// Return object with all categories, true = subscribed (not unsubscribed)
|
||||
const preferences: Record<string, boolean> = {};
|
||||
for (const [category] of Object.entries(emailCategories)) {
|
||||
preferences[category] = !unsubscribedCategories.has(category);
|
||||
}
|
||||
|
||||
return preferences;
|
||||
}),
|
||||
|
||||
updatePreferences: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
categories: z.record(z.string(), z.boolean()),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (!ctx.session.userId || !ctx.session.user?.email) {
|
||||
throw new Error('User not authenticated');
|
||||
}
|
||||
|
||||
const email = ctx.session.user.email;
|
||||
|
||||
// Process each category
|
||||
for (const [category, subscribed] of Object.entries(input.categories)) {
|
||||
if (subscribed) {
|
||||
// User wants to subscribe - delete unsubscribe record if exists
|
||||
await db.emailUnsubscribe.deleteMany({
|
||||
where: {
|
||||
email,
|
||||
category,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// User wants to unsubscribe - upsert unsubscribe record
|
||||
await db.emailUnsubscribe.upsert({
|
||||
where: {
|
||||
email_category: {
|
||||
email,
|
||||
category,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
email,
|
||||
category,
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
@@ -8,7 +8,6 @@ import { zOnboardingProject } from '@openpanel/validation';
|
||||
|
||||
import { hashPassword } from '@openpanel/common/server';
|
||||
import { addDays } from 'date-fns';
|
||||
import { addTrialEndingSoonJob, miscQueue } from '../../../queue';
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
|
||||
|
||||
async function createOrGetOrganization(
|
||||
@@ -30,16 +29,10 @@ async function createOrGetOrganization(
|
||||
subscriptionEndsAt: addDays(new Date(), TRIAL_DURATION_IN_DAYS),
|
||||
subscriptionStatus: 'trialing',
|
||||
timezone: input.timezone,
|
||||
onboarding: '',
|
||||
},
|
||||
});
|
||||
|
||||
if (!process.env.SELF_HOSTED) {
|
||||
await addTrialEndingSoonJob(
|
||||
organization.id,
|
||||
1000 * 60 * 60 * 24 * TRIAL_DURATION_IN_DAYS * 0.9,
|
||||
);
|
||||
}
|
||||
|
||||
return organization;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ const uid = new ShortUniqueId({ length: 6 });
|
||||
|
||||
// Helper to find widget by projectId and type
|
||||
async function findWidgetByType(projectId: string, type: string) {
|
||||
const widgets = await db.shareWidget.findMany({
|
||||
const widgets = await db.$primary().shareWidget.findMany({
|
||||
where: { projectId },
|
||||
});
|
||||
return widgets.find(
|
||||
|
||||
@@ -126,14 +126,21 @@ export const zSankeyOptions = z.object({
|
||||
include: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const zHistogramOptions = z.object({
|
||||
type: z.literal('histogram'),
|
||||
stacked: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const zReportOptions = z.discriminatedUnion('type', [
|
||||
zFunnelOptions,
|
||||
zRetentionOptions,
|
||||
zSankeyOptions,
|
||||
zHistogramOptions,
|
||||
]);
|
||||
|
||||
export type IReportOptions = z.infer<typeof zReportOptions>;
|
||||
export type ISankeyOptions = z.infer<typeof zSankeyOptions>;
|
||||
export type IHistogramOptions = z.infer<typeof zHistogramOptions>;
|
||||
|
||||
export const zWidgetType = z.enum(['realtime', 'counter']);
|
||||
export type IWidgetType = z.infer<typeof zWidgetType>;
|
||||
|
||||
@@ -5,13 +5,25 @@ import { RESERVED_EVENT_NAMES } from '@openpanel/constants';
|
||||
export const zTrackPayload = z
|
||||
.object({
|
||||
name: z.string().min(1),
|
||||
properties: z.record(z.unknown()).optional(),
|
||||
profileId: z.string().optional(),
|
||||
properties: z.record(z.string(), z.unknown()).optional(),
|
||||
profileId: z.string().or(z.number()).optional(),
|
||||
})
|
||||
.refine((data) => !RESERVED_EVENT_NAMES.includes(data.name as any), {
|
||||
message: `Event name cannot be one of the reserved names: ${RESERVED_EVENT_NAMES.join(', ')}`,
|
||||
path: ['name'],
|
||||
});
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.name !== 'revenue') return true;
|
||||
const revenue = data.properties?.__revenue;
|
||||
if (revenue === undefined) return true;
|
||||
return Number.isInteger(revenue);
|
||||
},
|
||||
{
|
||||
message: '__revenue must be an integer (no floats or strings)',
|
||||
path: ['properties', '__revenue'],
|
||||
},
|
||||
);
|
||||
|
||||
export const zIdentifyPayload = z.object({
|
||||
profileId: z.string().min(1),
|
||||
|
||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -880,6 +880,9 @@ importers:
|
||||
'@openpanel/logger':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/logger
|
||||
'@openpanel/payments':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/payments
|
||||
'@openpanel/queue':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/queue
|
||||
@@ -889,6 +892,9 @@ importers:
|
||||
bullmq:
|
||||
specifier: ^5.63.0
|
||||
version: 5.63.0
|
||||
date-fns:
|
||||
specifier: ^3.3.1
|
||||
version: 3.3.1
|
||||
express:
|
||||
specifier: ^4.18.2
|
||||
version: 4.18.2
|
||||
@@ -1133,6 +1139,9 @@ importers:
|
||||
|
||||
packages/email:
|
||||
dependencies:
|
||||
'@openpanel/db':
|
||||
specifier: workspace:*
|
||||
version: link:../db
|
||||
'@react-email/components':
|
||||
specifier: ^0.5.6
|
||||
version: 0.5.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
@@ -16527,6 +16536,7 @@ packages:
|
||||
tar@6.2.0:
|
||||
resolution: {integrity: sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==}
|
||||
engines: {node: '>=10'}
|
||||
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me
|
||||
|
||||
tar@7.4.3:
|
||||
resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==}
|
||||
|
||||
@@ -91,7 +91,7 @@ services:
|
||||
max-file: "3"
|
||||
|
||||
op-api:
|
||||
image: lindesvard/openpanel-api:2.0.0
|
||||
image: lindesvard/openpanel-api:2.0.1-rc
|
||||
restart: always
|
||||
command: >
|
||||
sh -c "
|
||||
@@ -121,7 +121,7 @@ services:
|
||||
max-file: "3"
|
||||
|
||||
op-dashboard:
|
||||
image: lindesvard/openpanel-dashboard:2.0.0
|
||||
image: lindesvard/openpanel-dashboard:2.0.1-rc
|
||||
restart: always
|
||||
depends_on:
|
||||
op-api:
|
||||
@@ -141,7 +141,7 @@ services:
|
||||
max-file: "3"
|
||||
|
||||
op-worker:
|
||||
image: lindesvard/openpanel-worker:2.0.0
|
||||
image: lindesvard/openpanel-worker:2.0.1-rc
|
||||
restart: always
|
||||
depends_on:
|
||||
op-api:
|
||||
|
||||
Reference in New Issue
Block a user