diff --git a/apps/api/scripts/migrate-client-settings.ts b/apps/api/scripts/migrate-client-settings.ts new file mode 100644 index 00000000..7dc2575d --- /dev/null +++ b/apps/api/scripts/migrate-client-settings.ts @@ -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(); diff --git a/apps/api/src/controllers/event.controller.ts b/apps/api/src/controllers/event.controller.ts index be711193..24e9eb1e 100644 --- a/apps/api/src/controllers/event.controller.ts +++ b/apps/api/src/controllers/event.controller.ts @@ -54,7 +54,7 @@ export async function postEvent( { type: 'incomingEvent', payload: { - projectId: request.projectId, + projectId, headers: getStringHeaders(request.headers), event: { ...request.body, diff --git a/apps/api/src/controllers/import.controller.ts b/apps/api/src/controllers/import.controller.ts index 355db752..188aa725 100644 --- a/apps/api/src/controllers/import.controller.ts +++ b/apps/api/src/controllers/import.controller.ts @@ -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, }; diff --git a/apps/api/src/controllers/profile.controller.ts b/apps/api/src/controllers/profile.controller.ts index 6e9acd75..cfb2aef7 100644 --- a/apps/api/src/controllers/profile.controller.ts +++ b/apps/api/src/controllers/profile.controller.ts @@ -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) { diff --git a/apps/api/src/hooks/client.hook.ts b/apps/api/src/hooks/client.hook.ts index 5f6052fa..4ec5ced2 100644 --- a/apps/api/src/hooks/client.hook.ts +++ b/apps/api/src/hooks/client.hook.ts @@ -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) { diff --git a/apps/api/src/hooks/ip.hook.ts b/apps/api/src/hooks/ip.hook.ts new file mode 100644 index 00000000..4493098f --- /dev/null +++ b/apps/api/src/hooks/ip.hook.ts @@ -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(); +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index a2296401..8cd38f10 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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); diff --git a/apps/api/src/utils/auth.ts b/apps/api/src/utils/auth.ts index fc50bae3..606abb1b 100644 --- a/apps/api/src/utils/auth.ts +++ b/apps/api/src/utils/auth.ts @@ -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 { + req: FastifyRequest<{ + Body: PostEventPayload | TrackHandlerPayload; + }>, +): Promise { + 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(['payload', 'profileId'], req.body) || // Track handler + path(['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 { +): Promise { 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 { +): Promise { 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'); diff --git a/apps/api/src/utils/rate-limiter.ts b/apps/api/src/utils/rate-limiter.ts index d28fe77b..8ab3132c 100644 --- a/apps/api/src/utils/rate-limiter.ts +++ b/apps/api/src/utils/rate-limiter.ts @@ -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'], + }); }, }); } diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout-project-selector.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout-project-selector.tsx index f29f2e30..d99c48ac 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout-project-selector.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout-project-selector.tsx @@ -77,7 +77,9 @@ export default function LayoutProjectSelector({ {projectId ? projects.find((p) => p.id === projectId)?.name - : 'Select project'} + : organizationId + ? organizations?.find((o) => o.id === organizationId)?.name + : 'Select project'} diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout-sidebar.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout-sidebar.tsx index ad3b98ca..7e0b37bf 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout-sidebar.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout-sidebar.tsx @@ -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({
- + + + { - mutation.mutate(values); - })} - > - - - Org. details - - - - - - - +
+
{ + mutation.mutate(values); + })} + > + + + Details + + + + + + +
+
); } diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/projects/edit-project-details.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/projects/edit-project-details.tsx new file mode 100644 index 00000000..89de3e4a --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/projects/edit-project-details.tsx @@ -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; + +export default function EditProjectDetails({ project }: Props) { + const [hasDomain, setHasDomain] = useState(true); + const form = useForm({ + 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 ( + + + Details + + +
{ + console.log(errors); + })} + className="col gap-4" + > + + +
+ + +
+ + + + ( + + + 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}`; + }), + ); + }} + /> + + )} + /> + { + return ( + +
Enable cross domain support
+
+ This will let you track users across multiple domains +
+
+ ); + }} + /> +
+ + + +
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/projects/edit-project-filters.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/projects/edit-project-filters.tsx new file mode 100644 index 00000000..cf4cba0b --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/projects/edit-project-filters.tsx @@ -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; + +export default function EditProjectFilters({ project }: Props) { + const form = useForm({ + 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 ( + + + Exclude events +

+ Exclude events from being tracked by adding filters. +

+
+ +
+ ( + + + + )} + /> + + ( + + + + )} + /> + + + +
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/projects/list-projects.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/projects/list-projects.tsx deleted file mode 100644 index 9a1c882a..00000000 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/projects/list-projects.tsx +++ /dev/null @@ -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 ( - <> -
-

Projects

- -
-
- - - What is a project - - 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. - - - - {projects.map((project) => { - const pClients = clients.filter( - (client) => client.projectId === project.id, - ); - return ( - - -
- {project.name} - - {pClients.length > 0 - ? `(${pClients.length} clients)` - : 'No clients created yet'} - -
-
- - - -
- {pClients.map((item) => { - return ( -
-
{item.name}
- - Client ID: ...{item.id.slice(-12)} - -
- {item.cors && - item.cors !== '*' && - `Website: ${item.cors}`} -
-
- -
-
- ); - })} - -
-
- - ); - })} - -
- - ); -} diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/projects/page.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/projects/page.tsx index 9013b553..903a8530 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/projects/page.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/projects/page.tsx @@ -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 ( - +
+
+

{project.name}

+
+ + + +
); } diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/projects/project-clients.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/projects/project-clients.tsx new file mode 100644 index 00000000..e202ece9 --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/projects/project-clients.tsx @@ -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 ( + + + Clients + + + + ({ + ...item, + project: omit(['clients'], item), + })) as unknown as IServiceClientWithProject[], + isFetching: false, + isLoading: false, + }} + /> + + + ); +} diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/page.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/page.tsx index f4019257..2f2acb5e 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/page.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/page.tsx @@ -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 (
- +
+ + +
-

Select project

{projects.map((item) => ( diff --git a/apps/dashboard/src/components/clients/table.tsx b/apps/dashboard/src/components/clients/table.tsx deleted file mode 100644 index a4daaad3..00000000 --- a/apps/dashboard/src/components/clients/table.tsx +++ /dev/null @@ -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[] = [ - { - accessorKey: 'name', - header: 'Name', - cell: ({ row }) => { - return ( -
-
{row.original.name}
-
- {row.original.project?.name ?? 'No project'} -
-
- ); - }, - }, - { - accessorKey: 'id', - header: 'Client ID', - }, - { - accessorKey: 'cors', - header: 'Cors', - }, - { - accessorKey: 'secret', - header: 'Secret', - cell: (info) => - info.getValue() ? ( -
Hidden
- ) : ( - 'None' - ), - }, - { - accessorKey: 'createdAt', - header: 'Created at', - cell({ row }) { - const date = row.original.createdAt; - return formatDate(date); - }, - }, - { - id: ACTIONS, - header: 'Actions', - cell: ({ row }) => , - }, -]; diff --git a/apps/dashboard/src/components/clients/table/columns.tsx b/apps/dashboard/src/components/clients/table/columns.tsx new file mode 100644 index 00000000..fc3b70c8 --- /dev/null +++ b/apps/dashboard/src/components/clients/table/columns.tsx @@ -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[] = [ + { + accessorKey: 'name', + header: 'Name', + cell: ({ row }) => { + return
{row.original.name}
; + }, + }, + { + accessorKey: 'id', + header: 'Client ID', + cell: ({ row }) =>
{row.original.id}
, + }, + // { + // accessorKey: 'secret', + // header: 'Secret', + // cell: (info) => + //
+ + // }, + { + accessorKey: 'createdAt', + header: 'Created at', + cell({ row }) { + const date = row.original.createdAt; + return ( +
{isToday(date) ? formatTime(date) : formatDateTime(date)}
+ ); + }, + }, + { + id: ACTIONS, + header: 'Actions', + cell: ({ row }) => , + }, + ]; + + return columns; +} diff --git a/apps/dashboard/src/components/clients/table/index.tsx b/apps/dashboard/src/components/clients/table/index.tsx new file mode 100644 index 00000000..e92c1d9e --- /dev/null +++ b/apps/dashboard/src/components/clients/table/index.tsx @@ -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; + cursor: number; + setCursor: Dispatch>; +}; + +export const ClientsTable = ({ query, ...props }: Props) => { + const columns = useColumns(); + const { data, isFetching, isLoading } = query; + + if (isLoading) { + return ; + } + + if (data?.length === 0) { + return ( + +

Could not find any clients

+
+ {'cursor' in props && props.cursor !== 0 && ( + + )} + +
+
+ ); + } + + return ( + <> + + {'cursor' in props && ( + + )} + + ); +}; diff --git a/apps/dashboard/src/components/forms/tag-input.tsx b/apps/dashboard/src/components/forms/tag-input.tsx index 13bae539..0e042e13 100644 --- a/apps/dashboard/src/components/forms/tag-input.tsx +++ b/apps/dashboard/src/components/forms/tag-input.tsx @@ -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} />
); diff --git a/apps/dashboard/src/components/full-width-navbar.tsx b/apps/dashboard/src/components/full-width-navbar.tsx index b1c8269d..63a01d56 100644 --- a/apps/dashboard/src/components/full-width-navbar.tsx +++ b/apps/dashboard/src/components/full-width-navbar.tsx @@ -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 (
- + {children}
diff --git a/apps/dashboard/src/components/projects/project-card.tsx b/apps/dashboard/src/components/projects/project-card.tsx index 500e32fa..a47e992f 100644 --- a/apps/dashboard/src/components/projects/project-card.tsx +++ b/apps/dashboard/src/components/projects/project-card.tsx @@ -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 // Should be solved: https://github.com/vercel/next.js/issues/61336 // But still get the error return ( - -
{name}
-
- - - -
-
- - - -
-
+ ); } diff --git a/apps/dashboard/src/components/settings-toggle.tsx b/apps/dashboard/src/components/settings-toggle.tsx index 37004e01..21ee635f 100644 --- a/apps/dashboard/src/components/settings-toggle.tsx +++ b/apps/dashboard/src/components/settings-toggle.tsx @@ -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 ( @@ -35,37 +39,47 @@ export default function SettingsToggle({ className }: Props) { - - - Create report - - - - - - - Settings - - Organization - - - Projects - - - Your profile - - - References - - - - Notifications - - - - Integrations - - + {projectId && ( + <> + + + Create report + + + + + + + Settings + + + Organization + + + + + Project & Clients + + + + Your profile + + + References + + + + Notifications + + + + + Integrations + + + + + )} Theme @@ -87,7 +101,14 @@ export default function SettingsToggle({ className }: Props) { - Logout + { + auth.signOut(); + }} + > + Logout + ); diff --git a/apps/dashboard/src/components/ui/checkbox.tsx b/apps/dashboard/src/components/ui/checkbox.tsx index 407f0582..29bcbcd9 100644 --- a/apps/dashboard/src/components/ui/checkbox.tsx +++ b/apps/dashboard/src/components/ui/checkbox.tsx @@ -52,7 +52,7 @@ const CheckboxInput = React.forwardRef< )} > -
{props.children}
+
{props.children}
)); CheckboxInput.displayName = 'CheckboxInput'; diff --git a/apps/dashboard/src/components/ui/combobox.tsx b/apps/dashboard/src/components/ui/combobox.tsx index da29f9b3..ec9eaa04 100644 --- a/apps/dashboard/src/components/ui/combobox.tsx +++ b/apps/dashboard/src/components/ui/combobox.tsx @@ -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({ )} - - - {searchable === true && ( - - )} - {typeof onCreate === 'function' && search ? ( - - - - ) : ( - Nothing selected - )} - { - if (search === '') return true; - return item.label.toLowerCase().includes(search.toLowerCase()); - })} - itemHeight={32} - itemKey="value" - className="min-w-60" - > - {(item) => ( - { - const value = find(currentValue)?.value ?? currentValue; - onChange(value as T); - setOpen(false); - }} - {...(item.disabled && { disabled: true })} - > - - {item.label} - + + + + {searchable === true && ( + )} - - - + {typeof onCreate === 'function' && search ? ( + + + + ) : ( + Nothing selected + )} + { + if (search === '') return true; + return item.label.toLowerCase().includes(search.toLowerCase()); + })} + itemHeight={32} + itemKey="value" + className="min-w-60" + > + {(item) => ( + { + const value = find(currentValue)?.value ?? currentValue; + onChange(value as T); + setOpen(false); + }} + {...(item.disabled && { disabled: true })} + > + + {item.label} + + )} + + + + ); } diff --git a/apps/dashboard/src/components/widget.tsx b/apps/dashboard/src/components/widget.tsx index 89511dab..06a018ae 100644 --- a/apps/dashboard/src/components/widget.tsx +++ b/apps/dashboard/src/components/widget.tsx @@ -9,7 +9,7 @@ export function WidgetHead({ children, className }: WidgetHeadProps) { return (
diff --git a/apps/dashboard/src/modals/AddClient.tsx b/apps/dashboard/src/modals/AddClient.tsx index 314c83a0..275215e5 100644 --- a/apps/dashboard/src/modals/AddClient.tsx +++ b/apps/dashboard/src/modals/AddClient.tsx @@ -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; -interface Props { - projectId: string; -} -export default function AddClient(props: Props) { +export default function AddClient() { const { organizationId, projectId } = useAppParams(); const router = useRouter(); const form = useForm({ 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 = (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)} > -
- { - return ( -
- - { - field.onChange(value); - }} - items={ - query.data?.map((item) => ({ - value: item.id, - label: item.name, - })) ?? [] - } - placeholder="Select a project" - /> -
- ); - }} - /> -
-
- - - ( - - 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(','), - ); - }} - /> - )} - /> - { - return ( - -
Enable cross domain support
-
- This will let you track users across multiple domains -
-
- ); - }} - /> -
-
-
-

+

{field.value === 'write' && 'Write: Is the default client type and is used for ingestion of data'} {field.value === 'read' && diff --git a/apps/dashboard/src/modals/AddProject.tsx b/apps/dashboard/src/modals/AddProject.tsx index af2be453..90b5fc6b 100644 --- a/apps/dashboard/src/modals/AddProject.tsx +++ b/apps/dashboard/src/modals/AddProject.tsx @@ -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; 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({ + const [hasDomain, setHasDomain] = useState(true); + const form = useForm({ 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 (

{ - mutation.mutate({ - ...values, - organizationId, - }); + onSubmit={form.handleSubmit(onSubmit, (errors) => { + console.log(errors); })} + className="col gap-4" > -
- + + +
+ +
+ + + + ( + + (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}`; + }), + ); + }} + /> + + )} + /> + { + return ( + +
Enable cross domain support
+
+ This will let you track users across multiple domains +
+
+ ); + }} + /> +
+ - - diff --git a/packages/db/prisma/migrations/20241205190155_extend_project_settings/migration.sql b/packages/db/prisma/migrations/20241205190155_extend_project_settings/migration.sql new file mode 100644 index 00000000..991e7e2e --- /dev/null +++ b/packages/db/prisma/migrations/20241205190155_extend_project_settings/migration.sql @@ -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 '[]'; diff --git a/packages/db/prisma/migrations/20241205191533_cors_as_array/migration.sql b/packages/db/prisma/migrations/20241205191533_cors_as_array/migration.sql new file mode 100644 index 00000000..163b1731 --- /dev/null +++ b/packages/db/prisma/migrations/20241205191533_cors_as_array/migration.sql @@ -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[]; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index dbbf2476..757d243d 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -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[] diff --git a/packages/db/src/clickhouse-client.ts b/packages/db/src/clickhouse-client.ts index b8083ee9..d22c90ea 100644 --- a/packages/db/src/clickhouse-client.ts +++ b/packages/db/src/clickhouse-client.ts @@ -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, }); diff --git a/packages/db/src/services/clients.service.ts b/packages/db/src/services/clients.service.ts index 65ea46b1..374102e9 100644 --- a/packages/db/src/services/clients.service.ts +++ b/packages/db/src/services/clients.service.ts @@ -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 { + return db.client.findUnique({ + where: { id }, + include: { + project: true, + }, + }); +} + +export const getClientByIdCached = cacheable(getClientById, 60 * 60 * 24); diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index d65f7ca9..d7f3d705 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -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[]; } } diff --git a/packages/trpc/src/routers/client.ts b/packages/trpc/src/routers/client.ts index 981342ad..b17ae46f 100644 --- a/packages/trpc/src/routers/client.ts +++ b/packages/trpc/src/routers/client.ts @@ -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 }); diff --git a/packages/trpc/src/routers/project.ts b/packages/trpc/src/routers/project.ts index a0c3477c..87d9e7e4 100644 --- a/packages/trpc/src/routers/project.ts +++ b/packages/trpc/src/routers/project.ts @@ -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: [], }, }); }), diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index 406a550e..6c9845a7 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -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; + +export const zProjectFilterProfileId = z.object({ + type: z.literal('profile_id'), + profileId: z.string(), +}); +export type IProjectFilterProfileId = z.infer; + +export const zProjectFilters = z.discriminatedUnion('type', [ + zProjectFilterIp, + zProjectFilterProfileId, +]); +export type IProjectFilters = z.infer; + +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;