diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/dashboards/[dashboardId]/list-reports.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/dashboards/[dashboardId]/list-reports.tsx index b6d322d6..2b8793c2 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/dashboards/[dashboardId]/list-reports.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/dashboards/[dashboardId]/list-reports.tsx @@ -30,17 +30,27 @@ import { getDefaultIntervalByRange, timeWindows, } from '@openpanel/constants'; -import type { IServiceDashboard, getReportsByDashboardId } from '@openpanel/db'; +import type { + IServiceDashboard, + ShareDashboard, + getReportsByDashboardId, +} from '@openpanel/db'; import { OverviewInterval } from '@/components/overview/overview-interval'; import { OverviewRange } from '@/components/overview/overview-range'; +import { DashboardShare } from '@/components/dashboard/dashboard-share'; interface ListReportsProps { reports: Awaited>; dashboard: IServiceDashboard; + shareDashboard: ShareDashboard | null; } -export function ListReports({ reports, dashboard }: ListReportsProps) { +export function ListReports({ + reports, + dashboard, + shareDashboard, +}: ListReportsProps) { const router = useRouter(); const params = useAppParams<{ dashboardId: string }>(); const { range, startDate, endDate, interval } = useOverviewOptions(); @@ -58,6 +68,7 @@ export function ListReports({ reports, dashboard }: ListReportsProps) {
+ + + + + {(!data || data.public === false) && ( + pushModal('ShareDashboardModal')}> + + Make public + + )} + {data?.public && ( + + + + View + + + )} + {data?.public && ( + { + mutation.mutate({ + ...data, + public: false, + password: null, + }); + }} + > + + Make private + + )} + + + + ); +} diff --git a/apps/dashboard/src/modals/ShareDashboardModal.tsx b/apps/dashboard/src/modals/ShareDashboardModal.tsx new file mode 100644 index 00000000..b5b81de7 --- /dev/null +++ b/apps/dashboard/src/modals/ShareDashboardModal.tsx @@ -0,0 +1,68 @@ +import { ButtonContainer } from '@/components/button-container'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { useAppParams } from '@/hooks/useAppParams'; +import { api, handleError } from '@/trpc/client'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { zShareDashboard } from '@openpanel/validation'; +import { useRouter } from 'next/navigation'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import type { z } from 'zod'; +import { popModal } from '.'; +import { ModalContent, ModalHeader } from './Modal/Container'; + +type IForm = z.infer; + +export default function ShareDashboardModal() { + const params = useAppParams<{ dashboardId: string }>(); + const { organizationId, dashboardId } = params; + const router = useRouter(); + + const { register, handleSubmit } = useForm({ + resolver: zodResolver(zShareDashboard), + defaultValues: { + public: true, + password: '', + dashboardId, + organizationId, + }, + }); + + const mutation = api.share.createDashboard.useMutation({ + onError: handleError, + onSuccess(res) { + router.refresh(); + toast('Success', { + description: `Your dashboard is now ${ + res.public ? 'public' : 'private' + }`, + }); + popModal(); + }, + }); + + return ( + + + mutation.mutate(values))}> + + + + + + + + ); +} diff --git a/apps/dashboard/src/modals/index.tsx b/apps/dashboard/src/modals/index.tsx index 1cc2b4b5..28b4df99 100644 --- a/apps/dashboard/src/modals/index.tsx +++ b/apps/dashboard/src/modals/index.tsx @@ -62,6 +62,9 @@ const modals = { ShareOverviewModal: dynamic(() => import('./ShareOverviewModal'), { loading: Loading, }), + ShareDashboardModal: dynamic(() => import('./ShareDashboardModal'), { + loading: Loading, + }), AddReference: dynamic(() => import('./AddReference'), { loading: Loading, }), diff --git a/packages/db/prisma/migrations/20251029161715_add_share_dashboard/migration.sql b/packages/db/prisma/migrations/20251029161715_add_share_dashboard/migration.sql new file mode 100644 index 00000000..25fcbe45 --- /dev/null +++ b/packages/db/prisma/migrations/20251029161715_add_share_dashboard/migration.sql @@ -0,0 +1,22 @@ +-- CreateTable +CREATE TABLE "shares_dashboards" ( + "id" TEXT NOT NULL, + "dashboardId" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "public" BOOLEAN NOT NULL DEFAULT false, + "password" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateIndex +CREATE UNIQUE INDEX "shares_dashboards_id_key" ON "shares_dashboards"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "shares_dashboards_dashboardId_key" ON "shares_dashboards"("dashboardId"); + +-- AddForeignKey +ALTER TABLE "shares_dashboards" ADD CONSTRAINT "shares_dashboards_dashboardId_fkey" FOREIGN KEY ("dashboardId") REFERENCES "dashboards"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "shares_dashboards" ADD CONSTRAINT "shares_dashboards_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 7eede070..6ea7e6fb 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -52,6 +52,7 @@ model Organization { Client Client[] Dashboard Dashboard[] ShareOverview ShareOverview[] + ShareDashboard ShareDashboard[] integrations Integration[] invites Invite[] timezone String? @@ -274,13 +275,14 @@ enum ChartType { } model Dashboard { - id String @id @default(dbgenerated("gen_random_uuid()")) + id String @id @default(dbgenerated("gen_random_uuid()")) name String - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) organizationId String projectId String - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) reports Report[] + shareDashboard ShareDashboard? createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt @@ -337,6 +339,20 @@ model ShareOverview { @@map("shares") } +model ShareDashboard { + id String @unique + dashboardId String @unique + dashboard Dashboard @relation(fields: [dashboardId], references: [id], onDelete: Cascade) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + organizationId String + public Boolean @default(false) + password String? + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + @@map("shares_dashboards") +} + model EventMeta { id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid name String diff --git a/packages/db/src/services/share.service.ts b/packages/db/src/services/share.service.ts index a1149f52..1b330a20 100644 --- a/packages/db/src/services/share.service.ts +++ b/packages/db/src/services/share.service.ts @@ -18,3 +18,22 @@ export function getShareByProjectId(projectId: string) { }, }); } + +export function getShareDashboardById(id: string) { + return db.shareDashboard.findFirst({ + where: { + id, + }, + include: { + dashboard: true, + }, + }); +} + +export function getShareByDashboardId(dashboardId: string) { + return db.shareDashboard.findUnique({ + where: { + dashboardId, + }, + }); +} diff --git a/packages/trpc/src/routers/auth.ts b/packages/trpc/src/routers/auth.ts index 3aecfc00..9687fa4b 100644 --- a/packages/trpc/src/routers/auth.ts +++ b/packages/trpc/src/routers/auth.ts @@ -15,6 +15,7 @@ import { generateSecureId } from '@openpanel/common/server/id'; import { connectUserToOrganization, db, + getShareDashboardById, getShareOverviewById, getUserAccount, } from '@openpanel/db'; @@ -363,6 +364,44 @@ export const authRouter = createTRPCRouter({ ...COOKIE_OPTIONS, }); + return true; + }), + + signInShareDashboard: publicProcedure + .use( + rateLimitMiddleware({ + max: 3, + windowMs: 30_000, + }), + ) + .input(zSignInShare) + .mutation(async ({ input, ctx }) => { + const { password, shareId } = input; + const share = await getShareDashboardById(shareId); + + if (!share) { + throw TRPCNotFoundError('Share not found'); + } + + if (!share.public) { + throw TRPCNotFoundError('Share is not public'); + } + + if (!share.password) { + throw TRPCNotFoundError('Share is not password protected'); + } + + const validPassword = await verifyPasswordHash(share.password, password); + + if (!validPassword) { + throw TRPCAccessError('Incorrect password'); + } + + ctx.setCookie(`shared-dashboard-${shareId}`, '1', { + maxAge: 60 * 60 * 24 * 7, + ...COOKIE_OPTIONS, + }); + return true; }), }); diff --git a/packages/trpc/src/routers/share.ts b/packages/trpc/src/routers/share.ts index 356c46ef..3e154245 100644 --- a/packages/trpc/src/routers/share.ts +++ b/packages/trpc/src/routers/share.ts @@ -1,7 +1,7 @@ import ShortUniqueId from 'short-unique-id'; import { db } from '@openpanel/db'; -import { zShareOverview } from '@openpanel/validation'; +import { zShareDashboard, zShareOverview } from '@openpanel/validation'; import { hashPassword } from '@openpanel/auth'; import { createTRPCRouter, protectedProcedure } from '../trpc'; @@ -33,4 +33,27 @@ export const shareRouter = createTRPCRouter({ }, }); }), + + createDashboard: protectedProcedure + .input(zShareDashboard) + .mutation(async ({ input }) => { + const passwordHash = input.password + ? await hashPassword(input.password) + : null; + + return db.shareDashboard.upsert({ + where: { dashboardId: input.dashboardId }, + create: { + id: uid.rnd(), + organizationId: input.organizationId, + dashboardId: input.dashboardId, + public: input.public, + password: passwordHash, + }, + update: { + public: input.public, + password: passwordHash, + }, + }); + }), }); diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index fd7f4a15..d3e20acd 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -181,6 +181,13 @@ export const zShareOverview = z.object({ public: z.boolean(), }); +export const zShareDashboard = z.object({ + organizationId: z.string(), + dashboardId: z.string(), + password: z.string().nullable(), + public: z.boolean(), +}); + export const zCreateReference = z.object({ title: z.string(), description: z.string().nullish(),