feat: plan/limit system + role-based page access
Plan & limit enforcement: - src/lib/plans.ts: PLAN_LIMITS (free: 15 props, 25 customers, 5 presentations, 1 member, 5 imgs/prop) - checkLimit() + limitErrorMessage() — role-aware error messages (owner: upgrade CTA, others: contact admin) - Create actions (property, customer, presentation, team invite): hard limit check before create - PropertyImageUploader: maxImages prop + isOwner-aware toast - UsageBadge component: usage counter shown only to owner (green→amber→red) Role-based access: - TenantContext + ActiveContext: add memberCount + role fields - Dashboard layout: non-owner on free plan with >1 member → /plan-limit - /plan-limit: blocked-access page with owner contact info + sign-out - AppSidebar: minRole filtering — Plan & Faturalama (owner only), Çalışma Alanı/Yatırımcılar (admin+) - settings/billing: owner-only hard redirect - settings/workspace + settings/members: member redirect, admin read-only - settings/investors: member redirect - workspace-actions + logo-actions: restricted to owner only - Workspace form: canEdit = owner only (admin sees read-only view)
This commit is contained in:
@@ -3,14 +3,25 @@ export const dynamic = "force-dynamic";
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
import { listCustomers } from "@/lib/appwrite/customer-queries";
|
||||
import { CustomersClient } from "@/components/customers/customers-client";
|
||||
import { checkLimit, PLAN_LIMITS } from "@/lib/plans";
|
||||
|
||||
export default async function CustomersPage() {
|
||||
const ctx = await requireTenant();
|
||||
const customers = await listCustomers(ctx.tenantId);
|
||||
const [customers, limitResult] = await Promise.all([
|
||||
listCustomers(ctx.tenantId),
|
||||
checkLimit(ctx.tenantId, ctx.settings?.plan, "customers"),
|
||||
]);
|
||||
|
||||
const plan = ctx.settings?.plan ?? "free";
|
||||
const limit = PLAN_LIMITS[plan].customers;
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6">
|
||||
<CustomersClient initialCustomers={customers} />
|
||||
<CustomersClient
|
||||
initialCustomers={customers}
|
||||
isOwner={ctx.role === "owner"}
|
||||
usageLimit={limit !== Infinity ? { current: limitResult.current, limit } : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,18 +24,22 @@ export type ShellCompany = {
|
||||
logoUrl?: string | null;
|
||||
};
|
||||
|
||||
export type ShellRole = "owner" | "admin" | "member";
|
||||
|
||||
export function DashboardShell({
|
||||
user,
|
||||
company,
|
||||
children,
|
||||
initialPrefs,
|
||||
pendingMatchCount = 0,
|
||||
role = "member",
|
||||
}: {
|
||||
user: ShellUser;
|
||||
company: ShellCompany;
|
||||
children: React.ReactNode;
|
||||
initialPrefs: ThemePrefs;
|
||||
pendingMatchCount?: number;
|
||||
role?: ShellRole;
|
||||
}) {
|
||||
const [themeCustomizerOpen, setThemeCustomizerOpen] = React.useState(false);
|
||||
const { config } = useSidebarConfig();
|
||||
@@ -63,6 +67,7 @@ export function DashboardShell({
|
||||
collapsible={config.collapsible}
|
||||
side={config.side}
|
||||
pendingMatchCount={pendingMatchCount}
|
||||
role={role}
|
||||
/>
|
||||
<SidebarInset>
|
||||
<SiteHeader company={company} />
|
||||
@@ -84,6 +89,7 @@ export function DashboardShell({
|
||||
collapsible={config.collapsible}
|
||||
side={config.side}
|
||||
pendingMatchCount={pendingMatchCount}
|
||||
role={role}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
@@ -9,6 +10,7 @@ import { InvestorsClient } from "@/components/investors/investors-client";
|
||||
|
||||
export default async function InvestorsPage() {
|
||||
const ctx = await requireTenant();
|
||||
if (ctx.role === "member") redirect("/dashboard");
|
||||
const { tablesDB } = createAdminClient();
|
||||
|
||||
const result = await tablesDB.listRows({
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getLogoUrl } from "@/lib/appwrite/storage";
|
||||
import { createAdminClient, createSessionClient, getCurrentUser } from "@/lib/appwrite/server";
|
||||
import { DATABASE_ID, TABLES } from "@/lib/appwrite/schema";
|
||||
import type { ThemePrefs } from "@/lib/appwrite/theme-prefs-actions";
|
||||
import { PLAN_LIMITS } from "@/lib/plans";
|
||||
import { DashboardShell } from "./dashboard-shell";
|
||||
|
||||
export default async function DashboardLayout({
|
||||
@@ -19,6 +20,13 @@ export default async function DashboardLayout({
|
||||
const ctx = await getActiveContext();
|
||||
if (!ctx) redirect("/onboarding");
|
||||
|
||||
// Hard plan enforcement: non-owner members on free plan with more than 1 confirmed member
|
||||
const plan = ctx.settings?.plan ?? "free";
|
||||
const memberLimit = PLAN_LIMITS[plan].teamMembers;
|
||||
if (ctx.role !== "owner" && memberLimit !== Infinity && ctx.memberCount > memberLimit) {
|
||||
redirect("/plan-limit");
|
||||
}
|
||||
|
||||
let pendingMatchCount = 0;
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
@@ -53,7 +61,7 @@ export default async function DashboardLayout({
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardShell user={user} company={company} initialPrefs={themePrefs} pendingMatchCount={pendingMatchCount}>
|
||||
<DashboardShell user={user} company={company} initialPrefs={themePrefs} pendingMatchCount={pendingMatchCount} role={ctx.role}>
|
||||
{children}
|
||||
</DashboardShell>
|
||||
);
|
||||
|
||||
@@ -8,12 +8,13 @@ import { listProperties } from "@/lib/appwrite/property-queries";
|
||||
import { DATABASE_ID, TABLES, type Presentation } from "@/lib/appwrite/schema";
|
||||
import { createAdminClient } from "@/lib/appwrite/server";
|
||||
import { PresentationsClient } from "@/components/presentations/presentations-client";
|
||||
import { checkLimit, PLAN_LIMITS } from "@/lib/plans";
|
||||
|
||||
export default async function PresentationsPage() {
|
||||
const ctx = await requireTenant();
|
||||
const { tablesDB } = createAdminClient();
|
||||
|
||||
const [customers, properties, presResult] = await Promise.all([
|
||||
const [customers, properties, presResult, limitResult] = await Promise.all([
|
||||
listCustomers(ctx.tenantId),
|
||||
listProperties(ctx.tenantId),
|
||||
tablesDB.listRows({
|
||||
@@ -25,12 +26,15 @@ export default async function PresentationsPage() {
|
||||
Query.limit(200),
|
||||
],
|
||||
}),
|
||||
checkLimit(ctx.tenantId, ctx.settings?.plan, "presentations"),
|
||||
]).catch((e) => {
|
||||
console.error("[PresentationsPage] data fetch failed:", e);
|
||||
throw e;
|
||||
});
|
||||
|
||||
const presentations = JSON.parse(JSON.stringify(presResult.rows)) as Presentation[];
|
||||
const plan = ctx.settings?.plan ?? "free";
|
||||
const presLimit = PLAN_LIMITS[plan].presentations;
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6">
|
||||
@@ -38,6 +42,8 @@ export default async function PresentationsPage() {
|
||||
initialPresentations={presentations}
|
||||
customers={customers}
|
||||
properties={properties}
|
||||
isOwner={ctx.role === "owner"}
|
||||
usageLimit={presLimit !== Infinity ? { current: limitResult.current, limit: presLimit } : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,14 +3,28 @@ export const dynamic = "force-dynamic";
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
import { listProperties } from "@/lib/appwrite/property-queries";
|
||||
import { PropertiesClient } from "@/components/properties/properties-client";
|
||||
import { checkLimit, PLAN_LIMITS } from "@/lib/plans";
|
||||
|
||||
export default async function PropertiesPage() {
|
||||
const ctx = await requireTenant();
|
||||
const properties = await listProperties(ctx.tenantId);
|
||||
const [properties, limitResult] = await Promise.all([
|
||||
listProperties(ctx.tenantId),
|
||||
checkLimit(ctx.tenantId, ctx.settings?.plan, "properties"),
|
||||
]);
|
||||
|
||||
const plan = ctx.settings?.plan ?? "free";
|
||||
const limit = PLAN_LIMITS[plan].properties;
|
||||
|
||||
const imgLimit = PLAN_LIMITS[plan].propertyImages;
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6 overflow-hidden">
|
||||
<PropertiesClient initialProperties={properties} />
|
||||
<PropertiesClient
|
||||
initialProperties={properties}
|
||||
isOwner={ctx.role === "owner"}
|
||||
usageLimit={limit !== Infinity ? { current: limitResult.current, limit } : undefined}
|
||||
maxImagesPerProperty={imgLimit !== Infinity ? imgLimit : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ export default async function BillingPage({
|
||||
redirect("/onboarding");
|
||||
}
|
||||
|
||||
if (ctx.role !== "owner") redirect("/dashboard");
|
||||
|
||||
const params = await searchParams;
|
||||
const upgraded = params.upgraded === "1";
|
||||
const downgraded = params.downgraded === "1";
|
||||
|
||||
@@ -23,6 +23,8 @@ export default async function MembersPage() {
|
||||
redirect("/onboarding");
|
||||
}
|
||||
|
||||
if (ctx.role === "member") redirect("/dashboard");
|
||||
|
||||
const canManage = ctx.role === "owner" || ctx.role === "admin";
|
||||
const isOwner = ctx.role === "owner";
|
||||
|
||||
|
||||
@@ -20,7 +20,9 @@ export default async function WorkspaceSettingsPage() {
|
||||
redirect("/onboarding");
|
||||
}
|
||||
|
||||
const canEdit = ctx.role === "owner" || ctx.role === "admin";
|
||||
if (ctx.role === "member") redirect("/dashboard");
|
||||
|
||||
const canEdit = ctx.role === "owner";
|
||||
const officeName = ctx.settings?.officeName ?? "Ofis";
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user