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:
egecankomur
2026-05-12 18:46:02 +03:00
parent 7c23a2b4ae
commit 933cb17107
27 changed files with 475 additions and 44 deletions
+44 -15
View File
@@ -30,9 +30,26 @@ import {
SidebarMenuItem,
} from "@/components/ui/sidebar";
import type { ShellCompany, ShellUser } from "@/app/(dashboard)/dashboard-shell";
import type { Icon } from "@/lib/icons";
import type { ShellCompany, ShellRole, ShellUser } from "@/app/(dashboard)/dashboard-shell";
const navGroups = [
type NavItem = {
title: string;
url: string;
icon?: Icon;
items?: { title: string; url: string; badge?: number }[];
minRole?: ShellRole;
};
type NavGroup = { label: string; items: NavItem[] };
const ROLE_RANK: Record<ShellRole, number> = { owner: 3, admin: 2, member: 1 };
function hasAccess(minRole: ShellRole | undefined, userRole: ShellRole): boolean {
if (!minRole) return true;
return ROLE_RANK[userRole] >= ROLE_RANK[minRole];
}
const navGroups: NavGroup[] = [
{
label: "Genel",
items: [
@@ -70,6 +87,7 @@ const navGroups = [
title: "Yatırımcılar",
url: "/investors",
icon: Wallet,
minRole: "admin" as ShellRole,
},
],
},
@@ -100,6 +118,7 @@ const navGroups = [
title: "Çalışma Alanı",
url: "/settings/workspace",
icon: GearSix,
minRole: "admin" as ShellRole,
items: [
{ title: "Ofis Bilgileri", url: "/settings/workspace" },
{ title: "Ekip Üyeleri", url: "/settings/members" },
@@ -114,6 +133,7 @@ const navGroups = [
title: "Plan & Faturalama",
url: "/settings/billing",
icon: CreditCard,
minRole: "owner" as ShellRole,
},
],
},
@@ -123,24 +143,29 @@ export function AppSidebar({
user,
company,
pendingMatchCount = 0,
role = "member",
...props
}: React.ComponentProps<typeof Sidebar> & {
user: ShellUser;
company: ShellCompany;
pendingMatchCount?: number;
role?: ShellRole;
}) {
// Inject badge into the Eşleşmeler sub-item
const groups = navGroups.map((group) => ({
...group,
items: group.items.map((item) => ({
...item,
items: item.items?.map((sub) =>
sub.url === "/customers/matches" && pendingMatchCount > 0
? { ...sub, badge: pendingMatchCount }
: sub,
),
})),
}));
const groups = navGroups
.map((group) => ({
...group,
items: group.items
.filter((item) => hasAccess(item.minRole, role))
.map((item) => ({
...item,
items: item.items?.map((sub) =>
sub.url === "/customers/matches" && pendingMatchCount > 0
? { ...sub, badge: pendingMatchCount }
: sub,
),
})),
}))
.filter((group) => group.items.length > 0);
return (
<Sidebar {...props}>
@@ -174,7 +199,11 @@ export function AppSidebar({
</SidebarHeader>
<SidebarContent>
{groups.map((group) => (
<NavMain key={group.label} label={group.label} items={group.items} />
<NavMain
key={group.label}
label={group.label}
items={group.items.map(({ minRole: _minRole, ...rest }) => rest)}
/>
))}
</SidebarContent>
<SidebarFooter>
@@ -18,6 +18,7 @@ import { deleteCustomerAction } from "@/lib/appwrite/customer-actions";
import { CustomerFormSheet } from "./customer-form-sheet";
import { CustomersPipeline } from "./customers-pipeline";
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
import { UsageBadge } from "@/components/ui/usage-badge";
import type { Customer, CustomerType, CustomerStage } from "@/lib/appwrite/schema";
import {
CUSTOMER_TYPE_LABELS,
@@ -29,9 +30,11 @@ type ViewMode = "list" | "pipeline";
interface CustomersClientProps {
initialCustomers: Customer[];
isOwner?: boolean;
usageLimit?: { current: number; limit: number };
}
export function CustomersClient({ initialCustomers }: CustomersClientProps) {
export function CustomersClient({ initialCustomers, isOwner, usageLimit }: CustomersClientProps) {
const router = useRouter();
const [customers, setCustomers] = useState(initialCustomers);
const [sheetOpen, setSheetOpen] = useState(false);
@@ -79,7 +82,10 @@ export function CustomersClient({ initialCustomers }: CustomersClientProps) {
{/* Header */}
<div className="flex items-center justify-between gap-3 flex-wrap">
<h1 className="text-2xl font-bold">Müşteriler</h1>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap">
{isOwner && usageLimit && (
<UsageBadge current={usageLimit.current} limit={usageLimit.limit} label="müşteri" />
)}
{/* View toggle */}
<div className="flex rounded-md border overflow-hidden">
<button
@@ -15,18 +15,23 @@ import {
import { deletePresentationAction } from "@/lib/appwrite/presentation-actions";
import { PresentationFormSheet } from "./presentation-form-sheet";
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
import { UsageBadge } from "@/components/ui/usage-badge";
import type { Customer, Presentation, Property } from "@/lib/appwrite/schema";
interface PresentationsClientProps {
initialPresentations: Presentation[];
customers: Customer[];
properties: Property[];
isOwner?: boolean;
usageLimit?: { current: number; limit: number };
}
export function PresentationsClient({
initialPresentations,
customers,
properties,
isOwner,
usageLimit,
}: PresentationsClientProps) {
const router = useRouter();
const [presentations, setPresentations] = useState(initialPresentations);
@@ -86,12 +91,17 @@ export function PresentationsClient({
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center justify-between gap-3 flex-wrap">
<h1 className="text-2xl font-bold">Sunumlar</h1>
<Button onClick={openCreate} size="sm" data-tour="presentations-add">
<Plus className="mr-1.5 size-4" />
Yeni Sunum
</Button>
<div className="flex items-center gap-2">
{isOwner && usageLimit && (
<UsageBadge current={usageLimit.current} limit={usageLimit.limit} label="sunum" />
)}
<Button onClick={openCreate} size="sm" data-tour="presentations-add">
<Plus className="mr-1.5 size-4" />
Yeni Sunum
</Button>
</div>
</div>
<div data-tour="presentations-table" className="rounded-md border">
@@ -20,6 +20,7 @@ import { ImageLightbox } from "./image-lightbox";
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
import { PropertiesMapView } from "@/components/map/properties-map-view";
import { getPropertyImagePreviewUrl, parseImageIds } from "@/lib/appwrite/storage-utils";
import { UsageBadge } from "@/components/ui/usage-badge";
import type { Property, PropertyStatus } from "@/lib/appwrite/schema";
import {
PROPERTY_STATUS_LABELS,
@@ -29,11 +30,14 @@ import {
interface PropertiesClientProps {
initialProperties: Property[];
isOwner?: boolean;
usageLimit?: { current: number; limit: number };
maxImagesPerProperty?: number;
}
type ViewMode = "list" | "gallery" | "map";
export function PropertiesClient({ initialProperties }: PropertiesClientProps) {
export function PropertiesClient({ initialProperties, isOwner, usageLimit, maxImagesPerProperty }: PropertiesClientProps) {
const router = useRouter();
const [properties, setProperties] = useState(initialProperties);
const [sheetOpen, setSheetOpen] = useState(false);
@@ -125,7 +129,10 @@ export function PropertiesClient({ initialProperties }: PropertiesClientProps) {
</p>
)}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap">
{isOwner && usageLimit && (
<UsageBadge current={usageLimit.current} limit={usageLimit.limit} label="ilan" />
)}
{/* View toggle */}
<div className="flex rounded-md border overflow-hidden">
{([
@@ -397,6 +404,8 @@ export function PropertiesClient({ initialProperties }: PropertiesClientProps) {
onOpenChange={setSheetOpen}
property={editing}
onSuccess={() => router.refresh()}
maxImages={maxImagesPerProperty}
isOwner={isOwner}
/>
<DeleteConfirmDialog
open={!!deleteTarget}
@@ -34,9 +34,11 @@ interface PropertyFormSheetProps {
onOpenChange: (v: boolean) => void;
property?: Property | null;
onSuccess?: () => void;
maxImages?: number;
isOwner?: boolean;
}
export function PropertyFormSheet({ open, onOpenChange, property, onSuccess }: PropertyFormSheetProps) {
export function PropertyFormSheet({ open, onOpenChange, property, onSuccess, maxImages, isOwner }: PropertyFormSheetProps) {
const isMobile = useIsMobile();
const action = property ? updatePropertyAction.bind(null, property.$id) : createPropertyAction;
@@ -207,7 +209,7 @@ export function PropertyFormSheet({ open, onOpenChange, property, onSuccess }: P
</div>
<div className="grid gap-1.5">
<Label>Fotoğraflar</Label>
<PropertyImageUploader name="imageIds" initialImageIds={parseImageIds(property?.imageIds)} />
<PropertyImageUploader name="imageIds" initialImageIds={parseImageIds(property?.imageIds)} maxImages={maxImages} isOwner={isOwner} />
</div>
</>
),
@@ -15,9 +15,11 @@ interface PropertyImageUploaderProps {
name: string;
initialImageIds?: string[];
onChangeIds?: (ids: string[]) => void;
maxImages?: number;
isOwner?: boolean;
}
export function PropertyImageUploader({ name, initialImageIds = [], onChangeIds }: PropertyImageUploaderProps) {
export function PropertyImageUploader({ name, initialImageIds = [], onChangeIds, maxImages, isOwner }: PropertyImageUploaderProps) {
const [imageIds, setImageIds] = useState<string[]>(initialImageIds);
const [queue, setQueue] = useState<UploadingFile[]>([]);
const [dragging, setDragging] = useState(false);
@@ -40,6 +42,14 @@ export function PropertyImageUploader({ name, initialImageIds = [], onChangeIds
toast.error(`${file.name}: 50 MB sınırını aşıyor.`);
return;
}
if (maxImages !== undefined && imageIds.length >= maxImages) {
toast.error(
isOwner
? `Ücretsiz planda ilan başına en fazla ${maxImages} fotoğraf ekleyebilirsiniz. Pro'ya geçerek limitsiz yükleyin.`
: "Bu çalışma alanı fotoğraf limitine ulaştı. Yöneticinizle iletişime geçin.",
);
return;
}
const uid = crypto.randomUUID();
setQueue((q) => [...q, { uid, name: file.name, progress: 0, phase: "compressing" }]);
@@ -94,7 +104,7 @@ export function PropertyImageUploader({ name, initialImageIds = [], onChangeIds
xhr.open("POST", "/api/properties/images");
xhr.send(fd);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
}, [imageIds, maxImages]); // eslint-disable-line react-hooks/exhaustive-deps
const handleFiles = useCallback((files: FileList | null) => {
if (!files) return;
+54
View File
@@ -0,0 +1,54 @@
"use client";
import { Crown } from "@/lib/icons";
import type { LimitKey } from "@/lib/plans";
interface UsageBadgeProps {
current: number;
limit: number;
label: string;
limitKey?: LimitKey;
}
export function UsageBadge({ current, limit, label }: UsageBadgeProps) {
if (limit === Infinity || limit <= 0) return null;
const pct = current / limit;
const isWarning = pct >= 0.8;
const isFull = current >= limit;
return (
<span
className={`inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full border font-medium ${
isFull
? "bg-destructive/10 text-destructive border-destructive/20"
: isWarning
? "bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-950/30 dark:text-amber-400 dark:border-amber-800"
: "bg-muted text-muted-foreground border-transparent"
}`}
title={`${current}/${limit} ${label}`}
>
{isFull && <Crown className="size-3" weight="fill" />}
{current}/{limit} {label}
</span>
);
}
interface UpgradeBannerProps {
message: string;
}
export function UpgradeBanner({ message }: UpgradeBannerProps) {
return (
<div className="flex items-center gap-2 rounded-lg border border-amber-200 bg-amber-50 px-4 py-2.5 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
<Crown className="size-4 shrink-0 text-amber-500" weight="fill" />
<span className="flex-1">{message}</span>
<a
href="/dashboard/settings?tab=plan"
className="whitespace-nowrap font-semibold text-amber-700 underline-offset-2 hover:underline dark:text-amber-400"
>
Pro&apos;ya Geç
</a>
</div>
);
}