feature(dashboard): actually add password production to overviews

This commit is contained in:
Carl-Gerhard Lindesvärd
2025-01-21 15:19:50 +00:00
parent 08bfff94cf
commit f216a7b9c5
7 changed files with 192 additions and 39 deletions

View File

@@ -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 <ShareEnterPassword shareId={share.id} />;
}
}
return (
<div>
{searchParams.header !== '0' && (
<div className="flex items-center justify-between border-b border-border bg-white p-4">
<div className="leading-none">
<span className="mb-4">{organization?.name}</span>
<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.project?.name}</h1>
</div>
<a
href="https://openpanel.dev?utm_source=openpanel.dev&utm_medium=share"
className="flex flex-col items-end text-lg font-medium"
className="col gap-1 items-end"
>
<span className="text-sm">POWERED BY</span>
<span>openpanel.dev</span>
<span className="text-xs">POWERED BY</span>
<span className="text-xl font-medium">openpanel.dev</span>
</a>
</div>
)}

View File

@@ -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<ISignInShare>({
resolver: zodResolver(zSignInShare),
defaultValues: {
password: '',
shareId,
},
});
const onSubmit = form.handleSubmit((data) => {
mutation.mutate({
password: data.password,
shareId,
});
});
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-lg text-muted-foreground leading-normal">
Please enter correct password to access this overview
</div>
</div>
<form onSubmit={onSubmit} className="col gap-4 mt-6">
<Input
{...form.register('password')}
type="password"
placeholder="Enter your password"
size="large"
/>
<Button type="submit">Get access</Button>
</form>
</div>
<div className="p-6 text-xs max-w-sm col gap-0.5">
<p>
Powered by{' '}
<a href="https://openpanel.dev" className="font-medium">
OpenPanel.dev
</a>
</p>
<p>
The best web and product analytics tool out there (our honest
opinion).
</p>
<p>
<a href="https://dashboard.openpanel.dev/onboarding">
Try it for free today!
</a>
</p>
</div>
</div>
);
}

View File

@@ -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 (
<div className="col gap-2">
<div className="flex gap-2 items-center justify-between">
<Label className="mb-0">{label}</Label>
<Switch checked={active} onCheckedChange={onActiveChange} />
</div>
<AnimateHeight open={active}>{children}</AnimateHeight>
</div>
);
}

View File

@@ -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<IForm>({
const { register, handleSubmit } = useForm<IForm>({
resolver: zodResolver(validator),
defaultValues: {
public: true,
@@ -47,46 +48,27 @@ export default function ShareOverviewModal() {
});
return (
<ModalContent>
<ModalHeader title="Overview access" />
<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
className="flex flex-col gap-4"
onSubmit={handleSubmit((values) => {
mutation.mutate(values);
})}
>
<Controller
name="public"
control={control}
render={({ field }) => (
<label
htmlFor="public"
className="mb-4 flex items-center gap-2 font-medium leading-none"
>
<Checkbox
id="public"
ref={field.ref}
onBlur={field.onBlur}
defaultChecked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked);
}}
/>
Make it public!
</label>
)}
/>
<InputWithLabel
label="Password"
placeholder="Make your overview accessable with password"
<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}>
Update
Make it public
</Button>
</ButtonContainer>
</form>

View File

@@ -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;
}),
});

View File

@@ -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,
},
});
}),

View File

@@ -334,3 +334,9 @@ export const zRequestResetPassword = z.object({
email: z.string().email(),
});
export type IRequestResetPassword = z.infer<typeof zRequestResetPassword>;
export const zSignInShare = z.object({
password: z.string().min(1),
shareId: z.string().min(1),
});
export type ISignInShare = z.infer<typeof zSignInShare>;