diff --git a/apps/dashboard/src/app/(public)/share/overview/[id]/page.tsx b/apps/dashboard/src/app/(public)/share/overview/[id]/page.tsx index ad32969a..0fbdcfaf 100644 --- a/apps/dashboard/src/app/(public)/share/overview/[id]/page.tsx +++ b/apps/dashboard/src/app/(public)/share/overview/[id]/page.tsx @@ -9,8 +9,10 @@ import OverviewTopPages from '@/components/overview/overview-top-pages'; import OverviewTopSources from '@/components/overview/overview-top-sources'; import { notFound } from 'next/navigation'; +import { ShareEnterPassword } from '@/components/auth/share-enter-password'; import { OverviewRange } from '@/components/overview/overview-range'; import { getOrganizationBySlug, getShareOverviewById } from '@openpanel/db'; +import { cookies } from 'next/headers'; interface PageProps { params: { @@ -35,20 +37,27 @@ export default async function Page({ const projectId = share.projectId; const organization = await getOrganizationBySlug(share.organizationId); + if (share.password) { + const cookie = cookies().get(`shared-overview-${share.id}`)?.value; + if (!cookie) { + return ; + } + } + return (
{searchParams.header !== '0' && ( -
-
- {organization?.name} +
+
+ {organization?.name}

{share.project?.name}

- POWERED BY - openpanel.dev + POWERED BY + openpanel.dev
)} diff --git a/apps/dashboard/src/components/auth/share-enter-password.tsx b/apps/dashboard/src/components/auth/share-enter-password.tsx new file mode 100644 index 00000000..ee6083b4 --- /dev/null +++ b/apps/dashboard/src/components/auth/share-enter-password.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { ModalHeader } from '@/modals/Modal/Container'; +import { api } from '@/trpc/client'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { type ISignInShare, zSignInShare } from '@openpanel/validation'; +import { useRouter } from 'next/navigation'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { LogoSquare } from '../logo'; +import { Button } from '../ui/button'; +import { Input } from '../ui/input'; + +export function ShareEnterPassword({ shareId }: { shareId: string }) { + const router = useRouter(); + const mutation = api.auth.signInShare.useMutation({ + onSuccess() { + router.refresh(); + }, + onError() { + toast.error('Incorrect password'); + }, + }); + const form = useForm({ + resolver: zodResolver(zSignInShare), + defaultValues: { + password: '', + shareId, + }, + }); + + const onSubmit = form.handleSubmit((data) => { + mutation.mutate({ + password: data.password, + shareId, + }); + }); + + return ( +
+
+
+ +
Overview is locked
+
+ Please enter correct password to access this overview +
+
+
+ + +
+
+
+

+ Powered by{' '} + + OpenPanel.dev + +

+

+ The best web and product analytics tool out there (our honest + opinion). +

+

+ + Try it for free today! + +

+
+
+ ); +} diff --git a/apps/dashboard/src/components/ui/input-with-toggle.tsx b/apps/dashboard/src/components/ui/input-with-toggle.tsx new file mode 100644 index 00000000..666ca5b2 --- /dev/null +++ b/apps/dashboard/src/components/ui/input-with-toggle.tsx @@ -0,0 +1,28 @@ +import type { Dispatch, SetStateAction } from 'react'; +import AnimateHeight from '../animate-height'; +import { Label } from './label'; +import { Switch } from './switch'; + +type Props = { + active: boolean; + onActiveChange: (newValue: boolean) => void; + label: string; + children: React.ReactNode; +}; + +export function InputWithToggle({ + active, + onActiveChange, + label, + children, +}: Props) { + return ( +
+
+ + +
+ {children} +
+ ); +} diff --git a/apps/dashboard/src/modals/ShareOverviewModal.tsx b/apps/dashboard/src/modals/ShareOverviewModal.tsx index 2efb20a6..656eb5d4 100644 --- a/apps/dashboard/src/modals/ShareOverviewModal.tsx +++ b/apps/dashboard/src/modals/ShareOverviewModal.tsx @@ -6,12 +6,13 @@ import { useAppParams } from '@/hooks/useAppParams'; import { api, handleError } from '@/trpc/client'; import { zodResolver } from '@hookform/resolvers/zod'; import { useRouter } from 'next/navigation'; -import { Controller, useForm } from 'react-hook-form'; +import { Controller, useForm, useWatch } from 'react-hook-form'; import { toast } from 'sonner'; import type { z } from 'zod'; import { zShareOverview } from '@openpanel/validation'; +import { Input } from '@/components/ui/input'; import { popModal } from '.'; import { ModalContent, ModalHeader } from './Modal/Container'; @@ -23,7 +24,7 @@ export default function ShareOverviewModal() { const { projectId, organizationId } = useAppParams(); const router = useRouter(); - const { register, handleSubmit, control } = useForm({ + const { register, handleSubmit } = useForm({ resolver: zodResolver(validator), defaultValues: { public: true, @@ -47,46 +48,27 @@ export default function ShareOverviewModal() { }); return ( - - + +
{ mutation.mutate(values); })} > - ( - - )} - /> - diff --git a/packages/trpc/src/routers/auth.ts b/packages/trpc/src/routers/auth.ts index 009a463c..bb855b70 100644 --- a/packages/trpc/src/routers/auth.ts +++ b/packages/trpc/src/routers/auth.ts @@ -1,5 +1,6 @@ import { Arctic, + COOKIE_OPTIONS, createSession, deleteSessionTokenCookie, generateSessionToken, @@ -11,12 +12,18 @@ import { verifyPasswordHash, } from '@openpanel/auth'; import { generateSecureId } from '@openpanel/common/server/id'; -import { connectUserToOrganization, db, getUserAccount } from '@openpanel/db'; +import { + connectUserToOrganization, + db, + getShareOverviewById, + getUserAccount, +} from '@openpanel/db'; import { sendEmail } from '@openpanel/email'; import { zRequestResetPassword, zResetPassword, zSignInEmail, + zSignInShare, zSignUpEmail, } from '@openpanel/validation'; import * as bcrypt from 'bcrypt'; @@ -272,4 +279,42 @@ export const authRouter = createTRPCRouter({ session: publicProcedure.query(async ({ ctx }) => { return ctx.session; }), + + signInShare: publicProcedure + .use( + rateLimitMiddleware({ + max: 3, + windowMs: 30_000, + }), + ) + .input(zSignInShare) + .mutation(async ({ input, ctx }) => { + const { password, shareId } = input; + const share = await getShareOverviewById(input.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-overview-${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 c339a256..356c46ef 100644 --- a/packages/trpc/src/routers/share.ts +++ b/packages/trpc/src/routers/share.ts @@ -3,6 +3,7 @@ import ShortUniqueId from 'short-unique-id'; import { db } from '@openpanel/db'; import { zShareOverview } from '@openpanel/validation'; +import { hashPassword } from '@openpanel/auth'; import { createTRPCRouter, protectedProcedure } from '../trpc'; const uid = new ShortUniqueId({ length: 6 }); @@ -11,6 +12,10 @@ export const shareRouter = createTRPCRouter({ shareOverview: protectedProcedure .input(zShareOverview) .mutation(async ({ input }) => { + const passwordHash = input.password + ? await hashPassword(input.password) + : null; + return db.shareOverview.upsert({ where: { projectId: input.projectId, @@ -20,11 +25,11 @@ export const shareRouter = createTRPCRouter({ organizationId: input.organizationId, projectId: input.projectId, public: input.public, - password: input.password || null, + password: passwordHash, }, update: { public: input.public, - password: input.password, + password: passwordHash, }, }); }), diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index ea0d7e26..7103154b 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -334,3 +334,9 @@ export const zRequestResetPassword = z.object({ email: z.string().email(), }); export type IRequestResetPassword = z.infer; + +export const zSignInShare = z.object({ + password: z.string().min(1), + shareId: z.string().min(1), +}); +export type ISignInShare = z.infer;