mirror of
https://github.com/hanzoai/captable.git
synced 2026-10-16 14:21:29 +00:00
Captable sessions end with the IAM token; links, roles and uploads tighten
A session lives as long as the IAM access token it signed in with: the jwt callback, which every session read runs through, ends a session whose token is missing or expired, and an unused session lasts 24 hours. Someone IAM moves or disables loses Captable when their token expires, and signs in again through IAM. The request log keeps the path alone, with invitation and signing tokens replaced; queries are never logged. Share links are minted only by the mutations that mail them. Reading who a data room or update is shared with returns no links, and the copy-link button is gone. A member recipient opens a link only while still ACTIVE, and removing a member deletes their recipient rows. Investor updates render from their stored blocks: text is React text, links keep http(s) or mailto addresses, images http(s). No update HTML is stored or shown. Only an ADMIN writes a role, assigns one, or acts on an ADMIN; a company keeps its last ACTIVE ADMIN; an edit that names no role keeps the member's role; custom roles are looked up within the company. Uploads land where the server says: profile photos under the account, everything else under the company's public id, signed only for an ACTIVE member (the founder's logo excepted). Public objects are PNG, JPEG, WebP or GIF, with their type and disposition signed into the PUT. bucket.create refuses a key outside the company's prefix. A recipient's signing status moves forward once, so two signings cannot both land. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -9,13 +9,9 @@ import { useState } from "react";
|
||||
|
||||
type DocumentUploadModalProps = {
|
||||
trigger: React.ReactNode;
|
||||
companyPublicId: string;
|
||||
};
|
||||
|
||||
const DocumentUploadModal = ({
|
||||
trigger,
|
||||
companyPublicId,
|
||||
}: DocumentUploadModalProps) => {
|
||||
const DocumentUploadModal = ({ trigger }: DocumentUploadModalProps) => {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -35,7 +31,6 @@ const DocumentUploadModal = ({
|
||||
>
|
||||
<Uploader
|
||||
shouldUpload={true}
|
||||
identifier={companyPublicId}
|
||||
keyPrefix="generic-documents"
|
||||
tags={[TAG.GENERIC]}
|
||||
onSuccess={async (uploadedData: UploadReturn) => {
|
||||
|
||||
-3
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import EmptyState from "@/components/common/empty-state";
|
||||
import type { ExtendedDataRoomRecipientType } from "@/components/common/share-modal";
|
||||
import DataRoomFileExplorer from "@/components/documents/data-room/explorer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
@@ -94,7 +93,6 @@ const DataRoomFiles = ({
|
||||
|
||||
<DataRoomUploader
|
||||
dataRoom={dataRoom}
|
||||
companyPublicId={companyPublicId}
|
||||
trigger={
|
||||
<Button>
|
||||
<RiAddFill className="mr-2 h-5 w-5" />
|
||||
@@ -123,7 +121,6 @@ const DataRoomFiles = ({
|
||||
>
|
||||
<DataRoomUploader
|
||||
dataRoom={dataRoom}
|
||||
companyPublicId={companyPublicId}
|
||||
trigger={
|
||||
<Button>
|
||||
<RiAddFill className="mr-2 h-5 w-5" />
|
||||
|
||||
+1
-7
@@ -10,15 +10,10 @@ import { useState } from "react";
|
||||
|
||||
type DataRoomUploaderProps = {
|
||||
trigger: React.ReactNode;
|
||||
companyPublicId: string;
|
||||
dataRoom: DataRoom;
|
||||
};
|
||||
|
||||
const DataRoomUploader = ({
|
||||
trigger,
|
||||
companyPublicId,
|
||||
dataRoom,
|
||||
}: DataRoomUploaderProps) => {
|
||||
const DataRoomUploader = ({ trigger, dataRoom }: DataRoomUploaderProps) => {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -40,7 +35,6 @@ const DataRoomUploader = ({
|
||||
<Uploader
|
||||
shouldUpload
|
||||
multiple={true}
|
||||
identifier={companyPublicId}
|
||||
keyPrefix={`data-room/${dataRoom.publicId}`}
|
||||
tags={[TAG.DATA_ROOM]}
|
||||
onSuccess={async (upload: UploadReturn) => {
|
||||
|
||||
@@ -6,20 +6,16 @@ import { RiAddFill } from "@remixicon/react";
|
||||
import React from "react";
|
||||
|
||||
type DocumentUploadButtonProps = {
|
||||
companyPublicId: string;
|
||||
buttonDisplayName: string;
|
||||
};
|
||||
|
||||
export const DocumentUploadButton = ({
|
||||
companyPublicId,
|
||||
buttonDisplayName,
|
||||
}: DocumentUploadButtonProps) => {
|
||||
return (
|
||||
<Button
|
||||
onClick={() => {
|
||||
pushModal("DocumentUploadModal", {
|
||||
companyPublicId,
|
||||
});
|
||||
pushModal("DocumentUploadModal");
|
||||
}}
|
||||
>
|
||||
<RiAddFill className="mr-2 h-5 w-5" />
|
||||
|
||||
@@ -37,10 +37,7 @@ const DocumentsPage = async () => {
|
||||
subtitle="Please click the button below to upload a new document."
|
||||
>
|
||||
{canUpload && (
|
||||
<DocumentUploadButton
|
||||
companyPublicId={session.user.companyPublicId}
|
||||
buttonDisplayName="Upload a document"
|
||||
/>
|
||||
<DocumentUploadButton buttonDisplayName="Upload a document" />
|
||||
)}
|
||||
</EmptyState>
|
||||
);
|
||||
@@ -53,10 +50,7 @@ const DocumentsPage = async () => {
|
||||
description="Upload documents to your company's document library."
|
||||
action={
|
||||
canUpload ? (
|
||||
<DocumentUploadButton
|
||||
companyPublicId={session.user.companyPublicId}
|
||||
buttonDisplayName="Document"
|
||||
/>
|
||||
<DocumentUploadButton buttonDisplayName="Document" />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -25,7 +25,6 @@ const DocumentsPage = async () => {
|
||||
subtitle="Please click the button below to upload a new document."
|
||||
>
|
||||
<DocumentUploadModal
|
||||
companyPublicId={session.user.companyPublicId}
|
||||
trigger={
|
||||
<Button>
|
||||
<RiAddFill className="mr-2 h-5 w-5" />
|
||||
@@ -44,7 +43,6 @@ const DocumentsPage = async () => {
|
||||
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" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import FilePreview from "@/components/file/preview";
|
||||
import { SharePageLayout } from "@/components/share/page-layout";
|
||||
import { type JWTVerifyResult, decode } from "@/lib/jwt";
|
||||
import { recipientMayOpen } from "@/lib/share";
|
||||
import { db } from "@/server/db";
|
||||
import { getPresignedGetUrl } from "@/server/file-uploads";
|
||||
import { RiFolder3Fill as FolderIcon } from "@remixicon/react";
|
||||
@@ -31,18 +32,19 @@ const DataRoomPage = async ({
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const recipient = await db.dataRoomRecipient.findFirstOrThrow({
|
||||
const recipient = await db.dataRoomRecipient.findFirst({
|
||||
where: {
|
||||
id: recipientId,
|
||||
dataRoomId,
|
||||
id: String(recipientId),
|
||||
dataRoomId: String(dataRoomId),
|
||||
},
|
||||
|
||||
select: {
|
||||
id: true,
|
||||
memberId: true,
|
||||
member: { select: { status: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
if (!recipient || !recipientMayOpen(recipient)) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import DataRoomFileExplorer from "@/components/documents/data-room/explorer";
|
||||
import { SharePageLayout } from "@/components/share/page-layout";
|
||||
import { type JWTVerifyResult, decode } from "@/lib/jwt";
|
||||
import { recipientMayOpen } from "@/lib/share";
|
||||
import { db } from "@/server/db";
|
||||
import { RiFolder3Fill as FolderIcon } from "@remixicon/react";
|
||||
import { notFound } from "next/navigation";
|
||||
@@ -29,18 +30,19 @@ const DataRoomPage = async ({
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const recipient = await db.dataRoomRecipient.findFirstOrThrow({
|
||||
const recipient = await db.dataRoomRecipient.findFirst({
|
||||
where: {
|
||||
id: recipientId,
|
||||
dataRoomId,
|
||||
id: String(recipientId),
|
||||
dataRoomId: String(dataRoomId),
|
||||
},
|
||||
|
||||
select: {
|
||||
id: true,
|
||||
memberId: true,
|
||||
member: { select: { status: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
if (!recipient || !recipientMayOpen(recipient)) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ import { SharePageLayout } from "@/components/share/page-layout";
|
||||
import { Avatar, AvatarImage } from "@/components/ui/avatar";
|
||||
import UpdateRenderer from "@/components/update/renderer";
|
||||
import { type JWTVerifyResult, decode } from "@/lib/jwt";
|
||||
import { recipientMayOpen } from "@/lib/share";
|
||||
import { UpdateStatusEnum } from "@/prisma/enums";
|
||||
import { db } from "@/server/db";
|
||||
import { render } from "@react-email/components";
|
||||
import { RiLock2Line } from "@remixicon/react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Fragment } from "react";
|
||||
@@ -73,10 +73,10 @@ const PublicUpdatePage = async ({
|
||||
// which ends the link at once rather than when it expires.
|
||||
const recipient = await db.updateRecipient.findFirst({
|
||||
where: { id: String(payload.recipientId), updateId: update.id },
|
||||
select: { id: true },
|
||||
select: { memberId: true, member: { select: { status: true } } },
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
if (!recipient || !recipientMayOpen(recipient)) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
@@ -96,20 +96,8 @@ const PublicUpdatePage = async ({
|
||||
);
|
||||
}
|
||||
|
||||
const recipients = await db.updateRecipient.findFirst({
|
||||
where: {
|
||||
id: payload.recipientId,
|
||||
updateId: update.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipients) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const company = update?.company;
|
||||
const author = update?.author;
|
||||
const html = await render(<UpdateRenderer html={update.html} />);
|
||||
|
||||
return (
|
||||
<SharePageLayout
|
||||
@@ -142,11 +130,9 @@ const PublicUpdatePage = async ({
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<article
|
||||
className="prose"
|
||||
//biome-ignore lint/security/noDangerouslySetInnerHtml: allow dangerouslySetInnerHtml
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
<article className="prose">
|
||||
<UpdateRenderer content={update.content} />
|
||||
</article>
|
||||
</div>
|
||||
</Fragment>
|
||||
</SharePageLayout>
|
||||
|
||||
@@ -5,20 +5,20 @@ export interface PresignedPut {
|
||||
url: string;
|
||||
key: string;
|
||||
bucketUrl: string;
|
||||
/** The headers the PUT is signed with, sent exactly as given. */
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Sends a file's bytes to the object store through a presigned PUT. */
|
||||
export const putFile = async (
|
||||
file: File,
|
||||
{ url, key, bucketUrl }: PresignedPut,
|
||||
{ url, key, bucketUrl, headers }: PresignedPut,
|
||||
bucketMode: "publicBucket" | "privateBucket",
|
||||
) => {
|
||||
const body = await file.arrayBuffer();
|
||||
const res = await fetch(url, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
},
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
if (!res.ok) {
|
||||
|
||||
+6
-10
@@ -1,13 +1,12 @@
|
||||
import type { getPresignedUrlOptions } from "@/server/file-uploads";
|
||||
import { presignUpload } from "@/server/storage";
|
||||
import { type UploadRequest, presignUpload } from "@/server/storage";
|
||||
import { putFile } from "./put-file";
|
||||
|
||||
export type { TUploadFile } from "./put-file";
|
||||
|
||||
/**
|
||||
* Uploads a file from the browser. The URL it PUTs to is minted by the
|
||||
* `presignUpload` action, which signs only for the caller's own account or the
|
||||
* company they are an active member of.
|
||||
* `presignUpload` action, which decides where it lands: the caller's account
|
||||
* or the company they are an active member of.
|
||||
*
|
||||
* usage
|
||||
* ```js
|
||||
@@ -23,17 +22,14 @@ export type { TUploadFile } from "./put-file";
|
||||
*/
|
||||
export const uploadFile = async (
|
||||
file: File,
|
||||
options: Pick<
|
||||
getPresignedUrlOptions,
|
||||
"expiresIn" | "keyPrefix" | "identifier"
|
||||
>,
|
||||
bucketMode: "publicBucket" | "privateBucket" = "privateBucket",
|
||||
{ keyPrefix }: Pick<UploadRequest, "keyPrefix">,
|
||||
bucketMode: UploadRequest["bucketMode"] = "privateBucket",
|
||||
) => {
|
||||
const presigned = await presignUpload({
|
||||
contentType: file.type,
|
||||
fileName: file.name,
|
||||
bucketMode,
|
||||
...options,
|
||||
keyPrefix,
|
||||
});
|
||||
return putFile(file, presigned, bucketMode);
|
||||
};
|
||||
|
||||
@@ -8,35 +8,25 @@ import MultipleSelector, { type Option } from "@/components/ui/multi-selector";
|
||||
|
||||
import type { ShareContactType } from "@/schema/contacts";
|
||||
import type { DataRoomRecipient, UpdateRecipient } from "@prisma/client";
|
||||
import {
|
||||
RiCheckLine as CheckIcon,
|
||||
RiDeleteBin2Line as DeleteIcon,
|
||||
RiLink as LinkIcon,
|
||||
} from "@remixicon/react";
|
||||
import { RiDeleteBin2Line as DeleteIcon } from "@remixicon/react";
|
||||
import { Fragment, useState } from "react";
|
||||
import { useCopyToClipboard } from "usehooks-ts";
|
||||
import { popModal } from "../modals";
|
||||
|
||||
export interface ExtendedDataRoomRecipientType extends DataRoomRecipient {
|
||||
token: string | object;
|
||||
}
|
||||
export interface ExtendedUpdateRecipientType extends UpdateRecipient {
|
||||
token: string | object;
|
||||
}
|
||||
// Recipients are listed without their links: a link reaches its recipient by
|
||||
// mail alone, so sharing again is how a lost one is replaced.
|
||||
type Recipients =
|
||||
| {
|
||||
type: "dataroom";
|
||||
recipients: ExtendedDataRoomRecipientType[] | [];
|
||||
recipients: DataRoomRecipient[] | [];
|
||||
}
|
||||
| {
|
||||
type: "update";
|
||||
recipients: ExtendedUpdateRecipientType[] | [];
|
||||
recipients: UpdateRecipient[] | [];
|
||||
};
|
||||
|
||||
export type Props = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
baseLink: string;
|
||||
contacts: ShareContactType[] | [];
|
||||
recipientsPayload: Recipients;
|
||||
onShare: (data: { others: object[]; selectedContacts: object[] }) => void;
|
||||
@@ -46,32 +36,15 @@ export type Props = {
|
||||
const Share = ({
|
||||
title,
|
||||
subtitle,
|
||||
baseLink,
|
||||
contacts,
|
||||
recipientsPayload,
|
||||
onShare,
|
||||
removeAccess,
|
||||
}: Props) => {
|
||||
const [loading, _setLoading] = useState<boolean>(false);
|
||||
const [_copiedText, copy] = useCopyToClipboard();
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<Option[]>([]);
|
||||
const { recipients } = recipientsPayload;
|
||||
|
||||
const copyToClipboard = (id: string, text: string) => {
|
||||
copy(text)
|
||||
.then(() => {
|
||||
setCopiedId(id);
|
||||
|
||||
setTimeout(() => {
|
||||
setCopiedId(null);
|
||||
}, 1000);
|
||||
})
|
||||
.catch(() => {
|
||||
setCopiedId(null);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal size="lg" title={title} subtitle={subtitle} scrollable={true}>
|
||||
<Fragment>
|
||||
@@ -148,26 +121,6 @@ const Share = ({
|
||||
<DeleteIcon className="h-4 w-4" />
|
||||
Remove
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={"secondary"}
|
||||
className="flex text-sm font-medium text-primary/70 hover:text-primary/90"
|
||||
onClick={() =>
|
||||
copyToClipboard(
|
||||
recipient.id,
|
||||
`${baseLink}?token=${recipient.token as string}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Fragment>
|
||||
{copiedId === recipient.id ? (
|
||||
<CheckIcon className="h-4 w-4 text-green-500 transition" />
|
||||
) : (
|
||||
<LinkIcon className="h-4 w-4 transition" />
|
||||
)}
|
||||
Copy link
|
||||
</Fragment>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -66,10 +66,8 @@ const Pages: CommandOption[] = [
|
||||
id: "documents",
|
||||
title: "Upload document",
|
||||
icon: RiUploadCloud2Fill,
|
||||
onClick: (companyPublicId: string) => {
|
||||
pushModal("DocumentUploadModal", {
|
||||
companyPublicId,
|
||||
});
|
||||
onClick: () => {
|
||||
pushModal("DocumentUploadModal");
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -116,9 +116,7 @@ export const ProfileSettings = ({ memberProfile }: ProfileType) => {
|
||||
const { fileUrl } = await uploadFile(
|
||||
file,
|
||||
{
|
||||
expiresIn: 3600,
|
||||
keyPrefix: "profile-avatars",
|
||||
identifier: session.user.id,
|
||||
},
|
||||
"publicBucket",
|
||||
);
|
||||
|
||||
@@ -6,13 +6,7 @@ import { TAG } from "@/lib/tags";
|
||||
import { api } from "@/trpc/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
type DocumentUploadModalProps = {
|
||||
companyPublicId: string;
|
||||
};
|
||||
|
||||
export const DocumentUploadModal = ({
|
||||
companyPublicId,
|
||||
}: DocumentUploadModalProps) => {
|
||||
export const DocumentUploadModal = () => {
|
||||
const router = useRouter();
|
||||
|
||||
const { mutateAsync } = api.document.create.useMutation();
|
||||
@@ -24,7 +18,6 @@ export const DocumentUploadModal = ({
|
||||
>
|
||||
<Uploader
|
||||
shouldUpload={true}
|
||||
identifier={companyPublicId}
|
||||
keyPrefix="generic-documents"
|
||||
tags={[TAG.GENERIC]}
|
||||
onSuccess={async (uploadedData: UploadReturn) => {
|
||||
|
||||
@@ -69,7 +69,6 @@ export function AddRecipientStep({ companyPublicId }: AddRecipientStepProps) {
|
||||
throw new Error("no document found to upload");
|
||||
}
|
||||
const { key, mimeType, name, size } = await uploadFile(document, {
|
||||
identifier: companyPublicId,
|
||||
keyPrefix: "unsigned-esign-doc",
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import ShareModal, {
|
||||
type ExtendedDataRoomRecipientType,
|
||||
} from "@/components/common/share-modal";
|
||||
import { env } from "@/env";
|
||||
import ShareModal from "@/components/common/share-modal";
|
||||
import type { ShareContactType, ShareRecipientType } from "@/schema/contacts";
|
||||
import { api } from "@/trpc/react";
|
||||
import type { DataRoom } from "@prisma/client";
|
||||
@@ -18,8 +15,6 @@ export const ShareDataRoomModal = ({
|
||||
dataRoom,
|
||||
contacts,
|
||||
}: ShareDataRoomModalProps) => {
|
||||
const baseUrl = env.NEXT_PUBLIC_BASE_URL;
|
||||
|
||||
const { data, refetch: refetchRecipients } =
|
||||
api.dataRoom.getDataRoom.useQuery({
|
||||
dataRoomPublicId: dataRoom.publicId,
|
||||
@@ -55,10 +50,9 @@ export const ShareDataRoomModal = ({
|
||||
<ShareModal
|
||||
recipientsPayload={{
|
||||
type: "dataroom",
|
||||
recipients: (recipients as ExtendedDataRoomRecipientType[]) ?? [],
|
||||
recipients: recipients ?? [],
|
||||
}}
|
||||
contacts={contacts}
|
||||
baseLink={`${baseUrl}/data-rooms/${dataRoom.publicId}`}
|
||||
title={`Share data room - "${dataRoom.name}"`}
|
||||
subtitle="Share this data room with others."
|
||||
onShare={async ({ selectedContacts, others }) => {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import ShareModal, {
|
||||
type ExtendedUpdateRecipientType,
|
||||
} from "@/components/common/share-modal";
|
||||
import { env } from "@/env";
|
||||
import ShareModal from "@/components/common/share-modal";
|
||||
import type { ShareRecipientType } from "@/schema/contacts";
|
||||
import { api } from "@/trpc/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -18,7 +15,6 @@ type ShareUpdateModalProps = {
|
||||
|
||||
export const ShareUpdateModal = ({ update }: ShareUpdateModalProps) => {
|
||||
const router = useRouter();
|
||||
const baseUrl = env.NEXT_PUBLIC_BASE_URL;
|
||||
|
||||
const { data: recipients, refetch: refetchRecipients } =
|
||||
api.update.getRecipients.useQuery({
|
||||
@@ -56,10 +52,9 @@ export const ShareUpdateModal = ({ update }: ShareUpdateModalProps) => {
|
||||
<ShareModal
|
||||
recipientsPayload={{
|
||||
type: "update",
|
||||
recipients: (recipients as ExtendedUpdateRecipientType[]) ?? [],
|
||||
recipients: recipients ?? [],
|
||||
}}
|
||||
contacts={contacts || []}
|
||||
baseLink={`${baseUrl}/updates/${update.publicId}`}
|
||||
title={"Share investor updates"}
|
||||
subtitle="Share important updates with fellow members/stakeholders"
|
||||
onShare={async ({ selectedContacts, others }) => {
|
||||
|
||||
@@ -119,9 +119,7 @@ export const CompanyForm = ({ type, data }: CompanyFormProps) => {
|
||||
const { fileUrl } = await uploadFile(
|
||||
file,
|
||||
{
|
||||
expiresIn: 3600,
|
||||
keyPrefix: "company-logos",
|
||||
identifier: user.user.id,
|
||||
},
|
||||
"publicBucket",
|
||||
);
|
||||
|
||||
@@ -46,7 +46,6 @@ export function SafeDocuments() {
|
||||
|
||||
for (const document of documentsList) {
|
||||
const { key, mimeType, name, size } = await uploadFile(document, {
|
||||
identifier: session.user.companyPublicId,
|
||||
keyPrefix: "existing-safes",
|
||||
});
|
||||
|
||||
|
||||
@@ -116,7 +116,6 @@ export function SafeTemplate() {
|
||||
invariant(doc, "document not found");
|
||||
|
||||
const { key, mimeType, name, size } = await uploadFile(doc, {
|
||||
identifier: session.user.companyPublicId,
|
||||
keyPrefix: "new-safes",
|
||||
});
|
||||
|
||||
|
||||
@@ -49,7 +49,6 @@ export const Documents = () => {
|
||||
|
||||
for (const document of documentsList) {
|
||||
const { key, mimeType, name, size } = await uploadFile(document, {
|
||||
identifier: session.user.companyPublicId,
|
||||
keyPrefix: "stock-option-docs",
|
||||
});
|
||||
|
||||
|
||||
@@ -45,7 +45,6 @@ export const Documents = () => {
|
||||
const uploadedDocuments: { name: string; bucketId: string }[] = [];
|
||||
for (const document of documentsList) {
|
||||
const { key, mimeType, name, size } = await uploadFile(document, {
|
||||
identifier: session.user.companyPublicId,
|
||||
keyPrefix: "shares-docs",
|
||||
});
|
||||
const { id: bucketId, name: docName } = await handleBucketUpload({
|
||||
|
||||
@@ -27,21 +27,18 @@ type UploadProps =
|
||||
| {
|
||||
shouldUpload?: true;
|
||||
onSuccess?: (data: UploadReturn) => void | Promise<void>;
|
||||
identifier: string;
|
||||
keyPrefix: TypeKeyPrefixes;
|
||||
tags: TagType[];
|
||||
}
|
||||
| {
|
||||
shouldUpload: false;
|
||||
onSuccess?: (data: FileWithPath[]) => void | Promise<void>;
|
||||
identifier?: never;
|
||||
keyPrefix?: never;
|
||||
tags?: TagType[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
header?: React.ReactNode;
|
||||
// should be companyPublicId or memberId or userId
|
||||
|
||||
multiple?: boolean;
|
||||
} & DocumentUploadDropzone &
|
||||
@@ -49,7 +46,6 @@ type Props = {
|
||||
|
||||
export function Uploader({
|
||||
header,
|
||||
identifier,
|
||||
keyPrefix,
|
||||
onSuccess,
|
||||
multiple = false,
|
||||
@@ -78,7 +74,6 @@ export function Uploader({
|
||||
|
||||
for (const file of acceptedFiles) {
|
||||
const { key, mimeType, name, size } = await uploadFile(file, {
|
||||
identifier: identifier as string,
|
||||
keyPrefix: keyPrefix as TypeKeyPrefixes,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Blocks, safeUrl } from "./blocks";
|
||||
|
||||
const html = (blocks: unknown) =>
|
||||
renderToStaticMarkup(<Blocks blocks={blocks} />);
|
||||
const text = (t: string, styles = {}) => ({ type: "text", text: t, styles });
|
||||
|
||||
describe("an update's blocks", () => {
|
||||
it("render text as text, never as markup", () => {
|
||||
const out = html([
|
||||
{
|
||||
id: "a",
|
||||
type: "paragraph",
|
||||
content: [text('<img src=x onerror="alert(1)"><script>x</script>')],
|
||||
},
|
||||
]);
|
||||
expect(out).not.toContain("<img");
|
||||
expect(out).not.toContain("<script");
|
||||
expect(out).toContain("<script>");
|
||||
});
|
||||
|
||||
it("keep a link only when its address is http(s) or mailto", () => {
|
||||
const link = (href: string) =>
|
||||
html([
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "link", href, content: [text("go")] }],
|
||||
},
|
||||
]);
|
||||
expect(link("https://hanzo.ai/x")).toContain('href="https://hanzo.ai/x"');
|
||||
expect(link("mailto:[email protected]")).toContain('href="mailto:[email protected]"');
|
||||
for (const bad of [
|
||||
"javascript:alert(1)",
|
||||
" JavaScript:alert(1)",
|
||||
"data:text/html,<script>",
|
||||
"/relative",
|
||||
]) {
|
||||
const out = link(bad);
|
||||
expect(out).not.toContain("href");
|
||||
expect(out).toContain("go");
|
||||
}
|
||||
});
|
||||
|
||||
it("drop an image whose address is not http(s)", () => {
|
||||
expect(
|
||||
html([{ type: "image", props: { url: "javascript:alert(1)" } }]),
|
||||
).toBe("");
|
||||
expect(
|
||||
html([
|
||||
{ type: "image", props: { url: "https://x.test/a.png", caption: "A" } },
|
||||
]),
|
||||
).toContain('<img src="https://x.test/a.png" alt="A"/>');
|
||||
});
|
||||
|
||||
it("keep headings, styles and lists", () => {
|
||||
const out = html([
|
||||
{ type: "heading", props: { level: 2 }, content: [text("Q3")] },
|
||||
{ type: "bulletListItem", content: [text("one", { bold: true })] },
|
||||
{ type: "bulletListItem", content: [text("two")] },
|
||||
{ type: "numberedListItem", content: [text("first")] },
|
||||
]);
|
||||
expect(out).toBe(
|
||||
"<h2>Q3</h2><ul><li><span><strong>one</strong></span></li><li><span>two</span></li></ul><ol><li><span>first</span></li></ol>",
|
||||
);
|
||||
});
|
||||
|
||||
it("render nothing for a document that is not one", () => {
|
||||
for (const bad of [null, "<p>x</p>", 7, { type: "paragraph" }]) {
|
||||
expect(html(bad)).toBe("");
|
||||
}
|
||||
});
|
||||
|
||||
it("ignore attributes it does not know", () => {
|
||||
const out = html([
|
||||
{
|
||||
type: "paragraph",
|
||||
props: { textAlignment: "center;color:red", onclick: "x" },
|
||||
content: [text("t", { textColor: "url(javascript:x)" })],
|
||||
},
|
||||
]);
|
||||
expect(out).toBe("<p>t</p>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("safeUrl", () => {
|
||||
it("normalises what it keeps", () => {
|
||||
expect(safeUrl(" https://hanzo.ai ", ["https:"])).toBe("https://hanzo.ai/");
|
||||
expect(safeUrl(42, ["https:"])).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
// Renders an investor update from the blocks the editor stores (BlockNote's
|
||||
// document JSON). Every string reaches the page as React text, so nothing a
|
||||
// writer stores is ever parsed as markup; a link or image keeps its address only
|
||||
// when it is http(s) (or mailto, for a link). Anything the renderer does not
|
||||
// know is dropped, never passed through.
|
||||
|
||||
import { Fragment, type ReactNode, createElement } from "react";
|
||||
|
||||
type Json = unknown;
|
||||
|
||||
const record = (v: Json): Record<string, Json> =>
|
||||
v && typeof v === "object" && !Array.isArray(v)
|
||||
? (v as Record<string, Json>)
|
||||
: {};
|
||||
|
||||
const list = (v: Json): Json[] => (Array.isArray(v) ? v : []);
|
||||
|
||||
const text = (v: Json): string => (typeof v === "string" ? v : "");
|
||||
|
||||
/** An address a link or an image may keep, or "" when it may keep none. */
|
||||
export const safeUrl = (raw: Json, schemes: readonly string[]): string => {
|
||||
const value = text(raw).trim();
|
||||
if (!value) return "";
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return schemes.includes(url.protocol) ? url.href : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const LINK_SCHEMES = ["http:", "https:", "mailto:"] as const;
|
||||
const IMAGE_SCHEMES = ["http:", "https:"] as const;
|
||||
|
||||
const ALIGN = new Set(["left", "center", "right", "justify"]);
|
||||
|
||||
/** One run of styled text. */
|
||||
function Styled({ node }: { node: Record<string, Json> }) {
|
||||
const styles = record(node.styles);
|
||||
let out: ReactNode = text(node.text);
|
||||
if (styles.code === true) out = <code>{out}</code>;
|
||||
if (styles.bold === true) out = <strong>{out}</strong>;
|
||||
if (styles.italic === true) out = <em>{out}</em>;
|
||||
if (styles.underline === true) out = <u>{out}</u>;
|
||||
if (styles.strike === true) out = <s>{out}</s>;
|
||||
return <>{out}</>;
|
||||
}
|
||||
|
||||
/** A block's inline content: styled text and links. */
|
||||
function Inline({ content }: { content: Json }) {
|
||||
return (
|
||||
<>
|
||||
{list(content).map((item, i) => {
|
||||
const node = record(item);
|
||||
if (node.type === "text") {
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: inline runs have no id
|
||||
return <Styled key={i} node={node} />;
|
||||
}
|
||||
if (node.type === "link") {
|
||||
const href = safeUrl(node.href, LINK_SCHEMES);
|
||||
const inner = <Inline content={node.content} />;
|
||||
return href ? (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: inline runs have no id
|
||||
<a key={i} href={href} rel="noopener noreferrer nofollow">
|
||||
{inner}
|
||||
</a>
|
||||
) : (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: inline runs have no id
|
||||
<Fragment key={i}>{inner}</Fragment>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function alignment(props: Record<string, Json>) {
|
||||
const align = text(props.textAlignment);
|
||||
return ALIGN.has(align) && align !== "left"
|
||||
? { textAlign: align as "center" | "right" | "justify" }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** A block's own element, without its children. */
|
||||
function Body({ block }: { block: Record<string, Json> }) {
|
||||
const props = record(block.props);
|
||||
const style = alignment(props);
|
||||
switch (block.type) {
|
||||
case "heading": {
|
||||
const level = props.level === 2 ? 2 : props.level === 3 ? 3 : 1;
|
||||
return createElement(
|
||||
`h${level}`,
|
||||
{ style },
|
||||
<Inline content={block.content} />,
|
||||
);
|
||||
}
|
||||
case "checkListItem":
|
||||
return (
|
||||
<span style={style}>
|
||||
<input type="checkbox" checked={props.checked === true} disabled />{" "}
|
||||
<Inline content={block.content} />
|
||||
</span>
|
||||
);
|
||||
case "bulletListItem":
|
||||
case "numberedListItem":
|
||||
return (
|
||||
<span style={style}>
|
||||
<Inline content={block.content} />
|
||||
</span>
|
||||
);
|
||||
case "image": {
|
||||
const src = safeUrl(props.url, IMAGE_SCHEMES);
|
||||
if (!src) return null;
|
||||
const caption = text(props.caption);
|
||||
return (
|
||||
<figure>
|
||||
<img src={src} alt={caption} />
|
||||
{caption ? <figcaption>{caption}</figcaption> : null}
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
case "table": {
|
||||
const rows = list(record(block.content).rows);
|
||||
return (
|
||||
<table>
|
||||
<tbody>
|
||||
{rows.map((row, r) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: table rows have no id
|
||||
<tr key={r}>
|
||||
{list(record(row).cells).map((cell, c) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: table cells have no id
|
||||
<td key={c}>
|
||||
<Inline content={cell} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return (
|
||||
<p style={style}>
|
||||
<Inline content={block.content} />
|
||||
</p>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const LIST_TAG: Record<string, "ul" | "ol"> = {
|
||||
bulletListItem: "ul",
|
||||
checkListItem: "ul",
|
||||
numberedListItem: "ol",
|
||||
};
|
||||
|
||||
/** Blocks in order, with consecutive list items of one kind in one list. */
|
||||
export function Blocks({ blocks }: { blocks: Json }) {
|
||||
const items = list(blocks).map(record);
|
||||
const out: ReactNode[] = [];
|
||||
for (let i = 0; i < items.length; ) {
|
||||
const block = items[i] as Record<string, Json>;
|
||||
const tag = LIST_TAG[text(block.type)];
|
||||
if (!tag) {
|
||||
out.push(
|
||||
<Fragment key={text(block.id) || i}>
|
||||
<Body block={block} />
|
||||
<Blocks blocks={block.children} />
|
||||
</Fragment>,
|
||||
);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const run: Record<string, Json>[] = [];
|
||||
while (i < items.length && items[i]?.type === block.type) {
|
||||
run.push(items[i] as Record<string, Json>);
|
||||
i += 1;
|
||||
}
|
||||
out.push(
|
||||
createElement(
|
||||
tag,
|
||||
{ key: text(block.id) || `list-${i}` },
|
||||
run.map((item, n) => (
|
||||
<li key={text(item.id) || n}>
|
||||
<Body block={item} />
|
||||
<Blocks blocks={item.children} />
|
||||
</li>
|
||||
)),
|
||||
),
|
||||
);
|
||||
}
|
||||
return <>{out}</>;
|
||||
}
|
||||
@@ -169,7 +169,6 @@ const UpdatesEditor = ({
|
||||
const [content, setContent] = useState<Block[]>(
|
||||
(update?.content as Block[]) ?? defaultContent,
|
||||
);
|
||||
const [html, setHtml] = useState<string>(update?.html ?? "");
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const editor = useCreateBlockNote({
|
||||
@@ -228,7 +227,6 @@ const UpdatesEditor = ({
|
||||
|
||||
const data = {
|
||||
title,
|
||||
html,
|
||||
content,
|
||||
publicId: update?.publicId,
|
||||
};
|
||||
@@ -241,7 +239,6 @@ const UpdatesEditor = ({
|
||||
|
||||
const data = {
|
||||
title,
|
||||
html,
|
||||
content,
|
||||
};
|
||||
|
||||
@@ -379,10 +376,8 @@ const UpdatesEditor = ({
|
||||
className="py-5"
|
||||
editor={editor}
|
||||
theme="light"
|
||||
onChange={async () => {
|
||||
onChange={() => {
|
||||
setContent(editor.document);
|
||||
const html = await editor.blocksToHTMLLossy(editor.document);
|
||||
setHtml(html);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import "@blocknote/react/style.css";
|
||||
import "@/styles/editor.css";
|
||||
import { Blocks } from "./blocks";
|
||||
|
||||
type UpdatesEditorProps = {
|
||||
html: string;
|
||||
type UpdateRendererProps = {
|
||||
/** The update's stored blocks (Update.content). */
|
||||
content: unknown;
|
||||
};
|
||||
|
||||
const UpdateRenderer = ({ html }: UpdatesEditorProps) => {
|
||||
return <div dangerouslySetInnerHTML={{ __html: html }} />;
|
||||
/** An investor update as its readers see it. */
|
||||
const UpdateRenderer = ({ content }: UpdateRendererProps) => {
|
||||
return <Blocks blocks={content} />;
|
||||
};
|
||||
|
||||
export default UpdateRenderer;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { loggedPath } from "./log-path";
|
||||
|
||||
describe("loggedPath", () => {
|
||||
it("replaces the secret after an invitation or a signing link", () => {
|
||||
expect(loggedPath("/verify-member/3f9c0a1b")).toBe("/verify-member/:token");
|
||||
expect(loggedPath("/esign/eyJhbGciOi.x.y")).toBe("/esign/:token");
|
||||
});
|
||||
|
||||
it("keeps every other segment", () => {
|
||||
expect(loggedPath("/v1/trpc/member.acceptMember")).toBe(
|
||||
"/v1/trpc/member.acceptMember",
|
||||
);
|
||||
expect(loggedPath("/data-rooms/abc123")).toBe("/data-rooms/abc123");
|
||||
expect(loggedPath("/")).toBe("/");
|
||||
});
|
||||
|
||||
it("leaves a route with no secret yet as it is", () => {
|
||||
expect(loggedPath("/verify-member/")).toBe("/verify-member/");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
/** The routes whose next path segment is a secret: an invitation, a signing link. */
|
||||
const SECRET_AFTER = new Set(["verify-member", "esign"]);
|
||||
|
||||
/**
|
||||
* The part of a request path a log may keep: the path alone — a query carries
|
||||
* share tokens and tRPC inputs — with every secret segment replaced.
|
||||
*/
|
||||
export const loggedPath = (pathname: string): string => {
|
||||
const parts = pathname.split("/");
|
||||
return parts
|
||||
.map((part, i) =>
|
||||
i > 0 && SECRET_AFTER.has(parts[i - 1] ?? "") && part ? ":token" : part,
|
||||
)
|
||||
.join("/");
|
||||
};
|
||||
@@ -134,9 +134,11 @@ export async function getPermissions({ db, session }: getPermissionsOptions) {
|
||||
interface getRoleByIdOption {
|
||||
id?: string | null | undefined;
|
||||
tx: TPrismaOrTransaction;
|
||||
/** A custom role is one of this company's, or none. */
|
||||
companyId: string;
|
||||
}
|
||||
|
||||
export const getRoleById = async ({ id, tx }: getRoleByIdOption) => {
|
||||
export const getRoleById = async ({ id, tx, companyId }: getRoleByIdOption) => {
|
||||
if (!id || id === "") {
|
||||
return { role: null, customRoleId: null };
|
||||
}
|
||||
@@ -146,7 +148,7 @@ export const getRoleById = async ({ id, tx }: getRoleByIdOption) => {
|
||||
}
|
||||
|
||||
const { id: customRoleId } = await tx.customRole.findFirstOrThrow({
|
||||
where: { id },
|
||||
where: { id, companyId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
LAST_ADMIN,
|
||||
ONLY_ADMIN_TOUCHES_ADMIN,
|
||||
ONLY_ADMIN_WRITES_ROLES,
|
||||
type Seat,
|
||||
memberChangeRefusal,
|
||||
roleWriteRefusal,
|
||||
} from "./admin";
|
||||
|
||||
const admin = { role: "ADMIN" as const };
|
||||
const custom = { role: "CUSTOM" as const };
|
||||
const adminSeat: Seat = { role: "ADMIN", customRoleId: null, status: "ACTIVE" };
|
||||
const customSeat: Seat = {
|
||||
role: "CUSTOM",
|
||||
customRoleId: "r1",
|
||||
status: "ACTIVE",
|
||||
};
|
||||
const toCustom = (id: string) => ({
|
||||
role: { role: "CUSTOM" as const, customRoleId: id },
|
||||
});
|
||||
const toAdmin = { role: { role: "ADMIN" as const, customRoleId: null } };
|
||||
|
||||
describe("roleWriteRefusal", () => {
|
||||
it("lets only an admin write a role", () => {
|
||||
expect(roleWriteRefusal(admin)).toBeNull();
|
||||
expect(roleWriteRefusal(custom)).toBe(ONLY_ADMIN_WRITES_ROLES);
|
||||
expect(roleWriteRefusal({ role: null })).toBe(ONLY_ADMIN_WRITES_ROLES);
|
||||
});
|
||||
});
|
||||
|
||||
describe("memberChangeRefusal", () => {
|
||||
const refuse = (
|
||||
caller: { role: "ADMIN" | "CUSTOM" | null },
|
||||
target: Seat,
|
||||
change: Parameters<typeof memberChangeRefusal>[0]["change"],
|
||||
activeAdmins = 2,
|
||||
) => memberChangeRefusal({ caller, target, change, activeAdmins });
|
||||
|
||||
it("refuses a non-admin any role change, their own included", () => {
|
||||
expect(refuse(custom, customSeat, toCustom("r2"))).toBe(
|
||||
ONLY_ADMIN_WRITES_ROLES,
|
||||
);
|
||||
expect(refuse(custom, customSeat, toAdmin)).toBe(ONLY_ADMIN_WRITES_ROLES);
|
||||
expect(
|
||||
refuse(
|
||||
custom,
|
||||
{ ...customSeat, role: null, customRoleId: null },
|
||||
toCustom("r1"),
|
||||
),
|
||||
).toBe(ONLY_ADMIN_WRITES_ROLES);
|
||||
});
|
||||
|
||||
it("lets a non-admin change what is not a role", () => {
|
||||
expect(refuse(custom, customSeat, {})).toBeNull();
|
||||
expect(refuse(custom, customSeat, toCustom("r1"))).toBeNull();
|
||||
expect(refuse(custom, customSeat, { status: "INACTIVE" })).toBeNull();
|
||||
expect(refuse(custom, customSeat, { remove: true })).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a non-admin anything done to an admin", () => {
|
||||
for (const change of [
|
||||
{},
|
||||
{ status: "INACTIVE" as const },
|
||||
{ remove: true as const },
|
||||
]) {
|
||||
expect(refuse(custom, adminSeat, change)).toBe(ONLY_ADMIN_TOUCHES_ADMIN);
|
||||
}
|
||||
});
|
||||
|
||||
it("lets an admin change roles and admins", () => {
|
||||
expect(refuse(admin, customSeat, toAdmin)).toBeNull();
|
||||
expect(refuse(admin, adminSeat, toCustom("r1"))).toBeNull();
|
||||
expect(refuse(admin, adminSeat, { remove: true })).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the last active admin", () => {
|
||||
expect(refuse(admin, adminSeat, { remove: true }, 1)).toBe(LAST_ADMIN);
|
||||
expect(refuse(admin, adminSeat, { status: "INACTIVE" }, 1)).toBe(
|
||||
LAST_ADMIN,
|
||||
);
|
||||
expect(refuse(admin, adminSeat, toCustom("r1"), 1)).toBe(LAST_ADMIN);
|
||||
expect(refuse(admin, adminSeat, toAdmin, 1)).toBeNull();
|
||||
expect(refuse(admin, adminSeat, { status: "ACTIVE" }, 1)).toBeNull();
|
||||
});
|
||||
|
||||
it("does not count a pending admin invitation as the last admin", () => {
|
||||
expect(
|
||||
refuse(admin, { ...adminSeat, status: "PENDING" }, { remove: true }, 1),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
// Who may change whose role. A role is authority, so only an ADMIN writes one —
|
||||
// a custom role's permissions or a member's role — and only an ADMIN acts on an
|
||||
// ADMIN. A company always keeps one ACTIVE ADMIN.
|
||||
|
||||
export type Role = "ADMIN" | "CUSTOM" | null;
|
||||
export type Status = "ACTIVE" | "INACTIVE" | "PENDING";
|
||||
|
||||
/** A member as a change sees it. */
|
||||
export interface Seat {
|
||||
role: Role;
|
||||
customRoleId: string | null;
|
||||
status: Status;
|
||||
}
|
||||
|
||||
/** What a change does to a member: removes them, or sets a role or a status. */
|
||||
export type Change =
|
||||
| { remove: true }
|
||||
| {
|
||||
remove?: false;
|
||||
role?: { role: Role; customRoleId: string | null };
|
||||
status?: Status;
|
||||
};
|
||||
|
||||
export const ONLY_ADMIN_WRITES_ROLES = "Only an admin can change a role.";
|
||||
export const ONLY_ADMIN_TOUCHES_ADMIN = "Only an admin can change an admin.";
|
||||
export const LAST_ADMIN =
|
||||
"This is the company's last admin. Make another member an admin first.";
|
||||
|
||||
/** Why the caller may not write a role at all, or null when they may. */
|
||||
export const roleWriteRefusal = (caller: { role: Role }): string | null =>
|
||||
caller.role === "ADMIN" ? null : ONLY_ADMIN_WRITES_ROLES;
|
||||
|
||||
/**
|
||||
* Why `caller` may not make `change` to `target`, or null when they may.
|
||||
* `activeAdmins` counts the company's ACTIVE ADMINs, the target included.
|
||||
*/
|
||||
export const memberChangeRefusal = ({
|
||||
caller,
|
||||
target,
|
||||
change,
|
||||
activeAdmins,
|
||||
}: {
|
||||
caller: { role: Role };
|
||||
target: Seat;
|
||||
change: Change;
|
||||
activeAdmins: number;
|
||||
}): string | null => {
|
||||
const admin = caller.role === "ADMIN";
|
||||
const next = change.remove ? undefined : change;
|
||||
|
||||
if (target.role === "ADMIN" && !admin) return ONLY_ADMIN_TOUCHES_ADMIN;
|
||||
|
||||
const roleChanges =
|
||||
!!next?.role &&
|
||||
(next.role.role !== target.role ||
|
||||
next.role.customRoleId !== target.customRoleId);
|
||||
if (roleChanges && !admin) return ONLY_ADMIN_WRITES_ROLES;
|
||||
|
||||
const losesAdmin =
|
||||
target.role === "ADMIN" &&
|
||||
target.status === "ACTIVE" &&
|
||||
(change.remove === true ||
|
||||
(roleChanges && next?.role?.role !== "ADMIN") ||
|
||||
(next?.status !== undefined && next.status !== "ACTIVE"));
|
||||
if (losesAdmin && activeAdmins <= 1) return LAST_ADMIN;
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SESSION_MAX_AGE, iamTokenExpiry, sessionLive } from "./session";
|
||||
|
||||
const jwt = (claims: object) =>
|
||||
`h.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.s`;
|
||||
|
||||
describe("iamTokenExpiry", () => {
|
||||
it("reads the token response's expiry first", () => {
|
||||
expect(
|
||||
iamTokenExpiry({
|
||||
expires_at: 1_700_000_000,
|
||||
access_token: jwt({ exp: 1 }),
|
||||
}),
|
||||
).toBe(1_700_000_000_000);
|
||||
});
|
||||
|
||||
it("falls back to the access token's own exp", () => {
|
||||
expect(iamTokenExpiry({ access_token: jwt({ exp: 1_700_000_500 }) })).toBe(
|
||||
1_700_000_500_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("is 0 when neither states one", () => {
|
||||
expect(iamTokenExpiry({})).toBe(0);
|
||||
expect(iamTokenExpiry({ access_token: "opaque" })).toBe(0);
|
||||
expect(iamTokenExpiry({ access_token: jwt({ sub: "x" }) })).toBe(0);
|
||||
expect(iamTokenExpiry({ expires_at: null, access_token: "a.!!!.b" })).toBe(
|
||||
0,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessionLive", () => {
|
||||
const now = 1_700_000_000_000;
|
||||
|
||||
it("holds while the IAM token it signed in with is unexpired", () => {
|
||||
expect(
|
||||
sessionLive({ hanzoToken: "t", hanzoTokenExpires: now + 1 }, now),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("ends when that token expires", () => {
|
||||
expect(sessionLive({ hanzoToken: "t", hanzoTokenExpires: now }, now)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
sessionLive({ hanzoToken: "t", hanzoTokenExpires: now - 1 }, now),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("never holds without a token or a stated expiry", () => {
|
||||
expect(sessionLive({}, now)).toBe(false);
|
||||
expect(sessionLive({ hanzoTokenExpires: now + 1 }, now)).toBe(false);
|
||||
expect(sessionLive({ hanzoToken: "t" }, now)).toBe(false);
|
||||
expect(sessionLive({ hanzoToken: "t", hanzoTokenExpires: 0 }, 0)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SESSION_MAX_AGE", () => {
|
||||
it("is shorter than the IAM access token's 168 hours", () => {
|
||||
expect(SESSION_MAX_AGE).toBeLessThanOrEqual(168 * 60 * 60);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
// A Captable session lives as long as the IAM access token it signed in with,
|
||||
// and no longer. IAM decides who may sign in (org hanzo, enabled accounts), so
|
||||
// a person IAM moves or disables loses Captable when that token expires, and
|
||||
// the next sign-in goes back through IAM.
|
||||
|
||||
/**
|
||||
* How long a session lasts unused. It is shorter than the IAM access token's
|
||||
* own lifetime (168 hours on admin/hanzo-captable), which bounds every session
|
||||
* however often it is used.
|
||||
*/
|
||||
export const SESSION_MAX_AGE = 24 * 60 * 60;
|
||||
|
||||
/** What a session's token carries about the IAM sign-in behind it. */
|
||||
export interface IamBinding {
|
||||
hanzoToken?: string;
|
||||
hanzoTokenExpires?: number;
|
||||
}
|
||||
|
||||
/** The `exp` claim of a JWT, in seconds since the epoch, or 0. */
|
||||
const jwtExp = (token: string | null | undefined): number => {
|
||||
const payload = token?.split(".")[1];
|
||||
if (!payload) return 0;
|
||||
try {
|
||||
const claims = JSON.parse(Buffer.from(payload, "base64url").toString());
|
||||
return typeof claims?.exp === "number" ? claims.exp : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* When the access token a sign-in received expires, in milliseconds since the
|
||||
* epoch: the token response's `expires_in` (which next-auth keeps as
|
||||
* `expires_at`, in seconds), else the token's own `exp`, else 0 — and a session
|
||||
* whose token states no expiry is not one this app keeps.
|
||||
*/
|
||||
export const iamTokenExpiry = (account: {
|
||||
expires_at?: number | null;
|
||||
access_token?: string | null;
|
||||
}): number => {
|
||||
if (typeof account.expires_at === "number" && account.expires_at > 0) {
|
||||
return account.expires_at * 1000;
|
||||
}
|
||||
return jwtExp(account.access_token) * 1000;
|
||||
};
|
||||
|
||||
/** Whether a session still holds an unexpired IAM token. */
|
||||
export const sessionLive = (token: IamBinding, now = Date.now()): boolean =>
|
||||
!!token.hanzoToken &&
|
||||
typeof token.hanzoTokenExpires === "number" &&
|
||||
token.hanzoTokenExpires > now;
|
||||
@@ -0,0 +1,68 @@
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { dirname, join, relative } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { recipientMayOpen } from "./share";
|
||||
|
||||
describe("recipientMayOpen", () => {
|
||||
it("opens for a recipient who is not a member", () => {
|
||||
expect(recipientMayOpen({ memberId: null, member: null })).toBe(true);
|
||||
});
|
||||
|
||||
it("opens for a member only while ACTIVE", () => {
|
||||
const at = (status: string) =>
|
||||
recipientMayOpen({ memberId: "m1", member: { status } });
|
||||
expect(at("ACTIVE")).toBe(true);
|
||||
expect(at("INACTIVE")).toBe(false);
|
||||
expect(at("PENDING")).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a member recipient whose member row is gone", () => {
|
||||
expect(recipientMayOpen({ memberId: "m1", member: null })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// A share link is access. It is minted where it is mailed and nowhere else, so
|
||||
// a read of who something is shared with never hands one out.
|
||||
const src = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const root = dirname(src);
|
||||
|
||||
function files(dir: string): string[] {
|
||||
return readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
|
||||
const p = join(dir, e.name);
|
||||
if (e.isDirectory()) return files(p);
|
||||
return /\.(ts|tsx)$/.test(e.name) && !e.name.endsWith(".test.ts")
|
||||
? [p]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
describe("share links", () => {
|
||||
it("are minted only by the mutations that mail them", () => {
|
||||
const mints = files(src).flatMap((f) => {
|
||||
const n = (readFileSync(f, "utf8").match(/encodeShare\(/g) ?? []).length;
|
||||
return n ? [`${relative(root, f)} ${n}`] : [];
|
||||
});
|
||||
// add-fields is the signing link, which EncodeEmailToken wraps for the
|
||||
// signing mail and the next signer's mail alike.
|
||||
expect(mints.sort()).toEqual([
|
||||
"src/trpc/routers/data-room-router/router.ts 1",
|
||||
"src/trpc/routers/template-field-router/procedures/add-fields.ts 1",
|
||||
"src/trpc/routers/update/procedures/share-update.ts 1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("are minted in the data room's share mutation, not its read", () => {
|
||||
const text = readFileSync(
|
||||
join(src, "trpc/routers/data-room-router/router.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const read = text.slice(
|
||||
text.indexOf("getDataRoom:"),
|
||||
text.indexOf("save:"),
|
||||
);
|
||||
const share = text.slice(text.indexOf("share:"), text.indexOf("unShare:"));
|
||||
expect(read).not.toContain("encodeShare(");
|
||||
expect(share).toContain("encodeShare(");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Whether a share link's recipient may still open what it names. A recipient
|
||||
* who is one of the company's members opens it only while still an ACTIVE
|
||||
* member; anyone else holds it until it is unshared or the link expires.
|
||||
*/
|
||||
export const recipientMayOpen = (recipient: {
|
||||
memberId: string | null;
|
||||
member: { status: string } | null;
|
||||
}): boolean =>
|
||||
recipient.memberId === null || recipient.member?.status === "ACTIVE";
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { companyOwnsKey, uploadRefusal } from "./upload-policy";
|
||||
|
||||
describe("uploadRefusal", () => {
|
||||
const up = (
|
||||
keyPrefix: string,
|
||||
bucketMode: "publicBucket" | "privateBucket",
|
||||
contentType: string,
|
||||
) => uploadRefusal({ keyPrefix, bucketMode, contentType });
|
||||
|
||||
it("signs a logo or an avatar as a public picture", () => {
|
||||
expect(up("company-logos", "publicBucket", "image/png")).toBeNull();
|
||||
expect(up("profile-avatars", "publicBucket", "image/webp")).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a public object that is not a picture it allows", () => {
|
||||
for (const type of ["text/html", "image/svg+xml", "application/pdf", ""]) {
|
||||
expect(up("company-logos", "publicBucket", type)).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps company documents private and pictures public", () => {
|
||||
expect(up("generic-documents", "publicBucket", "image/png")).not.toBeNull();
|
||||
expect(up("company-logos", "privateBucket", "image/png")).not.toBeNull();
|
||||
expect(
|
||||
up("generic-documents", "privateBucket", "application/pdf"),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("companyOwnsKey", () => {
|
||||
it("is a key under the company's own prefix", () => {
|
||||
expect(companyOwnsKey("abc123/shares-docs-x-1a2b.pdf", "abc123")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(companyOwnsKey("abc123/data-room/r1-x.pdf", "abc123")).toBe(true);
|
||||
});
|
||||
|
||||
it("is never another prefix, a look-alike, or a climb out", () => {
|
||||
expect(companyOwnsKey("other/x.pdf", "abc123")).toBe(false);
|
||||
expect(companyOwnsKey("abc1234/x.pdf", "abc123")).toBe(false);
|
||||
expect(companyOwnsKey("abc123/../other/x.pdf", "abc123")).toBe(false);
|
||||
expect(companyOwnsKey("abc123/x.pdf", "")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
// What a browser may put in the object store, and where.
|
||||
|
||||
/** The prefixes whose objects are pictures served to anyone, from the public bucket. */
|
||||
export const PUBLIC_PREFIXES = ["company-logos", "profile-avatars"] as const;
|
||||
|
||||
/** The picture types a public object may be. No SVG: it can carry script. */
|
||||
export const PUBLIC_IMAGE_TYPES = [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
] as const;
|
||||
|
||||
const isPublicPrefix = (prefix: string) =>
|
||||
(PUBLIC_PREFIXES as readonly string[]).includes(prefix);
|
||||
|
||||
/**
|
||||
* Why an upload may not be signed, or null when it may: a public object is one
|
||||
* of the public prefixes and an allowed picture type, and every other prefix
|
||||
* goes to the private bucket.
|
||||
*/
|
||||
export const uploadRefusal = ({
|
||||
keyPrefix,
|
||||
bucketMode,
|
||||
contentType,
|
||||
}: {
|
||||
keyPrefix: string;
|
||||
bucketMode: "publicBucket" | "privateBucket";
|
||||
contentType: string;
|
||||
}): string | null => {
|
||||
if (isPublicPrefix(keyPrefix) !== (bucketMode === "publicBucket")) {
|
||||
return "This file cannot be stored there.";
|
||||
}
|
||||
if (
|
||||
bucketMode === "publicBucket" &&
|
||||
!(PUBLIC_IMAGE_TYPES as readonly string[]).includes(contentType)
|
||||
) {
|
||||
return "Upload a PNG, JPEG, WebP or GIF image.";
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether an object key was minted for this company: every key a member's
|
||||
* upload is signed for starts with the company's public id (the store signs
|
||||
* that prefix itself), so a key under any other prefix is another owner's.
|
||||
*/
|
||||
export const companyOwnsKey = (key: string, companyPublicId: string) =>
|
||||
!!companyPublicId &&
|
||||
key.startsWith(`${companyPublicId}/`) &&
|
||||
!key.split("/").includes("..");
|
||||
+11
-3
@@ -1,17 +1,25 @@
|
||||
import { loggedPath } from "@/lib/log-path";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { NextResponse } from "next/server";
|
||||
import { type NextRequest, userAgent } from "next/server";
|
||||
import { env } from "./env";
|
||||
|
||||
const log = logger.child({ module: "middleware" });
|
||||
// This function can be marked `async` if using `await` inside
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
if (env.LOGS || env.NODE_ENV === "production" || env.NODE_ENV === "staging") {
|
||||
const { url, ip, geo, method } = request;
|
||||
const { method, nextUrl } = request;
|
||||
const time = new Date().toISOString();
|
||||
const { device, browser, isBot } = userAgent(request);
|
||||
|
||||
log.info({ method, time, url, ip, device, browser, geo, isBot });
|
||||
log.info({
|
||||
method,
|
||||
time,
|
||||
path: loggedPath(nextUrl.pathname),
|
||||
device,
|
||||
browser,
|
||||
isBot,
|
||||
});
|
||||
}
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
@@ -41,6 +41,10 @@ function determineCookieName(authUrl: string): string {
|
||||
|
||||
async function validateSessionCookie(authUrl: string, c: Context) {
|
||||
const session = await fetchSessionFromAuthUrl(authUrl, c);
|
||||
// An ended session answers as an empty object, not an error.
|
||||
if (!session?.user?.id) {
|
||||
throw new Error("no session");
|
||||
}
|
||||
const companyIdParam = c.req.param("companyId");
|
||||
const { db } = c.get("services");
|
||||
|
||||
|
||||
+13
-6
@@ -10,6 +10,7 @@ import {
|
||||
} from "next-auth";
|
||||
|
||||
import { env } from "@/env";
|
||||
import { SESSION_MAX_AGE, iamTokenExpiry, sessionLive } from "@/lib/session";
|
||||
import type { MemberStatusEnum } from "@/prisma/enums";
|
||||
import { type TPrismaOrTransaction, db } from "@/server/db";
|
||||
import { getToken } from "next-auth/jwt";
|
||||
@@ -156,9 +157,16 @@ export const authOptions: NextAuthOptions = {
|
||||
// reachable at all.
|
||||
if (account?.access_token) {
|
||||
token.hanzoToken = account.access_token;
|
||||
token.hanzoTokenExpires = account.expires_at
|
||||
? account.expires_at * 1000
|
||||
: undefined;
|
||||
token.hanzoTokenExpires = iamTokenExpiry(account);
|
||||
}
|
||||
|
||||
// Every read of a session runs through here — the tRPC context, server
|
||||
// pages, the REST API's session cookie and the server actions all call
|
||||
// getServerSession — so this one check ends the session everywhere at
|
||||
// once. next-auth answers a throw by clearing the cookie and reporting no
|
||||
// session.
|
||||
if (!sessionLive(token)) {
|
||||
throw new Error("the Hanzo sign-in behind this session has expired");
|
||||
}
|
||||
|
||||
// The org claim rides the verified id_token. It is not a User column, and
|
||||
@@ -222,6 +230,7 @@ export const authOptions: NextAuthOptions = {
|
||||
secret: env.NEXTAUTH_SECRET,
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
maxAge: SESSION_MAX_AGE,
|
||||
},
|
||||
providers: [HanzoIAMProvider()],
|
||||
|
||||
@@ -262,9 +271,7 @@ export async function hanzoAccessToken(headers: Headers): Promise<string> {
|
||||
secret: env.NEXTAUTH_SECRET,
|
||||
});
|
||||
|
||||
if (!token?.hanzoToken) return "";
|
||||
if (token.hanzoTokenExpires && token.hanzoTokenExpires <= Date.now())
|
||||
return "";
|
||||
if (!token?.hanzoToken || !sessionLive(token)) return "";
|
||||
return token.hanzoToken;
|
||||
}
|
||||
|
||||
|
||||
@@ -75,21 +75,32 @@ export const getPresignedPutUrl = async ({
|
||||
12,
|
||||
)}${ext}`;
|
||||
|
||||
// A public object is served to anyone, so the type and disposition it is
|
||||
// served with are signed into its PUT: an upload carrying any others is
|
||||
// refused by the store. A private object is only ever read through a signed
|
||||
// GET, and keeps the octet-stream type its bytes have always been sent with.
|
||||
const isPublic = bucketMode === "publicBucket";
|
||||
const headers: Record<string, string> = isPublic
|
||||
? { "Content-Type": contentType, "Content-Disposition": "inline" }
|
||||
: { "Content-Type": "application/octet-stream" };
|
||||
|
||||
const putObjectCommand = new PutObjectCommand({
|
||||
Bucket: bucketMode === "privateBucket" ? PrivateBucket : PublicBucket,
|
||||
Bucket: isPublic ? PublicBucket : PrivateBucket,
|
||||
Key,
|
||||
ContentType: contentType,
|
||||
ACL: bucketMode === "privateBucket" ? "private" : "public-read",
|
||||
...(isPublic && { ContentDisposition: "inline" }),
|
||||
ACL: isPublic ? "public-read" : "private",
|
||||
});
|
||||
|
||||
const url: string = await getSignedUrl(S3, putObjectCommand, {
|
||||
expiresIn: expiresIn ?? TEN_MINUTES_IN_SECONDS,
|
||||
...(isPublic && { signableHeaders: new Set(["content-type"]) }),
|
||||
});
|
||||
|
||||
const bucketUrl = new URL(url);
|
||||
bucketUrl.search = "";
|
||||
|
||||
return { url, key: Key, bucketUrl: bucketUrl.toString() };
|
||||
return { url, key: Key, bucketUrl: bucketUrl.toString(), headers };
|
||||
};
|
||||
|
||||
export const getPresignedGetUrl = async (
|
||||
|
||||
@@ -4,6 +4,12 @@ import {
|
||||
parseMemberIdentifier,
|
||||
sameAddress,
|
||||
} from "@/lib/invite";
|
||||
import {
|
||||
type Change,
|
||||
type Role,
|
||||
memberChangeRefusal,
|
||||
roleWriteRefusal,
|
||||
} from "@/lib/rbac/admin";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { nanoid } from "nanoid";
|
||||
import { type TPrismaOrTransaction, db } from "./db";
|
||||
@@ -121,3 +127,43 @@ export async function revokeExistingInviteTokens({
|
||||
where: { identifier: { endsWith: `:${memberId}` } },
|
||||
});
|
||||
}
|
||||
|
||||
/** Refuses a caller who may not write roles (roleWriteRefusal). */
|
||||
export function authorizeRoleWrite(caller: { role: Role }) {
|
||||
const refusal = roleWriteRefusal(caller);
|
||||
if (refusal) throw new TRPCError({ code: "FORBIDDEN", message: refusal });
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a member of the company and refuses `change` when the caller may not
|
||||
* make it (memberChangeRefusal): a role written by a non-admin, anything a
|
||||
* non-admin does to an admin, or the company losing its last ACTIVE ADMIN.
|
||||
*/
|
||||
export async function authorizeMemberChange(
|
||||
tx: TPrismaOrTransaction,
|
||||
{
|
||||
caller,
|
||||
companyId,
|
||||
memberId,
|
||||
change,
|
||||
}: {
|
||||
caller: { role: Role };
|
||||
companyId: string;
|
||||
memberId: string;
|
||||
change: Change;
|
||||
},
|
||||
) {
|
||||
const target = await tx.member.findFirst({
|
||||
where: { id: memberId, companyId },
|
||||
select: { role: true, customRoleId: true, status: true },
|
||||
});
|
||||
if (!target) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "member not found" });
|
||||
}
|
||||
const activeAdmins = await tx.member.count({
|
||||
where: { companyId, role: "ADMIN", status: "ACTIVE" },
|
||||
});
|
||||
const refusal = memberChangeRefusal({ caller, target, change, activeAdmins });
|
||||
if (refusal) throw new TRPCError({ code: "FORBIDDEN", message: refusal });
|
||||
return target;
|
||||
}
|
||||
|
||||
+43
-27
@@ -6,7 +6,9 @@
|
||||
// member of it. The signing itself is ./file-uploads.ts, which checks nothing
|
||||
// and is reachable from server code alone.
|
||||
|
||||
import { uploadRefusal } from "@/lib/upload-policy";
|
||||
import { checkMembership, getServerAuthSession } from "@/server/auth";
|
||||
import { companyStands } from "@/server/company";
|
||||
import { db } from "@/server/db";
|
||||
import {
|
||||
getPresignedGetUrl,
|
||||
@@ -34,37 +36,51 @@ const activeMembership = async (
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* A presigned PUT, for a file filed under the caller's own account (a profile
|
||||
* photo, the logo of a company being founded) or under the company they are an
|
||||
* active member of. The object key is minted here, never taken from the caller.
|
||||
*/
|
||||
export async function presignUpload(options: getPresignedUrlOptions) {
|
||||
const session = await signedIn();
|
||||
const { identifier, keyPrefix, bucketMode } = options;
|
||||
/** An upload a browser may ask to be signed. Where it lands is decided here. */
|
||||
export type UploadRequest = Pick<
|
||||
getPresignedUrlOptions,
|
||||
"contentType" | "fileName" | "keyPrefix" | "bucketMode"
|
||||
>;
|
||||
|
||||
// Only pictures meant to be shown anywhere go to the public bucket.
|
||||
if (
|
||||
bucketMode === "publicBucket" &&
|
||||
keyPrefix !== "company-logos" &&
|
||||
keyPrefix !== "profile-avatars"
|
||||
) {
|
||||
/**
|
||||
* A presigned PUT for an ACTIVE member: a profile photo under their account,
|
||||
* anything else under their company's public id, and a public object only as
|
||||
* an allowed picture (uploadRefusal). The one upload without a membership is
|
||||
* the founder's logo, made before the company it belongs to exists. The key's
|
||||
* prefix is minted here, never taken from the caller.
|
||||
*/
|
||||
export async function presignUpload(request: UploadRequest) {
|
||||
const session = await signedIn();
|
||||
const { keyPrefix, bucketMode, contentType, fileName } = request;
|
||||
|
||||
const refusal = uploadRefusal({ keyPrefix, bucketMode, contentType });
|
||||
if (refusal) throw new Error(refusal);
|
||||
|
||||
const membership = await activeMembership(session);
|
||||
let identifier: string;
|
||||
if (membership) {
|
||||
identifier =
|
||||
keyPrefix === "profile-avatars"
|
||||
? session.user.id
|
||||
: (
|
||||
await db.company.findUniqueOrThrow({
|
||||
where: { id: membership.companyId },
|
||||
select: { publicId: true },
|
||||
})
|
||||
).publicId;
|
||||
} else if (keyPrefix === "company-logos" && !(await companyStands())) {
|
||||
identifier = session.user.id;
|
||||
} else {
|
||||
throw refused();
|
||||
}
|
||||
|
||||
if (identifier !== session.user.id) {
|
||||
const membership = await activeMembership(session);
|
||||
if (
|
||||
!membership ||
|
||||
identifier !== session.user.companyPublicId ||
|
||||
!session.user.companyPublicId
|
||||
) {
|
||||
throw refused();
|
||||
}
|
||||
}
|
||||
|
||||
// The URL lives for the store's default ten minutes, whatever was asked.
|
||||
return getPresignedPutUrl({ ...options, expiresIn: undefined });
|
||||
return getPresignedPutUrl({
|
||||
contentType,
|
||||
fileName,
|
||||
keyPrefix,
|
||||
bucketMode,
|
||||
identifier,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { TPrismaOrTransaction } from "@/server/db";
|
||||
import { type EsignGetTemplateType, getEsignTemplate } from "@/server/esign";
|
||||
import { EncodeEmailToken } from "@/trpc/routers/template-field-router/procedures/add-fields";
|
||||
import type { EsignRecipientStatus } from "@prisma/client";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
export class EsignService {
|
||||
getTemplate(templateId: string, tx: TPrismaOrTransaction) {
|
||||
@@ -32,15 +33,29 @@ export class EsignService {
|
||||
});
|
||||
}
|
||||
|
||||
updateRecipientStatus(
|
||||
/**
|
||||
* Moves a recipient to `status` from PENDING or SENT, and only from there: of
|
||||
* two signings racing for one recipient, the second finds it already moved,
|
||||
* changes nothing, and is refused, so a document is signed and completed once.
|
||||
*/
|
||||
async updateRecipientStatus(
|
||||
recipientId: string,
|
||||
status: EsignRecipientStatus,
|
||||
tx: TPrismaOrTransaction,
|
||||
) {
|
||||
return tx.esignRecipient.update({
|
||||
where: { id: recipientId },
|
||||
const from = (["PENDING", "SENT"] as EsignRecipientStatus[]).filter(
|
||||
(s) => s !== status,
|
||||
);
|
||||
const { count } = await tx.esignRecipient.updateMany({
|
||||
where: { id: recipientId, status: { in: from } },
|
||||
data: { status },
|
||||
});
|
||||
if (count !== 1) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "This document has already been signed.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async handleOrderedDelivery(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { companyOwnsKey } from "@/lib/upload-policy";
|
||||
import { Audit } from "@/server/audit";
|
||||
import type { TPrismaOrTransaction } from "@/server/db";
|
||||
import { withAccessControl } from "@/trpc/api/trpc";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import {
|
||||
type TypeZodCreateBucketMutationSchema,
|
||||
ZodCreateBucketMutationSchema,
|
||||
@@ -48,14 +50,34 @@ export const createBucketHandler = async ({
|
||||
export const createBucketProcedure = withAccessControl
|
||||
.meta({ policies: { documents: { allow: ["create"] } } })
|
||||
.input(ZodCreateBucketMutationSchema)
|
||||
.mutation(async ({ ctx: { db, userAgent, requestIp, session }, input }) => {
|
||||
const { name, companyId, id } = session.user;
|
||||
|
||||
return await createBucketHandler({
|
||||
.mutation(
|
||||
async ({
|
||||
ctx: { db, userAgent, requestIp, session, membership },
|
||||
input,
|
||||
db,
|
||||
userAgent,
|
||||
requestIp,
|
||||
user: { name: name || "", companyId, id },
|
||||
});
|
||||
});
|
||||
}) => {
|
||||
const { name, id } = session.user;
|
||||
const { companyId } = membership;
|
||||
|
||||
// A bucket records an object this company's upload put in the store: the
|
||||
// store signs every such upload under the company's public id, so a key
|
||||
// under any other prefix is not this company's to record.
|
||||
const { publicId } = await db.company.findUniqueOrThrow({
|
||||
where: { id: companyId },
|
||||
select: { publicId: true },
|
||||
});
|
||||
if (!companyOwnsKey(input.key, publicId)) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "This file was not uploaded for this company.",
|
||||
});
|
||||
}
|
||||
|
||||
return await createBucketHandler({
|
||||
input,
|
||||
db,
|
||||
userAgent,
|
||||
requestIp,
|
||||
user: { name: name || "", companyId, id },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { RBAC } from "@/lib/rbac";
|
||||
import { ShareRecipientSchema } from "@/schema/contacts";
|
||||
import { Audit } from "@/server/audit";
|
||||
import { createTRPCRouter, withAccessControl } from "@/trpc/api/trpc";
|
||||
import type { DataRoom } from "@prisma/client";
|
||||
import type { DataRoom, DataRoomRecipient } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { DataRoomSchema } from "./schema";
|
||||
|
||||
@@ -32,7 +32,7 @@ export const dataRoomRouter = createTRPCRouter({
|
||||
} as {
|
||||
dataRoom: object;
|
||||
documents: object[];
|
||||
recipients: object[];
|
||||
recipients: DataRoomRecipient[];
|
||||
company: object;
|
||||
};
|
||||
|
||||
@@ -84,17 +84,10 @@ export const dataRoomRouter = createTRPCRouter({
|
||||
}));
|
||||
}
|
||||
|
||||
// Who it is shared with, never their links: a link is minted only
|
||||
// where it is mailed (share), so reading a room hands out no access.
|
||||
if (include.recipients) {
|
||||
response.recipients = await Promise.all(
|
||||
dataRoom.recipients.map(async (recipient) => ({
|
||||
...recipient,
|
||||
token: await encodeShare({
|
||||
companyId,
|
||||
dataRoomId: dataRoom.id,
|
||||
recipientId: recipient.id,
|
||||
}),
|
||||
})),
|
||||
);
|
||||
response.recipients = dataRoom.recipients;
|
||||
}
|
||||
|
||||
return { dataRoom };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { sendMemberInviteEmailJob } from "@/jobs/member-inivite-email";
|
||||
import { generateMemberIdentifier } from "@/lib/invite";
|
||||
import { getRoleById } from "@/lib/rbac/access-control";
|
||||
import { memberChangeRefusal } from "@/lib/rbac/admin";
|
||||
import { Audit } from "@/server/audit";
|
||||
import {
|
||||
generateInviteToken,
|
||||
@@ -71,14 +72,22 @@ export const inviteMemberProcedure = withAccessControl
|
||||
});
|
||||
}
|
||||
|
||||
const role = await getRoleById({ id: roleId, tx });
|
||||
// A role grants what its holder could already grant, so only an ADMIN
|
||||
// makes another ADMIN.
|
||||
if (role.role === "ADMIN" && membership.role !== "ADMIN") {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Only an admin can make another member an admin.",
|
||||
});
|
||||
const role = await getRoleById({ id: roleId, tx, companyId });
|
||||
// Inviting with a role writes that role; re-inviting a pending seat
|
||||
// changes it, so it is that seat's change to make.
|
||||
const refusal = memberChangeRefusal({
|
||||
caller: membership,
|
||||
target: prevMember ?? {
|
||||
role: null,
|
||||
customRoleId: null,
|
||||
status: "PENDING",
|
||||
},
|
||||
change: { role },
|
||||
// A pending seat is never the company's last admin.
|
||||
activeAdmins: 0,
|
||||
});
|
||||
if (refusal) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: refusal });
|
||||
}
|
||||
|
||||
const seatHolder =
|
||||
|
||||
@@ -2,6 +2,7 @@ import { sendMemberInviteEmailJob } from "@/jobs/member-inivite-email";
|
||||
import { generateMemberIdentifier, isPlaceholder } from "@/lib/invite";
|
||||
import { Audit } from "@/server/audit";
|
||||
import {
|
||||
authorizeMemberChange,
|
||||
generateInviteToken,
|
||||
revokeExistingInviteTokens,
|
||||
} from "@/server/member";
|
||||
@@ -33,6 +34,13 @@ export const reInviteProcedure = withAccessControl
|
||||
},
|
||||
});
|
||||
|
||||
await authorizeMemberChange(tx, {
|
||||
caller: membership,
|
||||
companyId,
|
||||
memberId: input.memberId,
|
||||
change: {},
|
||||
});
|
||||
|
||||
const member = await tx.member.findFirstOrThrow({
|
||||
where: {
|
||||
id: input.memberId,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Audit } from "@/server/audit";
|
||||
import type { TPrismaOrTransaction } from "@/server/db";
|
||||
import { authorizeMemberChange } from "@/server/member";
|
||||
import {
|
||||
withAccessControl,
|
||||
type withAccessControlTrpcContextType,
|
||||
@@ -40,6 +41,22 @@ export async function removeMemberHandler({
|
||||
const { memberId } = input;
|
||||
const { companyId } = membership;
|
||||
|
||||
await authorizeMemberChange(db, {
|
||||
caller: membership,
|
||||
companyId,
|
||||
memberId,
|
||||
change: { remove: true },
|
||||
});
|
||||
|
||||
// A member's share links end with the membership: the recipient rows that
|
||||
// name them go with it, rather than surviving as links nobody holds to account.
|
||||
await db.dataRoomRecipient.deleteMany({
|
||||
where: { memberId, dataRoom: { companyId } },
|
||||
});
|
||||
await db.updateRecipient.deleteMany({
|
||||
where: { memberId, update: { companyId } },
|
||||
});
|
||||
|
||||
const member = await db.member.delete({
|
||||
where: {
|
||||
id: memberId,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Audit } from "@/server/audit";
|
||||
import { authorizeMemberChange } from "@/server/member";
|
||||
import { withAccessControl } from "@/trpc/api/trpc";
|
||||
import { ZodToggleActivationMutationSchema } from "../schema";
|
||||
|
||||
@@ -16,6 +17,13 @@ export const toggleActivation = withAccessControl
|
||||
await db.$transaction(async (tx) => {
|
||||
const { companyId } = membership;
|
||||
|
||||
await authorizeMemberChange(tx, {
|
||||
caller: membership,
|
||||
companyId,
|
||||
memberId,
|
||||
change: { status },
|
||||
});
|
||||
|
||||
const member = await tx.member.update({
|
||||
where: {
|
||||
id: memberId,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getRoleById } from "@/lib/rbac/access-control";
|
||||
import { Audit } from "@/server/audit";
|
||||
import { authorizeMemberChange } from "@/server/member";
|
||||
import { withAccessControl } from "@/trpc/api/trpc";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { ZodUpdateMemberMutationSchema } from "../schema";
|
||||
|
||||
export const updateMemberProcedure = withAccessControl
|
||||
@@ -21,15 +21,18 @@ export const updateMemberProcedure = withAccessControl
|
||||
const user = session.user;
|
||||
|
||||
await db.$transaction(async (tx) => {
|
||||
const role = await getRoleById({ tx, id: roleId });
|
||||
// A role grants what its holder could already grant, so only an ADMIN
|
||||
// makes another ADMIN.
|
||||
if (role.role === "ADMIN" && membership.role !== "ADMIN") {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Only an admin can make another member an admin.",
|
||||
});
|
||||
}
|
||||
// A role is touched only when the edit names one; an edit that says
|
||||
// nothing about it keeps the member's role as it is.
|
||||
const role =
|
||||
roleId === undefined
|
||||
? undefined
|
||||
: await getRoleById({ tx, id: roleId, companyId });
|
||||
await authorizeMemberChange(tx, {
|
||||
caller: membership,
|
||||
companyId,
|
||||
memberId,
|
||||
change: role ? { role } : {},
|
||||
});
|
||||
|
||||
const member = await tx.member.update({
|
||||
where: {
|
||||
@@ -39,16 +42,12 @@ export const updateMemberProcedure = withAccessControl
|
||||
},
|
||||
data: {
|
||||
...rest,
|
||||
...(role && { role: role.role }),
|
||||
customRole: {
|
||||
...(role.customRoleId
|
||||
? {
|
||||
connect: {
|
||||
id: role.customRoleId,
|
||||
},
|
||||
}
|
||||
: { disconnect: true }),
|
||||
},
|
||||
...(role && {
|
||||
role: role.role,
|
||||
customRole: role.customRoleId
|
||||
? { connect: { id: role.customRoleId } }
|
||||
: { disconnect: true },
|
||||
}),
|
||||
user: {
|
||||
update: {
|
||||
name,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { TActions } from "@/lib/rbac/actions";
|
||||
import type { TPermission } from "@/lib/rbac/schema";
|
||||
import type { TSubjects } from "@/lib/rbac/subjects";
|
||||
import { Audit } from "@/server/audit";
|
||||
import { authorizeRoleWrite } from "@/server/member";
|
||||
import { withAccessControl } from "@/trpc/api/trpc";
|
||||
import {
|
||||
type TypeZodCreateRoleMutationSchema,
|
||||
@@ -20,6 +21,7 @@ export const createRolesProcedure = withAccessControl
|
||||
input,
|
||||
ctx: { db, membership, requestIp, userAgent, session },
|
||||
}) => {
|
||||
authorizeRoleWrite(membership);
|
||||
const { user } = session;
|
||||
const permissions = extractPermission(input.permissions);
|
||||
const role = await db.customRole.create({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getRoleById } from "@/lib/rbac/access-control";
|
||||
import { Audit } from "@/server/audit";
|
||||
import { authorizeRoleWrite } from "@/server/member";
|
||||
import { withAccessControl } from "@/trpc/api/trpc";
|
||||
import { ZodDeleteRoleMutationSchema } from "../schema";
|
||||
|
||||
@@ -11,14 +12,12 @@ export const deleteRoleProcedure = withAccessControl
|
||||
})
|
||||
.input(ZodDeleteRoleMutationSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const {
|
||||
membership: { companyId },
|
||||
userAgent,
|
||||
requestIp,
|
||||
session,
|
||||
} = ctx;
|
||||
const { membership, userAgent, requestIp, session } = ctx;
|
||||
const { companyId } = membership;
|
||||
authorizeRoleWrite(membership);
|
||||
|
||||
await ctx.db.$transaction(async (tx) => {
|
||||
const role = await getRoleById({ id: input.roleId, tx });
|
||||
const role = await getRoleById({ id: input.roleId, tx, companyId });
|
||||
const { user } = session;
|
||||
if (!role.customRoleId) {
|
||||
throw new Error("default roles cannot be deleted");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getRoleById } from "@/lib/rbac/access-control";
|
||||
import { Audit } from "@/server/audit";
|
||||
import { authorizeRoleWrite } from "@/server/member";
|
||||
import { withAccessControl } from "@/trpc/api/trpc";
|
||||
import { ZodUpdateRoleMutationSchema } from "../schema";
|
||||
import { extractPermission } from "./create-role";
|
||||
@@ -16,16 +17,21 @@ export const updateRolesProcedure = withAccessControl
|
||||
input,
|
||||
ctx: { db, membership, userAgent, requestIp, session },
|
||||
}) => {
|
||||
authorizeRoleWrite(membership);
|
||||
const permissions = extractPermission(input.permissions);
|
||||
const { user } = session;
|
||||
await db.$transaction(async (tx) => {
|
||||
const id = await getRoleById({ id: input.roleId, tx });
|
||||
const id = await getRoleById({
|
||||
id: input.roleId,
|
||||
tx,
|
||||
companyId: membership.companyId,
|
||||
});
|
||||
|
||||
if (!id.customRoleId) {
|
||||
throw new Error("role id not found");
|
||||
}
|
||||
|
||||
const role = await db.customRole.update({
|
||||
const role = await tx.customRole.update({
|
||||
where: {
|
||||
companyId: membership.companyId,
|
||||
id: id.customRoleId,
|
||||
@@ -46,7 +52,7 @@ export const updateRolesProcedure = withAccessControl
|
||||
requestIp,
|
||||
},
|
||||
target: [{ type: "role", id: role.id }],
|
||||
summary: `${user.name} deleted the role ${role.name}`,
|
||||
summary: `${user.name} updated the role ${role.name}`,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@ export const cloneUpdateProcedure = withAccessControl
|
||||
const userName = ctx.session.user.name;
|
||||
const { userAgent, requestIp } = ctx;
|
||||
const publicId = generatePublicId();
|
||||
const { title, content, html } = input;
|
||||
const { title, content } = input;
|
||||
|
||||
if (title.length === 0 || content.length === 0) {
|
||||
return {
|
||||
@@ -25,7 +25,8 @@ export const cloneUpdateProcedure = withAccessControl
|
||||
await ctx.db.$transaction(async (tx) => {
|
||||
const update = await tx.update.create({
|
||||
data: {
|
||||
html,
|
||||
// An update is read from its blocks; no stored markup is ever shown.
|
||||
html: "",
|
||||
title: `Copy of - ${title}`,
|
||||
content,
|
||||
publicId,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { encodeShare } from "@/lib/jwt";
|
||||
import { withAccessControl } from "@/trpc/api/trpc";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -46,16 +45,7 @@ export const getRecipientsProcedure = withAccessControl
|
||||
},
|
||||
});
|
||||
|
||||
const recipients = await Promise.all(
|
||||
data.map(async (recipient) => ({
|
||||
...recipient,
|
||||
token: await encodeShare({
|
||||
updateId,
|
||||
companyId,
|
||||
recipientId: recipient.id,
|
||||
}),
|
||||
})),
|
||||
);
|
||||
|
||||
return recipients;
|
||||
// Who it is shared with, never their links: a link is minted only where
|
||||
// it is mailed (share), so reading an update hands out no access.
|
||||
return data;
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ export const saveUpdateProcedure = withAccessControl
|
||||
const userName = ctx.session.user.name;
|
||||
const { userAgent, requestIp } = ctx;
|
||||
const publicId = input.publicId ?? generatePublicId();
|
||||
const { title, content, html } = input;
|
||||
const { title, content } = input;
|
||||
|
||||
const action = input.publicId ? "update" : "create";
|
||||
if (!RBAC.allows(ctx.permissions, "updates", action)) {
|
||||
@@ -33,7 +33,8 @@ export const saveUpdateProcedure = withAccessControl
|
||||
const update = await tx.update.update({
|
||||
where: { publicId, companyId },
|
||||
data: {
|
||||
html,
|
||||
// An update is read from its blocks; no stored markup is ever shown.
|
||||
html: "",
|
||||
title,
|
||||
content,
|
||||
},
|
||||
@@ -56,7 +57,8 @@ export const saveUpdateProcedure = withAccessControl
|
||||
} else {
|
||||
const update = await tx.update.create({
|
||||
data: {
|
||||
html,
|
||||
// An update is read from its blocks; no stored markup is ever shown.
|
||||
html: "",
|
||||
title,
|
||||
content,
|
||||
publicId,
|
||||
|
||||
@@ -5,7 +5,6 @@ export const UpdateMutationSchema = z.object({
|
||||
publicId: z.string().optional(),
|
||||
title: z.string(),
|
||||
content: z.any(),
|
||||
html: z.string(),
|
||||
isPublic: z.boolean().optional(),
|
||||
recipients: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
// Next compiles JSX with React's automatic runtime; the tests compile it the
|
||||
// same way, so a component renders under vitest exactly as the app builds it.
|
||||
export default defineConfig({
|
||||
esbuild: { jsx: "automatic" },
|
||||
});
|
||||
Reference in New Issue
Block a user