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 index bf9969ad..31d5c4dd 100644 --- 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 @@ -108,12 +108,11 @@ export default function EditProjectDetails({ project }: Props) { control={form.control} render={({ field }) => ( ( - + ; - -export default function AddProject() { - const { organizationId } = useAppParams(); - 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 ( - - -
- - -
- - -
- - - - ( - - (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/modals/add-project.tsx b/apps/dashboard/src/modals/add-project.tsx new file mode 100644 index 00000000..eb44dc2a --- /dev/null +++ b/apps/dashboard/src/modals/add-project.tsx @@ -0,0 +1,222 @@ +'use client'; + +import AnimateHeight from '@/components/animate-height'; +import { ButtonContainer } from '@/components/button-container'; +import { CreateClientSuccess } from '@/components/clients/create-client-success'; +import { CheckboxItem } from '@/components/forms/checkbox-item'; +import { InputWithLabel, WithLabel } from '@/components/forms/input-with-label'; +import TagInput from '@/components/forms/tag-input'; +import { Button } from '@/components/ui/button'; +import { useAppParams } from '@/hooks/useAppParams'; +import { api, handleError } from '@/trpc/client'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { zOnboardingProject } from '@openpanel/validation'; +import { + MonitorIcon, + SaveIcon, + ServerIcon, + SmartphoneIcon, +} from 'lucide-react'; +import { useEffect } from 'react'; +import { Controller, useForm, useWatch } from 'react-hook-form'; +import { toast } from 'sonner'; +import type { z } from 'zod'; +import { popModal } from '.'; +import { ModalContent, ModalHeader } from './Modal/Container'; + +const validator = zOnboardingProject; +type IForm = z.infer; + +export default function AddProject() { + const { organizationId } = useAppParams(); + const form = useForm({ + resolver: zodResolver(validator), + defaultValues: { + organizationId, + timezone: '', // Not used + project: '', + domain: '', + cors: [], + website: false, + app: false, + backend: false, + }, + }); + const mutation = api.project.create.useMutation({ + onError: handleError, + onSuccess: () => { + toast.success('Project created'); + }, + }); + + const onSubmit = (values: IForm) => { + mutation.mutate(values); + }; + + const isWebsite = useWatch({ + name: 'website', + control: form.control, + }); + + const isApp = useWatch({ + name: 'app', + control: form.control, + }); + + const isBackend = useWatch({ + name: 'backend', + control: form.control, + }); + + useEffect(() => { + if (!isWebsite) { + form.setValue('domain', null); + form.setValue('cors', []); + } + }, [isWebsite, form]); + + useEffect(() => { + form.clearErrors(); + }, [isWebsite, isApp, isBackend]); + + return ( + + {mutation.isSuccess ? ( + <> + + {mutation.data.client && ( + + )} + + + + + ) : ( + <> + +
+ + +
+ ( + + +
+ { + const value = e.target.value.trim(); + if ( + value.includes('.') && + form.getValues().cors.length === 0 && + !form.formState.errors.domain + ) { + form.setValue('cors', [value]); + } + }} + /> + + ( + + + tag === '*' + ? 'Accept events from any 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}`; + }), + ); + }} + /> + + )} + /> +
+
+
+ )} + /> + ( + + )} + /> + ( + + )} + /> +
+ + + + + + + )} +
+ ); +} diff --git a/apps/dashboard/src/modals/index.tsx b/apps/dashboard/src/modals/index.tsx index f8dc4388..1cc2b4b5 100644 --- a/apps/dashboard/src/modals/index.tsx +++ b/apps/dashboard/src/modals/index.tsx @@ -38,7 +38,7 @@ const modals = { EditClient: dynamic(() => import('./EditClient'), { loading: Loading, }), - AddProject: dynamic(() => import('./AddProject'), { + AddProject: dynamic(() => import('./add-project'), { loading: Loading, }), AddClient: dynamic(() => import('./AddClient'), { diff --git a/packages/trpc/src/routers/project.ts b/packages/trpc/src/routers/project.ts index 10c49a1c..31fbce96 100644 --- a/packages/trpc/src/routers/project.ts +++ b/packages/trpc/src/routers/project.ts @@ -1,6 +1,10 @@ import { z } from 'zod'; +import crypto from 'node:crypto'; +import { stripTrailingSlash } from '@openpanel/common'; +import { hashPassword } from '@openpanel/common/server'; import { + type Prisma, db, getClientById, getClientByIdCached, @@ -8,12 +12,10 @@ import { getProjectByIdCached, getProjectsByOrganizationId, } from '@openpanel/db'; - -import { stripTrailingSlash } from '@openpanel/common'; -import { zProject } from '@openpanel/validation'; +import { zOnboardingProject, zProject } from '@openpanel/validation'; import { addDays, addHours } from 'date-fns'; import { getProjectAccess } from '../access'; -import { TRPCAccessError } from '../errors'; +import { TRPCAccessError, TRPCBadRequestError } from '../errors'; import { createTRPCRouter, protectedProcedure } from '../trpc'; export const projectRouter = createTRPCRouter({ @@ -81,25 +83,50 @@ export const projectRouter = createTRPCRouter({ return res; }), create: protectedProcedure - .input( - zProject.omit({ id: true }).merge( - z.object({ - organizationId: z.string(), - }), - ), - ) + .input(zOnboardingProject) .mutation(async ({ input }) => { - return db.project.create({ + if (!input.organizationId) { + throw TRPCBadRequestError('Organization is required'); + } + + const secret = `sec_${crypto.randomBytes(10).toString('hex')}`; + const data: Prisma.ClientCreateArgs['data'] = { + organizationId: input.organizationId, + name: 'First client', + type: 'write', + secret: await hashPassword(secret), + }; + const project = await db.project.create({ data: { - id: await getId('project', input.name), + id: await getId('project', input.project), organizationId: input.organizationId, - name: input.name, + name: input.project, domain: input.domain, cors: input.cors, - crossDomain: input.crossDomain, + crossDomain: false, filters: [], + clients: { + create: data, + }, + }, + include: { + clients: { + select: { + id: true, + }, + }, }, }); + + return { + ...project, + client: project.clients[0] + ? { + id: project.clients[0].id, + secret, + } + : null, + }; }), delete: protectedProcedure .input(