feature(dashboard): actually add password production to overviews
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
78
apps/dashboard/src/components/auth/share-enter-password.tsx
Normal file
78
apps/dashboard/src/components/auth/share-enter-password.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
apps/dashboard/src/components/ui/input-with-toggle.tsx
Normal file
28
apps/dashboard/src/components/ui/input-with-toggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user