Captable grants access by invitation and role, never by signing in

Sign-in binds an account to its IAM subject only; an address that already
names another account is refused, not merged. The profile is IAM's userinfo,
and each sign-in records whether IAM has proven the address.

An invitation is accepted by its token alone: the seat and the address come
from the token row, the address must be the one IAM has proven for the
signed-in account, the seat must still be pending, and the token is spent
with every other token for that seat. Invitations seat a stand-in with no
address until accepted, expire after seven days, and leave no link in logs.

Every procedure that reads or changes company data requires an ACTIVE
member whose role grants the action; the cap table, investor updates, data
rooms, templates, contacts and API tokens all name their subject, and
`securities` and `updates` are new subjects a saved custom role holds
nothing on. Only an admin makes another admin. Company creation is
bootstrap-only, and profile edits no longer change the sign-in address.

Share links for data rooms, updates and signing expire after 30 days and
end when their recipient is removed. Signing takes the link token and fills
only the signer's own fields.

The object-store signers are server-only; the browser reaches them through
two Server Actions that require a signed-in caller and, for company files,
an active member whose company owns the file. The REST API requires an
active membership and an unexpired token.

The local email/password lane — sign-up, email verification, password
reset and password change — is removed; Hanzo IAM is the only sign-in.


Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
zooqueen
2026-10-16 07:21:13 -07:00
co-authored by Hanzo Dev
parent 3c831f4949
commit f5db9eba80
122 changed files with 1570 additions and 2606 deletions
@@ -2,7 +2,7 @@ import FileIcon from "@/components/common/file-icon";
import FilePreview from "@/components/file/preview";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { withServerComponentSession } from "@/server/auth";
import { serverPermission } from "@/lib/rbac/access-control";
import { db } from "@/server/db";
import { getPresignedGetUrl } from "@/server/file-uploads";
import { RiArrowLeftSLine } from "@remixicon/react";
@@ -15,8 +15,11 @@ const DocumentPreview = async ({
}: {
params: { publicId: string; bucketId: string };
}) => {
const session = await withServerComponentSession();
const companyId = session?.user?.companyId;
const { membership, allowed } = await serverPermission("documents", "read");
if (!allowed) {
return notFound();
}
const { companyId } = membership;
const document = await db.document.findFirst({
where: {
bucketId,
@@ -3,7 +3,7 @@
import { dayjsExt } from "@/common/dayjs";
import FileIcon from "@/components/common/file-icon";
import { Card } from "@/components/ui/card";
import { getPresignedGetUrl } from "@/server/file-uploads";
import { presignDownload } from "@/server/storage";
import { RiMore2Fill } from "@remixicon/react";
import { useRouter } from "next/navigation";
@@ -35,7 +35,7 @@ type DocumentTableProps = {
const DocumentsTable = ({ documents, companyPublicId }: DocumentTableProps) => {
const router = useRouter();
const openFileOnTab = async (key: string) => {
const fileUrl = await getPresignedGetUrl(key);
const fileUrl = await presignDownload(key);
window.open(fileUrl.url, "_blank");
};
@@ -1,8 +1,10 @@
import EmptyState from "@/components/common/empty-state";
import { Button } from "@/components/ui/button";
import { serverPermission } from "@/lib/rbac/access-control";
import { getServerComponentAuthSession } from "@/server/auth";
import { db } from "@/server/db";
import { RiAddFill, RiFolderCheckFill } from "@remixicon/react";
import { notFound } from "next/navigation";
import { Fragment } from "react";
import DataRoomPopover from "./components/data-room-popover";
import Folders from "./components/dataroom-folders";
@@ -32,8 +34,12 @@ const DataRoomPage = async () => {
return null;
}
const { companyId, companyPublicId } = session.user;
const dataRooms = await getDataRooms(companyId);
const { membership, allowed } = await serverPermission("documents", "read");
if (!allowed) {
return notFound();
}
const { companyPublicId } = session.user;
const dataRooms = await getDataRooms(membership.companyId);
return (
<Fragment>
@@ -7,13 +7,8 @@ export const metadata: Metadata = {
title: "Security",
};
// Sign-in and its password are Hanzo's; nothing here holds one.
const SecurityLists = [
{
title: "Password",
description: "Update your password to keep your account secure and safe.",
href: "security/password",
buttonDisplayName: "Update password",
},
{
title: "Passkeys",
description: "Add, delete or update passkey configuration.",
@@ -33,7 +28,7 @@ export default async function SecurityPage() {
<>
<SettingsHeader
title="Security"
subtitle="Manage your password and security settings."
subtitle="Manage your security settings."
showBackArrow={false}
/>
{SecurityLists.map((security) => (
@@ -1,24 +0,0 @@
import { SettingsHeader } from "@/components/security/SettingHeader";
import { UpdatePasswordForm } from "@/components/security/password/update-password-form";
import { Card } from "@/components/ui/card";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Security",
};
export default function SecurityPage() {
return (
<div className="flex flex-col gap-y-3">
<div>
<SettingsHeader
title="Update Password"
subtitle="Update your password to keep your account secure and safe."
/>
</div>
<Card className="mx-auto mt-3 w-[28rem] sm:w-[38rem] md:w-full">
<UpdatePasswordForm />
</Card>
</div>
);
}
@@ -1,21 +1,30 @@
import { serverPermission } from "@/lib/rbac/access-control";
import { db } from "@/server/db";
import { notFound } from "next/navigation";
import { EditorWrapper } from "./editor-wrapper";
async function getUpdate(publicId: string) {
return await db.update.findFirstOrThrow({
where: { publicId },
});
}
const UpdatePage = async ({
params: { publicId, updatePublicId },
}: {
params: { publicId: string; updatePublicId: string };
}) => {
if (updatePublicId === "new") {
const isNew = updatePublicId === "new";
const { membership, allowed } = await serverPermission(
"updates",
isNew ? "create" : "read",
);
if (!allowed) {
return notFound();
}
if (isNew) {
return <EditorWrapper companyPublicId={publicId} mode="new" />;
}
const update = await getUpdate(updatePublicId);
const update = await db.update.findFirst({
where: { publicId: updatePublicId, companyId: membership.companyId },
});
if (!update) {
return notFound();
}
return (
<EditorWrapper companyPublicId={publicId} update={update} mode="edit" />
@@ -1,5 +1,6 @@
import { CompanyForm } from "@/components/onboarding/company-form";
import { withServerComponentSession } from "@/server/auth";
import { COMPANY_STANDS, companyStands } from "@/server/company";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
@@ -15,6 +16,21 @@ const OnboardingPage = async () => {
redirect("/dashboard");
}
// Signing in grants nothing: a person with no membership of the company that
// stands sees who to ask, not a form that would make them an admin.
if (await companyStands()) {
return (
<div className="flex min-h-screen justify-center bg-background px-5 pb-5 pt-20">
<div className="w-full max-w-2xl rounded-xl border bg-card p-10 text-card-foreground shadow">
<h1 className="mb-2 text-2xl font-semibold tracking-tight">
No access yet
</h1>
<p className="text-sm text-muted-foreground">{COMPANY_STANDS}</p>
</div>
</div>
);
}
return (
<div className="flex min-h-screen justify-center bg-background px-5 pb-5 pt-20">
<div className="w-full max-w-2xl rounded-xl border bg-card p-10 text-card-foreground shadow">
+1 -4
View File
@@ -22,8 +22,6 @@ export default async function SigningPage(props: SigningPageProps) {
const {
fields,
url,
recipientId,
templateId,
signableFields,
status: templateStatus,
} = await api.template.getSigningFields.query({
@@ -55,8 +53,7 @@ export default async function SigningPage(props: SigningPageProps) {
</div>
<div className="sticky top-0 flex min-h-full w-80 flex-col lg:border-l">
<SigningFields
recipientId={recipientId}
templateId={templateId}
token={token}
fields={signableFields}
companyPublicId={companyPublicId}
/>
@@ -1,8 +0,0 @@
import CheckEmailComponent from "@/components/onboarding/check-email";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Check Email",
};
export default function CheckEmail() {
return <CheckEmailComponent />;
}
@@ -1,10 +0,0 @@
import EmailSent from "@/components/onboarding/email-sent";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Email Sent",
};
export default function EmailSentPage() {
return <EmailSent />;
}
@@ -1,9 +0,0 @@
import ForgotPassword from "@/components/onboarding/forgot-password";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Forgot Password",
};
export default function ForgotPasswordPage() {
return <ForgotPassword />;
}
+12 -3
View File
@@ -1,5 +1,6 @@
import { APP_NAME } from "@/lib/branding";
import SignInForm from "@/components/onboarding/signin";
import { APP_NAME } from "@/lib/branding";
import { sameSitePath } from "@/lib/same-site";
import { getServerComponentAuthSession } from "@/server/auth";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
@@ -9,15 +10,23 @@ export const metadata: Metadata = {
description: `Login to ${APP_NAME}`,
};
export default async function SignIn() {
export default async function SignIn({
searchParams,
}: {
searchParams: { callbackUrl?: string; error?: string };
}) {
const session = await getServerComponentAuthSession();
const callbackUrl = sameSitePath(searchParams.callbackUrl, "/onboarding");
if (session?.user) {
if (callbackUrl !== "/onboarding") {
return redirect(callbackUrl);
}
if (session?.user?.companyPublicId) {
return redirect(`/${session.user.companyPublicId}`);
}
return redirect("/onboarding");
}
return <SignInForm />;
return <SignInForm callbackUrl={callbackUrl} error={searchParams.error} />;
}
@@ -1,31 +0,0 @@
import { Button } from "@/components/ui/button";
import { RiCheckboxCircleLine } from "@remixicon/react";
import type { Metadata } from "next";
import Link from "next/link";
export const metadata: Metadata = {
title: "Password Updated",
};
export default function PasswordUpdated() {
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="grid w-full max-w-md grid-cols-1 gap-5 rounded-xl border bg-card p-10 text-card-foreground shadow">
<div className="flex flex-col gap-y-2 text-center">
<RiCheckboxCircleLine className="h-10 w-auto" />
<h1 className="text-2xl font-semibold tracking-tight">
Password Updated
</h1>
<p className="text-sm text-muted-foreground">
Your password has been updated successfully.
</p>
<Link href="/" className="mt-4">
<Button size="lg">Return to login page</Button>
</Link>
</div>
</div>
</div>
);
}
@@ -1,18 +0,0 @@
import { ResetPasswordForm } from "@/components/onboarding/reset-password";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Reset Password",
};
export type PageProps = {
params: {
token: string;
};
};
export default async function ResetPasswordPage({
params: { token },
}: PageProps) {
return <ResetPasswordForm token={token} />;
}
@@ -1,30 +0,0 @@
import { Button } from "@/components/ui/button";
import type { Metadata } from "next";
import Link from "next/link";
export const metadata: Metadata = {
title: "Reset Password'",
};
export default function EmailVerificationWithoutTokenPage() {
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="grid w-full max-w-md grid-cols-1 gap-5 rounded-xl border bg-card p-10 text-card-foreground shadow">
<div className="flex flex-col gap-y-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight">
Uh oh! Looks like you&apos;re missing a token
</h1>
<p className="text-sm text-muted-foreground">
It seems that there is no token provided, if you are trying to reset
your password please follow the link in your email.
</p>
<Link href="/" className="mt-4">
<Button size="lg">Go back home</Button>
</Link>
</div>
</div>
</div>
);
}
@@ -1,37 +0,0 @@
import { SetPasswordForm } from "@/components/onboarding/set-password";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Set Password",
};
export type PageProps = {
params: {
token: string;
};
searchParams: {
verificationToken: string;
email: string;
};
};
export default async function SetPasswordPage({
params: { token },
searchParams,
}: PageProps) {
const verificationToken = searchParams.verificationToken;
const email = searchParams.email;
if (!verificationToken || !email) {
redirect("/set-password");
}
return (
<SetPasswordForm
token={token}
email={email}
verificationToken={verificationToken}
/>
);
}
@@ -1,30 +0,0 @@
import { Button } from "@/components/ui/button";
import type { Metadata } from "next";
import Link from "next/link";
export const metadata: Metadata = {
title: "Set Password'",
};
export default function SetPasswordWithoutTokenPage() {
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="grid w-full max-w-md grid-cols-1 gap-5 rounded-xl border bg-card p-10 text-card-foreground shadow">
<div className="flex flex-col gap-y-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight">
Uh oh! Looks like you&apos;re missing a token
</h1>
<p className="text-sm text-muted-foreground">
It seems that there is no token provided, if you are trying to set
your password please follow the link in your email.
</p>
<Link href="/" className="mt-4">
<Button size="lg">Go back home</Button>
</Link>
</div>
</div>
</div>
);
}
-23
View File
@@ -1,23 +0,0 @@
import { APP_NAME } from "@/lib/branding";
import SignUpForm from "@/components/onboarding/signup";
import { getServerComponentAuthSession } from "@/server/auth";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Sign Up",
description: `Sign Up to ${APP_NAME}`,
};
export default async function SignIn() {
const session = await getServerComponentAuthSession();
if (session?.user) {
if (session?.user?.companyPublicId) {
return redirect(`/${session.user.companyPublicId}`);
}
return redirect("/onboarding");
}
return <SignUpForm />;
}
@@ -1,18 +0,0 @@
import VerifyEmail from "@/components/onboarding/verify-email";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Verify Email",
};
export type PageProps = {
params: {
token: string;
};
};
export default async function VerifyEmailPage({
params: { token },
}: PageProps) {
return <VerifyEmail token={token} />;
}
@@ -1,30 +0,0 @@
import { Button } from "@/components/ui/button";
import type { Metadata } from "next";
import Link from "next/link";
export const metadata: Metadata = {
title: "Verify Email",
};
export default function EmailVerificationWithoutTokenPage() {
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="grid w-full max-w-md grid-cols-1 gap-5 rounded-xl border bg-card p-10 text-card-foreground shadow">
<div className="flex flex-col gap-y-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight">
Uh oh! Looks like you&apos;re missing a token
</h1>
<p className="text-sm text-muted-foreground">
It seems that there is no token provided, if you are trying to
verify your email please follow the link in your email.
</p>
<Link href="/" className="mt-4">
<Button size="lg">Go back home</Button>
</Link>
</div>
</div>
</div>
);
}
+11
View File
@@ -69,6 +69,17 @@ const PublicUpdatePage = async ({
return notFound();
}
// The link names the recipient it was sent to; unsharing removes that row,
// 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 },
});
if (!recipient) {
return notFound();
}
const canRenderInPublic =
update.status === UpdateStatusEnum.PUBLIC && update.public;
+29 -19
View File
@@ -1,6 +1,9 @@
import EmptyState from "@/components/common/empty-state";
import { VerifyMemberForm } from "@/components/member/verify-member-form";
import { authOptions } from "@/server/auth";
import { checkVerificationToken } from "@/server/member";
import { db } from "@/server/db";
import { inviteSeat, resolveInvite } from "@/server/member";
import { TRPCError } from "@trpc/server";
import type { Metadata } from "next";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
@@ -9,32 +12,39 @@ export const metadata: Metadata = {
title: "Verify member",
};
/**
* An invitation is opened by signing in with Hanzo as the person it was sent
* to. The page checks the token and the seat the same way accepting does, so a
* person who cannot take it is told why before filling anything in; accepting
* checks again inside its own transaction.
*/
export default async function VerifyMember({
params: { token },
searchParams,
}: {
params: { token: string };
searchParams: {
passwordResetToken: string;
email: string;
};
}) {
const session = await getServerSession(authOptions);
const passwordResetToken = searchParams.passwordResetToken;
const email = searchParams.email;
const params = new URLSearchParams({
email: email,
verificationToken: token,
});
if (!session?.user || !session?.user.email) {
redirect(`/set-password/${passwordResetToken}?${params.toString()}`);
if (!session?.user) {
const back = `/verify-member/${encodeURIComponent(token)}`;
redirect(`/login?callbackUrl=${encodeURIComponent(back)}`);
}
// check if token is valid
const { memberId } = await checkVerificationToken(token, session.user.email);
try {
const invite = await resolveInvite(db, token);
await inviteSeat(db, invite, session.user.id);
} catch (error) {
if (error instanceof TRPCError) {
return (
<EmptyState
title="This invitation cannot be accepted"
subtitle={error.message}
error={true}
/>
);
}
throw error;
}
return <VerifyMemberForm memberId={memberId} token={token} />;
return <VerifyMemberForm token={token} />;
}
+49
View File
@@ -0,0 +1,49 @@
import { env } from "@/env";
/** A presigned PUT, as either side of the app mints it. */
export interface PresignedPut {
url: string;
key: string;
bucketUrl: string;
}
/** Sends a file's bytes to the object store through a presigned PUT. */
export const putFile = async (
file: File,
{ url, key, bucketUrl }: PresignedPut,
bucketMode: "publicBucket" | "privateBucket",
) => {
const body = await file.arrayBuffer();
const res = await fetch(url, {
method: "PUT",
headers: {
"Content-Type": "application/octet-stream",
},
body,
});
if (!res.ok) {
throw new Error(
`Failed to upload file "${file.name}", failed with status code ${res.status}`,
);
}
const { name, type, size } = file;
let fileUrl = bucketUrl;
const uploadDomain =
process.env.NEXT_PUBLIC_UPLOAD_DOMAIN || env.NEXT_PUBLIC_UPLOAD_DOMAIN;
if (bucketMode === "publicBucket" && uploadDomain) {
fileUrl = `${uploadDomain}/${key}`;
}
return {
key,
name,
mimeType: type,
size,
fileUrl,
};
};
export type TUploadFile = Awaited<ReturnType<typeof putFile>>;
+11 -61
View File
@@ -1,11 +1,14 @@
import { env } from "@/env";
import {
getPresignedGetUrl,
getPresignedPutUrl,
type getPresignedUrlOptions,
} from "@/server/file-uploads";
import type { getPresignedUrlOptions } from "@/server/file-uploads";
import { 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.
*
* usage
* ```js
* import { uploadFile } from '@/common/uploads'
@@ -18,7 +21,6 @@ import {
* };
* ```
*/
export const uploadFile = async (
file: File,
options: Pick<
@@ -27,63 +29,11 @@ export const uploadFile = async (
>,
bucketMode: "publicBucket" | "privateBucket" = "privateBucket",
) => {
const { url, key, bucketUrl } = await getPresignedPutUrl({
const presigned = await presignUpload({
contentType: file.type,
fileName: file.name,
bucketMode,
...options,
});
const body = await file.arrayBuffer();
const res = await fetch(url, {
method: "PUT",
headers: {
"Content-Type": "application/octet-stream",
},
body,
});
if (!res.ok) {
throw new Error(
`Failed to upload file "${file.name}", failed with status code ${res.status}`,
);
}
const { name, type, size } = file;
let fileUrl = bucketUrl;
const uploadDomain =
process.env.NEXT_PUBLIC_UPLOAD_DOMAIN || env.NEXT_PUBLIC_UPLOAD_DOMAIN;
if (bucketMode === "publicBucket" && uploadDomain) {
fileUrl = `${uploadDomain}/${key}`;
}
return {
key,
name,
mimeType: type,
size,
fileUrl,
};
};
export type TUploadFile = Awaited<ReturnType<typeof uploadFile>>;
export const getFileFromS3 = async (key: string) => {
const { url } = await getPresignedGetUrl(key);
const response = await fetch(url, {
method: "GET",
});
if (!response.ok) {
throw new Error(
`Failed to get file "${key}", failed with status code ${response.status}`,
);
}
const buffer = await response.arrayBuffer();
const binaryData = new Uint8Array(buffer);
return binaryData;
return putFile(file, presigned, bucketMode);
};
+3 -4
View File
@@ -122,7 +122,7 @@ export const columns: ColumnDef<Member[number]>[] = [
<div className=" ml-2">
<p>{row.original?.user?.name}</p>
<p>{row.original?.user?.email}</p>
<p>{row.original?.user?.email ?? row.original?.workEmail}</p>
</div>
</div>
),
@@ -175,7 +175,6 @@ export const columns: ColumnDef<Member[number]>[] = [
const status = member.status;
const memberId = member.id;
const email = member?.user?.email;
const isActive = status === "ACTIVE";
const isPending = status === "PENDING";
@@ -184,8 +183,8 @@ export const columns: ColumnDef<Member[number]>[] = [
const handleRevokeOrRemove = async () => {
try {
if (isPending && email) {
await revokeInvite.mutateAsync({ email, memberId });
if (isPending) {
await revokeInvite.mutateAsync({ memberId });
} else {
await removeMember.mutateAsync({ memberId });
}
+7 -7
View File
@@ -12,6 +12,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import {
Form,
FormControl,
@@ -22,11 +23,10 @@ import {
} from "../ui/form";
interface VerifyMemberFormProps {
memberId: string;
token: string;
}
export function VerifyMemberForm({ memberId, token }: VerifyMemberFormProps) {
export function VerifyMemberForm({ token }: VerifyMemberFormProps) {
const { update } = useSession();
const router = useRouter();
const acceptMember = api.member.acceptMember.useMutation({
@@ -34,18 +34,20 @@ export function VerifyMemberForm({ memberId, token }: VerifyMemberFormProps) {
await update();
router.push(`/${publicId}`);
},
onError: (error) => {
toast.error(error.message);
},
});
const form = useForm<TypeZodAcceptMemberMutationSchema>({
resolver: zodResolver(ZodAcceptMemberMutationSchema),
defaultValues: {
name: "",
memberId,
token,
workEmail: "",
},
});
async function onSubmit(values: TypeZodAcceptMemberMutationSchema) {
function onSubmit(values: TypeZodAcceptMemberMutationSchema) {
acceptMember.mutate(values);
}
@@ -54,9 +56,7 @@ export function VerifyMemberForm({ memberId, token }: VerifyMemberFormProps) {
<div className="flex min-h-screen w-full items-center justify-center">
<div className="flex flex-col gap-y-4">
<div className="flex flex-col gap-y-2 text-center">
<h1 className="text-3xl font-semibold">
Welcome! 👋
</h1>
<h1 className="text-3xl font-semibold">Welcome! 👋</h1>
<p className="text-muted-foreground">
Enter your information to complete onboarding
</p>
@@ -5,7 +5,7 @@ interface LoginFormHeaderProps {
page?: string;
}
export function AuthFormHeader({ page }: LoginFormHeaderProps) {
export function AuthFormHeader(_props: LoginFormHeaderProps) {
return (
<div className="flex flex-col gap-y-2 text-center">
<div className="flex justify-center">
@@ -13,9 +13,7 @@ export function AuthFormHeader({ page }: LoginFormHeaderProps) {
</div>
<h1 className="mb-2 text-2xl font-semibold tracking-tight">
{page === "signup"
? `Signup to ${APP_NAME}`
: `Login to ${APP_NAME}`}
Login to {APP_NAME}
</h1>
</div>
);
@@ -1,60 +0,0 @@
"use client";
import { Button } from "@/components/ui/button";
import { api } from "@/trpc/react";
import { RiMailLine } from "@remixicon/react";
import { useSearchParams } from "next/navigation";
import { toast } from "sonner";
const CheckEmailComponent = () => {
const searchParams = useSearchParams();
const email = searchParams.get("email");
const { mutateAsync, isLoading } = api.auth.resendEmail.useMutation({
onSuccess: () => {
toast.success("🎉 Email successfully re-sent.");
},
onError: () => {
toast.error(
"Uh oh! Something went wrong, please try again or contact support.",
);
},
});
async function Resend() {
try {
if (email) {
await mutateAsync(email);
}
} catch (err) {
console.error(err);
}
}
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="grid w-full max-w-md grid-cols-1 gap-5 rounded-xl border bg-card p-10 text-card-foreground shadow">
<div className="flex flex-col gap-y-2 text-center">
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<span className="text-foreground">
<RiMailLine className="h-6 w-auto" />
</span>
</div>
<h1 className="text-2xl font-semibold tracking-tight">
Check your email
</h1>
</div>
<div className="mb-2 text-center">
We&apos;ve sent an email to
<span className="text-sm font-bold"> {email} </span>. Please click the
link in the email to verify your account.
</div>
<Button onClick={Resend} disabled={!email} loading={isLoading}>
Resend verification email
</Button>
</div>
</div>
);
};
export default CheckEmailComponent;
+5 -1
View File
@@ -89,7 +89,11 @@ export const CompanyForm = ({ type, data }: CompanyFormProps) => {
});
const onBoardingMutation = api.onboarding.onboard.useMutation({
onSuccess: async ({ publicId }) => {
onSuccess: async ({ success, message, publicId }) => {
if (!success || !publicId) {
toast.error(message);
return;
}
await update();
router.push(`/${publicId}`);
@@ -1,26 +0,0 @@
"use client";
import { Button } from "@/components/ui/button";
import { RiMailLine } from "@remixicon/react";
import Link from "next/link";
const EmailSent = () => {
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="grid w-full max-w-md grid-cols-1 gap-5 rounded-xl border bg-card p-10 text-card-foreground shadow">
<div className="flex flex-col gap-y-2 text-center">
<RiMailLine className="mb-1 h-10 w-auto" />
<h1 className="text-2xl font-semibold tracking-tight">Email sent!</h1>
</div>
<div className="text-center">
A password reset email has been sent, if you have an account you
should see it in your inbox shortly.
</div>
<Link href="/" className="mt-4 text-center">
<Button size="lg">Back to login</Button>
</Link>
</div>
</div>
);
};
export default EmailSent;
@@ -1,108 +0,0 @@
"use client";
import { CaptableLogo } from "@/components/common/logo";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/trpc/react";
import { zodResolver } from "@hookform/resolvers/zod";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const ForgotPassword = () => {
const inputSchema = z.object({ email: z.string().email() });
const form = useForm<z.infer<typeof inputSchema>>({
resolver: zodResolver(inputSchema),
defaultValues: {
email: "",
},
});
const isSubmitting = form.formState.isSubmitting;
const router = useRouter();
const { mutateAsync } = api.auth.forgotPassword.useMutation({
onSuccess: () => {
toast.success("🎉 Reset password email sent.");
router.replace("/email-sent");
},
onError: ({ message }) => {
toast.error(`🔥 Error - ${message}`);
},
});
const onSubmit = async (values: z.infer<typeof inputSchema>) => {
await mutateAsync(values.email);
};
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="grid w-full max-w-md grid-cols-1 gap-5 rounded-xl border bg-card p-10 text-card-foreground shadow">
<div className="flex flex-col gap-y-2 text-center">
<CaptableLogo className="mb-1 h-10 w-auto" />
<h1 className="text-2xl font-semibold tracking-tight">
Forgot your password?
</h1>
</div>
<div className="mb-2 text-center text-sm text-muted-foreground">
Enter your email and we&apos;ll email you a special link to reset your
password.
</div>
<>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<div className="grid gap-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<div className="grid gap-4">
<FormLabel htmlFor="email">Email</FormLabel>
<FormControl>
<Input
id="email"
type="email"
autoCapitalize="none"
autoComplete="email"
autoCorrect="off"
autoFocus
required
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormMessage className="text-xs font-light" />
</div>
</FormItem>
)}
/>
<Button loading={isSubmitting} type="submit">
Reset Password
</Button>
</div>
</form>
</Form>
<span className="text-center text-sm text-muted-foreground">
Remembered your password?{" "}
<Link
href="/login"
className="underline underline-offset-4 hover:text-primary"
>
Login
</Link>
</span>
</>
</div>
</div>
);
};
export default ForgotPassword;
@@ -1,141 +0,0 @@
"use client";
import { CaptableLogo } from "@/components/common/logo";
import { PasswordInput } from "@/components/ui/password-input";
import { api } from "@/trpc/react";
import { ZPasswordSchema } from "@/trpc/routers/auth/schema";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
export const ZResetPasswordFormSchema = z
.object({
password: ZPasswordSchema,
repeatedPassword: ZPasswordSchema,
})
.refine((data) => data.password === data.repeatedPassword, {
path: ["repeatedPassword"],
message: "Passwords don't match",
});
export type TResetPasswordFormSchema = z.infer<typeof ZResetPasswordFormSchema>;
export type ResetPasswordFormProps = {
token: string;
};
export const ResetPasswordForm = ({ token }: ResetPasswordFormProps) => {
const router = useRouter();
const form = useForm<TResetPasswordFormSchema>({
values: {
password: "",
repeatedPassword: "",
},
resolver: zodResolver(ZResetPasswordFormSchema),
});
const isSubmitting = form.formState.isSubmitting;
const { mutateAsync } = api.auth.newPassword.useMutation({
onSuccess: () => {
toast.success("🎉 Password updated successfully.");
router.replace("/password-updated");
},
onError: ({ message }) => {
toast.error(`🔥 Error - ${message}`);
},
});
const onFormSubmit = async ({
password,
}: Omit<TResetPasswordFormSchema, "repeatedPassword">) => {
await mutateAsync({ password, token });
};
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="grid w-full max-w-md grid-cols-1 gap-5 rounded-xl border bg-card p-10 text-card-foreground shadow">
<div className="flex flex-col gap-y-2 text-center">
<CaptableLogo className="mb-1 h-10 w-auto" />
<h1 className="text-2xl font-semibold tracking-tight">
Reset your password
</h1>
</div>
<Form {...form}>
<form
className="space-y-8"
onSubmit={form.handleSubmit(onFormSubmit)}
>
<div className="grid gap-4">
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<div className="grid gap-3">
<FormLabel htmlFor="password">Password</FormLabel>
<FormControl>
<PasswordInput
id="password"
autoCapitalize="none"
autoCorrect="off"
autoFocus
required
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormMessage className="text-xs font-light" />
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="repeatedPassword"
render={({ field }) => (
<FormItem>
<div className="grid gap-3">
<FormLabel htmlFor="password">Confirm password</FormLabel>
<FormControl>
<Input
id="repeatPassword"
type="password"
autoCapitalize="none"
autoCorrect="off"
autoFocus
required
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormMessage className="text-xs font-light" />
</div>
</FormItem>
)}
/>
<Button type="submit" size="lg" loading={isSubmitting}>
{isSubmitting ? "Resetting Password..." : "Reset Password"}
</Button>
</div>
</form>
</Form>
</div>
</div>
);
};
@@ -1,149 +0,0 @@
"use client";
import { CaptableLogo } from "@/components/common/logo";
import { PasswordInput } from "@/components/ui/password-input";
import { api } from "@/trpc/react";
import { ZPasswordSchema } from "@/trpc/routers/auth/schema";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { signIn } from "next-auth/react";
import { toast } from "sonner";
export const ZSetPasswordFormSchema = z
.object({
password: ZPasswordSchema,
repeatedPassword: ZPasswordSchema,
})
.refine((data) => data.password === data.repeatedPassword, {
path: ["repeatedPassword"],
message: "Passwords don't match",
});
export type TSetPasswordFormSchema = z.infer<typeof ZSetPasswordFormSchema>;
export type SetPasswordFormProps = {
token: string;
email: string;
verificationToken: string;
};
export const SetPasswordForm = ({
token,
email,
verificationToken,
}: SetPasswordFormProps) => {
const form = useForm<TSetPasswordFormSchema>({
values: {
password: "",
repeatedPassword: "",
},
resolver: zodResolver(ZSetPasswordFormSchema),
});
const isSubmitting = form.formState.isSubmitting;
const { mutateAsync } = api.auth.newPassword.useMutation({
onSuccess: async () => {
toast.success("🎉 Password updated successfully.");
await signIn("credentials", {
password: form.getValues("password"),
email: email,
callbackUrl: `/verify-member/${verificationToken}?email=${email}`,
});
},
onError: ({ message }) => {
toast.error(`🔥 Error - ${message}`);
},
});
const onFormSubmit = async ({
password,
}: Omit<TSetPasswordFormSchema, "repeatedPassword">) => {
await mutateAsync({ password, token });
};
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="grid w-full max-w-md grid-cols-1 gap-5 rounded-xl border bg-card p-10 text-card-foreground shadow">
<div className="flex flex-col gap-y-2 text-center">
<CaptableLogo className="mb-1 h-10 w-auto" />
<h1 className="text-2xl font-semibold tracking-tight">
Set your password
</h1>
</div>
<Form {...form}>
<form
className="space-y-8"
onSubmit={form.handleSubmit(onFormSubmit)}
>
<div className="grid gap-4">
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<div className="grid gap-3">
<FormLabel htmlFor="password">Password</FormLabel>
<FormControl>
<PasswordInput
id="password"
autoCapitalize="none"
autoCorrect="off"
autoFocus
required
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormMessage className="text-xs font-light" />
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="repeatedPassword"
render={({ field }) => (
<FormItem>
<div className="grid gap-3">
<FormLabel htmlFor="password">Confirm password</FormLabel>
<FormControl>
<Input
id="repeatPassword"
type="password"
autoCapitalize="none"
autoCorrect="off"
autoFocus
required
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormMessage className="text-xs font-light" />
</div>
</FormItem>
)}
/>
<Button type="submit" size="lg" loading={isSubmitting}>
{isSubmitting ? "Setting Password..." : "Set Password"}
</Button>
</div>
</form>
</Form>
</div>
</div>
);
};
+37 -16
View File
@@ -4,24 +4,45 @@ import { Button } from "@/components/ui/button";
import { signIn } from "next-auth/react";
import { AuthFormHeader } from "../auth-form-header";
const IAM_PROVIDER_NAME =
process.env.NEXT_PUBLIC_IAM_PROVIDER_NAME || "Hanzo";
const IAM_PROVIDER_NAME = process.env.NEXT_PUBLIC_IAM_PROVIDER_NAME || "Hanzo";
const SignInForm = () => {
async function signInWithIAM() {
await signIn("hanzo-iam", { callbackUrl: "/onboarding" });
}
/** What a refused sign-in says, by the code next-auth puts in `?error=`. */
const REFUSALS: Record<string, string> = {
OAuthAccountNotLinked:
"This email address already belongs to a Captable account that was never signed in with Hanzo, so it cannot be joined to yours. Ask a company admin to invite you again.",
AccessDenied: "This account may not sign in here.",
};
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="grid w-full max-w-md grid-cols-1 gap-5 rounded-xl border bg-card p-10 text-card-foreground shadow">
<AuthFormHeader page="signin" />
<Button type="button" onClick={signInWithIAM}>
Sign in with <span className="font-bold">{IAM_PROVIDER_NAME}</span>
</Button>
</div>
</div>
);
interface SignInFormProps {
/** Where to land after signing in: a path on this site, already checked. */
callbackUrl: string;
error?: string;
}
const SignInForm = ({ callbackUrl, error }: SignInFormProps) => {
async function signInWithIAM() {
await signIn("hanzo-iam", { callbackUrl });
}
const refusal = error
? REFUSALS[error] ?? "Signing in did not complete. Try again."
: "";
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="grid w-full max-w-md grid-cols-1 gap-5 rounded-xl border bg-card p-10 text-card-foreground shadow">
<AuthFormHeader page="signin" />
{refusal ? (
<p role="alert" className="text-sm text-destructive">
{refusal}
</p>
) : null}
<Button type="button" onClick={signInWithIAM}>
Sign in with <span className="font-bold">{IAM_PROVIDER_NAME}</span>
</Button>
</div>
</div>
);
};
export default SignInForm;
@@ -1,37 +0,0 @@
"use client";
import { Button } from "@/components/ui/button";
import { signIn } from "next-auth/react";
import { AuthFormHeader } from "../auth-form-header";
const IAM_PROVIDER_NAME =
process.env.NEXT_PUBLIC_IAM_PROVIDER_NAME || "Hanzo";
const SignUpForm = () => {
async function signInWithIAM() {
await signIn("hanzo-iam", { callbackUrl: "/onboarding" });
}
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="grid w-full max-w-md grid-cols-1 gap-5 rounded-xl border bg-card p-10 text-card-foreground shadow">
<AuthFormHeader page="signup" />
<Button type="button" onClick={signInWithIAM}>
Sign up with{" "}
<span className="font-bold">{IAM_PROVIDER_NAME}</span>
</Button>
<span className="text-center text-sm text-muted-foreground">
Already have an account?{" "}
<a
href="/login"
className="underline underline-offset-4 hover:text-primary"
>
Login
</a>
</span>
</div>
</div>
);
};
export default SignUpForm;
@@ -1,116 +0,0 @@
"use client";
import { Button } from "@/components/ui/button";
import { api } from "@/trpc/react";
import {
RiArrowRightLine,
RiMailCheckLine,
RiMailCloseLine,
} from "@remixicon/react";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
const VerifyEmail = ({ token }: { token: string }) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | undefined>("");
const [success, setSuccess] = useState<string | undefined>("");
const { mutateAsync } = api.auth.verifyEmail.useMutation({
onSuccess: async ({ message }) => {
setLoading(false);
setSuccess(message);
},
onError: (error) => {
setLoading(false);
setError(error.message);
},
});
const onSubmit = useCallback(async () => {
if (success || error) return;
if (!token) {
setLoading(false);
setError("Missing token!");
return;
}
try {
await mutateAsync(token);
} catch (err) {
console.error(err);
setLoading(false);
setError("Something went wrong! Please try again.");
}
}, [token, success, error]);
useEffect(() => {
void onSubmit();
}, [onSubmit]);
if (loading) {
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="grid w-full max-w-md grid-cols-1 gap-5 rounded-xl border bg-card p-10 text-card-foreground shadow">
<div className="flex flex-col gap-y-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight">
Verifying...
</h1>
</div>
</div>
</div>
);
}
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="grid w-full max-w-md grid-cols-1 gap-5 rounded-xl border bg-card p-10 text-card-foreground shadow">
<div className="flex flex-col gap-y-2 text-center">
{success ? (
<>
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<span className="text-foreground">
<RiMailCheckLine className="h-6 w-auto" />
</span>
</div>
<h1 className="text-2xl font-semibold tracking-tight">
{success}
</h1>
<div className="mb-2 text-center text-sm text-muted-foreground">
Your account has been verified. Please login to continue.
</div>
</>
) : (
<>
<RiMailCloseLine className="mb-1 h-10 w-auto" />
<h1 className="text-2xl font-semibold tracking-tight">
Verification Failed
</h1>
<div className="mb-2 text-center text-sm text-muted-foreground">
{error}
</div>
</>
)}
{success ? (
<Link href="/signin" className="mt-4">
<Button size="lg">
Continue to Login page
<RiArrowRightLine className="ml-2 h-4 w-4" />
</Button>
</Link>
) : (
<Link href="/signup" className="mt-4">
<Button size="lg">
Try signing up again
<RiArrowRightLine className="ml-2 h-4 w-4" />
</Button>
</Link>
)}
</div>
</div>
</div>
);
};
export default VerifyEmail;
+2 -2
View File
@@ -35,7 +35,7 @@ import { SortButton } from "@/components/ui/data-table/data-table-buttons";
import { DataTableContent } from "@/components/ui/data-table/data-table-content";
import { DataTableHeader } from "@/components/ui/data-table/data-table-header";
import { DataTablePagination } from "@/components/ui/data-table/data-table-pagination";
import { getPresignedGetUrl } from "@/server/file-uploads";
import { presignDownload } from "@/server/storage";
import type { RouterOutputs } from "@/trpc/shared";
import { RiFileDownloadLine, RiMore2Fill } from "@remixicon/react";
import { useRouter } from "next/navigation";
@@ -190,7 +190,7 @@ export const columns: ColumnDef<Safe[number]>[] = [
},
cell: ({ row }) => {
const openFileOnTab = async (key: string) => {
const fileUrl = await getPresignedGetUrl(key);
const fileUrl = await presignDownload(key);
window.open(fileUrl.url, "_blank");
};
@@ -35,7 +35,7 @@ import { SortButton } from "@/components/ui/data-table/data-table-buttons";
import { DataTableContent } from "@/components/ui/data-table/data-table-content";
import { DataTableHeader } from "@/components/ui/data-table/data-table-header";
import { DataTablePagination } from "@/components/ui/data-table/data-table-pagination";
import { getPresignedGetUrl } from "@/server/file-uploads";
import { presignDownload } from "@/server/storage";
import type { RouterOutputs } from "@/trpc/shared";
import { RiFileDownloadLine, RiMore2Fill } from "@remixicon/react";
import { useRouter } from "next/navigation";
@@ -190,7 +190,7 @@ export const columns: ColumnDef<Option[number]>[] = [
},
cell: ({ row }) => {
const openFileOnTab = async (key: string) => {
const fileUrl = await getPresignedGetUrl(key);
const fileUrl = await presignDownload(key);
window.open(fileUrl.url, "_blank");
};
@@ -35,7 +35,7 @@ import {
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { formatCurrency, formatNumber } from "@/lib/utils";
import { getPresignedGetUrl } from "@/server/file-uploads";
import { presignDownload } from "@/server/storage";
import { api } from "@/trpc/react";
import {
DropdownMenu,
@@ -252,7 +252,7 @@ export const columns: ColumnDef<Share[number]>[] = [
const documents = row?.original?.documents;
const openFileOnTab = async (key: string) => {
const fileUrl = await getPresignedGetUrl(key);
const fileUrl = await presignDownload(key);
window.open(fileUrl.url, "_blank");
};
@@ -1,159 +0,0 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { PasswordInput } from "@/components/ui/password-input";
import { api } from "@/trpc/react";
import { ZPasswordSchema } from "@/trpc/routers/auth/schema";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
export const ZChangePasswordFormSchema = z
.object({
currentPassword: ZPasswordSchema,
newPassword: ZPasswordSchema,
repeatedPassword: ZPasswordSchema,
})
.refine((data) => data.newPassword === data.repeatedPassword, {
path: ["repeatedPassword"],
message: "Passwords don't match",
});
export type TChangePasswordFormSchema = z.infer<
typeof ZChangePasswordFormSchema
>;
export const UpdatePasswordForm = () => {
const form = useForm<TChangePasswordFormSchema>({
values: {
currentPassword: "",
newPassword: "",
repeatedPassword: "",
},
resolver: zodResolver(ZChangePasswordFormSchema),
});
const isSubmitting = form.formState.isSubmitting;
const isDirty = form.formState.isDirty;
const { mutateAsync } = api.security.updatePassword.useMutation({
onSuccess: ({ success }) => {
if (success) {
toast.success("Password updated successfully!");
form.reset();
}
},
onError: (error: { message: string } | Error | null | undefined) => {
toast.error(
error?.message ?? "An error occurred while updating your password",
);
},
});
const onFormSubmit = async (data: TChangePasswordFormSchema) => {
await mutateAsync({
currentPassword: data.currentPassword,
newPassword: data.newPassword,
});
};
return (
<div className="grid w-full grid-cols-1 gap-5 p-8">
<Form {...form}>
<form className="space-y-8" onSubmit={form.handleSubmit(onFormSubmit)}>
<div className="grid gap-4">
<FormField
control={form.control}
name="currentPassword"
render={({ field }) => (
<FormItem>
<div className="grid gap-3">
<FormLabel htmlFor="password">Current password</FormLabel>
<FormControl>
<PasswordInput
id="password"
autoCapitalize="none"
autoCorrect="off"
autoFocus
required
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormMessage className="text-xs font-light" />
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="newPassword"
render={({ field }) => (
<FormItem>
<div className="grid gap-3">
<FormLabel htmlFor="password">New password</FormLabel>
<FormControl>
<PasswordInput
id="password"
autoCapitalize="none"
autoCorrect="off"
autoFocus
required
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormMessage className="text-xs font-light" />
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="repeatedPassword"
render={({ field }) => (
<FormItem>
<div className="grid gap-3">
<FormLabel htmlFor="password">Verify password</FormLabel>
<FormControl>
<PasswordInput
id="repeatPassword"
autoCapitalize="none"
autoCorrect="off"
autoFocus
required
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormMessage className="text-xs font-light" />
</div>
</FormItem>
)}
/>
<div className="mt-5 flex justify-end">
<Button
type="submit"
size="lg"
loading={isSubmitting}
disabled={isSubmitting || !isDirty}
>
{isSubmitting ? "Updating..." : "Update Password"}
</Button>
</div>
</div>
</form>
</Form>
</div>
);
};
@@ -10,15 +10,14 @@ import { toast } from "sonner";
interface SigningFieldFormProps extends ComponentProps<"form"> {
companyPublicId?: string;
children: ReactNode;
recipientId: string;
templateId: string;
/** The signing link's token: the only thing that says who is signing. */
token: string;
}
export function SigningFieldForm({
companyPublicId,
children,
recipientId,
templateId,
token,
...rest
}: SigningFieldFormProps) {
const { handleSubmit } = useFormContext<TemplateSigningFieldForm>();
@@ -39,8 +38,7 @@ export function SigningFieldForm({
const onSubmit = async (values: TemplateSigningFieldForm) => {
await mutateAsync({
data: values.fieldValues,
recipientId,
templateId,
token,
});
};
@@ -8,22 +8,16 @@ type Fields = RouterOutputs["template"]["getSigningFields"]["fields"];
interface SigningFieldsProps {
fields: Fields;
companyPublicId: string | undefined;
recipientId: string;
templateId: string;
token: string;
}
export function SigningFields({
fields,
companyPublicId,
recipientId,
templateId,
token,
}: SigningFieldsProps) {
return (
<SigningFieldForm
recipientId={recipientId}
templateId={templateId}
companyPublicId={companyPublicId}
>
<SigningFieldForm token={token} companyPublicId={companyPublicId}>
{fields.map((item) => (
<FieldRenderer
name={item.name}
-65
View File
@@ -1,65 +0,0 @@
import {
Body,
Button,
Container,
Head,
Heading,
Hr,
Html,
Link,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
import * as React from "react";
import { constants } from "../lib/constants";
interface AccountVerificationEmailProps {
verifyLink: string;
}
export const AccountVerificationEmail = ({
verifyLink,
}: AccountVerificationEmailProps) => (
<Html>
<Head />
<Preview>Your email verification link for {constants.title}</Preview>
<Tailwind>
<Body className="mx-auto my-auto bg-white font-sans">
<Container className="mx-auto my-[40px] w-[465px] border-separate rounded border border-solid border-neutral-200 p-[20px]">
<Heading className="mx-0 my-[30px] p-0 text-[24px] font-normal text-black">
Your verification email link for {constants.title}
</Heading>
<Section>
<Section className="mb-[5px] mt-[10px] ">
<Button
className="rounded bg-black px-5 py-3 text-center text-[12px] font-semibold text-white no-underline"
href={verifyLink}
>
Verify
</Button>
</Section>
</Section>
<Text className="!text-[14px] leading-[24px] text-black">
or copy and paste this URL into your browser:{" "}
<Link
href={verifyLink}
className="break-all text-blue-600 no-underline"
>
{verifyLink}
</Link>
</Text>
<Hr className="mx-0 my-[26px] w-full border border-solid border-neutral-200" />
<Link
href={constants.url}
className="text-sm !text-gray-400 no-underline"
>
{constants.title}
</Link>
</Container>
</Body>
</Tailwind>
</Html>
);
export default AccountVerificationEmail;
-68
View File
@@ -1,68 +0,0 @@
import {
Body,
Button,
Container,
Head,
Heading,
Hr,
Html,
Link,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
import * as React from "react";
import { constants } from "../lib/constants";
interface MagicLinkEmailProps {
magicLink: string;
}
export const MagicLinkEmail = ({ magicLink }: MagicLinkEmailProps) => (
<Html>
<Head />
<Preview>Your magic link for {constants.title}</Preview>
<Tailwind>
<Body className="mx-auto my-auto bg-white font-sans">
<Container className="mx-auto my-[40px] w-[465px] border-separate rounded border border-solid border-neutral-200 p-[20px]">
<Heading className="mx-0 my-[30px] p-0 text-[24px] font-normal text-black">
Your magic link for {constants.title}
</Heading>
<Section>
<Section className="mb-[5px] mt-[10px] ">
<Button
className="rounded bg-black px-5 py-3 text-center text-[12px] font-semibold text-white no-underline"
href={magicLink}
>
Login
</Button>
</Section>
</Section>
<Text className="!text-[14px] leading-[24px] text-black">
or copy and paste this URL into your browser:{" "}
<Link
href={magicLink}
className="break-all text-blue-600 no-underline"
>
{magicLink}
</Link>
</Text>
<Hr className="mx-0 my-[26px] w-full border border-solid border-neutral-200" />
<Link
href={constants.url}
className="text-sm !text-gray-400 no-underline"
>
{constants.title}
</Link>
</Container>
</Body>
</Tailwind>
</Html>
);
MagicLinkEmail.PreviewProps = {
magicLink: `${constants.url}/v1/iam/callback/email?callbackUrl=http%3A%2F%2Flocalhost%3A3000%2Fonboarding&token=671d9eac4043bbe1c22aeafd419ddfe79c2282ec755c558ea789671fdaffe8dd&email=ceo%40example.com`,
} as MagicLinkEmailProps;
export default MagicLinkEmail;
-63
View File
@@ -1,63 +0,0 @@
import {
Body,
Button,
Container,
Head,
Heading,
Hr,
Html,
Link,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
import * as React from "react";
import { constants } from "../lib/constants";
interface PasswordResetEmailProps {
resetLink: string;
}
export const PasswordResetEmail = ({ resetLink }: PasswordResetEmailProps) => (
<Html>
<Head />
<Preview>Your password reset link for {constants.title}</Preview>
<Tailwind>
<Body className="mx-auto my-auto bg-white font-sans">
<Container className="mx-auto my-[40px] w-[465px] border-separate rounded border border-solid border-neutral-200 p-[20px]">
<Heading className="mx-0 my-[30px] p-0 text-[24px] font-normal text-black">
Your password reset link for {constants.title}
</Heading>
<Section>
<Section className="mb-[5px] mt-[10px] ">
<Button
className="rounded bg-black px-5 py-3 text-center text-[12px] font-semibold text-white no-underline"
href={resetLink}
>
Reset
</Button>
</Section>
</Section>
<Text className="!text-[14px] leading-[24px] text-black">
or copy and paste this URL into your browser:{" "}
<Link
href={resetLink}
className="break-all text-blue-600 no-underline"
>
{resetLink}
</Link>
</Text>
<Hr className="mx-0 my-[26px] w-full border border-solid border-neutral-200" />
<Link
href={constants.url}
className="text-sm !text-gray-400 no-underline"
>
{constants.title}
</Link>
</Container>
</Body>
</Tailwind>
</Html>
);
export default PasswordResetEmail;
-33
View File
@@ -1,33 +0,0 @@
import AccountVerificationEmail from "@/emails/AccountVerificationEmail";
import { env } from "@/env";
import { sendMail } from "@/server/mailer";
import { render } from "@react-email/components";
import { z } from "zod";
import { defineJob, defineWorker, defineWorkerConfig } from "../lib/queue";
const config = defineWorkerConfig({
name: "email.auth-verify",
schema: z.object({
email: z.string(),
token: z.string(),
}),
});
export const authVerificationEmailJob = defineJob(config);
export const authVerificationEmailWorker = defineWorker(config, async (job) => {
const { email, token } = job.data;
const baseUrl = env.NEXT_PUBLIC_BASE_URL;
const confirmLink = `${baseUrl}/verify-email/${token}`;
const html = await render(
AccountVerificationEmail({
verifyLink: confirmLink,
}),
);
await sendMail({
to: email,
subject: "Confirm your email",
html,
});
});
+4 -10
View File
@@ -10,7 +10,6 @@ const config = defineWorkerConfig({
name: "email.member-invite",
schema: z.object({
email: z.string(),
passwordResetToken: z.string(),
user: z.object({
email: z.string().email().nullish(),
name: z.string().nullish(),
@@ -25,18 +24,13 @@ const config = defineWorkerConfig({
export const sendMemberInviteEmailJob = defineJob(config);
export const sendMemberInviteEmailWorker = defineWorker(config, async (job) => {
const { email, passwordResetToken, verificationToken, company, user } =
job.data;
const { email, verificationToken, company, user } = job.data;
const baseUrl = env.NEXT_PUBLIC_BASE_URL;
const params = new URLSearchParams({
passwordResetToken,
email,
});
const inviteLink = `${baseUrl}/verify-member/${verificationToken}?${params.toString()}`;
console.log("sending invite email to", email, ": ", inviteLink);
// The link is the invitation's secret, so it goes in the mail and nowhere else.
const inviteLink = `${baseUrl}/verify-member/${verificationToken}`;
console.log("sending invite email to", email);
await sendMail({
to: email,
-34
View File
@@ -1,34 +0,0 @@
import PasswordResetEmail from "@/emails/PasswordResetEmail";
import { env } from "@/env";
import { sendMail } from "@/server/mailer";
import { render } from "@react-email/components";
import { z } from "zod";
import { defineJob, defineWorker, defineWorkerConfig } from "../lib/queue";
const config = defineWorkerConfig({
name: "email.password-reset",
schema: z.object({
email: z.string(),
token: z.string(),
}),
});
export const passwordResetEmailJob = defineJob(config);
export const passwordResetEmailWorker = defineWorker(config, async (job) => {
const { email, token } = job.data;
const baseUrl = env.NEXT_PUBLIC_BASE_URL;
const confirmLink = `${baseUrl}/reset-password/${token}`;
const html = await render(
PasswordResetEmail({
resetLink: confirmLink,
}),
);
await sendMail({
to: email,
subject: "Reset your password",
html,
});
});
-4
View File
@@ -1,21 +1,17 @@
import { queueManager } from "../lib/queue";
import { authVerificationEmailWorker } from "./auth-verification-email";
import { eSignCompletePDFWorker } from "./esign-complete-pdf";
import { eSignConfirmationEmailWorker } from "./esign-confirmation-email";
import { eSignNotificationEmailWorker } from "./esign-email";
import { eSignPdfWorker } from "./esign-pdf";
import { sendMemberInviteEmailWorker } from "./member-inivite-email";
import { passwordResetEmailWorker } from "./password-reset-email";
import { shareDataRoomEmailWorker } from "./share-data-room-email";
import { shareUpdateEmailWorker } from "./share-update-email";
export async function registerJobs() {
queueManager.register(shareDataRoomEmailWorker);
queueManager.register(shareUpdateEmailWorker);
queueManager.register(passwordResetEmailWorker);
queueManager.register(sendMemberInviteEmailWorker);
queueManager.register(eSignNotificationEmailWorker);
queueManager.register(authVerificationEmailWorker);
queueManager.register(eSignConfirmationEmailWorker);
queueManager.register(eSignPdfWorker);
queueManager.register(eSignCompletePDFWorker);
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import {
generateMemberIdentifier,
isPlaceholder,
parseMemberIdentifier,
sameAddress,
} from "./invite";
describe("invitation identifiers", () => {
it("round-trips the address and the seat", () => {
const id = generateMemberIdentifier({
email: " [email protected] ",
memberId: "clxwbok580000i7nge8nm1ry0",
});
expect(id).toBe("[email protected]:clxwbok580000i7nge8nm1ry0");
expect(parseMemberIdentifier(id)).toEqual({
email: "[email protected]",
memberId: "clxwbok580000i7nge8nm1ry0",
});
});
it("names no seat when either half is missing", () => {
expect(parseMemberIdentifier("[email protected]")).toBeNull();
expect(parseMemberIdentifier(":clxwbok58")).toBeNull();
expect(parseMemberIdentifier("[email protected]:")).toBeNull();
});
});
describe("sameAddress", () => {
it("ignores case and surrounding space", () => {
expect(sameAddress("[email protected]", " [email protected]")).toBe(true);
});
it("never matches an absent or empty address", () => {
expect(sameAddress(null, "[email protected]")).toBe(false);
expect(sameAddress(undefined, "[email protected]")).toBe(false);
expect(sameAddress("", "")).toBe(false);
expect(sameAddress("[email protected]", " ")).toBe(false);
});
it("tells two mailboxes apart", () => {
expect(sameAddress("[email protected]", "[email protected]")).toBe(false);
});
});
describe("isPlaceholder", () => {
const none = { email: null, password: null, _count: { accounts: 0 } };
it("is a row with no address, no password and no sign-in", () => {
expect(isPlaceholder(none)).toBe(true);
});
it("is never a row someone can sign in as or be reached at", () => {
expect(isPlaceholder({ ...none, email: "[email protected]" })).toBe(false);
expect(isPlaceholder({ ...none, password: "$2a$10$x" })).toBe(false);
expect(isPlaceholder({ ...none, _count: { accounts: 1 } })).toBe(false);
});
});
+36
View File
@@ -0,0 +1,36 @@
// The pure half of an invitation: how its token row names the seat it opens and
// the address it was sent to, and what a stand-in seat holder looks like.
//
// An invitation is a VerificationToken whose identifier is
// `<invited address>:<member id>` and whose token is the secret the mail carries.
/** Whether two addresses are the same mailbox, as this app stores them. */
export const sameAddress = (a: string | null | undefined, b: string) =>
!!a && !!b.trim() && a.trim().toLowerCase() === b.trim().toLowerCase();
/** The identifier an invitation token is filed under. */
export const generateMemberIdentifier = ({
email,
memberId,
}: {
email: string;
memberId: string;
}) => `${email.trim().toLowerCase()}:${memberId}`;
/**
* The address and member an identifier names, or null when it names none. The
* member id is a cuid, so it is everything after the last colon.
*/
export const parseMemberIdentifier = (identifier: string) => {
const at = identifier.lastIndexOf(":");
const email = at > 0 ? identifier.slice(0, at) : "";
const memberId = at > 0 ? identifier.slice(at + 1) : "";
return email && memberId ? { email, memberId } : null;
};
/** A user row nobody can sign in as: the stand-in an invitation seats. */
export const isPlaceholder = (user: {
email: string | null;
password: string | null;
_count: { accounts: number };
}) => !user.email && !user.password && user._count.accounts === 0;
+20 -2
View File
@@ -6,14 +6,32 @@ import {
jwtVerify,
} from "jose";
export const encode = async (data: JWTPayload) => {
/**
* How long a link sent to someone outside the app stays good: a data room, an
* investor update, a document to sign. The recipient row it names is checked on
* every open as well, so withdrawing access ends a link before it expires; a
* fresh link is issued by sharing again.
*/
export const SHARE_TTL = "30d";
/** Signs a link token that expires after SHARE_TTL. */
export const encodeShare = async (data: JWTPayload) => {
return await new SignJWT(data)
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime(SHARE_TTL)
.sign(JWT_SECRET);
};
/**
* Verifies a link token. A token with no expiry is refused: every link this app
* issues carries one, so one without it predates expiry and stays dead.
*/
export const decode = async (data: string) => {
return await jwtVerify(data, JWT_SECRET);
return await jwtVerify(data, JWT_SECRET, {
algorithms: ["HS256"],
requiredClaims: ["exp"],
});
};
export type { JWTPayload, JWTVerifyResult };
+13
View File
@@ -163,6 +163,19 @@ export const getServerPermissions = cache(async () => {
return val;
});
/**
* The signed-in member's active membership and whether their role grants one
* action on one subject — for a server page that reads the database itself
* rather than through a procedure.
*/
export const serverPermission = async (
subject: TSubjects,
action: TActions,
) => {
const { permissions, membership } = await getServerPermissions();
return { membership, allowed: RBAC.allows(permissions, subject, action) };
};
export const serverAccessControl = async () => {
const { permissions } = await getServerPermissions();
+13
View File
@@ -131,6 +131,19 @@ export class RBAC {
return this.policy;
}
/** Whether permissions grant one action on one subject. */
static allows(
permissions: TPermission[],
subject: TSubjects,
action: TActions,
): boolean {
return permissions.some(
(p) =>
p.subject === subject &&
(p.actions.includes("*") || p.actions.includes(action)),
);
}
static normalizePermissionsMap(permissions: TPermission[]) {
const permissionMap = new Map<TSubjects, TActions[]>();
+48
View File
@@ -73,3 +73,51 @@ describe("evaluating a query", () => {
});
}
});
describe("RBAC.allows", () => {
test("grants an action a role lists, or every action for *", () => {
expect(
RBAC.allows(
[{ subject: "securities", actions: ["read"] }],
"securities",
"read",
),
).toBe(true);
expect(
RBAC.allows(
[{ subject: "securities", actions: ["*"] }],
"securities",
"delete",
),
).toBe(true);
});
test("refuses an action a role does not list", () => {
expect(
RBAC.allows(
[{ subject: "securities", actions: ["read"] }],
"securities",
"update",
),
).toBe(false);
});
test("refuses a subject a role was saved without", () => {
expect(
RBAC.allows(
[{ subject: "stakeholder", actions: ["*"] }],
"securities",
"read",
),
).toBe(false);
});
});
describe("a role saved before a subject existed", () => {
test("is refused on that subject by enforce", () => {
const { val } = new RBAC()
.addPolicies({ securities: { allow: ["read"] } })
.enforce([{ subject: "stakeholder", actions: ["*"] }]);
expect(val?.valid).toBe(false);
});
});
+5
View File
@@ -1,7 +1,12 @@
// What a role grants actions on. `securities` is the cap table itself — share
// classes, equity plans, shares, options, SAFEs and convertibles; `updates` is
// investor updates. A role saved before a subject existed holds nothing on it.
export const SUBJECTS = [
"billing",
"members",
"stakeholder",
"securities",
"updates",
"roles",
"audits",
"documents",
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { sameSitePath } from "./same-site";
describe("sameSitePath", () => {
it("keeps a path on this site", () => {
expect(sameSitePath("/verify-member/abc", "/onboarding")).toBe(
"/verify-member/abc",
);
});
it("refuses anything a browser reads as another host", () => {
for (const path of [
"//evil.example",
"/\\evil.example",
"https://evil.example",
"evil.example",
"/\tevil",
]) {
expect(sameSitePath(path, "/onboarding")).toBe("/onboarding");
}
});
it("falls back when there is none", () => {
expect(sameSitePath(undefined, "/onboarding")).toBe("/onboarding");
expect(sameSitePath("", "/onboarding")).toBe("/onboarding");
});
});
+8
View File
@@ -0,0 +1,8 @@
/**
* A return path is honoured only when it is a path on this site: one leading
* slash, then anything but a second slash or a backslash, which a browser reads
* as another host, and no whitespace, which a browser strips before reading it.
* Anything else returns to `fallback`.
*/
export const sameSitePath = (path: string | undefined, fallback: string) =>
path && /^\/(?![/\\])/.test(path) && !/\s/.test(path) ? path : fallback;
-55
View File
@@ -1,55 +0,0 @@
import { nanoid } from "nanoid";
import { db } from "@/server/db";
import { getPasswordResetokenByEmail } from "@/server/password-reset-token";
import { getVerificationTokenByEmail } from "@/server/verification-token";
export const generateVerificationToken = async (email: string) => {
const token = nanoid(32);
const expires = new Date(new Date().getTime() + 3600 * 1000);
const existingToken = await getVerificationTokenByEmail(email);
if (existingToken) {
await db.verificationToken.delete({
where: {
id: existingToken.id,
},
});
}
const verificationToken = await db.verificationToken.create({
data: {
identifier: email,
token,
expires,
},
});
return verificationToken;
};
export const generatePasswordResetToken = async (email: string) => {
const token = nanoid(32);
const expires = new Date(new Date().getTime() + 3600 * 1000);
const existingToken = await getPasswordResetokenByEmail(email);
if (existingToken) {
await db.passwordResetToken.delete({
where: {
id: existingToken.id,
},
});
}
const passwordResetToken = await db.passwordResetToken.create({
data: {
email,
token,
expires,
},
});
return passwordResetToken;
};
+50
View File
@@ -0,0 +1,50 @@
import { readFileSync, readdirSync } from "node:fs";
import { dirname, join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
// A "use server" module turns every export into a public POST endpoint, so a
// module that is one must decide who is asking before it does anything. The
// object-store signers in file-uploads.ts check nothing and are server-only;
// storage.ts is the one module a browser reaches them through.
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) ? [p] : [];
});
}
const USE_SERVER = /^\s*["']use server["'];?/m;
describe("server actions", () => {
it("are declared in storage.ts and nowhere else", () => {
const modules = files(src)
.filter((f) => USE_SERVER.test(readFileSync(f, "utf8")))
.map((f) => relative(root, f));
expect(modules).toEqual(["src/server/storage.ts"]);
});
it("each establish a signed-in caller first", () => {
const text = readFileSync(join(src, "server", "storage.ts"), "utf8");
const actions = [
...text.matchAll(/export async function (\w+)\([^)]*\)\s*{\s*([^\n]*)/g),
];
expect(actions.map(([, name]) => name)).toEqual([
"presignUpload",
"presignDownload",
]);
for (const [, , first] of actions) {
expect(first).toContain("await signedIn()");
}
});
it("keep the unchecked signers server-only", () => {
const text = readFileSync(join(src, "server", "file-uploads.ts"), "utf8");
expect(text).toMatch(/import "server-only";/);
expect(USE_SERVER.test(text)).toBe(false);
});
});
+2 -1
View File
@@ -99,7 +99,7 @@ async function checkMembership(userId: string, c: Context) {
}
const membership = await db.member.findFirst({
where: { companyId, userId },
where: { companyId, userId, status: "ACTIVE", isOnboarded: true },
select: {
id: true,
companyId: true,
@@ -133,6 +133,7 @@ function findAccessToken(clientId: string, c: Context) {
clientId,
typeEnum: "api",
active: true,
OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
},
select: {
clientId: true,
+5 -1
View File
@@ -26,7 +26,11 @@ export const getMany = withAuthApiV1
const { membership } = c.get("session");
const userMemberships = await db.member.findMany({
where: { userId: membership.userId },
where: {
userId: membership.userId,
status: "ACTIVE",
isOnboarded: true,
},
select: { companyId: true },
});
+8 -1
View File
@@ -45,8 +45,15 @@ export const getOne = withAuthApiV1
const { membership } = c.get("session");
const { id: companyId } = c.req.valid("param");
// The caller's own active membership of this company, found by the user
// the credential belongs to: a bearer token carries no member id.
const member = await db.member.findFirst({
where: { companyId, id: membership.memberId },
where: {
companyId,
userId: membership.userId,
status: "ACTIVE",
isOnboarded: true,
},
select: { companyId: true },
});
+45 -8
View File
@@ -20,6 +20,13 @@ const IAM_URL = process.env.IAM_URL;
const IAM_CLIENT_ID = process.env.IAM_CLIENT_ID;
const IAM_CLIENT_SECRET = process.env.IAM_CLIENT_SECRET;
/**
* The IAM sign-in. An account here is the IAM subject it signed in as and
* nothing else: next-auth finds it by (provider, providerAccountId = sub), and a
* first sign-in whose address already names a user without that subject is
* refused (OAuthAccountNotLinked) rather than merged into it. An address is a
* label a person can be given, not proof they are the person it was given to.
*/
// biome-ignore lint/suspicious/noExplicitAny: IAM profile shape varies
function HanzoIAMProvider(): OAuthConfig<any> {
const issuer = IAM_URL || "https://hanzo.id";
@@ -40,19 +47,45 @@ function HanzoIAMProvider(): OAuthConfig<any> {
// sign-in failed; the handoff to hanzo.id looks perfectly healthy either
// way, so only completing a login tells the two apart.
idToken: true,
userinfo: { url: `${issuer}/v1/iam/oauth/userinfo` },
// The profile is IAM's userinfo, not the id_token's claims: only userinfo
// states whether IAM has proven the address (`email_verified`), and an
// invitation is accepted only by an address IAM has proven. next-auth hands
// this the TokenSet its OIDC callback validated (typed as its parameters),
// and given a TokenSet openid-client refuses a userinfo whose subject is
// not the id_token's.
userinfo: {
url: `${issuer}/v1/iam/oauth/userinfo`,
request: ({ client, tokens }) =>
client.userinfo(tokens as Parameters<typeof client.userinfo>[0]),
},
profile(profile) {
return {
id: profile.sub,
name: profile.displayName || profile.name || profile.preferred_username,
email: profile.email,
image: profile.avatar || profile.picture,
emailVerified: profile.email_verified === true ? new Date() : null,
};
},
allowDangerousEmailAccountLinking: true,
};
}
/**
* Records on the user row whether IAM has proven its address, as IAM answers on
* this sign-in. next-auth creates a user with `emailVerified: null` whatever the
* profile says, so the answer is written here, on every sign-in, and a proof IAM
* withdraws stops counting at the next one.
*/
const recordEmailProof = async (
userId: string,
profile: { emailVerified?: Date | null } | undefined,
) => {
await db.user.update({
where: { id: userId },
data: { emailVerified: profile?.emailVerified ?? null },
});
};
export const JWT_SECRET = new TextEncoder().encode(env.NEXTAUTH_SECRET);
declare module "next-auth" {
@@ -88,11 +121,11 @@ declare module "next-auth/jwt" {
export const authOptions: NextAuthOptions = {
events: {
async linkAccount({ user }) {
await db.user.update({
where: { id: user.id },
data: { emailVerified: new Date() },
});
async signIn({ user, profile }) {
await recordEmailProof(
user.id,
profile as { emailVerified?: Date | null } | undefined,
);
},
},
callbacks: {
@@ -186,7 +219,7 @@ export const authOptions: NextAuthOptions = {
},
// @ts-expect-error
adapter: PrismaAdapter(db),
secret: env.NEXTAUTH_SECRET ?? "secret",
secret: env.NEXTAUTH_SECRET,
session: {
strategy: "jwt",
},
@@ -267,11 +300,15 @@ export interface checkMembershipOptions {
}
export async function checkMembership({ session, tx }: checkMembershipOptions) {
// ACTIVE is read from the row, not from the session: the session carries the
// member id it signed in with, and a member an admin has since deactivated or
// removed must stop acting now rather than when the cookie lapses.
const data = await tx.member.findFirst({
where: {
id: session.user.memberId,
companyId: session.user.companyId,
isOnboarded: true,
status: "ACTIVE",
},
select: {
id: true,
+16 -1
View File
@@ -1,9 +1,24 @@
import { db } from "./db";
import { type TPrismaOrTransaction, db } from "./db";
/**
* The refusal a person meets when the company already stands. One deployment
* keeps one company — its cap table is the signed-in org's, read from Hanzo
* Cloud — so a second company would be a second name for the same cap table,
* and its founder its ADMIN by nothing more than signing in.
*/
export const COMPANY_STANDS =
"This Captable already has its company. Ask one of its admins to invite you.";
/** Whether the deployment's one company has been created. */
export const companyStands = async (tx: TPrismaOrTransaction = db) =>
(await tx.company.count()) > 0;
export const getCompanyList = async (userId: string) => {
const data = await db.member.findMany({
where: {
userId,
status: "ACTIVE",
isOnboarded: true,
},
select: {
id: true,
+3 -2
View File
@@ -1,8 +1,9 @@
/* eslint-disable @typescript-eslint/prefer-for-of */
import { dayjsExt } from "@/common/dayjs";
import { type TUploadFile, getFileFromS3, uploadFile } from "@/common/uploads";
import type { TUploadFile } from "@/common/put-file";
import { TAG } from "@/lib/tags";
import { AuditLogTemplate } from "@/pdf-templates/audit-log-template";
import { getFileFromS3, storeFile } from "@/server/file-uploads";
import { createBucketHandler } from "@/trpc/routers/bucket-router/procedures/create-bucket";
import { createDocumentHandler } from "@/trpc/routers/document-router/procedures/create-document";
import { renderToBuffer } from "@react-pdf/renderer";
@@ -215,7 +216,7 @@ export async function uploadEsignDocuments({
size: 0,
} as unknown as File;
const data = await uploadFile(file, {
const data = await storeFile(file, {
identifier: companyId,
keyPrefix: "signed-esign-doc",
});
+41 -1
View File
@@ -1,7 +1,10 @@
"use server";
// Server-only: these sign with the store's own credentials and check nothing.
// The browser reaches them only through the checks in ./storage.ts.
import "server-only";
import path from "node:path";
import { customId } from "@/common/id";
import { putFile } from "@/common/put-file";
import { env } from "@/env";
import {
DeleteObjectCommand,
@@ -113,3 +116,40 @@ export const deleteBucketFile = async (key: string) => {
}),
);
};
/** Uploads a file the server holds (a generated PDF), signing the PUT itself. */
export const storeFile = async (
file: File,
options: Pick<
getPresignedUrlOptions,
"expiresIn" | "keyPrefix" | "identifier"
>,
bucketMode: "publicBucket" | "privateBucket" = "privateBucket",
) => {
const presigned = await getPresignedPutUrl({
contentType: file.type,
fileName: file.name,
bucketMode,
...options,
});
return putFile(file, presigned, bucketMode);
};
/** Reads an object's bytes from the private bucket. */
export const getFileFromS3 = async (key: string) => {
const { url } = await getPresignedGetUrl(key);
const response = await fetch(url, {
method: "GET",
});
if (!response.ok) {
throw new Error(
`Failed to get file "${key}", failed with status code ${response.status}`,
);
}
const buffer = await response.arrayBuffer();
return new Uint8Array(buffer);
};
+108 -75
View File
@@ -1,90 +1,123 @@
import { createHash } from "@/lib/crypto";
import {
isPlaceholder,
parseMemberIdentifier,
sameAddress,
} from "@/lib/invite";
import { TRPCError } from "@trpc/server";
import { nanoid } from "nanoid";
import { type TPrismaOrTransaction, db } from "./db";
export const checkVerificationToken = async (
/** The refusals a person meets on an invitation, in words they can act on. */
export const INVITE_INVALID =
"This invitation is not valid, has expired, or has already been used. Ask a company admin to invite you again.";
export const INVITE_ADDRESS =
"This invitation was sent to a different email address than the one Hanzo has verified for your account. Sign in with the account the invitation was sent to, or ask a company admin to invite this address.";
export const INVITE_ALREADY_MEMBER =
"You are already a member of this company.";
export interface Invite {
memberId: string;
email: string;
}
/**
* Resolves the seat a still-valid invitation token opens. Expired, unknown and
* malformed tokens are one refusal, so a prober learns nothing about which
* tokens exist.
*/
export async function resolveInvite(
tx: TPrismaOrTransaction,
token: string,
userEmail: string | null | undefined,
) => {
// based on https://github.com/nextauthjs/next-auth/blob/46264fb42af4c3ef7137a5694875eaa1309462ea/packages/core/src/lib/actions/callback/index.ts#L200
const invite = await db.verificationToken.findFirst({
where: {
token,
},
});
const hasInvite = !!invite;
const expired = invite ? invite.expires.valueOf() < Date.now() : undefined;
const invalidInvite = !hasInvite || expired;
if (invalidInvite) {
throw new Error("invalid invite or invite expired");
): Promise<Invite> {
const invite = await tx.verificationToken.findFirst({ where: { token } });
if (!invite || invite.expires.valueOf() <= Date.now()) {
throw new TRPCError({ code: "NOT_FOUND", message: INVITE_INVALID });
}
const [email, memberId] = invite.identifier.split(":");
if (!memberId) {
throw new Error("member id not found");
const named = parseMemberIdentifier(invite.identifier);
if (!named) {
throw new TRPCError({ code: "NOT_FOUND", message: INVITE_INVALID });
}
if (!email) {
throw new Error("user email not found");
}
if (userEmail !== email) {
throw new Error("invalid email");
}
return { memberId, email };
};
interface generateMemberIdentifierOptions {
email: string;
memberId: string;
return named;
}
export const generateMemberIdentifier = ({
email,
memberId,
}: generateMemberIdentifierOptions) => {
return `${email}:${memberId}`;
};
export async function generateInviteToken() {
const ONE_DAY_IN_SECONDS = 86400;
const expires = new Date(Date.now() + ONE_DAY_IN_SECONDS * 1000);
const memberInviteTokenHash = await createHash(`member-${nanoid(16)}`);
return { expires, memberInviteTokenHash };
}
interface revokeExistingInviteTokensOptions {
memberId: string;
email: string;
tx?: TPrismaOrTransaction;
}
export async function revokeExistingInviteTokens({
email,
memberId,
tx,
}: revokeExistingInviteTokensOptions) {
const dbClient = tx ?? db;
const identifier = generateMemberIdentifier({
email,
memberId,
/**
* Whether the user may take the seat an invitation opens: IAM has proven the
* address the invitation was sent to is this user's, the seat is still pending,
* and it is held either by this user already or by the placeholder the
* invitation made — a row with no sign-in, no address and no password, which
* nobody can be. Returns the seat, or throws the refusal.
*/
export async function inviteSeat(
tx: TPrismaOrTransaction,
invite: Invite,
userId: string,
) {
const user = await tx.user.findUnique({
where: { id: userId },
select: { id: true, email: true, emailVerified: true },
});
if (!user?.emailVerified || !sameAddress(user.email, invite.email)) {
throw new TRPCError({ code: "FORBIDDEN", message: INVITE_ADDRESS });
}
const verificationToken = await dbClient.verificationToken.findMany({
where: {
identifier,
},
});
await dbClient.verificationToken.deleteMany({
where: {
token: {
in: verificationToken.map((item) => item.token),
const member = await tx.member.findFirst({
where: { id: invite.memberId, status: "PENDING" },
select: {
id: true,
companyId: true,
userId: true,
user: {
select: {
email: true,
password: true,
_count: { select: { accounts: true } },
},
},
},
});
if (!member) {
throw new TRPCError({ code: "NOT_FOUND", message: INVITE_INVALID });
}
if (member.userId === user.id) {
return { member, placeholder: null };
}
if (!isPlaceholder(member.user)) {
throw new TRPCError({ code: "FORBIDDEN", message: INVITE_INVALID });
}
const held = await tx.member.findUnique({
where: {
companyId_userId: { companyId: member.companyId, userId: user.id },
},
select: { id: true },
});
if (held) {
throw new TRPCError({ code: "CONFLICT", message: INVITE_ALREADY_MEMBER });
}
return { member, placeholder: member.userId };
}
/** An invitation is good for seven days from the mail that carries it. */
const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
export async function generateInviteToken() {
const expires = new Date(Date.now() + INVITE_TTL_MS);
const memberInviteTokenHash = await createHash(`member-${nanoid(32)}`);
return { expires, memberInviteTokenHash };
}
/** Withdraws every invitation token that opens this member's seat. */
export async function revokeExistingInviteTokens({
memberId,
tx,
}: {
memberId: string;
tx?: TPrismaOrTransaction;
}) {
const dbClient = tx ?? db;
await dbClient.verificationToken.deleteMany({
where: { identifier: { endsWith: `:${memberId}` } },
});
}
-25
View File
@@ -1,25 +0,0 @@
import { db } from "./db";
export const getPasswordResetTokenByToken = async (token: string) => {
try {
const passwordResetToken = await db.passwordResetToken.findUnique({
where: { token },
});
return passwordResetToken;
} catch {
return null;
}
};
export const getPasswordResetokenByEmail = async (email: string) => {
try {
const passwordResetToken = await db.passwordResetToken.findFirst({
where: { email },
});
return passwordResetToken;
} catch {
return null;
}
};
+94
View File
@@ -0,0 +1,94 @@
"use server";
// The two object-store calls a browser may make. Each is a Server Action — a
// public endpoint anyone can POST to — so each decides for itself who is
// asking: a signed-in person, and for anything of a company's, an ACTIVE
// member of it. The signing itself is ./file-uploads.ts, which checks nothing
// and is reachable from server code alone.
import { checkMembership, getServerAuthSession } from "@/server/auth";
import { db } from "@/server/db";
import {
getPresignedGetUrl,
getPresignedPutUrl,
type getPresignedUrlOptions,
} from "@/server/file-uploads";
const refused = () => new Error("You do not have access to this file.");
/** The signed-in session, or the refusal. */
const signedIn = async () => {
const session = await getServerAuthSession();
if (!session?.user?.id) throw refused();
return session;
};
/** The caller's active membership, or null when they hold none. */
const activeMembership = async (
session: Awaited<ReturnType<typeof signedIn>>,
) => {
try {
return await checkMembership({ session, tx: db });
} catch {
return null;
}
};
/**
* 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;
// Only pictures meant to be shown anywhere go to the public bucket.
if (
bucketMode === "publicBucket" &&
keyPrefix !== "company-logos" &&
keyPrefix !== "profile-avatars"
) {
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 });
}
/**
* A presigned GET, for an object that is one of the caller's company's
* documents or e-sign templates. Ownership is read from the rows that file the
* object, not from the shape of its key.
*/
export async function presignDownload(key: string) {
const session = await signedIn();
const membership = await activeMembership(session);
if (!membership || !key) throw refused();
const { companyId } = membership;
const [document, template] = await Promise.all([
db.document.findFirst({
where: { companyId, bucket: { key } },
select: { id: true },
}),
db.template.findFirst({
where: { companyId, bucket: { key } },
select: { id: true },
}),
]);
if (!document && !template) throw refused();
return getPresignedGetUrl(key);
}
-25
View File
@@ -1,25 +0,0 @@
import { db } from "./db";
export const getVerificationTokenByToken = async (token: string) => {
try {
const verificationToken = await db.verificationToken.findUnique({
where: { token },
});
return verificationToken;
} catch {
return null;
}
};
export const getVerificationTokenByEmail = async (email: string) => {
try {
const verificationToken = await db.verificationToken.findFirst({
where: { identifier: email },
});
return verificationToken;
} catch {
return null;
}
};
-4
View File
@@ -1,7 +1,6 @@
import { createTRPCRouter } from "@/trpc/api/trpc";
import { accessTokenRouter } from "../routers/access-token/router";
import { auditRouter } from "../routers/audit-router/router";
import { authRouter } from "../routers/auth/router";
import { bankAccountsRouter } from "../routers/bank-accounts/router";
import { bucketRouter } from "../routers/bucket-router/router";
import { commonRouter } from "../routers/common/router";
@@ -16,7 +15,6 @@ import { passkeyRouter } from "../routers/passkey-router/router";
import { rbacRouter } from "../routers/rbac-router/router";
import { safeRouter } from "../routers/safe/router";
import { securitiesRouter } from "../routers/securities-router/router";
import { securityRouter } from "../routers/security-router/router";
import { shareClassRouter } from "../routers/share-class/router";
import { stakeholderRouter } from "../routers/stakeholder-router/router";
import { templateFieldRouter } from "../routers/template-field-router/router";
@@ -44,11 +42,9 @@ export const appRouter = createTRPCRouter({
securities: securitiesRouter,
safe: safeRouter,
update: updateRouter,
auth: authRouter,
dataRoom: dataRoomRouter,
common: commonRouter,
passkey: passkeyRouter,
security: securityRouter,
rbac: rbacRouter,
accessToken: accessTokenRouter,
bankAccounts: bankAccountsRouter,
+3
View File
@@ -8,6 +8,7 @@ import z from "zod";
export const accessTokenRouter = createTRPCRouter({
listAll: withAccessControl
.meta({ policies: { developer: { allow: ["read"] } } })
.input(z.object({ typeEnum: z.nativeEnum(AccessTokenType) }))
.query(async ({ ctx, input }) => {
const {
@@ -42,6 +43,7 @@ export const accessTokenRouter = createTRPCRouter({
}),
create: withAccessControl
.meta({ policies: { developer: { allow: ["create"] } } })
.input(z.object({ typeEnum: z.nativeEnum(AccessTokenType) }))
.mutation(async ({ ctx, input }) => {
const {
@@ -93,6 +95,7 @@ export const accessTokenRouter = createTRPCRouter({
}),
delete: withAccessControl
.meta({ policies: { developer: { allow: ["delete"] } } })
.input(z.object({ tokenId: z.string() }))
.mutation(async ({ ctx, input }) => {
const {
@@ -1,28 +0,0 @@
import { passwordResetEmailJob } from "@/jobs/password-reset-email";
import { generatePasswordResetToken } from "@/lib/token";
import { getUserByEmail } from "@/server/user";
import { withoutAuth } from "@/trpc/api/trpc";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
export const forgotPasswordProcedure = withoutAuth
.input(z.string().email())
.mutation(async ({ input }) => {
const existingUser = await getUserByEmail(input);
if (!existingUser) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Email not found!",
});
}
const { email, token } = await generatePasswordResetToken(input);
await passwordResetEmailJob.emit({ email, token });
return {
success: true,
message:
"To reset your password, please click the link sent to your email.",
};
});
@@ -1,72 +0,0 @@
import { Audit } from "@/server/audit";
import { getPasswordResetTokenByToken } from "@/server/password-reset-token";
import { getUserByEmail } from "@/server/user";
import { withoutAuth } from "@/trpc/api/trpc";
import { TRPCError } from "@trpc/server";
import bcrypt from "bcryptjs";
import { ZNewPasswordProcedureSchema } from "../schema";
export const newPasswordProcedure = withoutAuth
.input(ZNewPasswordProcedureSchema)
.mutation(async ({ ctx, input }) => {
const { token, password } = input;
const { requestIp, userAgent } = ctx;
const existingToken = await getPasswordResetTokenByToken(token);
if (!existingToken) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid token!",
});
}
const hasExpired = new Date(existingToken.expires) < new Date();
if (hasExpired) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Token expired!",
});
}
const existingUser = await getUserByEmail(existingToken.email);
if (!existingUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Email not found!",
});
}
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(password, salt);
const user = await ctx.db.user.update({
where: { id: existingUser.id },
data: {
password: hashedPassword,
// since the user can only reset using email, we can assume the email is verified
emailVerified: new Date(),
},
});
await Audit.create(
{
action: "user.password-updated",
companyId: "",
actor: { type: "user", id: user.id },
context: {
userAgent,
requestIp,
},
target: [{ type: "user", id: user.id }],
summary: `${user.name} updated the password`,
},
ctx.db,
);
await ctx.db.passwordResetToken.delete({
where: { id: existingToken.id },
});
return {
success: true,
message: "Your password has been updated successfully.",
};
});
@@ -1,32 +0,0 @@
import { authVerificationEmailJob } from "@/jobs/auth-verification-email";
import { generateVerificationToken } from "@/lib/token";
import { getVerificationTokenByEmail } from "@/server/verification-token";
import { withoutAuth } from "@/trpc/api/trpc";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
export const resendEmailProcedure = withoutAuth
.input(z.string().email())
.mutation(async ({ input }) => {
const oldVerificationToken = await getVerificationTokenByEmail(input);
if (!oldVerificationToken) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Email not found!",
});
}
const verificationToken = await generateVerificationToken(input);
await authVerificationEmailJob.emit({
email: verificationToken.identifier,
token: verificationToken.token,
});
return {
success: true,
message:
"To verify your account, please click the verification link sent to your email.",
};
});
-64
View File
@@ -1,64 +0,0 @@
import { authVerificationEmailJob } from "@/jobs/auth-verification-email";
import { generateVerificationToken } from "@/lib/token";
import { Audit } from "@/server/audit";
import { withoutAuth } from "@/trpc/api/trpc";
import { TRPCError } from "@trpc/server";
import bcrypt from "bcryptjs";
import { ZSignUpMutationSchema } from "../schema";
export const signupProcedure = withoutAuth
.input(ZSignUpMutationSchema)
.mutation(async ({ ctx, input }) => {
const { name, email, password } = input;
const { userAgent, requestIp } = ctx;
const userExists = await ctx.db.user.findFirst({
where: {
email: email.toLowerCase(),
},
});
if (userExists) {
throw new TRPCError({
code: "FORBIDDEN",
message: "User already exists",
});
}
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(password, salt);
const user = await ctx.db.user.create({
data: {
name,
email,
password: hashedPassword,
},
});
await Audit.create(
{
action: "user.signed-up",
companyId: "",
actor: { type: "user", id: user.id },
context: {
userAgent,
requestIp,
},
target: [{ type: "user", id: user.id }],
summary: `${user.name} has created the Account`,
},
ctx.db,
);
const verificationToken = await generateVerificationToken(email);
await authVerificationEmailJob.emit({
email: verificationToken.identifier,
token: verificationToken.token,
});
return {
success: true,
message: "Please check your email to verify your account.",
};
});
@@ -1,68 +0,0 @@
import { Audit } from "@/server/audit";
import { getUserByEmail } from "@/server/user";
import { getVerificationTokenByToken } from "@/server/verification-token";
import { withoutAuth } from "@/trpc/api/trpc";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
export const verifyEmailProcedure = withoutAuth
.input(z.string())
.mutation(async ({ ctx, input }) => {
const { requestIp, userAgent } = ctx;
const existingToken = await getVerificationTokenByToken(input);
if (!existingToken) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Token does not exists!",
});
}
const hasExpired = new Date(existingToken.expires) < new Date();
if (hasExpired) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Token expired!",
});
}
const existingUser = await getUserByEmail(existingToken.identifier);
if (!existingUser) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Email does not exists!",
});
}
const user = await ctx.db.user.update({
where: { id: existingUser.id },
data: {
emailVerified: new Date(),
email: existingToken.identifier,
},
});
await Audit.create(
{
action: "user.verified",
companyId: "",
actor: { type: "user", id: user.id },
context: {
userAgent,
requestIp,
},
target: [{ type: "user", id: user.id }],
summary: `${user.name} changed the password`,
},
ctx.db,
);
await ctx.db.verificationToken.delete({
where: { id: existingToken.id },
});
return {
success: true,
message: "You're all set!",
};
});
-13
View File
@@ -1,13 +0,0 @@
import { createTRPCRouter } from "@/trpc/api/trpc";
import { forgotPasswordProcedure } from "./procedure/forgot-password";
import { newPasswordProcedure } from "./procedure/new-password";
import { resendEmailProcedure } from "./procedure/resend-email";
import { signupProcedure } from "./procedure/signup";
import { verifyEmailProcedure } from "./procedure/verify-email";
export const authRouter = createTRPCRouter({
signup: signupProcedure,
resendEmail: resendEmailProcedure,
verifyEmail: verifyEmailProcedure,
forgotPassword: forgotPasswordProcedure,
newPassword: newPasswordProcedure,
});
-27
View File
@@ -1,27 +0,0 @@
import { z } from "zod";
export const ZCurrentPasswordSchema = z
.string()
.min(6, { message: "Must be at least 6 characters in length" })
.max(72);
export const ZPasswordSchema = z
.string()
.regex(/.*[A-Z].*/, { message: "One uppercase character" })
.regex(/.*[a-z].*/, { message: "One lowercase character" })
.regex(/.*\d.*/, { message: "One number" })
.regex(/.*[`~<>?,.\/!@#$%^&*()\-_+=\"'|{}\[\];:\\].*/, {
message: "One special character is required",
})
.min(8, { message: "Must be at least 8 characters in length" })
.max(72, { message: "Cannot be more than 72 characters in length" });
export const ZSignUpMutationSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
password: ZPasswordSchema,
});
export const ZNewPasswordProcedureSchema = z.object({
token: z.string(),
password: z.string(),
});
@@ -1,6 +1,6 @@
import { Audit } from "@/server/audit";
import type { TPrismaOrTransaction } from "@/server/db";
import { withAuth } from "@/trpc/api/trpc";
import { withAccessControl } from "@/trpc/api/trpc";
import {
type TypeZodCreateBucketMutationSchema,
ZodCreateBucketMutationSchema,
@@ -45,7 +45,8 @@ export const createBucketHandler = async ({
return bucket;
};
export const createBucketProcedure = withAuth
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;
+51 -43
View File
@@ -1,52 +1,60 @@
import type { ShareContactType } from "@/schema/contacts";
import { createTRPCRouter, withAuth } from "@/trpc/api/trpc";
import { createTRPCRouter, withAccessControl } from "@/trpc/api/trpc";
export const commonRouter = createTRPCRouter({
getContacts: withAuth.query(async ({ ctx }) => {
const { db, captable, session } = ctx;
const user = session.user;
const companyId = user.companyId;
const contacts = [] as ShareContactType[];
// Everyone a data room or an update can be shared with: the company's members
// and its stakeholders, so it needs the right to read both.
getContacts: withAccessControl
.meta({
policies: {
members: { allow: ["read"] },
stakeholder: { allow: ["read"] },
},
})
.query(async ({ ctx }) => {
const { db, captable, membership } = ctx;
const { companyId } = membership;
const contacts = [] as ShareContactType[];
const [members, stakeholders] = await Promise.all([
db.member.findMany({
where: {
companyId,
},
const [members, stakeholders] = await Promise.all([
db.member.findMany({
where: {
companyId,
},
include: {
user: {
select: {
email: true,
name: true,
image: true,
include: {
user: {
select: {
email: true,
name: true,
image: true,
},
},
},
},
}),
captable.stakeholders.list(),
]);
(members || []).map((member) =>
contacts.push({
id: member.id,
image: member.user.image ?? undefined,
email: member.user.email ?? "",
value: member.user.email ?? "",
name: member.user.name ?? "",
type: "member",
}),
);
(stakeholders || []).map((stakeholder) =>
contacts.push({
id: stakeholder.id,
email: stakeholder.email,
value: stakeholder.email,
name: stakeholder.name,
institutionName: stakeholder.institutionName,
type: "stakeholder",
}),
);
}),
captable.stakeholders.list(),
]);
(members || []).map((member) =>
contacts.push({
id: member.id,
image: member.user.image ?? undefined,
email: member.user.email ?? "",
value: member.user.email ?? "",
name: member.user.name ?? "",
type: "member",
}),
);
(stakeholders || []).map((stakeholder) =>
contacts.push({
id: stakeholder.id,
email: stakeholder.email,
value: stakeholder.email,
name: stakeholder.name,
institutionName: stakeholder.institutionName,
type: "stakeholder",
}),
);
return contacts;
}),
return contacts;
}),
});
+37 -32
View File
@@ -1,5 +1,4 @@
import { Audit } from "@/server/audit";
import { checkMembership } from "@/server/auth";
import { createTRPCRouter, withAccessControl, withAuth } from "@/trpc/api/trpc";
import { ZodOnboardingMutationSchema } from "../onboarding-router/schema";
import { ZodSwitchCompanyMutationSchema } from "./schema";
@@ -9,49 +8,55 @@ export const companyRouter = createTRPCRouter({
// three incorporation fields, while this read also feeds the settings form the
// member's title and the company's logo, website, address, city, state,
// zipcode, country and incorporationDate. Moving it needs those on that route.
getCompany: withAuth.query(async ({ ctx }) => {
const user = ctx.session.user;
const companyId = user.companyId;
// Any active member reads the company it belongs to; the membership check is
// withAccessControl's, which reads ACTIVE from the row.
getCompany: withAccessControl
.meta({ policies: {} })
.query(async ({ ctx }) => {
const { memberId, companyId } = ctx.membership;
const company = await ctx.db.member.findFirstOrThrow({
where: {
id: user.memberId,
companyId,
},
select: {
id: true,
title: true,
company: {
select: {
id: true,
publicId: true,
name: true,
website: true,
incorporationDate: true,
incorporationType: true,
incorporationState: true,
incorporationCountry: true,
state: true,
city: true,
zipcode: true,
streetAddress: true,
country: true,
logo: true,
const company = await ctx.db.member.findFirstOrThrow({
where: {
id: memberId,
companyId,
},
select: {
id: true,
title: true,
company: {
select: {
id: true,
publicId: true,
name: true,
website: true,
incorporationDate: true,
incorporationType: true,
incorporationState: true,
incorporationCountry: true,
state: true,
city: true,
zipcode: true,
streetAddress: true,
country: true,
logo: true,
},
},
},
},
});
return company;
}),
});
return company;
}),
switchCompany: withAuth
.input(ZodSwitchCompanyMutationSchema)
.mutation(async ({ ctx, input }) => {
const { db } = ctx;
await db.$transaction(async (tx) => {
// Only one of the caller's own active memberships can be switched to.
const member = await tx.member.findFirst({
where: {
id: input.id,
userId: ctx.session.user.id,
status: "ACTIVE",
isOnboarded: true,
},
});
+131 -108
View File
@@ -1,17 +1,18 @@
import { generatePublicId } from "@/common/id";
import { env } from "@/env";
import { shareDataRoomEmailJob } from "@/jobs/share-data-room-email";
import { encode } from "@/lib/jwt";
import { encodeShare } from "@/lib/jwt";
import { RBAC } from "@/lib/rbac";
import { ShareRecipientSchema } from "@/schema/contacts";
import { Audit } from "@/server/audit";
import { checkMembership } from "@/server/auth";
import { createTRPCRouter, withAuth } from "@/trpc/api/trpc";
import { createTRPCRouter, withAccessControl } from "@/trpc/api/trpc";
import type { DataRoom } from "@prisma/client";
import { z } from "zod";
import { DataRoomSchema } from "./schema";
export const dataRoomRouter = createTRPCRouter({
getDataRoom: withAuth
getDataRoom: withAccessControl
.meta({ policies: { documents: { allow: ["read"] } } })
.input(
z.object({
dataRoomPublicId: z.string(),
@@ -35,12 +36,11 @@ export const dataRoomRouter = createTRPCRouter({
company: object;
};
const { db, session } = ctx;
const { db, membership } = ctx;
const { dataRoomPublicId, include } = input;
const { companyId } = membership;
const { dataRoom } = await db.$transaction(async (tx) => {
const { companyId } = await checkMembership({ session, tx });
const dataRoom = await tx.dataRoom.findUniqueOrThrow({
where: {
publicId: dataRoomPublicId,
@@ -88,7 +88,7 @@ export const dataRoomRouter = createTRPCRouter({
response.recipients = await Promise.all(
dataRoom.recipients.map(async (recipient) => ({
...recipient,
token: await encode({
token: await encodeShare({
companyId,
dataRoomId: dataRoom.id,
recipientId: recipient.id,
@@ -107,105 +107,126 @@ export const dataRoomRouter = createTRPCRouter({
return response;
}),
save: withAuth.input(DataRoomSchema).mutation(async ({ ctx, input }) => {
try {
let room = {} as DataRoom;
const { db, session, userAgent, requestIp } = ctx;
save: withAccessControl
.meta({ policies: { documents: { allow: ["create", "update"] } } })
.input(DataRoomSchema)
.mutation(async ({ ctx, input }) => {
try {
let room = {} as DataRoom;
const { db, session, userAgent, requestIp, membership, permissions } =
ctx;
const { companyId } = membership;
const { publicId } = input;
const { publicId } = input;
await db.$transaction(async (tx) => {
const { companyId } = await checkMembership({ tx, session });
const { user } = session;
if (!publicId) {
room = await tx.dataRoom.create({
data: {
name: input.name,
companyId,
publicId: generatePublicId(),
},
});
await Audit.create(
{
action: "dataroom.created",
companyId: user.companyId,
actor: { type: "user", id: user.id },
context: {
userAgent,
requestIp,
},
target: [{ type: "dataroom", id: room.id }],
summary: `${user.name} created the data room ${room.name}`,
},
tx,
);
} else {
room = await tx.dataRoom.update({
where: {
publicId,
},
data: {
name: input.name,
},
});
await Audit.create(
{
action: "dataroom.updated",
companyId: user.companyId,
actor: { type: "user", id: user.id },
context: {
userAgent,
requestIp,
},
target: [{ type: "dataroom", id: room.id }],
summary: `${user.name} updated the data room ${room.name}`,
},
tx,
);
const { documents, recipients } = input;
if (documents) {
await tx.dataRoomDocument.createMany({
data: documents.map((document) => ({
dataRoomId: room.id,
documentId: document.documentId,
})),
});
}
if (recipients) {
await tx.dataRoomRecipient.createMany({
data: recipients.map((recipient) => ({
dataRoomId: room.id,
email: recipient.email,
memberId: recipient.memberId,
stakeholderId: recipient.stakeholderId,
expiresAt: recipient.expiresAt,
})),
});
}
if (
!RBAC.allows(permissions, "documents", publicId ? "update" : "create")
) {
return { success: false, message: "You may not do this." };
}
});
return {
success: true,
message: "Successfully updated data room",
data: room,
};
} catch (error) {
console.error(error);
return {
success: false,
message:
"Oops, something went wrong while saving data room. Please try again.",
};
}
}),
await db.$transaction(async (tx) => {
const { user } = session;
if (!publicId) {
room = await tx.dataRoom.create({
data: {
name: input.name,
companyId,
publicId: generatePublicId(),
},
});
share: withAuth
await Audit.create(
{
action: "dataroom.created",
companyId: user.companyId,
actor: { type: "user", id: user.id },
context: {
userAgent,
requestIp,
},
target: [{ type: "dataroom", id: room.id }],
summary: `${user.name} created the data room ${room.name}`,
},
tx,
);
} else {
room = await tx.dataRoom.update({
where: {
publicId,
companyId,
},
data: {
name: input.name,
},
});
await Audit.create(
{
action: "dataroom.updated",
companyId: user.companyId,
actor: { type: "user", id: user.id },
context: {
userAgent,
requestIp,
},
target: [{ type: "dataroom", id: room.id }],
summary: `${user.name} updated the data room ${room.name}`,
},
tx,
);
const { documents, recipients } = input;
if (documents) {
// A room holds only its own company's documents.
const owned = await tx.document.count({
where: {
companyId,
id: { in: documents.map((document) => document.documentId) },
},
});
if (owned !== new Set(documents.map((d) => d.documentId)).size) {
throw new Error("a document does not belong to this company");
}
await tx.dataRoomDocument.createMany({
data: documents.map((document) => ({
dataRoomId: room.id,
documentId: document.documentId,
})),
});
}
if (recipients) {
await tx.dataRoomRecipient.createMany({
data: recipients.map((recipient) => ({
dataRoomId: room.id,
email: recipient.email,
memberId: recipient.memberId,
stakeholderId: recipient.stakeholderId,
})),
});
}
}
});
return {
success: true,
message: "Successfully updated data room",
data: room,
};
} catch (error) {
console.error(error);
return {
success: false,
message:
"Oops, something went wrong while saving data room. Please try again.",
};
}
}),
share: withAccessControl
.meta({ policies: { documents: { allow: ["update"] } } })
.input(
z.object({
dataRoomId: z.string(),
@@ -214,9 +235,10 @@ export const dataRoomRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
const { session, db, requestIp, userAgent } = ctx;
const { session, db, requestIp, userAgent, membership } = ctx;
const { dataRoomId, others, selectedContacts } = input;
const { name: senderName, email: senderEmail, companyId } = session.user;
const { name: senderName, email: senderEmail } = session.user;
const { companyId } = membership;
const { user } = session;
const dataRoom = await db.dataRoom.findUniqueOrThrow({
where: {
@@ -274,7 +296,7 @@ export const dataRoomRouter = createTRPCRouter({
return { recipientRecord };
});
const token = await encode({
const token = await encodeShare({
companyId,
dataRoomId,
recipientId: recipientRecord.id,
@@ -319,7 +341,8 @@ export const dataRoomRouter = createTRPCRouter({
};
}),
unShare: withAuth
unShare: withAccessControl
.meta({ policies: { documents: { allow: ["update"] } } })
.input(
z.object({
dataRoomId: z.string(),
@@ -327,9 +350,9 @@ export const dataRoomRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
const { session, db, requestIp, userAgent } = ctx;
const { session, db, requestIp, userAgent, membership } = ctx;
const { dataRoomId, recipientId } = input;
const companyId = session.user.companyId;
const { companyId } = membership;
const { user } = session;
const dataRoom = await db.dataRoom.findUniqueOrThrow({
where: {
@@ -1,6 +1,9 @@
import { Audit } from "@/server/audit";
import { checkMembership } from "@/server/auth";
import { withAuth, type withAuthTrpcContextType } from "@/trpc/api/trpc";
import {
withAccessControl,
type withAuthTrpcContextType,
} from "@/trpc/api/trpc";
import {
DocumentShareMutationSchema,
type TypeDocumentShareMutation,
@@ -56,8 +59,7 @@ export const createDocumentShareHandler = async ({
}
};
export const createDocumentShareProcedure = withAuth
export const createDocumentShareProcedure = withAccessControl
.meta({ policies: { documents: { allow: ["create"] } } })
.input(DocumentShareMutationSchema)
.mutation(async (opts) => {
return createDocumentShareHandler(opts);
});
.mutation((opts) => createDocumentShareHandler(opts));
+19 -13
View File
@@ -1,7 +1,7 @@
import { Audit } from "@/server/audit";
import { checkMembership } from "@/server/auth";
import { captableFailure } from "@/server/captable-api";
import { createTRPCRouter, withAuth } from "@/trpc/api/trpc";
import { createTRPCRouter, withAccessControl } from "@/trpc/api/trpc";
import { EquityPlanMutationSchema } from "./schema";
/** The wire body an equity plan is created with. */
@@ -18,11 +18,14 @@ const body = (input: Record<string, unknown>) => ({
});
export const equityPlanRouter = createTRPCRouter({
getPlans: withAuth.query(async ({ ctx }) => ({
data: await ctx.captable.plans.list(),
})),
getPlans: withAccessControl
.meta({ policies: { securities: { allow: ["read"] } } })
.query(async ({ ctx }) => ({
data: await ctx.captable.plans.list(),
})),
create: withAuth
create: withAccessControl
.meta({ policies: { securities: { allow: ["create"] } } })
.input(EquityPlanMutationSchema)
.mutation(async ({ ctx, input }) => {
const { userAgent, requestIp, captable, db, session } = ctx;
@@ -54,12 +57,15 @@ export const equityPlanRouter = createTRPCRouter({
// on /v1/captable/plans/{id} — measured, PUT/PATCH/POST all 404 there while
// the sibling classes route answers PATCH. Amending a plan needs
// `PATCH /v1/captable/plans/{id}`.
update: withAuth.input(EquityPlanMutationSchema).mutation(({ input }) => {
console.error("equityPlan.update: no upstream route", { id: input.id });
return {
success: false,
message:
"Editing an equity plan is not available yet. Hanzo Cloud serves no update route for plans.",
};
}),
update: withAccessControl
.meta({ policies: { securities: { allow: ["update"] } } })
.input(EquityPlanMutationSchema)
.mutation(({ input }) => {
console.error("equityPlan.update: no upstream route", { id: input.id });
return {
success: false,
message:
"Editing an equity plan is not available yet. Hanzo Cloud serves no update route for plans.",
};
}),
});
@@ -1,7 +1,21 @@
import { Audit } from "@/server/audit";
import {
INVITE_INVALID,
inviteSeat,
resolveInvite,
revokeExistingInviteTokens,
} from "@/server/member";
import { withAuth } from "@/trpc/api/trpc";
import { TRPCError } from "@trpc/server";
import { ZodAcceptMemberMutationSchema } from "../schema";
/**
* Takes the seat an invitation opens, for the signed-in user. The seat is the
* one the token names — the caller sends the token and nothing that says which
* member it is — and it is taken only by the person IAM has proven the invited
* address belongs to (inviteSeat). The token is spent in the same transaction,
* with every other token for that seat, so an invitation opens one seat once.
*/
export const acceptMemberProcedure = withAuth
.input(ZodAcceptMemberMutationSchema)
.mutation(async ({ ctx, input }) => {
@@ -9,11 +23,14 @@ export const acceptMemberProcedure = withAuth
const { userAgent, requestIp } = ctx;
const { publicId } = await ctx.db.$transaction(async (trx) => {
await trx.verificationToken.delete({
where: {
token: input.token,
},
});
const invite = await resolveInvite(trx, input.token);
const { member: seat, placeholder } = await inviteSeat(
trx,
invite,
user.id,
);
await revokeExistingInviteTokens({ memberId: seat.id, tx: trx });
await trx.user.update({
where: {
@@ -24,10 +41,10 @@ export const acceptMemberProcedure = withAuth
},
});
const member = await trx.member.update({
where: {
id: input.memberId,
},
// Taken only while still pending and held as inviteSeat saw it, so two
// accepts racing on one token cannot both land.
const taken = await trx.member.updateMany({
where: { id: seat.id, status: "PENDING", userId: seat.userId },
data: {
status: "ACTIVE",
lastAccessed: new Date(),
@@ -35,6 +52,15 @@ export const acceptMemberProcedure = withAuth
userId: user.id,
workEmail: input.workEmail,
},
});
if (taken.count !== 1) {
throw new TRPCError({ code: "NOT_FOUND", message: INVITE_INVALID });
}
const member = await trx.member.findUniqueOrThrow({
where: {
id: seat.id,
},
select: {
company: {
select: {
@@ -52,6 +78,13 @@ export const acceptMemberProcedure = withAuth
},
});
// The stand-in the invitation seated holds nothing once the seat is taken.
if (placeholder) {
await trx.user.deleteMany({
where: { id: placeholder, memberships: { none: {} } },
});
}
await Audit.create(
{
action: "member.accepted",
@@ -1,8 +1,11 @@
import { sendMemberInviteEmailJob } from "@/jobs/member-inivite-email";
import { generateMemberIdentifier } from "@/lib/invite";
import { getRoleById } from "@/lib/rbac/access-control";
import { generatePasswordResetToken } from "@/lib/token";
import { Audit } from "@/server/audit";
import { generateInviteToken, generateMemberIdentifier } from "@/server/member";
import {
generateInviteToken,
revokeExistingInviteTokens,
} from "@/server/member";
import { withAccessControl } from "@/trpc/api/trpc";
import { TRPCError } from "@trpc/server";
import { ZodInviteMemberMutationSchema } from "../schema";
@@ -17,16 +20,11 @@ export const inviteMemberProcedure = withAccessControl
.mutation(async ({ ctx, input }) => {
const user = ctx.session.user;
const { name, email, title, roleId } = input;
const {
userAgent,
requestIp,
membership: { companyId },
} = ctx;
const { userAgent, requestIp, membership } = ctx;
const { companyId } = membership;
const { expires, memberInviteTokenHash } = await generateInviteToken();
const { token: passwordResetToken } =
await generatePasswordResetToken(email);
const address = email.trim().toLowerCase();
const { company, verificationToken } = await ctx.db.$transaction(
async (tx) => {
@@ -40,33 +38,33 @@ export const inviteMemberProcedure = withAccessControl
},
});
// create or find user
const invitedUser = await tx.user.upsert({
// An invitation seats a person who already signs in here only when IAM
// has proven this address is theirs. Any other address is seated by a
// placeholder — no sign-in, no address, no password — that whoever IAM
// proves the address to takes over on accepting (inviteSeat). Seating a
// row by its address alone would hand the seat to whoever registered
// that address first.
const known = await tx.user.findFirst({
where: {
email,
},
update: {},
create: {
name,
email,
},
select: {
id: true,
email: address,
emailVerified: { not: null },
accounts: { some: {} },
},
select: { id: true },
});
// check if user is already a member
const prevMember = await tx.member.findUnique({
where: {
companyId_userId: {
companyId,
userId: invitedUser.id,
},
},
});
const prevMember = known
? await tx.member.findUnique({
where: {
companyId_userId: { companyId, userId: known.id },
},
})
: await tx.member.findFirst({
where: { companyId, status: "PENDING", workEmail: address },
});
// if already a member, throw error
if (prevMember && prevMember.status === "ACTIVE") {
if (prevMember && prevMember.status !== "PENDING") {
throw new TRPCError({
code: "FORBIDDEN",
message: "user already a member",
@@ -74,6 +72,24 @@ 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 seatHolder =
prevMember?.userId ??
known?.id ??
(
await tx.user.create({
data: { name },
select: { id: true },
})
).id;
// create member
const member = await tx.member.upsert({
@@ -82,8 +98,9 @@ export const inviteMemberProcedure = withAccessControl
isOnboarded: false,
lastAccessed: new Date(),
companyId,
userId: invitedUser.id,
userId: seatHolder,
status: "PENDING",
workEmail: address,
...role,
},
update: {
@@ -91,12 +108,13 @@ export const inviteMemberProcedure = withAccessControl
isOnboarded: false,
lastAccessed: new Date(),
status: "PENDING",
workEmail: address,
...role,
},
where: {
companyId_userId: {
companyId,
userId: invitedUser.id,
userId: seatHolder,
},
},
select: {
@@ -110,11 +128,13 @@ export const inviteMemberProcedure = withAccessControl
},
});
await revokeExistingInviteTokens({ memberId: member.id, tx });
// custom verification token for member invitation
const { token: verificationToken } = await tx.verificationToken.create({
data: {
identifier: generateMemberIdentifier({
email,
email: address,
memberId: member.id,
}),
token: memberInviteTokenHash,
@@ -143,8 +163,7 @@ export const inviteMemberProcedure = withAccessControl
await sendMemberInviteEmailJob.emit({
verificationToken,
passwordResetToken,
email,
email: address,
company,
user: {
email: user.email,
@@ -1,108 +1,132 @@
import { sendMemberInviteEmailJob } from "@/jobs/member-inivite-email";
import { generatePasswordResetToken } from "@/lib/token";
import { generateMemberIdentifier, isPlaceholder } from "@/lib/invite";
import { Audit } from "@/server/audit";
import { checkMembership } from "@/server/auth";
import {
generateInviteToken,
generateMemberIdentifier,
revokeExistingInviteTokens,
} from "@/server/member";
import { withAuth } from "@/trpc/api/trpc";
import { withAccessControl } from "@/trpc/api/trpc";
import { ZodReInviteMutationSchema } from "../schema";
export const reInviteProcedure = withAuth
export const reInviteProcedure = withAccessControl
.input(ZodReInviteMutationSchema)
.mutation(async ({ ctx: { session, db, requestIp, userAgent }, input }) => {
const user = session.user;
.meta({ policies: { members: { allow: ["create"] } } })
.mutation(
async ({
ctx: { session, db, requestIp, userAgent, membership },
input,
}) => {
const user = session.user;
const { companyId } = membership;
const { expires, memberInviteTokenHash } = await generateInviteToken();
const { expires, memberInviteTokenHash } = await generateInviteToken();
const { company, verificationToken, email, passwordResetToken } =
await db.$transaction(async (tx) => {
const { companyId } = await checkMembership({ session, tx });
const { company, verificationToken, email } = await db.$transaction(
async (tx) => {
const company = await tx.company.findFirstOrThrow({
where: {
id: companyId,
},
select: {
name: true,
id: true,
},
});
const company = await tx.company.findFirstOrThrow({
where: {
id: companyId,
},
select: {
name: true,
id: true,
},
});
const member = await tx.member.findFirstOrThrow({
where: {
id: input.memberId,
status: "PENDING",
companyId,
},
const member = await tx.member.findFirstOrThrow({
where: {
id: input.memberId,
status: "PENDING",
companyId,
},
include: {
user: {
select: {
name: true,
email: true,
include: {
user: {
select: {
name: true,
email: true,
password: true,
_count: { select: { accounts: true } },
},
},
},
},
});
});
const email = member.user.email;
const email = (member.workEmail ?? member.user.email)
?.trim()
.toLowerCase();
if (!email) {
throw new Error("invited email not found");
}
if (!email) {
throw new Error("invited email not found");
}
await revokeExistingInviteTokens({
memberId: member.id,
email,
tx,
});
// A seat an earlier invitation filled with a stand-in that carries
// the address is moved to the form the invitation now takes: the
// address on the seat, none on the stand-in, so the stand-in can
// never be mistaken for the person at that address.
if (
member.user.email &&
isPlaceholder({ ...member.user, email: null })
) {
await tx.user.update({
where: { id: member.userId },
data: { email: null },
});
}
if (member.workEmail !== email) {
await tx.member.update({
where: { id: member.id },
data: { workEmail: email },
});
}
// custom verification token for member invitation
const { token: verificationToken } = await tx.verificationToken.create({
data: {
identifier: generateMemberIdentifier({
email,
memberId: member.id,
}),
token: memberInviteTokenHash,
expires,
},
});
await revokeExistingInviteTokens({
memberId: member.id,
tx,
});
await Audit.create(
{
action: "member.re-invited",
companyId: company.id,
actor: { type: "user", id: user.id },
context: {
requestIp,
userAgent,
// custom verification token for member invitation
const { token: verificationToken } =
await tx.verificationToken.create({
data: {
identifier: generateMemberIdentifier({
email,
memberId: member.id,
}),
token: memberInviteTokenHash,
expires,
},
});
await Audit.create(
{
action: "member.re-invited",
companyId: company.id,
actor: { type: "user", id: user.id },
context: {
requestIp,
userAgent,
},
target: [{ type: "user", id: member.userId }],
summary: `${user.name} reinvited ${member.user?.name} to join ${company.name}`,
},
target: [{ type: "user", id: member.userId }],
summary: `${user.name} reinvited ${member.user?.name} to join ${company.name}`,
},
tx,
);
tx,
);
const { token: passwordResetToken } =
await generatePasswordResetToken(email);
return { verificationToken, company, email };
},
);
return { verificationToken, company, email, passwordResetToken };
await sendMemberInviteEmailJob.emit({
verificationToken,
email,
company,
user: {
email: user.email,
name: user.name,
},
});
await sendMemberInviteEmailJob.emit({
verificationToken,
passwordResetToken,
email,
company,
user: {
email: user.email,
name: user.name,
},
});
return { success: true };
});
return { success: true };
},
);
@@ -1,14 +1,17 @@
import { Audit } from "@/server/audit";
import { checkMembership } from "@/server/auth";
import type { TPrismaOrTransaction } from "@/server/db";
import { withAuth, type withAuthTrpcContextType } from "@/trpc/api/trpc";
import {
withAccessControl,
type withAccessControlTrpcContextType,
} from "@/trpc/api/trpc";
import {
type TypeZodRemoveMemberMutationSchema,
ZodRemoveMemberMutationSchema,
} from "../schema";
export const removeMemberProcedure = withAuth
export const removeMemberProcedure = withAccessControl
.input(ZodRemoveMemberMutationSchema)
.meta({ policies: { members: { allow: ["delete"] } } })
.mutation(async (args) => {
const data = await args.ctx.db.$transaction(async (db) => {
const data = await removeMemberHandler({
@@ -24,19 +27,18 @@ export const removeMemberProcedure = withAuth
interface removeMemberHandlerOptions {
input: TypeZodRemoveMemberMutationSchema;
ctx: Omit<withAuthTrpcContextType, "db"> & {
ctx: Omit<Awaited<withAccessControlTrpcContextType>, "db"> & {
db: TPrismaOrTransaction;
};
}
export async function removeMemberHandler({
ctx: { db, session, requestIp, userAgent },
ctx: { db, session, requestIp, userAgent, membership },
input,
}: removeMemberHandlerOptions) {
const user = session.user;
const { memberId } = input;
const { companyId } = await checkMembership({ session, tx: db });
const { companyId } = membership;
const member = await db.member.delete({
where: {
@@ -1,25 +1,23 @@
import { Audit } from "@/server/audit";
import { checkMembership } from "@/server/auth";
import { revokeExistingInviteTokens } from "@/server/member";
import { withAuth } from "@/trpc/api/trpc";
import { withAccessControl } from "@/trpc/api/trpc";
import { ZodRevokeInviteMutationSchema } from "../schema";
import { removeMemberHandler } from "./remove-member";
export const revokeInviteProcedure = withAuth
export const revokeInviteProcedure = withAccessControl
.input(ZodRevokeInviteMutationSchema)
.meta({ policies: { members: { allow: ["delete"] } } })
.mutation(async ({ ctx, input }) => {
const { db, session, requestIp, userAgent } = ctx;
const { db, session, requestIp, userAgent, membership } = ctx;
const user = session.user;
const { memberId, email } = input;
const { memberId } = input;
await db.$transaction(async (tx) => {
await checkMembership({ session, tx });
await revokeExistingInviteTokens({ memberId, email, tx });
const member = await tx.member.findFirst({
const member = await tx.member.findFirstOrThrow({
where: {
id: memberId,
companyId: membership.companyId,
status: "PENDING",
},
select: {
userId: true,
@@ -39,7 +37,7 @@ export const revokeInviteProcedure = withAuth
await Audit.create(
{
action: "member.revoked-invite",
companyId: user.companyId,
companyId: membership.companyId,
actor: { type: "user", id: user.id },
context: {
requestIp,
@@ -51,6 +49,8 @@ export const revokeInviteProcedure = withAuth
tx,
);
await revokeExistingInviteTokens({ memberId, tx });
await removeMemberHandler({
ctx: { ...ctx, db: tx },
input: { memberId: input.memberId },
@@ -1,57 +1,62 @@
import { Audit } from "@/server/audit";
import { checkMembership } from "@/server/auth";
import { withAuth } from "@/trpc/api/trpc";
import { withAccessControl } from "@/trpc/api/trpc";
import { ZodToggleActivationMutationSchema } from "../schema";
export const toggleActivation = withAuth
export const toggleActivation = withAccessControl
.input(ZodToggleActivationMutationSchema)
.mutation(async ({ ctx: { session, db, requestIp, userAgent }, input }) => {
const user = session.user;
const { memberId, status } = input;
.meta({ policies: { members: { allow: ["update"] } } })
.mutation(
async ({
ctx: { session, db, requestIp, userAgent, membership },
input,
}) => {
const user = session.user;
const { memberId, status } = input;
await db.$transaction(async (tx) => {
const { companyId } = await checkMembership({ session, tx });
await db.$transaction(async (tx) => {
const { companyId } = membership;
const member = await tx.member.update({
where: {
id: memberId,
companyId,
},
data: {
status,
},
select: {
userId: true,
user: {
select: {
name: true,
const member = await tx.member.update({
where: {
id: memberId,
companyId,
},
data: {
status,
},
select: {
userId: true,
user: {
select: {
name: true,
},
},
company: {
select: {
name: true,
},
},
},
company: {
select: {
name: true,
});
await Audit.create(
{
action: status ? "member.activated" : "member.deactivated",
companyId,
actor: { type: "user", id: user.id },
context: {
requestIp,
userAgent,
},
target: [{ type: "user", id: member.userId }],
summary: `${user.name} ${status ? "activated" : "deactivated"} ${
member.user?.name
} from ${member?.company.name}`,
},
},
tx,
);
});
await Audit.create(
{
action: status ? "member.activated" : "member.deactivated",
companyId,
actor: { type: "user", id: user.id },
context: {
requestIp,
userAgent,
},
target: [{ type: "user", id: member.userId }],
summary: `${user.name} ${status ? "activated" : "deactivated"} ${
member.user?.name
} from ${member?.company.name}`,
},
tx,
);
});
return { success: true };
});
return { success: true };
},
);
@@ -1,6 +1,7 @@
import { getRoleById } from "@/lib/rbac/access-control";
import { Audit } from "@/server/audit";
import { withAccessControl } from "@/trpc/api/trpc";
import { TRPCError } from "@trpc/server";
import { ZodUpdateMemberMutationSchema } from "../schema";
export const updateMemberProcedure = withAccessControl
@@ -21,6 +22,14 @@ export const updateMemberProcedure = withAccessControl
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.",
});
}
const member = await tx.member.update({
where: {
@@ -9,7 +9,9 @@ export const updateProfileProcedure = withAuth
const user = session.user;
if (input.type === PayloadType.PROFILE_DATA) {
const { fullName, loginEmail, workEmail, jobTitle } = input.payload;
// The sign-in address is IAM's — it is what an invitation is matched
// against — so a profile edit never changes it.
const { fullName, workEmail, jobTitle } = input.payload;
await db.$transaction(async (tx) => {
const member = await tx.member.update({
@@ -24,7 +26,6 @@ export const updateProfileProcedure = withAuth
user: {
update: {
name: fullName,
email: loginEmail,
},
},
},
-2
View File
@@ -21,7 +21,6 @@ export type TypeZodInviteMemberArrayMutationSchema = z.infer<
>;
export const ZodAcceptMemberMutationSchema = z.object({
memberId: z.string().min(1),
name: z.string().min(1, "This field is required"),
token: z.string().min(1),
workEmail: z.string().email().min(1, "This field is required"),
@@ -32,7 +31,6 @@ export type TypeZodAcceptMemberMutationSchema = z.infer<
>;
export const ZodRevokeInviteMutationSchema = z.object({
email: z.string().email().min(1),
memberId: z.string().min(1),
});
+12 -1
View File
@@ -1,17 +1,24 @@
import { generatePublicId } from "@/common/id";
import { COMPANY_STANDS, companyStands } from "@/server/company";
import { createTRPCRouter, withAuth } from "@/trpc/api/trpc";
import { TRPCError } from "@trpc/server";
import { ZodOnboardingMutationSchema } from "./schema";
import { Audit } from "@/server/audit";
// HERE: Reusing this same router for new company, onboarding and edit company.
export const onboardingRouter = createTRPCRouter({
// Founds the company, and only once: whoever signs in first after a fresh
// install bootstraps it as its ADMIN; everyone after joins by invitation.
onboard: withAuth
.input(ZodOnboardingMutationSchema)
.mutation(async ({ ctx, input }) => {
const { userAgent, requestIp } = ctx;
try {
const { publicId } = await ctx.db.$transaction(async (tx) => {
if (await companyStands(tx)) {
throw new TRPCError({ code: "FORBIDDEN", message: COMPANY_STANDS });
}
const publicId = generatePublicId();
const company = await tx.company.create({
@@ -22,13 +29,14 @@ export const onboardingRouter = createTRPCRouter({
},
});
// The address is IAM's and is never taken from this form: it is what
// an invitation is matched against.
const user = await tx.user.update({
where: {
id: ctx.session.user.id,
},
data: {
name: `${input.user.name}`,
email: `${input.user.email}`,
},
select: {
id: true,
@@ -82,6 +90,9 @@ export const onboardingRouter = createTRPCRouter({
return { success: true, message: "successfully onboarded", publicId };
} catch (error) {
if (error instanceof TRPCError && error.message === COMPANY_STANDS) {
return { success: false, message: COMPANY_STANDS };
}
console.error("Error onboarding:", error);
return {
success: false,
@@ -2,11 +2,12 @@ import { generatePublicId } from "@/common/id";
import { Audit } from "@/server/audit";
import { checkMembership } from "@/server/auth";
import { captableFailure } from "@/server/captable-api";
import { withAuth } from "@/trpc/api/trpc";
import { withAccessControl } from "@/trpc/api/trpc";
import { ZodAddExistingSafeMutationSchema } from "../schema";
import { safeBody } from "../wire";
export const addExistingSafeProcedure = withAuth
export const addExistingSafeProcedure = withAccessControl
.meta({ policies: { securities: { allow: ["create"] } } })
.input(ZodAddExistingSafeMutationSchema)
.mutation(async ({ ctx, input }) => {
const { userAgent, requestIp, captable, db, session } = ctx;
@@ -1,19 +1,20 @@
import fs from "node:fs";
import path from "node:path";
import { generatePublicId } from "@/common/id";
import { uploadFile } from "@/common/uploads";
import { invariant } from "@/lib/error";
import { TAG } from "@/lib/tags";
import { Audit } from "@/server/audit";
import { checkMembership } from "@/server/auth";
import { captableFailure } from "@/server/captable-api";
import { withAuth } from "@/trpc/api/trpc";
import { storeFile } from "@/server/file-uploads";
import { withAccessControl } from "@/trpc/api/trpc";
import { createBucketHandler } from "../../bucket-router/procedures/create-bucket";
import { createTemplateHandler } from "../../template-router/procedures/create-template";
import { ZodCreateSafeMutationSchema } from "../schema";
import { safeBody } from "../wire";
export const createSafeProcedure = withAuth
export const createSafeProcedure = withAccessControl
.meta({ policies: { securities: { allow: ["create"] } } })
.input(ZodCreateSafeMutationSchema)
.mutation(async ({ ctx, input }) => {
const { userAgent, requestIp, session, captable, db } = ctx;
@@ -27,7 +28,7 @@ export const createSafeProcedure = withAuth
// this app's own rows, so a refusal up there leaves nothing behind here.
await captable.safes.add(safeBody(generatePublicId(), inputRest));
let uploadData: Awaited<ReturnType<typeof uploadFile>> | null = null;
let uploadData: Awaited<ReturnType<typeof storeFile>> | null = null;
let document: { name: string; bucketId: string } | null = null;
if (input.safeTemplate !== "CUSTOM") {
@@ -46,7 +47,7 @@ export const createSafeProcedure = withAuth
size: pdfBuffer.byteLength,
} as unknown as File;
uploadData = await uploadFile(
uploadData = await storeFile(
file,
{ identifier: "templates", keyPrefix: "new-safes" },
"privateBucket",
@@ -1,10 +1,11 @@
import { Audit } from "@/server/audit";
import { checkMembership } from "@/server/auth";
import { captableFailure } from "@/server/captable-api";
import { withAuth } from "@/trpc/api/trpc";
import { withAccessControl } from "@/trpc/api/trpc";
import { ZodDeleteSafesMutationSchema } from "../schema";
export const deleteSafeProcedure = withAuth
export const deleteSafeProcedure = withAccessControl
.meta({ policies: { securities: { allow: ["delete"] } } })
.input(ZodDeleteSafesMutationSchema)
.mutation(
async ({ ctx: { db, captable, session, requestIp, userAgent }, input }) => {
@@ -1,8 +1,9 @@
import { documentsBySecurity } from "@/server/captable-documents";
import { withAuth } from "@/trpc/api/trpc";
import { withAccessControl } from "@/trpc/api/trpc";
export const getSafesProcedure = withAuth.query(
async ({ ctx: { db, captable } }) => {
export const getSafesProcedure = withAccessControl
.meta({ policies: { securities: { allow: ["read"] } } })
.query(async ({ ctx: { db, captable } }) => {
const safes = await captable.safes.list();
const documents = await documentsBySecurity(
db,
@@ -17,5 +18,4 @@ export const getSafesProcedure = withAuth.query(
}));
return { data };
},
);
});

Some files were not shown because too many files have changed in this diff Show More