Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 80 additions & 8 deletions src/app/(authenticated)/(dashboard)/[publicId]/captable/page.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,93 @@
import CaptableMatrix from "@/components/captable/captable-matrix";
import ConvertiblesCard from "@/components/captable/convertibles-card";
import EmptyState from "@/components/common/empty-state";
import OverviewCard from "@/components/dashboard/overview/top-card";
import { Button } from "@/components/ui/button";
import { UnAuthorizedState } from "@/components/ui/un-authorized-state";
import { serverAccessControl } from "@/lib/rbac/access-control";
import { withServerComponentSession } from "@/server/auth";
import { getCapTable } from "@/server/captable";
import { RiPieChartFill } from "@remixicon/react";
import type { Metadata } from "next";
import Link from "next/link";

export const metadata: Metadata = {
title: "Cap table",
};

const CaptablePage = () => {
const CaptablePage = async ({
params: { publicId },
}: {
params: { publicId: string };
}) => {
const { allow } = await serverAccessControl();

const canView = allow(true, ["stakeholder", "read"]);

if (!canView) {
return <UnAuthorizedState />;
}

const session = await withServerComponentSession();
const companyId = session?.user?.companyId;

const capTable = companyId ? await getCapTable(companyId) : null;

if (!capTable || capTable.summary.isEmpty) {
return (
<EmptyState
icon={<RiPieChartFill />}
title="Your cap table is empty"
subtitle="Issue shares, grant options or record fundraising to see your ownership breakdown."
>
<Button size="lg">
<Link href={`/${publicId}/securities/shares`}>Issue shares</Link>
</Button>
Comment on lines +43 to +45

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use Button as child wrapper for the link CTA.

This currently nests <Link> inside <Button>, which is invalid interactive nesting and can hurt accessibility/navigation behavior.

Suggested fix
-        <Button size="lg">
-          <Link href={`/${publicId}/securities/shares`}>Issue shares</Link>
+        <Button size="lg" asChild>
+          <Link href={`/${publicId}/securities/shares`}>Issue shares</Link>
         </Button>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<Button size="lg">
<Link href={`/${publicId}/securities/shares`}>Issue shares</Link>
</Button>
<Button size="lg" asChild>
<Link href={`/${publicId}/securities/shares`}>Issue shares</Link>
</Button>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/`(authenticated)/(dashboard)/[publicId]/captable/page.tsx around
lines 43 - 45, The current code nests an interactive Link element inside a
Button element, which violates HTML semantics and causes accessibility issues.
Restructure this so that the Link component wraps the Button component instead,
moving the href prop to the Link and the content text inside the Button. This
means the Link with href should be the parent element, and the Button with
size="lg" should be the child containing the "Issue shares" text.

</EmptyState>
);
}

return (
<EmptyState
icon={<RiPieChartFill />}
title="Work in progress."
subtitle="This page is not yet available."
>
<Button>Import existing captable</Button>
</EmptyState>
<div className="flex flex-col gap-y-3">
<div className="gap-y-3">
<h3 className="font-medium">Cap table</h3>
<p className="text-sm text-muted-foreground">
Fully-diluted ownership of your company by stakeholder
</p>
</div>

<section className="mt-3">
<div className="grid grid-cols-2 gap-8 md:grid-cols-2 lg:grid-cols-3">
<OverviewCard
title="Fully-diluted shares"
amount={capTable.summary.fullyDilutedShares}
/>
<OverviewCard
title="Amount raised"
amount={capTable.summary.amountRaised}
prefix="$"
/>
<OverviewCard
title="Stakeholders"
amount={capTable.summary.stakeholderCount}
format={false}
/>
</div>
</section>

<CaptableMatrix
shareClasses={capTable.shareClasses}
rows={capTable.rows}
pool={capTable.pool}
totals={capTable.totals}
/>

<ConvertiblesCard
safes={capTable.convertibles.safes}
notes={capTable.convertibles.notes}
totalCapital={capTable.convertibles.totalCapital}
/>
</div>
);
};

Expand Down
40 changes: 32 additions & 8 deletions src/app/(authenticated)/(dashboard)/[publicId]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,33 @@
import ActivitiesCard from "@/components/dashboard/overview/activities-card";
import DonutCard from "@/components/dashboard/overview/donut-card";
import EmptyOverview from "@/components/dashboard/overview/empty";
import SummaryTable from "@/components/dashboard/overview/summary-table";
import OverviewCard from "@/components/dashboard/overview/top-card";
import { withServerComponentSession } from "@/server/auth";
import { getOverviewData } from "@/server/overview";
import type { Metadata } from "next";

export const metadata: Metadata = {
title: "Overview",
};

const OverviewPage = ({
const OverviewPage = async ({
params: { publicId },
}: {
params: { publicId: string };
}) => {
const session = await withServerComponentSession();
const companyId = session?.user?.companyId;
const firstName = session?.user?.name?.split(" ")[0];

const overview = companyId ? await getOverviewData(companyId) : null;

if (!overview || overview.isEmpty) {
return <EmptyOverview firstName={firstName} publicCompanyId={publicId} />;
}
Comment on lines +14 to +27

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Overview page is missing RBAC gating.

This route fetches and renders live cap-table-derived data without a permission check, unlike sibling dashboard routes in this PR. Add serverAccessControl + unauthorized guard before loading overview data.

Suggested fix
 import EmptyOverview from "`@/components/dashboard/overview/empty`";
 import SummaryTable from "`@/components/dashboard/overview/summary-table`";
 import OverviewCard from "`@/components/dashboard/overview/top-card`";
+import { UnAuthorizedState } from "`@/components/ui/un-authorized-state`";
+import { serverAccessControl } from "`@/lib/rbac/access-control`";
 import { withServerComponentSession } from "`@/server/auth`";
 import { getOverviewData } from "`@/server/overview`";
 import type { Metadata } from "next";
@@
 const OverviewPage = async ({
   params: { publicId },
 }: {
   params: { publicId: string };
 }) => {
+  const { allow } = await serverAccessControl();
+  const canView = allow(true, ["stakeholder", "read"]);
+
+  if (!canView) {
+    return <UnAuthorizedState />;
+  }
+
   const session = await withServerComponentSession();
   const companyId = session?.user?.companyId;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/`(authenticated)/(dashboard)/[publicId]/page.tsx around lines 14 -
27, The OverviewPage component is missing RBAC permission checks before
accessing sensitive cap-table data, unlike other sibling dashboard routes. Add a
serverAccessControl call after obtaining the session and before invoking
getOverviewData to verify the user has permission to access the overview. If the
user is unauthorized, return an appropriate unauthorized component or error
response to prevent unauthorized data access.


return (
<>
{/* <EmptyOverview firstName={firstName} publicCompanyId={publicCompanyId} /> */}

<header>
<h3 className="font-medium">Overview</h3>
<p className="text-sm text-muted-foreground">
Expand All @@ -31,17 +42,27 @@ const OverviewPage = ({
<div className="grid grid-cols-2 gap-8 md:grid-cols-2 lg:grid-cols-3">
<OverviewCard
title="Amount raised"
amount={28000000}
amount={overview.amountRaised}
prefix="$"
/>
<OverviewCard title="Diluted shares" amount={7560010} />
<OverviewCard title="Stakeholders" amount={28} format={false} />
<OverviewCard
title="Diluted shares"
amount={overview.fullyDilutedShares}
/>
<OverviewCard
title="Stakeholders"
amount={overview.stakeholderCount}
format={false}
/>
</div>
</section>

{/* Tremor chart */}
<section className="mt-6">
<DonutCard />
<DonutCard
stakeholders={overview.ownershipByStakeholder}
shareClasses={overview.ownershipByShareClass}
/>
</section>
</div>

Expand All @@ -59,7 +80,10 @@ const OverviewPage = ({
Summary of your company{`'`}s captable
</p>

<SummaryTable />
<SummaryTable
rows={overview.summary}
totalRaised={overview.totalRaised}
/>
</div>
</>
);
Expand Down
74 changes: 63 additions & 11 deletions src/app/(authenticated)/(dashboard)/[publicId]/reports/page.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,73 @@
import EmptyState from "@/components/common/empty-state";
import { Button } from "@/components/ui/button";
import { RiFilePdf2Fill } from "@remixicon/react";
import ReportCard from "@/components/reports/report-card";
import { UnAuthorizedState } from "@/components/ui/un-authorized-state";
import { serverAccessControl } from "@/lib/rbac/access-control";
import type { Metadata } from "next";

export const metadata: Metadata = {
title: "Reports",
};

const ReportsPage = () => {
const reports = [
{
type: "captable-summary-pdf",
title: "Cap table summary",
description:
"Printable cap table: summary numbers, share classes and fully-diluted ownership by stakeholder.",
format: "PDF",
},
{
type: "captable-csv",
title: "Cap table matrix",
description:
"Fully-diluted ownership by stakeholder and share class, as a spreadsheet.",
format: "CSV",
},
{
type: "securities-ledger-csv",
title: "Securities ledger",
description:
"Every share certificate and option grant with quantities, prices, dates and statuses.",
format: "CSV",
},
{
type: "stakeholders-csv",
title: "Stakeholders",
description:
"All stakeholders with their contact details, type and relationship.",
format: "CSV",
},
] as const;

const ReportsPage = async () => {
const { allow } = await serverAccessControl();

const canView = allow(true, ["stakeholder", "read"]);

if (!canView) {
return <UnAuthorizedState />;
}

return (
<EmptyState
icon={<RiFilePdf2Fill />}
title="No reports available."
subtitle="Please click the button below to generate a report"
>
<Button>Coming soon...</Button>
</EmptyState>
<div className="flex flex-col gap-y-3">
<div className="gap-y-3">
<h3 className="font-medium">Reports</h3>
<p className="text-sm text-muted-foreground">
Download reports generated from your company{`'`}s cap table
</p>
</div>

<div className="mt-3 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{reports.map((report) => (
<ReportCard
key={report.type}
type={report.type}
title={report.title}
description={report.description}
format={report.format}
/>
))}
</div>
</div>
);
};

Expand Down
44 changes: 44 additions & 0 deletions src/app/api/(internal)/reports/[type]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { RBAC } from "@/lib/rbac";
import { getPermissions } from "@/lib/rbac/access-control";
import { getServerAuthSession } from "@/server/auth";
import { db } from "@/server/db";
import { REPORTS, isReportType } from "@/server/reports";

export const GET = async (
_req: Request,
{ params }: { params: { type: string } },
) => {
const session = await getServerAuthSession();

if (!session?.user?.companyId) {
return new Response("Unauthorized", { status: 401 });
}

const { err, val } = await getPermissions({ db, session });

if (err) {
return new Response("Forbidden", { status: 403 });
}

const rbac = new RBAC();
rbac.addPolicies({ stakeholder: { allow: ["read"] } });
const enforced = rbac.enforce(val.permissions);

if (enforced.err || !enforced.val.valid) {
return new Response("Forbidden", { status: 403 });
}

if (!isReportType(params.type)) {
return new Response("Not found", { status: 404 });
}

const report = await REPORTS[params.type].generate(val.membership.companyId);

return new Response(report.body, {
headers: {
"Content-Type": report.contentType,
"Content-Disposition": `attachment; filename="${report.filename}"`,
"Cache-Control": "no-store",
},
});
};
Loading
Loading