diff --git a/apps/dashboard/Dockerfile b/apps/dashboard/Dockerfile index eaea9a5c..348d35fb 100644 --- a/apps/dashboard/Dockerfile +++ b/apps/dashboard/Dockerfile @@ -30,12 +30,16 @@ ENV NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY ARG CLERK_SECRET_KEY ENV CLERK_SECRET_KEY=$CLERK_SECRET_KEY +ARG CLERK_SIGNING_SECRET +ENV CLERK_SIGNING_SECRET=$CLERK_SIGNING_SECRET + ARG NEXT_PUBLIC_SENTRY_DSN ENV NEXT_PUBLIC_SENTRY_DSN=$NEXT_PUBLIC_SENTRY_DSN ARG SENTRY_AUTH_TOKEN ENV SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN + ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 6bf527ab..5db03c8f 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -31,6 +31,7 @@ "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-progress": "^1.0.3", + "@radix-ui/react-radio-group": "^1.1.3", "@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-tabs": "^1.0.4", diff --git a/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/layout.tsx b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/layout.tsx index c9c01477..74f4844b 100644 --- a/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/layout.tsx +++ b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/layout.tsx @@ -1,7 +1,9 @@ +import { FullPageEmptyState } from '@/components/full-page-empty-state'; import { notFound } from 'next/navigation'; import { getCurrentOrganizations, + getCurrentProjects, getDashboardsByProjectId, getProjectsByOrganizationSlug, } from '@openpanel/db'; @@ -22,16 +24,30 @@ export default async function AppLayout({ }: AppLayoutProps) { const [organizations, projects, dashboards] = await Promise.all([ getCurrentOrganizations(), - getProjectsByOrganizationSlug(organizationId), + getCurrentProjects(organizationId), getDashboardsByProjectId(projectId), ]); if (!organizations.find((item) => item.slug === organizationId)) { - return notFound(); + return ( + + The organization you are looking for could not be found. + + ); } if (!projects.find((item) => item.id === projectId)) { - return notFound(); + return ( + + The project you are looking for could not be found. + + ); } return ( diff --git a/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/page-layout.tsx b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/page-layout.tsx index a2959441..7e382560 100644 --- a/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/page-layout.tsx +++ b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/page-layout.tsx @@ -1,4 +1,7 @@ -import { getProjectsByOrganizationSlug } from '@openpanel/db'; +import { + getCurrentProjects, + getProjectsByOrganizationSlug, +} from '@openpanel/db'; import LayoutProjectSelector from './layout-project-selector'; @@ -13,7 +16,7 @@ export default async function PageLayout({ title, organizationSlug, }: PageLayoutProps) { - const projects = await getProjectsByOrganizationSlug(organizationSlug); + const projects = await getCurrentProjects(organizationSlug); return ( <> diff --git a/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/settings/organization/invite-user.tsx b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/settings/organization/invite-user.tsx deleted file mode 100644 index bcc4b9e8..00000000 --- a/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/settings/organization/invite-user.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { api } from '@/app/_trpc/client'; -import { InputWithLabel } from '@/components/forms/input-with-label'; -import { Button } from '@/components/ui/button'; -import { useAppParams } from '@/hooks/useAppParams'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { SendIcon } from 'lucide-react'; -import { useRouter } from 'next/navigation'; -import { useForm } from 'react-hook-form'; -import { toast } from 'sonner'; -import type { z } from 'zod'; - -import { zInviteUser } from '@openpanel/validation'; - -type IForm = z.infer; - -export function InviteUser() { - const router = useRouter(); - const { organizationId: organizationSlug } = useAppParams(); - - const { register, handleSubmit, formState, reset } = useForm({ - resolver: zodResolver(zInviteUser), - defaultValues: { - organizationSlug, - email: '', - role: 'org:member', - }, - }); - - const mutation = api.organization.inviteUser.useMutation({ - onSuccess() { - toast('User invited!', { - description: 'The user has been invited to the organization.', - }); - reset(); - router.refresh(); - }, - }); - - return ( -
mutation.mutate(values))} - className="flex items-end gap-4" - > - - - - ); -} diff --git a/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/settings/organization/invited-users.tsx b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/settings/organization/invited-users.tsx deleted file mode 100644 index 33b8bfe6..00000000 --- a/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/settings/organization/invited-users.tsx +++ /dev/null @@ -1,65 +0,0 @@ -'use client'; - -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; -import { Widget, WidgetBody, WidgetHead } from '@/components/widget'; - -import type { IServiceInvites } from '@openpanel/db'; - -import { InviteUser } from './invite-user'; - -interface InvitedUsersProps { - invites: IServiceInvites; -} -export default function InvitedUsers({ invites }: InvitedUsersProps) { - return ( - - - Invites - - - - -
Invited users
- - - - Email - Role - Status - Created - - - - {invites.map((item) => { - return ( - - {item.email} - {item.role} - {item.status} - - {new Date(item.createdAt).toLocaleString()} - - - ); - })} - - {invites.length === 0 && ( - - - No invites - - - )} - -
-
-
- ); -} diff --git a/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/settings/organization/invites/create-invite.tsx b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/settings/organization/invites/create-invite.tsx new file mode 100644 index 00000000..428cc392 --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/settings/organization/invites/create-invite.tsx @@ -0,0 +1,141 @@ +import { api } from '@/app/_trpc/client'; +import { InputWithLabel } from '@/components/forms/input-with-label'; +import { Button } from '@/components/ui/button'; +import { ComboboxAdvanced } from '@/components/ui/combobox-advanced'; +import { Label } from '@/components/ui/label'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { + closeSheet, + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, + SheetTrigger, +} from '@/components/ui/sheet'; +import { useAppParams } from '@/hooks/useAppParams'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { PlusIcon, SendIcon } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { Controller, useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import type { z } from 'zod'; + +import type { IServiceProject } from '@openpanel/db'; +import { zInviteUser } from '@openpanel/validation'; + +type IForm = z.infer; + +interface Props { + projects: IServiceProject[]; +} + +export default function CreateInvite({ projects }: Props) { + const router = useRouter(); + const { organizationId: organizationSlug } = useAppParams(); + + const { register, handleSubmit, formState, reset, control } = useForm({ + resolver: zodResolver(zInviteUser), + defaultValues: { + organizationSlug, + access: [], + role: 'org:member', + }, + }); + + const mutation = api.organization.inviteUser.useMutation({ + onSuccess() { + toast('User invited!', { + description: 'The user has been invited to the organization.', + }); + reset(); + closeSheet(); + router.refresh(); + }, + }); + + return ( + + + + + + + Invite a user + + Invite users to your organization. They will recieve an email will + instructions. + + +
mutation.mutate(values))} + className="flex flex-col gap-8" + > + +
+ + ( + +
+ + +
+
+ + +
+
+ )} + /> +
+ ( +
+ + ({ + label: item.name, + value: item.id, + }))} + /> +

+ Leave empty to give access to all projects +

+
+ )} + /> + + + + +
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/settings/organization/invites/index.tsx b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/settings/organization/invites/index.tsx new file mode 100644 index 00000000..dfa64e75 --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/settings/organization/invites/index.tsx @@ -0,0 +1,18 @@ +import { getInvites, getProjectsByOrganizationSlug } from '@openpanel/db'; + +import Invites from './invites'; + +interface Props { + organizationSlug: string; +} + +const InvitesServer = async ({ organizationSlug }: Props) => { + const [invites, projects] = await Promise.all([ + getInvites(organizationSlug), + getProjectsByOrganizationSlug(organizationSlug), + ]); + + return ; +}; + +export default InvitesServer; diff --git a/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/settings/organization/invites/invites.tsx b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/settings/organization/invites/invites.tsx new file mode 100644 index 00000000..adf4537e --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/settings/organization/invites/invites.tsx @@ -0,0 +1,111 @@ +'use client'; + +import { Dot } from '@/components/dot'; +import { TooltipComplete } from '@/components/tooltip-complete'; +import { Badge } from '@/components/ui/badge'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Widget, WidgetHead } from '@/components/widget'; +import { cn } from '@/utils/cn'; + +import type { IServiceInvite, IServiceProject } from '@openpanel/db'; + +import CreateInvite from './create-invite'; + +interface Props { + invites: IServiceInvite[]; + projects: IServiceProject[]; +} + +const Invites = ({ invites, projects }: Props) => { + return ( + + + Invites + + + + + + Name + Role + Created + Status + Access + + + + {invites.map((item) => { + return ; + })} + +
+
+ ); +}; + +interface ItemProps extends IServiceInvite { + projects: IServiceProject[]; +} + +function Item({ + id, + email, + role, + createdAt, + projects, + publicMetadata, + status, +}: ItemProps) { + const access = (publicMetadata?.access ?? []) as string[]; + return ( + + {email} + {role} + + + {new Date(createdAt).toLocaleDateString()} + + + + + {status} + + + {access.map((id) => { + const project = projects.find((p) => p.id === id); + if (!project) { + return ( + + Unknown + + ); + } + return ( + + {project.name} + + ); + })} + {access.length === 0 && ( + All projects + )} + + + ); +} + +export default Invites; diff --git a/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/settings/organization/members/index.tsx b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/settings/organization/members/index.tsx new file mode 100644 index 00000000..89a424c3 --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/settings/organization/members/index.tsx @@ -0,0 +1,18 @@ +import { getMembers, getProjectsByOrganizationSlug } from '@openpanel/db'; + +import Members from './members'; + +interface Props { + organizationSlug: string; +} + +const MembersServer = async ({ organizationSlug }: Props) => { + const [members, projects] = await Promise.all([ + getMembers(organizationSlug), + getProjectsByOrganizationSlug(organizationSlug), + ]); + + return ; +}; + +export default MembersServer; diff --git a/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/settings/organization/members/members.tsx b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/settings/organization/members/members.tsx new file mode 100644 index 00000000..4fd1d6b5 --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/settings/organization/members/members.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { useState } from 'react'; +import { api } from '@/app/_trpc/client'; +import { ComboboxAdvanced } from '@/components/ui/combobox-advanced'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Widget, WidgetHead } from '@/components/widget'; + +import type { IServiceMember, IServiceProject } from '@openpanel/db'; + +interface Props { + members: IServiceMember[]; + projects: IServiceProject[]; +} + +const Members = ({ members, projects }: Props) => { + return ( + + + Members + + + + + Name + Role + Created + Access + + + + {members.map((item) => { + return ; + })} + +
+
+ ); +}; + +interface ItemProps extends IServiceMember { + projects: IServiceProject[]; +} + +function Item({ + id, + name, + role, + createdAt, + organization, + projects, + access: prevAccess, +}: ItemProps) { + const mutation = api.organization.updateMemberAccess.useMutation(); + const [access, setAccess] = useState( + prevAccess.map((item) => item.projectId) + ); + + return ( + + {name} + {role} + {new Date(createdAt).toLocaleString()} + + { + setAccess(newAccess); + mutation.mutate({ + userId: id!, + organizationSlug: organization.slug, + access: newAccess as string[], + }); + }} + items={projects.map((item) => ({ + label: item.name, + value: item.id, + }))} + /> + + + ); +} + +export default Members; diff --git a/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/settings/organization/page.tsx b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/settings/organization/page.tsx index 30652cf3..9ced455d 100644 --- a/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/settings/organization/page.tsx +++ b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/settings/organization/page.tsx @@ -1,11 +1,11 @@ import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout'; -import { clerkClient } from '@clerk/nextjs'; import { notFound } from 'next/navigation'; import { getInvites, getOrganizationBySlug } from '@openpanel/db'; import EditOrganization from './edit-organization'; -import InvitedUsers from './invited-users'; +import InvitesServer from './invites'; +import MembersServer from './members'; interface PageProps { params: { @@ -13,8 +13,10 @@ interface PageProps { }; } -export default async function Page({ params: { organizationId } }: PageProps) { - const organization = await getOrganizationBySlug(organizationId); +export default async function Page({ + params: { organizationId: organizationSlug }, +}: PageProps) { + const organization = await getOrganizationBySlug(organizationSlug); if (!organization) { return notFound(); @@ -23,10 +25,11 @@ export default async function Page({ params: { organizationId } }: PageProps) { const invites = await getInvites(organization.id); return ( - -
+ +
- + +
); diff --git a/apps/dashboard/src/app/(app)/[organizationId]/page.tsx b/apps/dashboard/src/app/(app)/[organizationId]/page.tsx index 47d95831..6675966a 100644 --- a/apps/dashboard/src/app/(app)/[organizationId]/page.tsx +++ b/apps/dashboard/src/app/(app)/[organizationId]/page.tsx @@ -3,6 +3,7 @@ import { ProjectCard } from '@/components/projects/project-card'; import { notFound, redirect } from 'next/navigation'; import { + getCurrentProjects, getOrganizationBySlug, getProjectsByOrganizationSlug, isWaitlistUserAccepted, @@ -19,7 +20,7 @@ interface PageProps { export default async function Page({ params: { organizationId } }: PageProps) { const [organization, projects] = await Promise.all([ getOrganizationBySlug(organizationId), - getProjectsByOrganizationSlug(organizationId), + getCurrentProjects(organizationId), ]); if (!organization) { diff --git a/apps/dashboard/src/app/api/clerk/webhook/route.ts b/apps/dashboard/src/app/api/clerk/webhook/route.ts new file mode 100644 index 00000000..7f4ab6ad --- /dev/null +++ b/apps/dashboard/src/app/api/clerk/webhook/route.ts @@ -0,0 +1,37 @@ +import type { WebhookEvent } from '@clerk/nextjs/server'; + +import { AccessLevel, db } from '@openpanel/db'; + +export async function POST(request: Request) { + const payload: WebhookEvent = await request.json(); + + if (payload.type === 'organizationMembership.created') { + const access = payload.data.public_metadata.access; + if (Array.isArray(access)) { + await db.projectAccess.createMany({ + data: access + .filter((a): a is string => typeof a === 'string') + .map((projectId) => ({ + organization_slug: payload.data.organization.slug!, + project_id: projectId, + user_id: payload.data.public_user_data.user_id, + level: AccessLevel.read, + })), + }); + } + } + if (payload.type === 'organizationMembership.deleted') { + await db.projectAccess.deleteMany({ + where: { + organization_slug: payload.data.organization.slug!, + user_id: payload.data.public_user_data.user_id, + }, + }); + } + + return Response.json({ message: 'Webhook received!' }); +} + +export async function GET() { + return Response.json({ message: 'Hello World!' }); +} diff --git a/apps/dashboard/src/components/dot.tsx b/apps/dashboard/src/components/dot.tsx new file mode 100644 index 00000000..6dd67aec --- /dev/null +++ b/apps/dashboard/src/components/dot.tsx @@ -0,0 +1,46 @@ +import { cn } from '@/utils/cn'; + +interface DotProps { + className?: string; + size?: number; + animated?: boolean; +} + +function filterCn(filter: string[], className: string | undefined) { + const split: string[] = className?.split(' ') || []; + return split + .filter((item) => !filter.some((filterItem) => item.startsWith(filterItem))) + .join(' '); +} + +export function Dot({ className, size = 8, animated }: DotProps) { + const style = { + width: size, + height: size, + }; + return ( +
+
+
+
+ ); +} diff --git a/apps/dashboard/src/components/full-page-empty-state.tsx b/apps/dashboard/src/components/full-page-empty-state.tsx index 092e9582..6c74dfbc 100644 --- a/apps/dashboard/src/components/full-page-empty-state.tsx +++ b/apps/dashboard/src/components/full-page-empty-state.tsx @@ -1,3 +1,4 @@ +import { cn } from '@/utils/cn'; import { BoxSelectIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; @@ -5,15 +6,17 @@ interface FullPageEmptyStateProps { icon?: LucideIcon; title: string; children: React.ReactNode; + className?: string; } export function FullPageEmptyState({ icon: Icon = BoxSelectIcon, title, children, + className, }: FullPageEmptyStateProps) { return ( -
+
diff --git a/apps/dashboard/src/components/report/sidebar/filters/FilterItem.tsx b/apps/dashboard/src/components/report/sidebar/filters/FilterItem.tsx index fb37473d..816bf370 100644 --- a/apps/dashboard/src/components/report/sidebar/filters/FilterItem.tsx +++ b/apps/dashboard/src/components/report/sidebar/filters/FilterItem.tsx @@ -120,11 +120,7 @@ export function FilterItem({ filter, event }: FilterProps) { items={valuesCombobox} value={filter.value} className="flex-1" - onChange={(setFn) => { - changeFilterValue( - typeof setFn === 'function' ? setFn(filter.value) : setFn - ); - }} + onChange={changeFilterValue} placeholder="Select..." />
diff --git a/apps/dashboard/src/components/tooltip-complete.tsx b/apps/dashboard/src/components/tooltip-complete.tsx new file mode 100644 index 00000000..20022a8c --- /dev/null +++ b/apps/dashboard/src/components/tooltip-complete.tsx @@ -0,0 +1,26 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'; + +interface TooltipCompleteProps { + children: React.ReactNode | string; + content: React.ReactNode | string; + disabled?: boolean; + side?: 'top' | 'right' | 'bottom' | 'left'; +} + +export function TooltipComplete({ + children, + disabled, + content, + side, +}: TooltipCompleteProps) { + return ( + + + {children} + + + {content} + + + ); +} diff --git a/apps/dashboard/src/components/ui/combobox-advanced.tsx b/apps/dashboard/src/components/ui/combobox-advanced.tsx index 91251087..667bf144 100644 --- a/apps/dashboard/src/components/ui/combobox-advanced.tsx +++ b/apps/dashboard/src/components/ui/combobox-advanced.tsx @@ -19,7 +19,7 @@ type IItem = Record<'value' | 'label', IValue>; interface ComboboxAdvancedProps { value: IValue[]; - onChange: React.Dispatch>; + onChange: (value: IValue[]) => void; items: IItem[]; placeholder: string; className?: string; @@ -57,12 +57,11 @@ export function ComboboxAdvanced({ }} onSelect={() => { setInputValue(''); - onChange((prev) => { - if (prev.includes(item.value)) { - return prev.filter((s) => s !== item.value); - } - return [...prev, item.value]; - }); + onChange( + value.includes(item.value) + ? value.filter((s) => s !== item.value) + : [...value, item.value] + ); }} className={'cursor-pointer flex items-center gap-2'} > diff --git a/apps/dashboard/src/components/ui/radio-group.tsx b/apps/dashboard/src/components/ui/radio-group.tsx index fb251a99..357aee18 100644 --- a/apps/dashboard/src/components/ui/radio-group.tsx +++ b/apps/dashboard/src/components/ui/radio-group.tsx @@ -1,47 +1,42 @@ -'use client'; +import * as React from "react" +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" +import { Circle } from "lucide-react" -import * as React from 'react'; -import { cn } from '@/utils/cn'; +import { cn } from "@/utils/cn" -export type RadioGroupProps = React.InputHTMLAttributes; -export type RadioGroupItemProps = - React.InputHTMLAttributes & { - active?: boolean; - }; +const RadioGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + ) +}) +RadioGroup.displayName = RadioGroupPrimitive.Root.displayName -const RadioGroup = React.forwardRef( - ({ className, type, ...props }, ref) => { - return ( -
- ); - } -); +const RadioGroupItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + + + + + ) +}) +RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName -const RadioGroupItem = React.forwardRef( - ({ className, active, ...props }, ref) => { - return ( -