Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof getReportsByDashboardId>>;
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();
Expand All @@ -58,6 +68,7 @@ export function ListReports({ reports, dashboard }: ListReportsProps) {
<div className="flex items-center justify-end gap-2">
<OverviewRange />
<OverviewInterval />
<DashboardShare data={shareDashboard} />
<Button
icon={PlusIcon}
onClick={() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { Padding } from '@/components/ui/padding';
import { notFound } from 'next/navigation';

import { getDashboardById, getReportsByDashboardId } from '@openpanel/db';
import {
getDashboardById,
getReportsByDashboardId,
getShareByDashboardId,
} from '@openpanel/db';

import { ListReports } from './list-reports';

Expand All @@ -15,9 +19,10 @@ interface PageProps {
export default async function Page({
params: { projectId, dashboardId },
}: PageProps) {
const [dashboard, reports] = await Promise.all([
const [dashboard, reports, shareDashboard] = await Promise.all([
getDashboardById(dashboardId, projectId),
getReportsByDashboardId(dashboardId),
getShareByDashboardId(dashboardId),
]);

if (!dashboard) {
Expand All @@ -26,7 +31,11 @@ export default async function Page({

return (
<Padding>
<ListReports reports={reports} dashboard={dashboard} />
<ListReports
reports={reports}
dashboard={dashboard}
shareDashboard={shareDashboard}
/>
</Padding>
);
}
78 changes: 78 additions & 0 deletions apps/dashboard/src/app/(public)/share/dashboard/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { ShareEnterPassword } from '@/components/auth/share-enter-password';
import { ReportChart } from '@/components/report-chart';
import {
getOrganizationById,
getReportsByDashboardId,
getShareDashboardById,
} from '@openpanel/db';
import { cookies } from 'next/headers';
import { notFound } from 'next/navigation';

interface PageProps {
params: {
id: string;
};
searchParams: {
header: string;
};
}

export default async function Page({
params: { id },
searchParams,
}: PageProps) {
const share = await getShareDashboardById(id);
if (!share) {
return notFound();
}
if (!share.public) {
return notFound();
}
const dashboardId = share.dashboardId;
const organization = await getOrganizationById(share.organizationId);

if (share.password) {
const cookie = cookies().get(`shared-dashboard-${share.id}`)?.value;
if (!cookie) {
return <ShareEnterPassword shareId={share.id} type="dashboard" />;
}
}

const reports = await getReportsByDashboardId(dashboardId);

return (
<div>
{searchParams.header !== '0' && (
<div className="flex items-center justify-between border-b border-border bg-background p-4">
<div className="col gap-1">
<span className="text-sm">{organization?.name}</span>
<h1 className="text-xl font-medium">{share.dashboard?.name}</h1>
</div>
<a
href="https://openpanel.dev?utm_source=openpanel.dev&utm_medium=share"
className="col gap-1 items-end"
>
<span className="text-xs">POWERED BY</span>
<span className="text-xl font-medium">openpanel.dev</span>
</a>
</div>
)}
<div className="p-4">
{reports.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
No reports in this dashboard
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{reports.map((report) => (
<div key={report.id} className="card p-4">
<div className="font-medium mb-4">{report.name}</div>
<ReportChart report={report} />
</div>
))}
</div>
)}
</div>
</div>
);
}
45 changes: 34 additions & 11 deletions apps/dashboard/src/components/auth/share-enter-password.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,33 @@ import { LogoSquare } from '../logo';
import { Button } from '../ui/button';
import { Input } from '../ui/input';

export function ShareEnterPassword({ shareId }: { shareId: string }) {
export function ShareEnterPassword({
shareId,
type = 'overview',
}: {
shareId: string;
type?: 'overview' | 'dashboard';
}) {
const router = useRouter();
const mutation = api.auth.signInShare.useMutation({
onSuccess() {
router.refresh();
},
onError() {
toast.error('Incorrect password');
},
});
const mutation =
type === 'dashboard'
? api.auth.signInShareDashboard.useMutation({
onSuccess() {
router.refresh();
},
onError() {
toast.error('Incorrect password');
},
})
: api.auth.signInShare.useMutation({
onSuccess() {
router.refresh();
},
onError() {
toast.error('Incorrect password');
},
});

const form = useForm<ISignInShare>({
resolver: zodResolver(zSignInShare),
defaultValues: {
Expand All @@ -36,14 +53,20 @@ export function ShareEnterPassword({ shareId }: { shareId: string }) {
});
});

const title = type === 'dashboard' ? 'Dashboard is locked' : 'Overview is locked';
const description =
type === 'dashboard'
? 'Please enter correct password to access this dashboard'
: 'Please enter correct password to access this overview';

return (
<div className="center-center h-screen w-screen p-4 col">
<div className="bg-background p-6 rounded-lg max-w-md w-full text-left">
<div className="col mt-1 flex-1 gap-2">
<LogoSquare className="size-12 mb-4" />
<div className="text-xl font-semibold">Overview is locked</div>
<div className="text-xl font-semibold">{title}</div>
<div className="text-lg text-muted-foreground leading-normal">
Please enter correct password to access this overview
{description}
</div>
</div>
<form onSubmit={onSubmit} className="col gap-4 mt-6">
Expand Down
74 changes: 74 additions & 0 deletions apps/dashboard/src/components/dashboard/dashboard-share.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
'use client';

import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { pushModal } from '@/modals';
import { api } from '@/trpc/client';
import { EyeIcon, Globe2Icon, LockIcon } from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';

import type { ShareDashboard } from '@openpanel/db';

interface DashboardShareProps {
data: ShareDashboard | null;
}

export function DashboardShare({ data }: DashboardShareProps) {
const router = useRouter();
const mutation = api.share.createDashboard.useMutation({
onSuccess() {
router.refresh();
},
});

return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button icon={data?.public ? Globe2Icon : LockIcon} responsive>
{data?.public ? 'Public' : 'Private'}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
{(!data || data.public === false) && (
<DropdownMenuItem onClick={() => pushModal('ShareDashboardModal')}>
<Globe2Icon size={16} className="mr-2" />
Make public
</DropdownMenuItem>
)}
{data?.public && (
<DropdownMenuItem asChild>
<Link
href={`${process.env.NEXT_PUBLIC_DASHBOARD_URL}/share/dashboard/${data.id}`}
>
<EyeIcon size={16} className="mr-2" />
View
</Link>
</DropdownMenuItem>
)}
{data?.public && (
<DropdownMenuItem
onClick={() => {
mutation.mutate({
...data,
public: false,
password: null,
});
}}
>
<LockIcon size={16} className="mr-2" />
Make private
</DropdownMenuItem>
)}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
68 changes: 68 additions & 0 deletions apps/dashboard/src/modals/ShareDashboardModal.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof zShareDashboard>;

export default function ShareDashboardModal() {
const params = useAppParams<{ dashboardId: string }>();
const { organizationId, dashboardId } = params;
const router = useRouter();

const { register, handleSubmit } = useForm<IForm>({
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 (
<ModalContent className="max-w-md">
<ModalHeader
title="Dashboard public availability"
text="You can choose if you want to add a password to make it a bit more private."
/>
<form onSubmit={handleSubmit((values) => mutation.mutate(values))}>
<Input
{...register('password')}
placeholder="Enter your password"
size="large"
/>
Comment on lines +52 to +56
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add password masking to the input field.

The password input field is missing the type="password" attribute, which means the password will be displayed as plain text instead of being masked. This is a security and user experience issue.

Apply this diff to mask the password input:

        <Input
          {...register('password')}
+         type="password"
          placeholder="Enter your password"
          size="large"
        />
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<Input
{...register('password')}
placeholder="Enter your password"
size="large"
/>
<Input
{...register('password')}
type="password"
placeholder="Enter your password"
size="large"
/>
🤖 Prompt for AI Agents
In apps/dashboard/src/modals/ShareDashboardModal.tsx around lines 52 to 56, the
password Input is missing masking; update the Input props to include
type="password" (and optionally autoComplete="current-password") so the value is
rendered as a masked password while keeping the existing
{...register('password')} and other props unchanged.

<ButtonContainer>
<Button type="button" variant="outline" onClick={() => popModal()}>
Cancel
</Button>
<Button type="submit" loading={mutation.isLoading}>
Make it public
</Button>
</ButtonContainer>
</form>
</ModalContent>
);
}
3 changes: 3 additions & 0 deletions apps/dashboard/src/modals/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ const modals = {
ShareOverviewModal: dynamic(() => import('./ShareOverviewModal'), {
loading: Loading,
}),
ShareDashboardModal: dynamic(() => import('./ShareDashboardModal'), {
loading: Loading,
}),
AddReference: dynamic(() => import('./AddReference'), {
loading: Loading,
}),
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
Comment on lines +1 to +16
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: Missing PRIMARY KEY constraint on the id column.

The migration creates a UNIQUE INDEX on id but does not define it as the PRIMARY KEY. This stems from the missing @id decorator in the Prisma schema.

This issue will be resolved by fixing the schema in packages/db/prisma/schema.prisma (see comment on that file) and regenerating the migration.

🤖 Prompt for AI Agents
In
packages/db/prisma/migrations/20251029161715_add_share_dashboard/migration.sql
around lines 1-16 the "shares_dashboards" table creates a UNIQUE INDEX on "id"
but does not declare "id" as the PRIMARY KEY; update the Prisma schema
(packages/db/prisma/schema.prisma) to mark the id field with @id and then
regenerate the migration so the SQL includes a PRIMARY KEY constraint for "id"
(or if you must edit this migration directly, add a PRIMARY KEY constraint to
the "id" column and remove the incorrect separate unique index).


-- 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;
Loading