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 OverviewTopSources from '@/components/overview/overview-top-sources';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
|
import { ShareEnterPassword } from '@/components/auth/share-enter-password';
|
||||||
import { OverviewRange } from '@/components/overview/overview-range';
|
import { OverviewRange } from '@/components/overview/overview-range';
|
||||||
import { getOrganizationBySlug, getShareOverviewById } from '@openpanel/db';
|
import { getOrganizationBySlug, getShareOverviewById } from '@openpanel/db';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: {
|
params: {
|
||||||
@@ -35,20 +37,27 @@ export default async function Page({
|
|||||||
const projectId = share.projectId;
|
const projectId = share.projectId;
|
||||||
const organization = await getOrganizationBySlug(share.organizationId);
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{searchParams.header !== '0' && (
|
{searchParams.header !== '0' && (
|
||||||
<div className="flex items-center justify-between border-b border-border bg-white p-4">
|
<div className="flex items-center justify-between border-b border-border bg-background p-4">
|
||||||
<div className="leading-none">
|
<div className="col gap-1">
|
||||||
<span className="mb-4">{organization?.name}</span>
|
<span className="text-sm">{organization?.name}</span>
|
||||||
<h1 className="text-xl font-medium">{share.project?.name}</h1>
|
<h1 className="text-xl font-medium">{share.project?.name}</h1>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
href="https://openpanel.dev?utm_source=openpanel.dev&utm_medium=share"
|
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 className="text-xs">POWERED BY</span>
|
||||||
<span>openpanel.dev</span>
|
<span className="text-xl font-medium">openpanel.dev</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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 { api, handleError } from '@/trpc/client';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useRouter } from 'next/navigation';
|
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 { toast } from 'sonner';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
import { zShareOverview } from '@openpanel/validation';
|
import { zShareOverview } from '@openpanel/validation';
|
||||||
|
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import { popModal } from '.';
|
import { popModal } from '.';
|
||||||
import { ModalContent, ModalHeader } from './Modal/Container';
|
import { ModalContent, ModalHeader } from './Modal/Container';
|
||||||
|
|
||||||
@@ -23,7 +24,7 @@ export default function ShareOverviewModal() {
|
|||||||
const { projectId, organizationId } = useAppParams();
|
const { projectId, organizationId } = useAppParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { register, handleSubmit, control } = useForm<IForm>({
|
const { register, handleSubmit } = useForm<IForm>({
|
||||||
resolver: zodResolver(validator),
|
resolver: zodResolver(validator),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
public: true,
|
public: true,
|
||||||
@@ -47,46 +48,27 @@ export default function ShareOverviewModal() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalContent>
|
<ModalContent className="max-w-md">
|
||||||
<ModalHeader title="Overview access" />
|
<ModalHeader
|
||||||
|
title="Dashboard public availability"
|
||||||
|
text="You can choose if you want to add a password to make it a bit more private."
|
||||||
|
/>
|
||||||
<form
|
<form
|
||||||
className="flex flex-col gap-4"
|
|
||||||
onSubmit={handleSubmit((values) => {
|
onSubmit={handleSubmit((values) => {
|
||||||
mutation.mutate(values);
|
mutation.mutate(values);
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Controller
|
<Input
|
||||||
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"
|
|
||||||
{...register('password')}
|
{...register('password')}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
size="large"
|
||||||
/>
|
/>
|
||||||
<ButtonContainer>
|
<ButtonContainer>
|
||||||
<Button type="button" variant="outline" onClick={() => popModal()}>
|
<Button type="button" variant="outline" onClick={() => popModal()}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" loading={mutation.isLoading}>
|
<Button type="submit" loading={mutation.isLoading}>
|
||||||
Update
|
Make it public
|
||||||
</Button>
|
</Button>
|
||||||
</ButtonContainer>
|
</ButtonContainer>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Arctic,
|
Arctic,
|
||||||
|
COOKIE_OPTIONS,
|
||||||
createSession,
|
createSession,
|
||||||
deleteSessionTokenCookie,
|
deleteSessionTokenCookie,
|
||||||
generateSessionToken,
|
generateSessionToken,
|
||||||
@@ -11,12 +12,18 @@ import {
|
|||||||
verifyPasswordHash,
|
verifyPasswordHash,
|
||||||
} from '@openpanel/auth';
|
} from '@openpanel/auth';
|
||||||
import { generateSecureId } from '@openpanel/common/server/id';
|
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 { sendEmail } from '@openpanel/email';
|
||||||
import {
|
import {
|
||||||
zRequestResetPassword,
|
zRequestResetPassword,
|
||||||
zResetPassword,
|
zResetPassword,
|
||||||
zSignInEmail,
|
zSignInEmail,
|
||||||
|
zSignInShare,
|
||||||
zSignUpEmail,
|
zSignUpEmail,
|
||||||
} from '@openpanel/validation';
|
} from '@openpanel/validation';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
@@ -272,4 +279,42 @@ export const authRouter = createTRPCRouter({
|
|||||||
session: publicProcedure.query(async ({ ctx }) => {
|
session: publicProcedure.query(async ({ ctx }) => {
|
||||||
return ctx.session;
|
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 { db } from '@openpanel/db';
|
||||||
import { zShareOverview } from '@openpanel/validation';
|
import { zShareOverview } from '@openpanel/validation';
|
||||||
|
|
||||||
|
import { hashPassword } from '@openpanel/auth';
|
||||||
import { createTRPCRouter, protectedProcedure } from '../trpc';
|
import { createTRPCRouter, protectedProcedure } from '../trpc';
|
||||||
|
|
||||||
const uid = new ShortUniqueId({ length: 6 });
|
const uid = new ShortUniqueId({ length: 6 });
|
||||||
@@ -11,6 +12,10 @@ export const shareRouter = createTRPCRouter({
|
|||||||
shareOverview: protectedProcedure
|
shareOverview: protectedProcedure
|
||||||
.input(zShareOverview)
|
.input(zShareOverview)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
|
const passwordHash = input.password
|
||||||
|
? await hashPassword(input.password)
|
||||||
|
: null;
|
||||||
|
|
||||||
return db.shareOverview.upsert({
|
return db.shareOverview.upsert({
|
||||||
where: {
|
where: {
|
||||||
projectId: input.projectId,
|
projectId: input.projectId,
|
||||||
@@ -20,11 +25,11 @@ export const shareRouter = createTRPCRouter({
|
|||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
projectId: input.projectId,
|
projectId: input.projectId,
|
||||||
public: input.public,
|
public: input.public,
|
||||||
password: input.password || null,
|
password: passwordHash,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
public: input.public,
|
public: input.public,
|
||||||
password: input.password,
|
password: passwordHash,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -334,3 +334,9 @@ export const zRequestResetPassword = z.object({
|
|||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
});
|
});
|
||||||
export type IRequestResetPassword = z.infer<typeof zRequestResetPassword>;
|
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