+
+
-
+
+
);
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 (
-
- );
- }
-);
-
-RadioGroup.displayName = 'RadioGroup';
-RadioGroupItem.displayName = 'RadioGroupItem';
-
-export { RadioGroup, RadioGroupItem };
+export { RadioGroup, RadioGroupItem }
diff --git a/apps/dashboard/src/components/ui/sheet.tsx b/apps/dashboard/src/components/ui/sheet.tsx
index e3b6b286..225ed9af 100644
--- a/apps/dashboard/src/components/ui/sheet.tsx
+++ b/apps/dashboard/src/components/ui/sheet.tsx
@@ -133,6 +133,14 @@ const SheetDescription = React.forwardRef<
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
+export function closeSheet() {
+ if (typeof document === 'undefined') return;
+ const element = document.querySelector('#close-sheet');
+ if (element instanceof HTMLElement) {
+ element.click();
+ }
+}
+
export {
Sheet,
SheetPortal,
diff --git a/apps/dashboard/src/components/ui/table.tsx b/apps/dashboard/src/components/ui/table.tsx
index e99dfa41..56d4727a 100644
--- a/apps/dashboard/src/components/ui/table.tsx
+++ b/apps/dashboard/src/components/ui/table.tsx
@@ -6,18 +6,14 @@ import { cn } from '@/utils/cn';
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes & {
- wrapper?: boolean;
overflow?: boolean;
}
->(({ className, wrapper, overflow = true, ...props }, ref) => (
+>(({ className, overflow = true, ...props }, ref) => (
@@ -79,7 +75,7 @@ const TableHead = React.forwardRef<
,
- React.ComponentPropsWithoutRef
->(({ className, sideOffset = 4, ...props }, ref) => (
-
-));
+ React.ComponentPropsWithoutRef & {
+ disabled?: boolean;
+ }
+>(({ className, sideOffset = 4, disabled, ...props }, ref) =>
+ disabled ? null : (
+
+ )
+);
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
diff --git a/apps/dashboard/src/middleware.ts b/apps/dashboard/src/middleware.ts
index a9af39af..1c966765 100644
--- a/apps/dashboard/src/middleware.ts
+++ b/apps/dashboard/src/middleware.ts
@@ -4,7 +4,7 @@ import { authMiddleware } from '@clerk/nextjs';
// Please edit this to allow other routes to be public as needed.
// See https://clerk.com/docs/references/nextjs/auth-middleware for more information about configuring your Middleware
export default authMiddleware({
- publicRoutes: ['/share/overview/:id', '/api/trpc(.*)'],
+ publicRoutes: ['/share/overview/:id', '/api/trpc(.*)', '/api/clerk/(.*)?'],
});
export const config = {
diff --git a/apps/dashboard/src/server/api/routers/organization.ts b/apps/dashboard/src/server/api/routers/organization.ts
index dc648f8b..8a816bc9 100644
--- a/apps/dashboard/src/server/api/routers/organization.ts
+++ b/apps/dashboard/src/server/api/routers/organization.ts
@@ -2,7 +2,7 @@ import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { clerkClient } from '@clerk/nextjs';
import { z } from 'zod';
-import { getOrganizationBySlug } from '@openpanel/db';
+import { db, getOrganizationBySlug } from '@openpanel/db';
import { zInviteUser } from '@openpanel/validation';
export const organizationRouter = createTRPCRouter({
@@ -30,6 +30,7 @@ export const organizationRouter = createTRPCRouter({
name: input.name,
});
}),
+
inviteUser: protectedProcedure
.input(zInviteUser)
.mutation(async ({ input, ctx }) => {
@@ -44,6 +45,50 @@ export const organizationRouter = createTRPCRouter({
emailAddress: input.email,
role: input.role,
inviterUserId: ctx.session.userId,
+ publicMetadata: {
+ access: input.access,
+ },
});
}),
+ revokeInvite: protectedProcedure
+ .input(
+ z.object({
+ organizationId: z.string(),
+ invitationId: z.string(),
+ })
+ )
+ .mutation(async ({ input, ctx }) => {
+ return clerkClient.organizations.revokeOrganizationInvitation({
+ organizationId: input.organizationId,
+ invitationId: input.invitationId,
+ requestingUserId: ctx.session.userId,
+ });
+ }),
+
+ updateMemberAccess: protectedProcedure
+ .input(
+ z.object({
+ userId: z.string(),
+ organizationSlug: z.string(),
+ access: z.array(z.string()),
+ })
+ )
+ .mutation(async ({ input }) => {
+ return db.$transaction([
+ db.projectAccess.deleteMany({
+ where: {
+ user_id: input.userId,
+ organization_slug: input.organizationSlug,
+ },
+ }),
+ db.projectAccess.createMany({
+ data: input.access.map((projectId) => ({
+ user_id: input.userId,
+ organization_slug: input.organizationSlug,
+ project_id: projectId,
+ level: 'read',
+ })),
+ }),
+ ]);
+ }),
});
diff --git a/packages/db/prisma/migrations/20240325221639_add_project_access/migration.sql b/packages/db/prisma/migrations/20240325221639_add_project_access/migration.sql
new file mode 100644
index 00000000..eb00e8af
--- /dev/null
+++ b/packages/db/prisma/migrations/20240325221639_add_project_access/migration.sql
@@ -0,0 +1,17 @@
+-- CreateEnum
+CREATE TYPE "AccessLevel" AS ENUM ('read', 'write', 'admin');
+
+-- CreateTable
+CREATE TABLE "project_access" (
+ "id" TEXT NOT NULL,
+ "project_id" TEXT NOT NULL,
+ "user_id" TEXT NOT NULL,
+ "level" "AccessLevel" NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "project_access_pkey" PRIMARY KEY ("id")
+);
+
+-- AddForeignKey
+ALTER TABLE "project_access" ADD CONSTRAINT "project_access_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
diff --git a/packages/db/prisma/migrations/20240325221913_add_uuid_project_access/migration.sql b/packages/db/prisma/migrations/20240325221913_add_uuid_project_access/migration.sql
new file mode 100644
index 00000000..4370f80b
--- /dev/null
+++ b/packages/db/prisma/migrations/20240325221913_add_uuid_project_access/migration.sql
@@ -0,0 +1,12 @@
+/*
+ Warnings:
+
+ - The primary key for the `project_access` table will be changed. If it partially fails, the table could be left without primary key constraint.
+ - The `id` column on the `project_access` table would be dropped and recreated. This will lead to data loss if there is data in the column.
+
+*/
+-- AlterTable
+ALTER TABLE "project_access" DROP CONSTRAINT "project_access_pkey",
+DROP COLUMN "id",
+ADD COLUMN "id" UUID NOT NULL DEFAULT gen_random_uuid(),
+ADD CONSTRAINT "project_access_pkey" PRIMARY KEY ("id");
diff --git a/packages/db/prisma/migrations/20240325222110_add_org_slug_to_project_access/migration.sql b/packages/db/prisma/migrations/20240325222110_add_org_slug_to_project_access/migration.sql
new file mode 100644
index 00000000..32d43810
--- /dev/null
+++ b/packages/db/prisma/migrations/20240325222110_add_org_slug_to_project_access/migration.sql
@@ -0,0 +1,8 @@
+/*
+ Warnings:
+
+ - Added the required column `organization_slug` to the `project_access` table without a default value. This is not possible if the table is not empty.
+
+*/
+-- AlterTable
+ALTER TABLE "project_access" ADD COLUMN "organization_slug" TEXT NOT NULL;
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index 1800d578..0adb4fc1 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -27,9 +27,30 @@ model Project {
EventMeta EventMeta[]
Reference Reference[]
+ access ProjectAccess[]
+
@@map("projects")
}
+enum AccessLevel {
+ read
+ write
+ admin
+}
+
+model ProjectAccess {
+ id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
+ project_id String
+ project Project @relation(fields: [project_id], references: [id])
+ organization_slug String
+ user_id String
+ level AccessLevel
+ createdAt DateTime @default(now())
+ updatedAt DateTime @default(now()) @updatedAt
+
+ @@map("project_access")
+}
+
model Event {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String
diff --git a/packages/db/src/services/organization.service.ts b/packages/db/src/services/organization.service.ts
index a4456d41..cb0821c9 100644
--- a/packages/db/src/services/organization.service.ts
+++ b/packages/db/src/services/organization.service.ts
@@ -2,21 +2,31 @@ import { auth, clerkClient } from '@clerk/nextjs';
import type {
Organization,
OrganizationInvitation,
+ OrganizationMembership,
} from '@clerk/nextjs/dist/types/server';
+import type { ProjectAccess } from '../prisma-client';
import { db } from '../prisma-client';
-export type IServiceOrganization = Awaited<
- ReturnType
->[number];
-
-export type IServiceInvites = Awaited>;
+export type IServiceOrganization = ReturnType;
+export type IServiceInvite = ReturnType;
+export type IServiceMember = ReturnType;
+export type IServiceProjectAccess = ReturnType;
export function transformOrganization(org: Organization) {
return {
id: org.id,
name: org.name,
- slug: org.slug,
+ slug: org.slug!,
+ };
+}
+
+export function transformAccess(access: ProjectAccess) {
+ return {
+ projectId: access.project_id,
+ userId: access.user_id,
+ level: access.level,
+ organizationSlug: access.organization_slug,
};
}
@@ -50,18 +60,77 @@ export async function getOrganizationByProjectId(projectId: string) {
export function transformInvite(invite: OrganizationInvitation) {
return {
id: invite.id,
+ organizationId: invite.organizationId,
email: invite.emailAddress,
role: invite.role,
status: invite.status,
createdAt: invite.createdAt,
updatedAt: invite.updatedAt,
+ publicMetadata: invite.publicMetadata,
};
}
-export async function getInvites(organizationId: string) {
+export async function getInvites(organizationSlug: string) {
+ const org = await getOrganizationBySlug(organizationSlug);
+ if (!org) return [];
return await clerkClient.organizations
.getOrganizationInvitationList({
- organizationId,
+ organizationId: org.id,
})
.then((invites) => invites.map(transformInvite));
}
+
+export function transformMember(
+ item: OrganizationMembership & {
+ access: IServiceProjectAccess[];
+ }
+) {
+ return {
+ memberId: item.id,
+ id: item.publicUserData?.userId,
+ name:
+ [item.publicUserData?.firstName, item.publicUserData?.lastName]
+ .filter(Boolean)
+ .join(' ') || 'Unknown',
+ role: item.role,
+ createdAt: item.createdAt,
+ updatedAt: item.updatedAt,
+ publicMetadata: item.publicMetadata,
+ organization: transformOrganization(item.organization),
+ access: item.access,
+ };
+}
+
+export async function getMembers(organizationSlug: string) {
+ const org = await getOrganizationBySlug(organizationSlug);
+ if (!org) return [];
+ const [members, access] = await Promise.all([
+ clerkClient.organizations.getOrganizationMembershipList({
+ organizationId: org.id,
+ }),
+ db.projectAccess.findMany({
+ where: {
+ organization_slug: organizationSlug,
+ },
+ }),
+ ]);
+
+ return members
+ .map((member) => {
+ const projectAccess = access.filter(
+ (item) => item.user_id === member.publicUserData?.userId
+ );
+ return {
+ ...member,
+ access: projectAccess.map(transformAccess),
+ };
+ })
+ .map(transformMember);
+}
+
+export async function getMember(organizationSlug: string, userId: string) {
+ const org = await getOrganizationBySlug(organizationSlug);
+ if (!org) return null;
+ const members = await getMembers(org.id);
+ return members.find((member) => member.id === userId) ?? null;
+}
diff --git a/packages/db/src/services/project.service.ts b/packages/db/src/services/project.service.ts
index 044cdc21..4c70831e 100644
--- a/packages/db/src/services/project.service.ts
+++ b/packages/db/src/services/project.service.ts
@@ -1,3 +1,6 @@
+import { auth } from '@clerk/nextjs';
+import { project } from 'ramda';
+
import type { Project } from '../prisma-client';
import { db } from '../prisma-client';
@@ -33,3 +36,31 @@ export async function getProjectsByOrganizationSlug(slug: string) {
return res.map(transformProject);
}
+
+export async function getCurrentProjects(slug: string) {
+ const session = auth();
+ if (!session.userId) {
+ return [];
+ }
+
+ const access = await db.projectAccess.findMany({
+ where: {
+ organization_slug: slug,
+ user_id: session.userId,
+ },
+ });
+
+ const res = await db.project.findMany({
+ where: {
+ organization_slug: slug,
+ },
+ });
+
+ if (access.length === 0) {
+ return res.map(transformProject);
+ }
+
+ return res
+ .filter((project) => access.some((a) => a.project_id === project.id))
+ .map(transformProject);
+}
diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts
index 7ca07272..01713bb6 100644
--- a/packages/validation/src/index.ts
+++ b/packages/validation/src/index.ts
@@ -80,7 +80,8 @@ export const zChartInput = z.object({
export const zInviteUser = z.object({
email: z.string().email(),
organizationSlug: z.string(),
- role: z.enum(['admin', 'org:member']),
+ role: z.enum(['org:admin', 'org:member']),
+ access: z.array(z.string()),
});
export const zShareOverview = z.object({
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 19ec4ac3..1f4feb2f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -168,6 +168,9 @@ importers:
'@radix-ui/react-progress':
specifier: ^1.0.3
version: 1.0.3(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0)
+ '@radix-ui/react-radio-group':
+ specifier: ^1.1.3
+ version: 1.1.3(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-scroll-area':
specifier: ^1.0.5
version: 1.0.5(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0)
@@ -5327,6 +5330,36 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
+ /@radix-ui/react-radio-group@1.1.3(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0):
+ resolution: {integrity: sha512-x+yELayyefNeKeTx4fjK6j99Fs6c4qKm3aY38G3swQVTN6xMpsrbigC0uHs2L//g8q4qR7qOcww8430jJmi2ag==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0
+ react-dom: ^16.8 || ^17.0 || ^18.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+ dependencies:
+ '@babel/runtime': 7.23.9
+ '@radix-ui/primitive': 1.0.1
+ '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.56)(react@18.2.0)
+ '@radix-ui/react-context': 1.0.1(@types/react@18.2.56)(react@18.2.0)
+ '@radix-ui/react-direction': 1.0.1(@types/react@18.2.56)(react@18.2.0)
+ '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0)
+ '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0)
+ '@radix-ui/react-roving-focus': 1.0.4(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0)
+ '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.56)(react@18.2.0)
+ '@radix-ui/react-use-previous': 1.0.1(@types/react@18.2.56)(react@18.2.0)
+ '@radix-ui/react-use-size': 1.0.1(@types/react@18.2.56)(react@18.2.0)
+ '@types/react': 18.2.56
+ '@types/react-dom': 18.2.19
+ react: 18.2.0
+ react-dom: 18.2.0(react@18.2.0)
+ dev: false
+
/@radix-ui/react-roving-focus@1.0.4(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-2mUg5Mgcu001VkGy+FfzZyzbmuUWzgWkj3rvv4yu+mLw03+mTzbxZHvfcGyFp2b8EkQeMkpRQ5FiA2Vr2O6TeQ==}
peerDependencies:
|