feature(dashboard): add ability to filter out events by profile id and ip (#101)

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-12-07 21:34:32 +01:00
committed by GitHub
parent 27ee623584
commit f4ad97d87d
39 changed files with 1148 additions and 542 deletions

View File

@@ -0,0 +1,124 @@
import { stripTrailingSlash } from '@openpanel/common';
import {
chQuery,
db,
getClientByIdCached,
getProjectByIdCached,
} from '@openpanel/db';
const pickBestDomain = (domains: string[]): string | null => {
// Filter out invalid domains
const validDomains = domains.filter(
(domain) =>
domain &&
!domain.includes('*') &&
!domain.includes('localhost') &&
!domain.includes('127.0.0.1'),
);
if (validDomains.length === 0) return null;
// Score each domain
const scoredDomains = validDomains.map((domain) => {
let score = 0;
// Prefer https (highest priority)
if (domain.startsWith('https://')) score += 100;
// Penalize domains from common providers like vercel, netlify, etc.
if (
domain.includes('vercel.app') ||
domain.includes('netlify.app') ||
domain.includes('herokuapp.com') ||
domain.includes('github.io') ||
domain.includes('gitlab.io') ||
domain.includes('surge.sh') ||
domain.includes('cloudfront.net') ||
domain.includes('firebaseapp.com') ||
domain.includes('azurestaticapps.net') ||
domain.includes('pages.dev') ||
domain.includes('ngrok-free.app') ||
domain.includes('ngrok.app')
) {
score -= 50;
}
// Penalize subdomains
const domainParts = domain
.replace('https://', '')
.replace('http://', '')
.split('.');
if (domainParts.length <= 2) score += 50;
// Tiebreaker: prefer shorter domains
score -= domain.length;
return { domain, score };
});
// Sort by score (highest first) and return the best domain
const bestDomain = scoredDomains.sort((a, b) => b.score - a.score)[0];
return bestDomain?.domain || null;
};
async function main() {
const projects = await db.project.findMany({
include: {
clients: true,
},
});
const matches = [];
for (const project of projects) {
const cors = [];
let crossDomain = false;
for (const client of project.clients) {
if (client.crossDomain) {
crossDomain = true;
}
cors.push(
...(client.cors?.split(',') ?? []).map((c) =>
stripTrailingSlash(c.trim()),
),
);
await getClientByIdCached.clear(client.id);
}
let domain = pickBestDomain(cors);
if (!domain) {
const res = await chQuery<{ origin: string }>(
`SELECT origin FROM events_distributed WHERE project_id = '${project.id}' and origin != ''`,
);
if (res.length) {
domain = pickBestDomain(res.map((r) => r.origin));
matches.push(domain);
} else {
console.log('No domain found for client');
}
}
await db.project.update({
where: { id: project.id },
data: {
cors,
crossDomain,
domain,
},
});
console.log('Updated', {
cors,
crossDomain,
domain,
});
await getProjectByIdCached.clear(project.id);
}
console.log('DONE');
console.log('DONE');
console.log('DONE');
console.log('DONE');
}
main();

View File

@@ -54,7 +54,7 @@ export async function postEvent(
{
type: 'incomingEvent',
payload: {
projectId: request.projectId,
projectId,
headers: getStringHeaders(request.headers),
event: {
...request.body,

View File

@@ -10,12 +10,17 @@ export async function importEvents(
}>,
reply: FastifyReply,
) {
const projectId = request.client?.projectId;
if (!projectId) {
throw new Error('Project ID is required');
}
const importedAt = formatClickhouseDate(new Date());
const values: IClickhouseEvent[] = request.body.map((event) => {
return {
...event,
properties: toDots(event.properties),
project_id: request.client?.projectId ?? '',
project_id: projectId,
created_at: formatClickhouseDate(event.created_at),
imported_at: importedAt,
};

View File

@@ -16,7 +16,10 @@ export async function updateProfile(
reply: FastifyReply,
) {
const { profileId, properties, ...rest } = request.body;
const projectId = request.projectId;
const projectId = request.client!.projectId;
if (!projectId) {
return reply.status(400).send('No projectId');
}
const ip = getClientIp(request)!;
const ua = request.headers['user-agent']!;
const uaInfo = parseUserAgent(ua, properties);
@@ -44,7 +47,10 @@ export async function incrementProfileProperty(
reply: FastifyReply,
) {
const { profileId, property, value } = request.body;
const projectId = request.projectId;
const projectId = request.client!.projectId;
if (!projectId) {
return reply.status(400).send('No projectId');
}
const profile = await getProfileById(profileId, projectId);
if (!profile) {
@@ -83,7 +89,10 @@ export async function decrementProfileProperty(
reply: FastifyReply,
) {
const { profileId, property, value } = request.body;
const projectId = request.projectId;
const projectId = request.client?.projectId;
if (!projectId) {
return reply.status(400).send('No projectId');
}
const profile = await getProfileById(profileId, projectId);
if (!profile) {

View File

@@ -1,20 +1,9 @@
import { SdkAuthError, validateSdkRequest } from '@/utils/auth';
import type { TrackHandlerPayload } from '@openpanel/sdk';
import type {
FastifyReply,
FastifyRequest,
HookHandlerDoneFunction,
} from 'fastify';
import type { FastifyReply, FastifyRequest } from 'fastify';
export async function clientHook(
req: FastifyRequest<{
Body: TrackHandlerPayload;
}>,
reply: FastifyReply,
) {
export async function clientHook(req: FastifyRequest, reply: FastifyReply) {
try {
const client = await validateSdkRequest(req.headers);
req.projectId = client.projectId;
const client = await validateSdkRequest(req);
req.client = client;
} catch (error) {
if (error instanceof SdkAuthError) {

View File

@@ -0,0 +1,18 @@
import { getClientIp } from '@/utils/parseIp';
import type {
FastifyReply,
FastifyRequest,
HookHandlerDoneFunction,
} from 'fastify';
export function ipHook(
request: FastifyRequest,
reply: FastifyReply,
done: HookHandlerDoneFunction,
) {
const ip = getClientIp(request);
if (ip) {
request.clientIp = ip;
}
done();
}

View File

@@ -11,7 +11,7 @@ import metricsPlugin from 'fastify-metrics';
import { path, pick } from 'ramda';
import { generateId } from '@openpanel/common';
import type { IServiceClient } from '@openpanel/db';
import type { IServiceClient, IServiceClientWithProject } from '@openpanel/db';
import { getRedisPub } from '@openpanel/redis';
import type { AppRouter } from '@openpanel/trpc';
import { appRouter, createContext } from '@openpanel/trpc';
@@ -21,6 +21,7 @@ import {
healthcheck,
healthcheckQueue,
} from './controllers/healthcheck.controller';
import { ipHook } from './hooks/ip.hook';
import { requestIdHook } from './hooks/request-id.hook';
import { requestLoggingHook } from './hooks/request-logging.hook';
import { timestampHook } from './hooks/timestamp.hook';
@@ -38,8 +39,8 @@ sourceMapSupport.install();
declare module 'fastify' {
interface FastifyRequest {
projectId: string;
client: IServiceClient | null;
client: IServiceClientWithProject | null;
clientIp?: string;
timestamp?: number;
}
}
@@ -60,6 +61,7 @@ const startServer = async () => {
: generateId(),
});
fastify.addHook('preHandler', ipHook);
fastify.addHook('preHandler', timestampHook);
fastify.addHook('onRequest', requestIdHook);
fastify.addHook('onResponse', requestLoggingHook);

View File

@@ -1,9 +1,19 @@
import type { RawRequestDefaultExpression } from 'fastify';
import type { FastifyRequest, RawRequestDefaultExpression } from 'fastify';
import jwt from 'jsonwebtoken';
import { verifyPassword } from '@openpanel/common/server';
import type { Client, IServiceClient } from '@openpanel/db';
import { ClientType, db } from '@openpanel/db';
import type {
Client,
IServiceClient,
IServiceClientWithProject,
} from '@openpanel/db';
import { ClientType, db, getClientByIdCached } from '@openpanel/db';
import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk';
import type {
IProjectFilterIp,
IProjectFilterProfileId,
} from '@openpanel/validation';
import { path } from 'ramda';
const cleanDomain = (domain: string) =>
domain
@@ -32,11 +42,12 @@ export class SdkAuthError extends Error {
}
}
type ClientWithProjectId = Client & { projectId: string };
export async function validateSdkRequest(
headers: RawRequestDefaultExpression['headers'],
): Promise<ClientWithProjectId> {
req: FastifyRequest<{
Body: PostEventPayload | TrackHandlerPayload;
}>,
): Promise<IServiceClientWithProject> {
const { headers, clientIp } = req;
const clientIdNew = headers['openpanel-client-id'] as string;
const clientIdOld = headers['mixan-client-id'] as string;
const clientSecretNew = headers['openpanel-client-secret'] as string;
@@ -59,22 +70,38 @@ export async function validateSdkRequest(
throw createError('Ingestion: Missing client id');
}
const client = await db.client.findUnique({
where: {
id: clientId,
},
});
const client = await getClientByIdCached(clientId);
if (!client) {
throw createError('Ingestion: Invalid client id');
}
if (!client.projectId) {
if (!client.project) {
throw createError('Ingestion: Client has no project');
}
if (client.cors) {
const domainAllowed = client.cors.split(',').find((domain) => {
// Filter out blocked IPs
const ipFilter = client.project.filters.filter(
(filter): filter is IProjectFilterIp => filter.type === 'ip',
);
if (ipFilter.some((filter) => filter.ip === clientIp)) {
throw createError('Ingestion: IP address is blocked by project filter');
}
// Filter out blocked profile ids
const profileFilter = client.project.filters.filter(
(filter): filter is IProjectFilterProfileId => filter.type === 'profile_id',
);
const profileId =
path<string | undefined>(['payload', 'profileId'], req.body) || // Track handler
path<string | undefined>(['profileId'], req.body); // Event handler
if (profileFilter.some((filter) => filter.profileId === profileId)) {
throw createError('Ingestion: Profile id is blocked by project filter');
}
if (client.project.cors) {
const domainAllowed = client.project.cors.find((domain) => {
const cleanedDomain = cleanDomain(domain);
// support wildcard domains `*.foo.com`
if (cleanedDomain.includes('*')) {
@@ -91,17 +118,17 @@ export async function validateSdkRequest(
});
if (domainAllowed) {
return client as ClientWithProjectId;
return client;
}
if (client.cors === '*' && origin) {
return client as ClientWithProjectId;
if (client.project.cors.includes('*') && origin) {
return client;
}
}
if (client.secret && clientSecret) {
if (await verifyPassword(clientSecret, client.secret)) {
return client as ClientWithProjectId;
return client;
}
}
@@ -110,14 +137,10 @@ export async function validateSdkRequest(
export async function validateExportRequest(
headers: RawRequestDefaultExpression['headers'],
): Promise<IServiceClient> {
): Promise<IServiceClientWithProject> {
const clientId = headers['openpanel-client-id'] as string;
const clientSecret = (headers['openpanel-client-secret'] as string) || '';
const client = await db.client.findUnique({
where: {
id: clientId,
},
});
const client = await getClientByIdCached(clientId);
if (!client) {
throw new Error('Export: Invalid client id');
@@ -140,14 +163,10 @@ export async function validateExportRequest(
export async function validateImportRequest(
headers: RawRequestDefaultExpression['headers'],
): Promise<IServiceClient> {
): Promise<IServiceClientWithProject> {
const clientId = headers['openpanel-client-id'] as string;
const clientSecret = (headers['openpanel-client-secret'] as string) || '';
const client = await db.client.findUnique({
where: {
id: clientId,
},
});
const client = await getClientByIdCached(clientId);
if (!client) {
throw new Error('Import: Invalid client id');

View File

@@ -28,7 +28,9 @@ export async function activateRateLimiter({
req.headers['x-forwarded-for']) as string;
},
onExceeded: (req, reply) => {
req.log.warn('Rate limit exceeded');
req.log.warn('Rate limit exceeded', {
clientId: req.headers['openpanel-client-id'],
});
},
});
}

View File

@@ -77,7 +77,9 @@ export default function LayoutProjectSelector({
<span className="mx-2 truncate">
{projectId
? projects.find((p) => p.id === projectId)?.name
: 'Select project'}
: organizationId
? organizations?.find((o) => o.id === organizationId)?.name
: 'Select project'}
</span>
<ChevronsUpDownIcon className="ml-auto h-4 w-4 shrink-0 opacity-50" />
</Button>

View File

@@ -14,6 +14,7 @@ import type {
getProjectsByOrganizationId,
} from '@openpanel/db';
import Link from 'next/link';
import LayoutMenu from './layout-menu';
import LayoutProjectSelector from './layout-project-selector';
@@ -64,7 +65,9 @@ export function LayoutSidebar({
</Button>
</div>
<div className="flex h-16 shrink-0 items-center gap-4 border-b border-border px-4">
<LogoSquare className="max-h-8" />
<Link href="/">
<LogoSquare className="max-h-8" />
</Link>
<LayoutProjectSelector
align="start"
projects={projects}

View File

@@ -41,26 +41,29 @@ export default function EditOrganization({
});
return (
<form
onSubmit={handleSubmit((values) => {
mutation.mutate(values);
})}
>
<Widget>
<WidgetHead className="flex items-center justify-between">
<span className="title">Org. details</span>
<Button size="sm" type="submit" disabled={!formState.isDirty}>
Save
</Button>
</WidgetHead>
<WidgetBody>
<InputWithLabel
label="Name"
{...register('name')}
defaultValue={organization?.name}
/>
</WidgetBody>
</Widget>
</form>
<section className="max-w-screen-sm">
<form
onSubmit={handleSubmit((values) => {
mutation.mutate(values);
})}
>
<Widget>
<WidgetHead className="flex items-center justify-between">
<span className="title">Details</span>
</WidgetHead>
<WidgetBody className="flex items-end gap-2">
<InputWithLabel
className="flex-1"
label="Name"
{...register('name')}
defaultValue={organization?.name}
/>
<Button size="sm" type="submit" disabled={!formState.isDirty}>
Save
</Button>
</WidgetBody>
</Widget>
</form>
</section>
);
}

View File

@@ -0,0 +1,178 @@
'use client';
import AnimateHeight from '@/components/animate-height';
import { InputWithLabel, WithLabel } from '@/components/forms/input-with-label';
import TagInput from '@/components/forms/tag-input';
import { Button } from '@/components/ui/button';
import { CheckboxInput } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { useAppParams } from '@/hooks/useAppParams';
import { api, handleError } from '@/trpc/client';
import { zodResolver } from '@hookform/resolvers/zod';
import type { IServiceProjectWithClients } from '@openpanel/db';
import { type IProjectEdit, zProject } from '@openpanel/validation';
import { SaveIcon } from 'lucide-react';
import { useState } from 'react';
import { Controller, UseFormReturn, useForm, useWatch } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod';
type Props = { project: IServiceProjectWithClients };
const validator = zProject.pick({
name: true,
id: true,
domain: true,
cors: true,
crossDomain: true,
});
type IForm = z.infer<typeof validator>;
export default function EditProjectDetails({ project }: Props) {
const [hasDomain, setHasDomain] = useState(true);
const form = useForm<IForm>({
resolver: zodResolver(validator),
defaultValues: {
id: project.id,
name: project.name,
domain: project.domain,
cors: project.cors,
crossDomain: project.crossDomain,
},
});
const mutation = api.project.update.useMutation({
onError: handleError,
onSuccess: () => {
toast.success('Project updated');
},
});
const onSubmit = (values: IForm) => {
if (hasDomain) {
let error = false;
if (values.cors.length === 0) {
form.setError('cors', {
type: 'required',
message: 'Please add at least one cors domain',
});
error = true;
}
if (!values.domain) {
form.setError('domain', {
type: 'required',
message: 'Please add a domain',
});
error = true;
}
if (error) {
return;
}
}
mutation.mutate(hasDomain ? values : { ...values, cors: [], domain: null });
};
return (
<Widget className="max-w-screen-md w-full">
<WidgetHead>
<span className="title">Details</span>
</WidgetHead>
<WidgetBody>
<form
onSubmit={form.handleSubmit(onSubmit, (errors) => {
console.log(errors);
})}
className="col gap-4"
>
<InputWithLabel
label="Name"
{...form.register('name')}
defaultValue={project.name}
/>
<div className="-mb-2 flex gap-2 items-center justify-between">
<Label className="mb-0">Domain</Label>
<Switch checked={hasDomain} onCheckedChange={setHasDomain} />
</div>
<AnimateHeight open={hasDomain}>
<Input
placeholder="Domain"
{...form.register('domain')}
className="mb-4"
error={form.formState.errors.domain?.message}
defaultValue={project.domain ?? ''}
/>
<Controller
name="cors"
control={form.control}
render={({ field }) => (
<WithLabel label="Cors">
<TagInput
{...field}
id="Cors"
error={form.formState.errors.cors?.message}
placeholder="Add a domain"
value={field.value ?? []}
renderTag={(tag) =>
tag === '*' ? 'Allow all domains' : tag
}
onChange={(newValue) => {
field.onChange(
newValue.map((item) => {
const trimmed = item.trim();
if (
trimmed.startsWith('http://') ||
trimmed.startsWith('https://') ||
trimmed === '*'
) {
return trimmed;
}
return `https://${trimmed}`;
}),
);
}}
/>
</WithLabel>
)}
/>
<Controller
name="crossDomain"
control={form.control}
render={({ field }) => {
return (
<CheckboxInput
className="mt-4"
ref={field.ref}
onBlur={field.onBlur}
defaultChecked={field.value}
onCheckedChange={field.onChange}
>
<div>Enable cross domain support</div>
<div className="font-normal text-muted-foreground">
This will let you track users across multiple domains
</div>
</CheckboxInput>
);
}}
/>
</AnimateHeight>
<Button
loading={mutation.isLoading}
type="submit"
icon={SaveIcon}
className="self-end"
>
Save
</Button>
</form>
</WidgetBody>
</Widget>
);
}

View File

@@ -0,0 +1,118 @@
'use client';
import { WithLabel } from '@/components/forms/input-with-label';
import TagInput from '@/components/forms/tag-input';
import { Button } from '@/components/ui/button';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { api, handleError } from '@/trpc/client';
import { zodResolver } from '@hookform/resolvers/zod';
import type { IServiceProjectWithClients } from '@openpanel/db';
import type {
IProjectFilterIp,
IProjectFilterProfileId,
} from '@openpanel/validation';
import { SaveIcon } from 'lucide-react';
import { Controller, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
type Props = { project: IServiceProjectWithClients };
const validator = z.object({
ips: z.array(z.string()),
profileIds: z.array(z.string()),
});
type IForm = z.infer<typeof validator>;
export default function EditProjectFilters({ project }: Props) {
const form = useForm<IForm>({
resolver: zodResolver(validator),
defaultValues: {
ips: project.filters
.filter((item): item is IProjectFilterIp => item.type === 'ip')
.map((item) => item.ip),
profileIds: project.filters
.filter(
(item): item is IProjectFilterProfileId => item.type === 'profile_id',
)
.map((item) => item.profileId),
},
});
const mutation = api.project.update.useMutation({
onError: handleError,
onSuccess: () => {
toast.success('Project filters updated');
},
});
const onSubmit = (values: IForm) => {
mutation.mutate({
id: project.id,
filters: [
...values.ips.map((ip) => ({ type: 'ip' as const, ip })),
...values.profileIds.map((profileId) => ({
type: 'profile_id' as const,
profileId,
})),
],
});
};
return (
<Widget className="max-w-screen-md w-full">
<WidgetHead className="col gap-2">
<span className="title">Exclude events</span>
<p className="text-muted-foreground">
Exclude events from being tracked by adding filters.
</p>
</WidgetHead>
<WidgetBody>
<form onSubmit={form.handleSubmit(onSubmit)} className="col gap-4">
<Controller
name="ips"
control={form.control}
render={({ field }) => (
<WithLabel label="IP addresses">
<TagInput
{...field}
id="IP addresses"
error={form.formState.errors.ips?.message}
placeholder="Exclude IP addresses"
value={field.value}
onChange={field.onChange}
/>
</WithLabel>
)}
/>
<Controller
name="profileIds"
control={form.control}
render={({ field }) => (
<WithLabel label="Profile IDs">
<TagInput
{...field}
id="Profile IDs"
error={form.formState.errors.profileIds?.message}
placeholder="Exclude Profile IDs"
value={field.value}
onChange={field.onChange}
/>
</WithLabel>
)}
/>
<Button
loading={mutation.isLoading}
type="submit"
icon={SaveIcon}
className="self-end"
>
Save
</Button>
</form>
</WidgetBody>
</Widget>
);
}

View File

@@ -1,114 +0,0 @@
'use client';
import { StickyBelowHeader } from '@/app/(app)/[organizationSlug]/[projectId]/layout-sticky-below-header';
import { ClientActions } from '@/components/clients/client-actions';
import { ProjectActions } from '@/components/projects/project-actions';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Tooltiper } from '@/components/ui/tooltip';
import { pushModal } from '@/modals';
import { InfoIcon, PlusIcon, PlusSquareIcon } from 'lucide-react';
import type { IServiceClientWithProject, IServiceProject } from '@openpanel/db';
interface ListProjectsProps {
projects: IServiceProject[];
clients: IServiceClientWithProject[];
}
export default function ListProjects({ projects, clients }: ListProjectsProps) {
return (
<>
<div className="row mb-4 justify-between">
<h1 className="text-2xl font-bold">Projects</h1>
<Button icon={PlusIcon} onClick={() => pushModal('AddProject')}>
<span className="max-sm:hidden">Create project</span>
<span className="sm:hidden">Project</span>
</Button>
</div>
<div className="card p-4">
<Alert className="mb-4">
<InfoIcon size={16} />
<AlertTitle>What is a project</AlertTitle>
<AlertDescription>
A project can be a website, mobile app or any other application that
you want to track event for. Each project can have one or more
clients. The client is used to send events to the project.
</AlertDescription>
</Alert>
<Accordion type="single" collapsible className="-mx-4">
{projects.map((project) => {
const pClients = clients.filter(
(client) => client.projectId === project.id,
);
return (
<AccordionItem
value={project.id}
key={project.id}
className="last:border-b-0"
>
<AccordionTrigger className="px-4">
<div className="flex-1 text-left">
{project.name}
<span className="ml-2 text-muted-foreground">
{pClients.length > 0
? `(${pClients.length} clients)`
: 'No clients created yet'}
</span>
</div>
<div className="mx-4" />
</AccordionTrigger>
<AccordionContent className="px-4">
<ProjectActions {...project} />
<div className="mt-4 grid gap-4 md:grid-cols-3">
{pClients.map((item) => {
return (
<div
className="relative rounded border border-border p-4"
key={item.id}
>
<div className="mb-1 font-medium">{item.name}</div>
<Tooltiper
className="text-muted-foreground"
content={item.id}
>
Client ID: ...{item.id.slice(-12)}
</Tooltiper>
<div className="text-muted-foreground">
{item.cors &&
item.cors !== '*' &&
`Website: ${item.cors}`}
</div>
<div className="absolute right-4 top-4">
<ClientActions {...item} />
</div>
</div>
);
})}
<button
type="button"
onClick={() => {
pushModal('AddClient', {
projectId: project.id,
});
}}
className="flex items-center justify-center gap-4 rounded bg-muted p-4"
>
<PlusSquareIcon />
<div className="font-medium">New client</div>
</button>
</div>
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
</div>
</>
);
}

View File

@@ -1,29 +1,40 @@
import { Padding } from '@/components/ui/padding';
import {
db,
getClientsByOrganizationId,
getProjectWithClients,
getProjectsByOrganizationId,
} from '@openpanel/db';
import ListProjects from './list-projects';
import { notFound } from 'next/navigation';
import EditProjectDetails from './edit-project-details';
import EditProjectFilters from './edit-project-filters';
import ProjectClients from './project-clients';
interface PageProps {
params: {
organizationSlug: string;
projectId: string;
};
}
export default async function Page({
params: { organizationSlug: organizationId },
}: PageProps) {
const [projects, clients] = await Promise.all([
getProjectsByOrganizationId(organizationId),
getClientsByOrganizationId(organizationId),
]);
export default async function Page({ params: { projectId } }: PageProps) {
const project = await getProjectWithClients(projectId);
if (!project) {
notFound();
}
return (
<Padding>
<ListProjects projects={projects} clients={clients} />
<div className="col gap-4">
<div className="row justify-between items-center">
<h1 className="text-2xl font-bold">{project.name}</h1>
</div>
<EditProjectDetails project={project} />
<EditProjectFilters project={project} />
<ProjectClients project={project} />
</div>
</Padding>
);
}

View File

@@ -0,0 +1,45 @@
'use client';
import { ClientsTable } from '@/components/clients/table';
import { Button } from '@/components/ui/button';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { pushModal } from '@/modals';
import type {
IServiceClientWithProject,
IServiceProjectWithClients,
} from '@openpanel/db';
import { PlusIcon } from 'lucide-react';
import { omit } from 'ramda';
type Props = { project: IServiceProjectWithClients };
export default function ProjectClients({ project }: Props) {
return (
<Widget className="max-w-screen-md w-full overflow-hidden">
<WidgetHead className="flex items-center justify-between">
<span className="title">Clients</span>
<Button
variant="outline"
icon={PlusIcon}
className="-my-1"
onClick={() => pushModal('AddClient')}
>
New client
</Button>
</WidgetHead>
<WidgetBody className="p-0 [&>div]:border-none [&>div]:rounded-none">
<ClientsTable
// @ts-expect-error
query={{
data: project.clients.map((item) => ({
...item,
project: omit(['clients'], item),
})) as unknown as IServiceClientWithProject[],
isFetching: false,
isLoading: false,
}}
/>
</WidgetBody>
</Widget>
);
}

View File

@@ -1,10 +1,11 @@
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import FullWidthNavbar from '@/components/full-width-navbar';
import ProjectCard from '@/components/projects/project-card';
import SignOutButton from '@/components/sign-out-button';
import { redirect } from 'next/navigation';
import SettingsToggle from '@/components/settings-toggle';
import { getCurrentOrganizations, getCurrentProjects } from '@openpanel/db';
import LayoutProjectSelector from './[projectId]/layout-project-selector';
interface PageProps {
params: {
@@ -21,7 +22,6 @@ export default async function Page({
]);
const organization = organizations.find((org) => org.id === organizationId);
console.log(organizations, organizationId, projects);
if (!organization) {
return (
@@ -42,10 +42,16 @@ export default async function Page({
return (
<div>
<FullWidthNavbar>
<SignOutButton />
<div className="row gap-4">
<LayoutProjectSelector
align="start"
projects={projects}
organizations={organizations}
/>
<SettingsToggle />
</div>
</FullWidthNavbar>
<div className="mx-auto flex flex-col gap-4 p-4 pt-20 md:max-w-[95vw] lg:max-w-[80vw] ">
<h1 className="text-xl font-medium">Select project</h1>
<div className="grid gap-4 md:grid-cols-2">
{projects.map((item) => (
<ProjectCard key={item.id} {...item} />

View File

@@ -1,55 +0,0 @@
import { formatDate } from '@/utils/date';
import type { ColumnDef } from '@tanstack/react-table';
import type { IServiceClientWithProject } from '@openpanel/db';
import { ACTIONS } from '../data-table';
import { ClientActions } from './client-actions';
export const columns: ColumnDef<IServiceClientWithProject>[] = [
{
accessorKey: 'name',
header: 'Name',
cell: ({ row }) => {
return (
<div>
<div>{row.original.name}</div>
<div className=" text-muted-foreground">
{row.original.project?.name ?? 'No project'}
</div>
</div>
);
},
},
{
accessorKey: 'id',
header: 'Client ID',
},
{
accessorKey: 'cors',
header: 'Cors',
},
{
accessorKey: 'secret',
header: 'Secret',
cell: (info) =>
info.getValue() ? (
<div className="italic text-muted-foreground">Hidden</div>
) : (
'None'
),
},
{
accessorKey: 'createdAt',
header: 'Created at',
cell({ row }) {
const date = row.original.createdAt;
return formatDate(date);
},
},
{
id: ACTIONS,
header: 'Actions',
cell: ({ row }) => <ClientActions {...row.original} />,
},
];

View File

@@ -0,0 +1,56 @@
import { EventIcon } from '@/components/events/event-icon';
import { ProjectLink } from '@/components/links';
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { TooltipComplete } from '@/components/tooltip-complete';
import { useNumber } from '@/hooks/useNumerFormatter';
import { pushModal } from '@/modals';
import { formatDateTime, formatTime } from '@/utils/date';
import { getProfileName } from '@/utils/getters';
import type { ColumnDef } from '@tanstack/react-table';
import { isToday } from 'date-fns';
import { ACTIONS } from '@/components/data-table';
import type { IServiceClientWithProject, IServiceEvent } from '@openpanel/db';
import { ClientActions } from '../client-actions';
export function useColumns() {
const number = useNumber();
const columns: ColumnDef<IServiceClientWithProject>[] = [
{
accessorKey: 'name',
header: 'Name',
cell: ({ row }) => {
return <div className="font-medium">{row.original.name}</div>;
},
},
{
accessorKey: 'id',
header: 'Client ID',
cell: ({ row }) => <div className="font-mono">{row.original.id}</div>,
},
// {
// accessorKey: 'secret',
// header: 'Secret',
// cell: (info) =>
// <div className="italic text-muted-foreground"></div>
// },
{
accessorKey: 'createdAt',
header: 'Created at',
cell({ row }) {
const date = row.original.createdAt;
return (
<div>{isToday(date) ? formatTime(date) : formatDateTime(date)}</div>
);
},
},
{
id: ACTIONS,
header: 'Actions',
cell: ({ row }) => <ClientActions {...row.original} />,
},
];
return columns;
}

View File

@@ -0,0 +1,67 @@
import { DataTable } from '@/components/data-table';
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { Pagination } from '@/components/pagination';
import { Button } from '@/components/ui/button';
import { TableSkeleton } from '@/components/ui/table';
import type { UseQueryResult } from '@tanstack/react-query';
import { GanttChartIcon, PlusIcon } from 'lucide-react';
import type { Dispatch, SetStateAction } from 'react';
import type { IServiceClientWithProject } from '@openpanel/db';
import { useAppParams } from '@/hooks/useAppParams';
import { pushModal } from '@/modals';
import { useColumns } from './columns';
type Props = {
query: UseQueryResult<IServiceClientWithProject[]>;
cursor: number;
setCursor: Dispatch<SetStateAction<number>>;
};
export const ClientsTable = ({ query, ...props }: Props) => {
const columns = useColumns();
const { data, isFetching, isLoading } = query;
if (isLoading) {
return <TableSkeleton cols={columns.length} />;
}
if (data?.length === 0) {
return (
<FullPageEmptyState title="No clients here" icon={GanttChartIcon}>
<p>Could not find any clients</p>
<div className="row gap-4 mt-4">
{'cursor' in props && props.cursor !== 0 && (
<Button
className="mt-8"
variant="outline"
onClick={() => props.setCursor((p) => p - 1)}
>
Go to previous page
</Button>
)}
<Button icon={PlusIcon} onClick={() => pushModal('AddClient')}>
Add client
</Button>
</div>
</FullPageEmptyState>
);
}
return (
<>
<DataTable data={data ?? []} columns={columns} />
{'cursor' in props && (
<Pagination
className="mt-2"
setCursor={props.setCursor}
cursor={props.cursor}
count={Number.POSITIVE_INFINITY}
take={50}
loading={isFetching}
/>
)}
</>
);
};

View File

@@ -14,6 +14,7 @@ type Props = {
className?: string;
onChange: (value: string[]) => void;
renderTag?: (tag: string) => string;
id?: string;
};
const TagInput = ({
@@ -22,6 +23,7 @@ const TagInput = ({
renderTag,
placeholder,
error,
id,
}: Props) => {
const value = (
Array.isArray(propValue) ? propValue : propValue ? [propValue] : []
@@ -34,7 +36,7 @@ const TagInput = ({
const [scope, animate] = useAnimate();
const appendTag = (tag: string) => {
onChange([...value, tag]);
onChange([...value, tag.trim()]);
};
const removeTag = (tag: string) => {
@@ -141,6 +143,7 @@ const TagInput = ({
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
id={id}
/>
</div>
);

View File

@@ -2,7 +2,7 @@
import { cn } from '@/utils/cn';
import { Logo } from './logo';
import { Logo, LogoSquare } from './logo';
type Props = {
children: React.ReactNode;
@@ -13,7 +13,7 @@ const FullWidthNavbar = ({ children, className }: Props) => {
return (
<div className={cn('border-b border-border bg-card', className)}>
<div className="mx-auto flex h-14 w-full items-center justify-between px-4 md:max-w-[95vw] lg:max-w-[80vw]">
<Logo />
<LogoSquare className="size-8" />
{children}
</div>
</div>

View File

@@ -5,30 +5,48 @@ import { escape } from 'sqlstring';
import type { IServiceProject } from '@openpanel/db';
import { TABLE_NAMES, chQuery } from '@openpanel/db';
import { SettingsIcon } from 'lucide-react';
import Link from 'next/link';
import { ChartSSR } from '../chart-ssr';
import { FadeIn } from '../fade-in';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { LinkButton } from '../ui/button';
function ProjectCard({ id, name, organizationId }: IServiceProject) {
function ProjectCard({ id, domain, name, organizationId }: IServiceProject) {
// For some unknown reason I get when navigating back to this page when using <Link />
// Should be solved: https://github.com/vercel/next.js/issues/61336
// But still get the error
return (
<a
href={`/${organizationId}/${id}`}
className="card inline-flex flex-col gap-2 p-4 transition-transform hover:-translate-y-1"
>
<div className="font-medium">{name}</div>
<div className="-mx-4 aspect-[15/1]">
<Suspense>
<ProjectChart id={id} />
</Suspense>
</div>
<div className="flex justify-end gap-4 ">
<Suspense>
<ProjectMetrics id={id} />
</Suspense>
</div>
</a>
<div className="relative card hover:-translate-y-px hover:shadow-sm">
<a
href={`/${organizationId}/${id}`}
className="col p-4 transition-transform"
>
<div className="font-medium flex items-center gap-2 text-lg pb-2">
<div className="row gap-2 flex-1">
{domain && <SerieIcon name={domain ?? ''} />}
{name}
</div>
</div>
<div className="-mx-4 aspect-[8/1]">
<Suspense>
<ProjectChart id={id} />
</Suspense>
</div>
<div className="flex justify-end gap-4 h-9 md:h-4">
<Suspense>
<ProjectMetrics id={id} />
</Suspense>
</div>
</a>
<LinkButton
variant="ghost"
href={`/${organizationId}/${id}/settings/projects`}
className="text-muted-foreground absolute top-2 right-2"
>
<SettingsIcon size={16} />
</LinkButton>
</div>
);
}

View File

@@ -17,6 +17,8 @@ import { CheckIcon, MoreHorizontalIcon, PlusIcon } from 'lucide-react';
import { useTheme } from 'next-themes';
import * as React from 'react';
import { useAppParams } from '@/hooks/useAppParams';
import { useAuth } from '@clerk/nextjs';
import { ProjectLink } from './links';
interface Props {
@@ -25,6 +27,8 @@ interface Props {
export default function SettingsToggle({ className }: Props) {
const { setTheme, theme } = useTheme();
const { projectId } = useAppParams();
const auth = useAuth();
return (
<DropdownMenu>
@@ -35,37 +39,47 @@ export default function SettingsToggle({ className }: Props) {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="w-56">
<DropdownMenuItem asChild>
<ProjectLink href="/reports">
Create report
<DropdownMenuShortcut>
<PlusIcon className="h-4 w-4" />
</DropdownMenuShortcut>
</ProjectLink>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>Settings</DropdownMenuLabel>
<DropdownMenuItem asChild>
<ProjectLink href="/settings/organization">Organization</ProjectLink>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<ProjectLink href="/settings/projects">Projects</ProjectLink>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<ProjectLink href="/settings/profile">Your profile</ProjectLink>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<ProjectLink href="/settings/references">References</ProjectLink>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<ProjectLink href="/settings/notifications">
Notifications
</ProjectLink>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<ProjectLink href="/settings/integrations">Integrations</ProjectLink>
</DropdownMenuItem>
<DropdownMenuSeparator />
{projectId && (
<>
<DropdownMenuItem asChild>
<ProjectLink href="/reports">
Create report
<DropdownMenuShortcut>
<PlusIcon className="h-4 w-4" />
</DropdownMenuShortcut>
</ProjectLink>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>Settings</DropdownMenuLabel>
<DropdownMenuItem asChild>
<ProjectLink href="/settings/organization">
Organization
</ProjectLink>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<ProjectLink href="/settings/projects">
Project & Clients
</ProjectLink>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<ProjectLink href="/settings/profile">Your profile</ProjectLink>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<ProjectLink href="/settings/references">References</ProjectLink>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<ProjectLink href="/settings/notifications">
Notifications
</ProjectLink>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<ProjectLink href="/settings/integrations">
Integrations
</ProjectLink>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuSub>
<DropdownMenuSubTrigger className="flex w-full items-center justify-between">
Theme
@@ -87,7 +101,14 @@ export default function SettingsToggle({ className }: Props) {
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-red-600">Logout</DropdownMenuItem>
<DropdownMenuItem
className="text-red-600"
onClick={() => {
auth.signOut();
}}
>
Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);

View File

@@ -52,7 +52,7 @@ const CheckboxInput = React.forwardRef<
)}
>
<Checkbox ref={ref} {...props} className="relative top-0.5" />
<div className=" font-medium">{props.children}</div>
<div className="font-medium leading-5">{props.children}</div>
</label>
));
CheckboxInput.displayName = 'CheckboxInput';

View File

@@ -14,6 +14,7 @@ import {
PopoverTrigger,
} from '@/components/ui/popover';
import { cn } from '@/utils/cn';
import { PopoverPortal } from '@radix-ui/react-popover';
import type { LucideIcon } from 'lucide-react';
import { Check, ChevronsUpDown } from 'lucide-react';
import VirtualList from 'rc-virtual-list';
@@ -98,67 +99,69 @@ export function Combobox<T extends string>({
</Button>
)}
</PopoverTrigger>
<PopoverContent
className="w-full max-w-md p-0"
align={align}
portal={portal}
>
<Command shouldFilter={false}>
{searchable === true && (
<CommandInput
placeholder="Search item..."
value={search}
onValueChange={setSearch}
/>
)}
{typeof onCreate === 'function' && search ? (
<CommandEmpty className="p-2">
<Button
onClick={() => {
onCreate(search as T);
setSearch('');
setOpen(false);
}}
>
Create &quot;{search}&quot;
</Button>
</CommandEmpty>
) : (
<CommandEmpty>Nothing selected</CommandEmpty>
)}
<VirtualList
height={Math.min(items.length * 32, 300)}
data={items.filter((item) => {
if (search === '') return true;
return item.label.toLowerCase().includes(search.toLowerCase());
})}
itemHeight={32}
itemKey="value"
className="min-w-60"
>
{(item) => (
<CommandItem
key={item.value}
value={item.value}
onSelect={(currentValue) => {
const value = find(currentValue)?.value ?? currentValue;
onChange(value as T);
setOpen(false);
}}
{...(item.disabled && { disabled: true })}
>
<Check
className={cn(
'mr-2 h-4 w-4 flex-shrink-0',
value === item.value ? 'opacity-100' : 'opacity-0',
)}
/>
{item.label}
</CommandItem>
<PopoverPortal>
<PopoverContent
className="w-full max-w-md p-0"
align={align}
portal={portal}
>
<Command shouldFilter={false}>
{searchable === true && (
<CommandInput
placeholder="Search item..."
value={search}
onValueChange={setSearch}
/>
)}
</VirtualList>
</Command>
</PopoverContent>
{typeof onCreate === 'function' && search ? (
<CommandEmpty className="p-2">
<Button
onClick={() => {
onCreate(search as T);
setSearch('');
setOpen(false);
}}
>
Create &quot;{search}&quot;
</Button>
</CommandEmpty>
) : (
<CommandEmpty>Nothing selected</CommandEmpty>
)}
<VirtualList
height={Math.min(items.length * 32, 300)}
data={items.filter((item) => {
if (search === '') return true;
return item.label.toLowerCase().includes(search.toLowerCase());
})}
itemHeight={32}
itemKey="value"
className="min-w-60"
>
{(item) => (
<CommandItem
key={item.value}
value={item.value}
onSelect={(currentValue) => {
const value = find(currentValue)?.value ?? currentValue;
onChange(value as T);
setOpen(false);
}}
{...(item.disabled && { disabled: true })}
>
<Check
className={cn(
'mr-2 h-4 w-4 flex-shrink-0',
value === item.value ? 'opacity-100' : 'opacity-0',
)}
/>
{item.label}
</CommandItem>
)}
</VirtualList>
</Command>
</PopoverContent>
</PopoverPortal>
</Popover>
);
}

View File

@@ -9,7 +9,7 @@ export function WidgetHead({ children, className }: WidgetHeadProps) {
return (
<div
className={cn(
'border-b border-border p-4 [&_.title]:whitespace-nowrap [&_.title]:font-medium',
'border-b border-border p-4 [&_.title]:whitespace-nowrap [&_.title]:font-semibold [&_.title]:text-lg',
className,
)}
>

View File

@@ -27,31 +27,21 @@ import { ModalContent, ModalHeader } from './Modal/Container';
const validation = z.object({
name: z.string().min(1),
cors: z.string().min(1).or(z.literal('')),
projectId: z.string(),
type: z.enum(['read', 'write', 'root']),
crossDomain: z.boolean().optional(),
});
type IForm = z.infer<typeof validation>;
interface Props {
projectId: string;
}
export default function AddClient(props: Props) {
export default function AddClient() {
const { organizationId, projectId } = useAppParams();
const router = useRouter();
const form = useForm<IForm>({
resolver: zodResolver(validation),
defaultValues: {
name: '',
cors: '',
projectId: props.projectId ?? projectId,
type: 'write',
crossDomain: undefined,
},
});
const [hasDomain, setHasDomain] = useState(true);
const mutation = api.client.create.useMutation({
onError: handleError,
onSuccess() {
@@ -63,19 +53,11 @@ export default function AddClient(props: Props) {
organizationId,
});
const onSubmit: SubmitHandler<IForm> = (values) => {
if (hasDomain && values.cors === '') {
return form.setError('cors', {
type: 'required',
message: 'Please add a domain',
});
}
mutation.mutate({
name: values.name,
cors: hasDomain ? values.cors : null,
projectId: values.projectId,
organizationId,
type: values.type,
crossDomain: values.crossDomain,
projectId,
organizationId,
});
};
@@ -106,33 +88,6 @@ export default function AddClient(props: Props) {
className="flex flex-col gap-4"
onSubmit={form.handleSubmit(onSubmit)}
>
<div>
<Controller
control={form.control}
name="projectId"
render={({ field }) => {
return (
<div>
<Label>Project</Label>
<Combobox
{...field}
className="w-full"
onChange={(value) => {
field.onChange(value);
}}
items={
query.data?.map((item) => ({
value: item.id,
label: item.name,
})) ?? []
}
placeholder="Select a project"
/>
</div>
);
}}
/>
</div>
<div>
<Label>Client name</Label>
<Input
@@ -142,67 +97,6 @@ export default function AddClient(props: Props) {
/>
</div>
<div>
<Label className="flex items-center justify-between">
<span>Domain(s)</span>
<Switch checked={hasDomain} onCheckedChange={setHasDomain} />
</Label>
<AnimateHeight open={hasDomain}>
<Controller
name="cors"
control={form.control}
render={({ field }) => (
<TagInput
{...field}
error={form.formState.errors.cors?.message}
placeholder="Add a domain"
value={field.value?.split(',') ?? []}
renderTag={(tag) =>
tag === '*' ? 'Allow all domains' : tag
}
onChange={(newValue) => {
field.onChange(
newValue
.map((item) => {
const trimmed = item.trim();
if (
trimmed.startsWith('http://') ||
trimmed.startsWith('https://') ||
trimmed === '*'
) {
return trimmed;
}
return `https://${trimmed}`;
})
.join(','),
);
}}
/>
)}
/>
<Controller
name="crossDomain"
control={form.control}
render={({ field }) => {
return (
<CheckboxInput
className="mt-4"
ref={field.ref}
onBlur={field.onBlur}
defaultChecked={field.value}
onCheckedChange={field.onChange}
>
<div>Enable cross domain support</div>
<div className="font-normal text-muted-foreground">
This will let you track users across multiple domains
</div>
</CheckboxInput>
);
}}
/>
</AnimateHeight>
</div>
<div>
<Controller
control={form.control}
@@ -233,7 +127,7 @@ export default function AddClient(props: Props) {
]}
placeholder="Select a project"
/>
<p className="mt-1 text-sm text-muted-foreground">
<p className="mt-2 text-sm text-muted-foreground">
{field.value === 'write' &&
'Write: Is the default client type and is used for ingestion of data'}
{field.value === 'read' &&

View File

@@ -1,67 +1,164 @@
'use client';
import AnimateHeight from '@/components/animate-height';
import { ButtonContainer } from '@/components/button-container';
import { InputWithLabel } from '@/components/forms/input-with-label';
import { InputWithLabel, WithLabel } from '@/components/forms/input-with-label';
import TagInput from '@/components/forms/tag-input';
import { Button } from '@/components/ui/button';
import { CheckboxInput } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { useAppParams } from '@/hooks/useAppParams';
import { api, handleError } from '@/trpc/client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import type { IServiceProjectWithClients } from '@openpanel/db';
import { zProject } from '@openpanel/validation';
import { SaveIcon } from 'lucide-react';
import { useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import { popModal } from '.';
import type { z } from 'zod';
import { ModalContent, ModalHeader } from './Modal/Container';
const validator = z.object({
name: z.string().min(1),
});
type Props = { project: IServiceProjectWithClients };
const validator = zProject.pick({
name: true,
domain: true,
cors: true,
crossDomain: true,
});
type IForm = z.infer<typeof validator>;
export default function AddProject() {
const { organizationId } = useAppParams();
const router = useRouter();
const mutation = api.project.create.useMutation({
onError: handleError,
onSuccess() {
router.refresh();
toast('Success', {
description: 'Project created! Lets create a client for it 🤘',
});
popModal();
},
});
const { register, handleSubmit, formState } = useForm<IForm>({
const [hasDomain, setHasDomain] = useState(true);
const form = useForm<IForm>({
resolver: zodResolver(validator),
defaultValues: {
name: '',
domain: '',
cors: [],
crossDomain: false,
},
});
const mutation = api.project.create.useMutation({
onError: handleError,
onSuccess: () => {
toast.success('Project created');
},
});
const onSubmit = (values: IForm) => {
if (hasDomain) {
let error = false;
if (values.cors.length === 0) {
form.setError('cors', {
type: 'required',
message: 'Please add at least one cors domain',
});
error = true;
}
if (!values.domain) {
form.setError('domain', {
type: 'required',
message: 'Please add a domain',
});
error = true;
}
if (error) {
return;
}
}
mutation.mutate({
...(hasDomain ? values : { ...values, cors: [], domain: null }),
organizationId,
});
};
return (
<ModalContent>
<ModalHeader title="Create project" />
<form
onSubmit={handleSubmit((values) => {
mutation.mutate({
...values,
organizationId,
});
onSubmit={form.handleSubmit(onSubmit, (errors) => {
console.log(errors);
})}
className="col gap-4"
>
<div className="flex flex-col gap-4">
<InputWithLabel
label="Name"
placeholder="Name"
{...register('name')}
/>
<InputWithLabel label="Name" {...form.register('name')} />
<div className="-mb-2 flex gap-2 items-center justify-between">
<Label className="mb-0">Domain</Label>
<Switch checked={hasDomain} onCheckedChange={setHasDomain} />
</div>
<AnimateHeight open={hasDomain}>
<Input
placeholder="Domain"
{...form.register('domain')}
className="mb-4"
error={form.formState.errors.domain?.message}
/>
<Controller
name="cors"
control={form.control}
render={({ field }) => (
<WithLabel label="Cors">
<TagInput
{...field}
id="Cors"
error={form.formState.errors.cors?.message}
placeholder="Add a domain"
value={field.value ?? []}
renderTag={(tag) => (tag === '*' ? 'Allow all domains' : tag)}
onChange={(newValue) => {
field.onChange(
newValue.map((item) => {
const trimmed = item.trim();
if (
trimmed.startsWith('http://') ||
trimmed.startsWith('https://') ||
trimmed === '*'
) {
return trimmed;
}
return `https://${trimmed}`;
}),
);
}}
/>
</WithLabel>
)}
/>
<Controller
name="crossDomain"
control={form.control}
render={({ field }) => {
return (
<CheckboxInput
className="mt-4"
ref={field.ref}
onBlur={field.onBlur}
defaultChecked={field.value}
onCheckedChange={field.onChange}
>
<div>Enable cross domain support</div>
<div className="font-normal text-muted-foreground">
This will let you track users across multiple domains
</div>
</CheckboxInput>
);
}}
/>
</AnimateHeight>
<ButtonContainer>
<Button type="button" variant="outline" onClick={() => popModal()}>
Cancel
</Button>
<Button type="submit" disabled={!formState.isDirty}>
Create
<Button loading={mutation.isLoading} type="submit" icon={SaveIcon}>
Save
</Button>
</ButtonContainer>
</form>

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "projects" ADD COLUMN "cors" TEXT,
ADD COLUMN "crossDomain" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "domain" TEXT,
ADD COLUMN "filters" JSONB NOT NULL DEFAULT '[]';

View File

@@ -0,0 +1,9 @@
/*
Warnings:
- The `cors` column on the `projects` table would be dropped and recreated. This will lead to data loss if there is data in the column.
*/
-- AlterTable
ALTER TABLE "projects" DROP COLUMN "cors",
ADD COLUMN "cors" TEXT[] DEFAULT ARRAY[]::TEXT[];

View File

@@ -80,6 +80,11 @@ model Project {
organizationId String
eventsCount Int @default(0)
types ProjectType[] @default([])
domain String?
cors String[] @default([])
crossDomain Boolean @default(false)
/// [IPrismaProjectFilters]
filters Json @default("[]")
events Event[]
profiles Profile[]

View File

@@ -39,7 +39,7 @@ export const CLICKHOUSE_OPTIONS: NodeClickHouseClientConfigOptions = {
export const originalCh = createClient({
// TODO: remove this after migration
url: process.env.CLICKHOUSE_URL_CLUSTER ?? process.env.CLICKHOUSE_URL,
url: process.env.CLICKHOUSE_URL_DIRECT ?? process.env.CLICKHOUSE_URL,
...CLICKHOUSE_OPTIONS,
});

View File

@@ -1,3 +1,4 @@
import { cacheable } from '@openpanel/redis';
import type { Client, Prisma } from '../prisma-client';
import { db } from '../prisma-client';
@@ -21,3 +22,16 @@ export async function getClientsByOrganizationId(organizationId: string) {
},
});
}
export async function getClientById(
id: string,
): Promise<IServiceClientWithProject | null> {
return db.client.findUnique({
where: { id },
include: {
project: true,
},
});
}
export const getClientByIdCached = cacheable(getClientById, 60 * 60 * 24);

View File

@@ -1,6 +1,7 @@
import type {
IIntegrationConfig,
INotificationRuleConfig,
IProjectFilters,
} from '@openpanel/validation';
import type { INotificationPayload } from './services/notification.service';
@@ -9,5 +10,6 @@ declare global {
type IPrismaNotificationRuleConfig = INotificationRuleConfig;
type IPrismaIntegrationConfig = IIntegrationConfig;
type IPrismaNotificationPayload = INotificationPayload;
type IPrismaProjectFilters = IProjectFilters[];
}
}

View File

@@ -1,7 +1,6 @@
import crypto from 'node:crypto';
import { z } from 'zod';
import { stripTrailingSlash } from '@openpanel/common';
import type { Prisma } from '@openpanel/db';
import { db } from '@openpanel/db';
@@ -47,8 +46,6 @@ export const clientRouter = createTRPCRouter({
name: z.string(),
projectId: z.string(),
organizationId: z.string(),
cors: z.string().nullable(),
crossDomain: z.boolean().optional(),
type: z.enum(['read', 'write', 'root']).optional(),
}),
)
@@ -59,9 +56,7 @@ export const clientRouter = createTRPCRouter({
projectId: input.projectId,
name: input.name,
type: input.type ?? 'write',
cors: input.cors ? stripTrailingSlash(input.cors) : null,
secret: await hashPassword(secret),
crossDomain: input.crossDomain ?? false,
};
const client = await db.client.create({ data });

View File

@@ -2,11 +2,14 @@ import { z } from 'zod';
import {
db,
getClientById,
getClientByIdCached,
getId,
getProjectByIdCached,
getProjectsByOrganizationId,
} from '@openpanel/db';
import { zProject } from '@openpanel/validation';
import { getProjectAccess } from '../access';
import { TRPCAccessError } from '../errors';
import { createTRPCRouter, protectedProcedure } from '../trpc';
@@ -24,13 +27,12 @@ export const projectRouter = createTRPCRouter({
}),
update: protectedProcedure
.input(
z.object({
id: z.string(),
name: z.string(),
}),
)
.input(zProject.partial())
.mutation(async ({ input, ctx }) => {
if (!input.id) {
throw new Error('Project ID is required to update a project');
}
const access = await getProjectAccess({
userId: ctx.session.userId,
projectId: input.id,
@@ -39,30 +41,52 @@ export const projectRouter = createTRPCRouter({
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
const res = await db.project.update({
where: {
id: input.id,
},
data: {
name: input.name,
crossDomain: input.crossDomain,
filters: input.filters,
cors: input.cors,
domain: input.domain,
},
include: {
clients: {
select: {
id: true,
},
},
},
});
await getProjectByIdCached.clear(input.id);
await Promise.all([
getProjectByIdCached.clear(input.id),
res.clients.map((client) => {
getClientByIdCached.clear(client.id);
}),
]);
return res;
}),
create: protectedProcedure
.input(
z.object({
name: z.string().min(1),
organizationId: z.string(),
}),
zProject.omit({ id: true }).merge(
z.object({
organizationId: z.string(),
}),
),
)
.mutation(async ({ input: { name, organizationId } }) => {
.mutation(async ({ input }) => {
return db.project.create({
data: {
id: await getId('project', name),
organizationId,
name: name,
id: await getId('project', input.name),
organizationId: input.organizationId,
name: input.name,
domain: input.domain,
cors: input.cors,
crossDomain: input.crossDomain,
filters: [],
},
});
}),

View File

@@ -271,3 +271,31 @@ export const zCreateNotificationRule = z.object({
sendToEmail: z.boolean(),
projectId: z.string(),
});
export const zProjectFilterIp = z.object({
type: z.literal('ip'),
ip: z.string(),
});
export type IProjectFilterIp = z.infer<typeof zProjectFilterIp>;
export const zProjectFilterProfileId = z.object({
type: z.literal('profile_id'),
profileId: z.string(),
});
export type IProjectFilterProfileId = z.infer<typeof zProjectFilterProfileId>;
export const zProjectFilters = z.discriminatedUnion('type', [
zProjectFilterIp,
zProjectFilterProfileId,
]);
export type IProjectFilters = z.infer<typeof zProjectFilters>;
export const zProject = z.object({
id: z.string(),
name: z.string().min(1),
filters: z.array(zProjectFilters).default([]),
domain: z.string().url().or(z.literal('').or(z.null())),
cors: z.array(z.string()).default([]),
crossDomain: z.boolean().default(false),
});
export type IProjectEdit = z.infer<typeof zProject>;