feat: document share (#235)
* feat: started document share * feat: add document share schema * feat: prepare schema for backend * feat: document share create trpc route * feat: hook form with trpc create route * feat: pass documentId to share document modal * feat: render document share for public * feat: add request access form for email and publicId to document share * feat: generate link using publicId * feat: layouting page for document analytics * feat: use publicId for document share url instead of document url * feat: replace hook form with server action for request access form * feat: reorganize files to support the navigation and sub-nav flow * feat: add missing index * chore: cleaning up document pages --------- Co-authored-by: Puru D <puru@dahal.me>
This commit is contained in:
committed by
GitHub
co-authored by
Puru D
parent
56ff8ac518
commit
80b8daeb8e
Generated
+908
-914
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -23,7 +23,7 @@
|
||||
"deploy:fly": "fly deploy"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/plots": "^2.1.15",
|
||||
"@ark-ui/react": "^2.2.3",
|
||||
"@aws-sdk/client-s3": "^3.525.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.540.0",
|
||||
"@blocknote/react": "^0.12.2",
|
||||
@@ -43,6 +43,7 @@
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-separator": "^1.0.3",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-toast": "^1.1.5",
|
||||
"@radix-ui/react-toolbar": "^1.0.4",
|
||||
@@ -66,6 +67,7 @@
|
||||
"cmdk": "^1.0.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"html-to-image": "^1.11.11",
|
||||
"input-otp": "^1.1.0",
|
||||
"jsx-email": "^1.7.2",
|
||||
"lodash-es": "^4.17.21",
|
||||
"nanoid": "^5.0.4",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "DocumentShare" (
|
||||
"id" TEXT NOT NULL,
|
||||
"link" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"recipients" JSONB,
|
||||
"emailProtected" BOOLEAN NOT NULL DEFAULT false,
|
||||
"documentId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "DocumentShare_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- The `recipients` column on the `DocumentShare` table would be dropped and recreated. This will lead to data loss if there is data in the column.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "DocumentShare" DROP COLUMN "recipients",
|
||||
ADD COLUMN "recipients" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `expiresAt` on the `DocumentShare` table. All the data in the column will be lost.
|
||||
- Added the required column `linkExpiresAt` to the `DocumentShare` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "DocumentShare" DROP COLUMN "expiresAt",
|
||||
ADD COLUMN "linkExpiresAt" TIMESTAMP(3) NOT NULL;
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `publicId` to the `DocumentShare` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "DocumentShare" ADD COLUMN "publicId" TEXT NOT NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DocumentShare_documentId_idx" ON "DocumentShare"("documentId");
|
||||
@@ -322,6 +322,8 @@ model Document {
|
||||
convertibleNoteId String?
|
||||
convertibleNote ConvertibleNote? @relation(fields: [convertibleNoteId], references: [id], onDelete: SetNull)
|
||||
|
||||
documentShares DocumentShare[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -335,6 +337,25 @@ model Document {
|
||||
@@index([convertibleNoteId])
|
||||
}
|
||||
|
||||
model DocumentShare {
|
||||
id String @id @default(cuid())
|
||||
link String
|
||||
publicId String
|
||||
|
||||
linkExpiresAt DateTime
|
||||
recipients String[] @default([])
|
||||
|
||||
emailProtected Boolean @default(false)
|
||||
|
||||
documentId String
|
||||
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([documentId])
|
||||
}
|
||||
|
||||
enum FieldTypes {
|
||||
TEXT
|
||||
RADIO
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
const DocumentDetailPage = async () => {
|
||||
return <div>document detail page</div>;
|
||||
};
|
||||
|
||||
export default DocumentDetailPage;
|
||||
@@ -13,6 +13,7 @@ const DocSign = async () => {
|
||||
return (
|
||||
<PageLayout
|
||||
title="eSign documents"
|
||||
description="Upload, sign and send documents for electronic signatures."
|
||||
action={<EsignDocumentUpload companyPublicId={user.companyPublicId} />}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
const DocumentAnalyticsPage = async () => {
|
||||
return <div>Document Analytics Page</div>;
|
||||
};
|
||||
|
||||
export default DocumentAnalyticsPage;
|
||||
+18
-20
@@ -1,13 +1,13 @@
|
||||
import DocumentsTable from "./table";
|
||||
import DocumentUploadModal from "./modal";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { PageLayout } from "@/components/dashboard/page-layout";
|
||||
import EmptyState from "@/components/shared/empty-state";
|
||||
import { RiUploadCloudLine, RiAddFill } from "@remixicon/react";
|
||||
import { api } from "@/trpc/server";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { withServerSession } from "@/server/auth";
|
||||
import { api } from "@/trpc/server";
|
||||
import { RiAddFill, RiUploadCloudLine } from "@remixicon/react";
|
||||
import { type Metadata } from "next";
|
||||
import DocumentUploadModal from "./modal";
|
||||
import DocumentsTable from "./table";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Documents",
|
||||
@@ -39,30 +39,28 @@ const DocumentsPage = async () => {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-3">
|
||||
<div className="flex items-center justify-between gap-y-3 ">
|
||||
<div className="gap-y-3">
|
||||
<h3 className="font-medium">Documents</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Templates, agreements, and other important documents
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<PageLayout
|
||||
title="Share documents"
|
||||
description="Share pitch decks, financials, and any other important documents."
|
||||
action={
|
||||
<DocumentUploadModal
|
||||
companyPublicId={session.user.companyPublicId}
|
||||
trigger={
|
||||
<Button>
|
||||
<RiAddFill className="mr-2 h-5 w-5" />
|
||||
Upload a document
|
||||
Document
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card className="mt-3">
|
||||
<div className="p-6">
|
||||
<DocumentsTable documents={documents} />
|
||||
<DocumentsTable
|
||||
companyPublicId={session.user.companyPublicId}
|
||||
documents={documents}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { dayjsExt } from "@/common/dayjs";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import FileIcon from "@/components/shared/file-icon";
|
||||
import { RiFileDownloadLine, RiMoreLine } from "@remixicon/react";
|
||||
import { getPresignedGetUrl } from "@/server/file-uploads";
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableRow,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import DocumentShareModal from "@/components/document/share/document-share-modal";
|
||||
import { type RouterOutputs } from "@/trpc/shared";
|
||||
|
||||
type DocumentType = RouterOutputs["document"]["getAll"];
|
||||
|
||||
type DocumentTableProps = {
|
||||
documents: DocumentType;
|
||||
companyPublicId: string;
|
||||
};
|
||||
|
||||
const DocumentsTable = ({ documents, companyPublicId }: DocumentTableProps) => {
|
||||
const router = useRouter();
|
||||
const openFileOnTab = async (key: string) => {
|
||||
const fileUrl = await getPresignedGetUrl(key);
|
||||
window.open(fileUrl.url, "_blank");
|
||||
};
|
||||
|
||||
const [openShareModal, setOpenShareModal] = useState(false);
|
||||
const [selectedDocumentId, setSelectedDocumentId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<Table className="">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
{/* <TableHead>Type</TableHead> */}
|
||||
<TableHead>Owner</TableHead>
|
||||
<TableHead>Uploaded</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{documents.map((document) => (
|
||||
<TableRow key={document.id}>
|
||||
<TableCell className="flex items-center ">
|
||||
<FileIcon type={document.bucket.mimeType} />
|
||||
<span className="flex">{document.name}</span>
|
||||
</TableCell>
|
||||
<TableCell>{document.uploader.user.name}</TableCell>
|
||||
<TableCell suppressHydrationWarning>
|
||||
{dayjsExt().to(document.createdAt)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={async () => {
|
||||
await openFileOnTab(document.bucket.key);
|
||||
}}
|
||||
className="cursor-pointer text-muted-foreground hover:text-primary/80"
|
||||
>
|
||||
<RiFileDownloadLine className="cursor-pointer text-muted-foreground hover:text-primary/80" />
|
||||
</button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<RiMoreLine className="cursor-pointer text-muted-foreground hover:text-primary/80" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel>Options</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (document) {
|
||||
setSelectedDocumentId(document.id);
|
||||
}
|
||||
setOpenShareModal(true);
|
||||
}}
|
||||
>
|
||||
Share document
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>E-sign document</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
router.push(
|
||||
`/${companyPublicId}/documents/${document.id}/analytics`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
Analytics
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
<DocumentShareModal
|
||||
title="Share document"
|
||||
subtitle="Create a link to share this document."
|
||||
open={openShareModal}
|
||||
setOpen={setOpenShareModal}
|
||||
documentId={selectedDocumentId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentsTable;
|
||||
@@ -1,72 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { dayjsExt } from "@/common/dayjs";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import FileIcon from "@/components/shared/file-icon";
|
||||
import { RiFileDownloadLine } from "@remixicon/react";
|
||||
import { getPresignedGetUrl } from "@/server/file-uploads";
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableRow,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
} from "@/components/ui/table";
|
||||
import { type RouterOutputs } from "@/trpc/shared";
|
||||
|
||||
type DocumentType = RouterOutputs["document"]["getAll"];
|
||||
|
||||
type DocumentTableProps = {
|
||||
documents: DocumentType;
|
||||
};
|
||||
|
||||
const DocumentsTable = ({ documents }: DocumentTableProps) => {
|
||||
const openFileOnTab = async (key: string) => {
|
||||
const fileUrl = await getPresignedGetUrl(key);
|
||||
window.open(fileUrl.url, "_blank");
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Table className="">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
{/* <TableHead>Type</TableHead> */}
|
||||
<TableHead>Owner</TableHead>
|
||||
<TableHead>Uploaded</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{documents.map((document) => (
|
||||
<TableRow key={document.id}>
|
||||
<TableCell className="flex items-center ">
|
||||
<FileIcon type={document.bucket.mimeType} />
|
||||
<span className="flex">{document.name}</span>
|
||||
</TableCell>
|
||||
<TableCell>{document.uploader.user.name}</TableCell>
|
||||
<TableCell suppressHydrationWarning>
|
||||
{dayjsExt().to(document.createdAt)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await openFileOnTab(document.bucket.key);
|
||||
}}
|
||||
className="cursor-pointer text-muted-foreground hover:text-primary/80"
|
||||
>
|
||||
<RiFileDownloadLine className="cursor-pointer text-muted-foreground hover:text-primary/80" />
|
||||
</button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentsTable;
|
||||
@@ -0,0 +1,51 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { db } from "@/server/db";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().min(1, "Email is required").email(),
|
||||
publicId: z.string().min(1, "Public Id is required"),
|
||||
});
|
||||
|
||||
export async function provideRequestAccess(_: unknown, formData: FormData) {
|
||||
const validatedFields = schema.safeParse({
|
||||
email: formData.get("email"),
|
||||
publicId: formData.get("publicId"),
|
||||
});
|
||||
|
||||
if (!validatedFields.success) {
|
||||
return {
|
||||
emailError: validatedFields.error.flatten().fieldErrors.email,
|
||||
};
|
||||
}
|
||||
|
||||
const { publicId } = validatedFields.data;
|
||||
|
||||
const documentShare = await db.documentShare.findFirst({
|
||||
where: {
|
||||
publicId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!documentShare) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
await db.documentShare.update({
|
||||
where: {
|
||||
id: documentShare.id,
|
||||
},
|
||||
data: {
|
||||
emailProtected: false,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath(`/document/${publicId}`);
|
||||
|
||||
return {
|
||||
emailError: "",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { PdfViewer } from "@/components/ui/pdf-viewer";
|
||||
|
||||
type DocumentSharePdfViewerProps = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
export const DocumentSharePdfViewer = ({
|
||||
url,
|
||||
}: DocumentSharePdfViewerProps) => {
|
||||
return (
|
||||
<PdfViewer onDocumentLoadSuccess={() => console.log("heer")} file={url} />
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { AccessRequestForm } from "@/components/document/share/access-request-form";
|
||||
import { DocumentOTPForm } from "@/components/document/share/document-otp-form";
|
||||
import { db } from "@/server/db";
|
||||
import { getPresignedGetUrl } from "@/server/file-uploads";
|
||||
import { notFound } from "next/navigation";
|
||||
import { DocumentSharePdfViewer } from "./document-share-pdf-viewer";
|
||||
|
||||
const DocumentSharePublicPage = async ({
|
||||
params: { publicId },
|
||||
}: {
|
||||
params: { publicId: string };
|
||||
}) => {
|
||||
const documentShare = await db.documentShare.findFirst({
|
||||
where: {
|
||||
publicId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!documentShare) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
if (documentShare.emailProtected) {
|
||||
return (
|
||||
<div className="grid h-screen w-full place-items-center bg-gray-100">
|
||||
<AccessRequestForm publicId={publicId} />
|
||||
<DocumentOTPForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const document = await db.document.findFirst({
|
||||
where: {
|
||||
id: documentShare.documentId,
|
||||
},
|
||||
select: {
|
||||
bucket: {
|
||||
select: {
|
||||
key: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const { url } = await getPresignedGetUrl(document.bucket.key);
|
||||
return (
|
||||
<div className="grid h-screen w-full bg-gray-100">
|
||||
<DocumentSharePdfViewer url={url} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentSharePublicPage;
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { provideRequestAccess } from "@/app/documents/share/[publicId]/actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@radix-ui/react-label";
|
||||
import { RiFileLockLine } from "@remixicon/react";
|
||||
import { useFormState, useFormStatus } from "react-dom";
|
||||
|
||||
export const AccessRequestForm = ({ publicId }: { publicId: string }) => {
|
||||
const [state, formAction] = useFormState(provideRequestAccess, {
|
||||
emailError: "",
|
||||
});
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
return (
|
||||
<Card className="flex w-full max-w-md flex-col items-center justify-center py-4">
|
||||
<CardHeader className="flex items-center justify-center">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-green-400">
|
||||
<RiFileLockLine className="h-12 w-12 text-neutral-800" />
|
||||
</div>
|
||||
<CardTitle>Request Document Access</CardTitle>
|
||||
|
||||
<CardDescription>This document is email protected.</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="w-full">
|
||||
<form
|
||||
action={formAction}
|
||||
className="flex flex-col items-center justify-center space-y-6"
|
||||
>
|
||||
<input type="hidden" name="publicId" value={publicId} />
|
||||
<div className="w-full">
|
||||
<Label htmlFor="email">Your Email</Label>
|
||||
<Input className="w-full" id="email" name="email" type="email" />
|
||||
<div>{state?.emailError}</div>
|
||||
</div>
|
||||
|
||||
<Button size="sm" className="w-full" type="submit" disabled={pending}>
|
||||
Submit
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { RiShieldCheckLine } from "@remixicon/react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
} from "@/components/ui/input-otp";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
const FormSchema = z.object({
|
||||
otp: z.string().min(6, {
|
||||
message: "Your one-time password must be 6 characters.",
|
||||
}),
|
||||
});
|
||||
|
||||
export function DocumentOTPForm() {
|
||||
const form = useForm<z.infer<typeof FormSchema>>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: {
|
||||
otp: "",
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(data: z.infer<typeof FormSchema>) {
|
||||
toast({
|
||||
title: "You submitted the following values:",
|
||||
description: (
|
||||
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
|
||||
<code className="text-white">{JSON.stringify(data, null, 2)}</code>
|
||||
</pre>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<Card className="flex w-full max-w-lg flex-col items-center justify-center py-4">
|
||||
<CardHeader className="flex items-center justify-center">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-green-400">
|
||||
<RiShieldCheckLine className="h-12 w-12 text-gray-600" />
|
||||
</div>
|
||||
|
||||
<CardTitle>Document Protected</CardTitle>
|
||||
<CardDescription>
|
||||
This document is protected. Please enter your one-time password.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col items-center justify-center space-y-6"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="otp"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Enter OTP</FormLabel>
|
||||
<FormControl>
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
render={({ slots }) => (
|
||||
<InputOTPGroup>
|
||||
{slots.map((slot, index) => (
|
||||
<InputOTPSlot key={index} {...slot} />
|
||||
))}{" "}
|
||||
</InputOTPGroup>
|
||||
)}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button size="sm" className="w-full" type="submit">
|
||||
Submit
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { TagsInput } from "@ark-ui/react";
|
||||
import { RiClipboardLine, RiCloseLine } from "@remixicon/react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/trpc/react";
|
||||
import { OpenCapLogo } from "@/components/shared/logo";
|
||||
import {
|
||||
DocumentShareMutationSchema,
|
||||
type TypeDocumentShareMutation,
|
||||
} from "@/trpc/routers/document-share-router/schema";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { useEffect } from "react";
|
||||
import { generatePublicId } from "@/common/id";
|
||||
|
||||
type DocumentShareModalProps = {
|
||||
title: string | React.ReactNode;
|
||||
subtitle: string | React.ReactNode;
|
||||
size?: "sm" | "md" | "lg" | "xl" | "2xl";
|
||||
open: boolean;
|
||||
documentId: string | null;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
const formSchema = DocumentShareMutationSchema;
|
||||
|
||||
const DocumentShareModal = ({
|
||||
open,
|
||||
setOpen,
|
||||
size,
|
||||
title,
|
||||
subtitle,
|
||||
documentId,
|
||||
}: DocumentShareModalProps) => {
|
||||
const publicId = generatePublicId();
|
||||
const form = useForm<TypeDocumentShareMutation>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
linkExpiresAt: new Date(),
|
||||
link: `http://localhost:3000/document/${publicId}`, // TODO: generate a link
|
||||
emailProtected: true,
|
||||
publicId,
|
||||
},
|
||||
});
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
if (documentId) {
|
||||
form.setValue("documentId", documentId);
|
||||
}
|
||||
}, [documentId, form]);
|
||||
|
||||
const { mutateAsync } = api.documentShare.create.useMutation({
|
||||
onSuccess: async ({ success, message }) => {
|
||||
toast({
|
||||
variant: success ? "default" : "destructive",
|
||||
title: success
|
||||
? "🎉 Successfully created"
|
||||
: "Uh oh! Something went wrong.",
|
||||
description: message,
|
||||
});
|
||||
|
||||
form.reset();
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
const router = useRouter();
|
||||
|
||||
const onSubmit = async (values: TypeDocumentShareMutation) => {
|
||||
await mutateAsync(values);
|
||||
|
||||
form.reset();
|
||||
router.refresh();
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"mb-10 mt-10 gap-0 bg-white p-0",
|
||||
size === "sm" && "sm:max-w-sm",
|
||||
size === "md" && "sm:max-w-md",
|
||||
size === "lg" && "sm:max-w-lg",
|
||||
size === "xl" && "sm:max-w-xl",
|
||||
size === "2xl" && "sm:max-w-2xl",
|
||||
)}
|
||||
>
|
||||
<div className="no-scrollbar max-h-[80vh] overflow-scroll">
|
||||
<header className="border-b border-gray-200 p-5">
|
||||
<div className="">
|
||||
<DialogHeader>
|
||||
<div className="flex justify-center">
|
||||
<OpenCapLogo className="mb-3 h-10 w-10" />
|
||||
</div>
|
||||
<DialogTitle className="mb-4 text-center">{title}</DialogTitle>
|
||||
<DialogDescription className="text-center">
|
||||
{subtitle}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className=" bg-gray-100 px-8 py-5">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-5 sm:grid-cols-6 md:col-span-2">
|
||||
<div className="relative flex items-center gap-2 sm:col-span-6">
|
||||
<div className="w-full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="link"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Link</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage className="text-xs font-light" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="inine-block rounded border p-2"
|
||||
>
|
||||
<RiClipboardLine />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="linkExpiresAt"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Link Expires At (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="date"
|
||||
{...field}
|
||||
value={
|
||||
field.value
|
||||
? new Date(field.value)
|
||||
.toISOString()
|
||||
.split("T")[0]
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className="text-xs font-light" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="my-4 sm:col-span-full">
|
||||
<TagsInput.Root
|
||||
className="flex flex-col space-y-2"
|
||||
max={3}
|
||||
allowOverflow
|
||||
defaultValue={[]}
|
||||
>
|
||||
{(api) => (
|
||||
<>
|
||||
<TagsInput.Label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||
Recipients (optional)
|
||||
</TagsInput.Label>
|
||||
<TagsInput.Control className="flex min-w-full flex-wrap gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50">
|
||||
{api.value.map((value, index) => (
|
||||
<TagsInput.Item
|
||||
key={index}
|
||||
index={index}
|
||||
value={value}
|
||||
>
|
||||
<TagsInput.ItemPreview className="inline-flex items-center rounded-md border px-1 py-0.5 text-xs">
|
||||
<TagsInput.ItemText>{value}</TagsInput.ItemText>
|
||||
<TagsInput.ItemDeleteTrigger className="ml-0.5">
|
||||
<RiCloseLine className="h-4 w-4" />
|
||||
</TagsInput.ItemDeleteTrigger>
|
||||
</TagsInput.ItemPreview>
|
||||
</TagsInput.Item>
|
||||
))}
|
||||
|
||||
<TagsInput.Input
|
||||
placeholder="Add Recipients"
|
||||
className="border-none bg-transparent outline-none"
|
||||
/>
|
||||
</TagsInput.Control>
|
||||
</>
|
||||
)}
|
||||
</TagsInput.Root>
|
||||
</div>
|
||||
<div className="mt-8 flex justify-between">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="emailProtected"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel className="order-last">
|
||||
Require Email Protection
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button loadingText="Submitting..." type="submit">
|
||||
Share document
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</section>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentShareModal;
|
||||
@@ -4,6 +4,7 @@ import Modal from "@/components/shared/modal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Uploader from "@/components/ui/uploader";
|
||||
import { api } from "@/trpc/react";
|
||||
import { RiAddFill } from "@remixicon/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface EsignDocumentUploadProps {
|
||||
@@ -18,7 +19,12 @@ const EsignDocumentUpload = ({ companyPublicId }: EsignDocumentUploadProps) => {
|
||||
<Modal
|
||||
title="Upload equity document"
|
||||
subtitle=""
|
||||
trigger={<Button>Upload Document</Button>}
|
||||
trigger={
|
||||
<Button>
|
||||
<RiAddFill className="mr-2 h-5 w-5" />
|
||||
Document
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Uploader
|
||||
identifier={companyPublicId}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { OTPInput, type SlotProps } from "input-otp";
|
||||
import { RiCrossLine } from "@remixicon/react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const InputOTP = React.forwardRef<
|
||||
React.ElementRef<typeof OTPInput>,
|
||||
React.ComponentPropsWithoutRef<typeof OTPInput>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<OTPInput
|
||||
ref={ref}
|
||||
containerClassName={cn("flex items-center gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
InputOTP.displayName = "InputOTP";
|
||||
|
||||
const InputOTPGroup = React.forwardRef<
|
||||
React.ElementRef<"div">,
|
||||
React.ComponentPropsWithoutRef<"div">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex items-center", className)} {...props} />
|
||||
));
|
||||
InputOTPGroup.displayName = "InputOTPGroup";
|
||||
|
||||
const InputOTPSlot = React.forwardRef<
|
||||
React.ElementRef<"div">,
|
||||
SlotProps & React.ComponentPropsWithoutRef<"div">
|
||||
>(({ char, hasFakeCaret, isActive, className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
|
||||
isActive && "z-10 ring-2 ring-ring ring-offset-background",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
InputOTPSlot.displayName = "InputOTPSlot";
|
||||
|
||||
const InputOTPSeparator = React.forwardRef<
|
||||
React.ElementRef<"div">,
|
||||
React.ComponentPropsWithoutRef<"div">
|
||||
>(({ ...props }, ref) => (
|
||||
<div ref={ref} role="separator" {...props}>
|
||||
<RiCrossLine />
|
||||
</div>
|
||||
));
|
||||
InputOTPSeparator.displayName = "InputOTPSeparator";
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { Switch };
|
||||
@@ -22,7 +22,6 @@ const countries = [
|
||||
{ name: "Indonesia", code: "ID" },
|
||||
{ name: "Switzerland", code: "CH" },
|
||||
{ name: "Italy", code: "IT" },
|
||||
|
||||
{ name: "Afghanistan", code: "AF" },
|
||||
{ name: "Åland Islands", code: "AX" },
|
||||
{ name: "Albania", code: "AL" },
|
||||
|
||||
@@ -36,6 +36,8 @@ export const AuditSchema = z.object({
|
||||
"safe.sent",
|
||||
"safe.signed",
|
||||
"safe.deleted",
|
||||
|
||||
"documentShare.created",
|
||||
]),
|
||||
occurredAt: z.date().optional(),
|
||||
actor: z.object({
|
||||
@@ -45,7 +47,7 @@ export const AuditSchema = z.object({
|
||||
|
||||
target: z.array(
|
||||
z.object({
|
||||
type: z.enum(["user", "company", "document", "option"]),
|
||||
type: z.enum(["user", "company", "document", "option", "documentShare"]),
|
||||
id: z.string().optional().nullable(),
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { companyRouter } from "../routers/company-router/router";
|
||||
import { shareClassRouter } from "../routers/share-class/router";
|
||||
import { equityPlanRouter } from "../routers/equity-plan/router";
|
||||
import { documentRouter } from "../routers/document-router/router";
|
||||
import { documentShareRouter } from "../routers/document-share-router/router";
|
||||
import { onboardingRouter } from "../routers/onboarding-router/router";
|
||||
import { memberRouter } from "../routers/member-router/router";
|
||||
import { bucketRouter } from "../routers/bucket-router/router";
|
||||
@@ -23,6 +24,7 @@ export const appRouter = createTRPCRouter({
|
||||
audit: auditRouter,
|
||||
company: companyRouter,
|
||||
document: documentRouter,
|
||||
documentShare: documentShareRouter,
|
||||
onboarding: onboardingRouter,
|
||||
shareClass: shareClassRouter,
|
||||
equityPlan: equityPlanRouter,
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { withAuth, type withAuthTrpcContextType } from "@/trpc/api/trpc";
|
||||
import {
|
||||
type TypeDocumentShareMutation,
|
||||
DocumentShareMutationSchema,
|
||||
} from "../schema";
|
||||
import { Audit } from "@/server/audit";
|
||||
|
||||
interface CreateDocumentShareHandlerOptions {
|
||||
input: TypeDocumentShareMutation;
|
||||
ctx: withAuthTrpcContextType;
|
||||
}
|
||||
|
||||
export const createDocumentShareHandler = async ({
|
||||
ctx,
|
||||
input,
|
||||
}: CreateDocumentShareHandlerOptions) => {
|
||||
const user = ctx.session.user;
|
||||
const { userAgent, requestIp } = ctx;
|
||||
const companyId = user.companyId;
|
||||
|
||||
const { recipients, ...rest } = input;
|
||||
|
||||
try {
|
||||
await ctx.db.$transaction(async (tx) => {
|
||||
const documentShare = await ctx.db.documentShare.create({
|
||||
data: {
|
||||
...rest,
|
||||
recipients: recipients ? [recipients] : [],
|
||||
},
|
||||
});
|
||||
|
||||
await Audit.create(
|
||||
{
|
||||
companyId,
|
||||
action: "documentShare.created",
|
||||
actor: { type: "user", id: user.id },
|
||||
context: {
|
||||
requestIp,
|
||||
userAgent,
|
||||
},
|
||||
target: [{ type: "documentShare", id: documentShare.id }],
|
||||
summary: `${user.name} created a document share: ${documentShare.link}`,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
return { success: true, message: "Document share created successfully." };
|
||||
} catch (err) {
|
||||
console.log("err here ====>", err);
|
||||
return {
|
||||
success: false,
|
||||
message: "Oops, something went wrong. Please try again later.",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const createDocumentShareProcedure = withAuth
|
||||
.input(DocumentShareMutationSchema)
|
||||
.mutation(async (opts) => {
|
||||
return createDocumentShareHandler(opts);
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createTRPCRouter } from "@/trpc/api/trpc";
|
||||
import { createDocumentShareProcedure } from "./procedures/create-document-share";
|
||||
|
||||
export const documentShareRouter = createTRPCRouter({
|
||||
create: createDocumentShareProcedure,
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const DocumentShareMutationSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
link: z.string().min(1, {
|
||||
message: "Link is required",
|
||||
}),
|
||||
linkExpiresAt: z.coerce.date({
|
||||
required_error: "Link expiry date is required",
|
||||
invalid_type_error: "This is not a valid date",
|
||||
}),
|
||||
recipients: z.string().optional(),
|
||||
emailProtected: z.boolean().default(true),
|
||||
documentId: z.string().min(1, {
|
||||
message: "Document Id is required",
|
||||
}),
|
||||
publicId: z.string().min(1, "PublicId is required"),
|
||||
});
|
||||
|
||||
export type TypeDocumentShareMutation = z.infer<
|
||||
typeof DocumentShareMutationSchema
|
||||
>;
|
||||
@@ -78,10 +78,15 @@ const config = {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: "0" },
|
||||
},
|
||||
"caret-blink": {
|
||||
"0%,70%,100%": { opacity: "1" },
|
||||
"20%,50%": { opacity: "0" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
"caret-blink": "caret-blink 1.25s ease-out infinite",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user