-
Notifications
You must be signed in to change notification settings - Fork 250
feat: add Dashboard public sharing functionality
#223
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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> | ||
| ); | ||
| } |
| 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> | ||
| ); | ||
| } |
| 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" | ||
| /> | ||
| <ButtonContainer> | ||
| <Button type="button" variant="outline" onClick={() => popModal()}> | ||
| Cancel | ||
| </Button> | ||
| <Button type="submit" loading={mutation.isLoading}> | ||
| Make it public | ||
| </Button> | ||
| </ButtonContainer> | ||
| </form> | ||
| </ModalContent> | ||
| ); | ||
| } | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Critical: Missing PRIMARY KEY constraint on the The migration creates a UNIQUE INDEX on This issue will be resolved by fixing the schema in 🤖 Prompt for AI Agents |
||
|
|
||
| -- 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; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
🤖 Prompt for AI Agents