feat: allow premium+ users to manage costs
Build & Push Docker Image / build (push) Successful in 2m19s
Build & Push Docker Image / build (push) Successful in 2m19s
- costs routes drop requireAdmin, keep auth + costs feature check
- useAuth exposes hasFeature(feature) based on entitlements
- tool-detail costs section gated by hasFeature('costs') instead of isAdmin
This commit is contained in:
@@ -2,7 +2,7 @@ import { Router, type IRouter } from "express";
|
|||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, toolsTable, toolCostsTable } from "@workspace/db";
|
import { db, toolsTable, toolCostsTable } from "@workspace/db";
|
||||||
import { requireAuth, requireAdmin } from "../middleware/auth";
|
import { requireAuth } from "../middleware/auth";
|
||||||
import { requireFeature } from "../middleware/feature";
|
import { requireFeature } from "../middleware/feature";
|
||||||
import { writeAuditLog } from "../lib/audit";
|
import { writeAuditLog } from "../lib/audit";
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ router.get("/tools/:id/costs", async (req, res): Promise<void> => {
|
|||||||
res.json(costs);
|
res.json(costs);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/tools/:id/costs", requireAuth, requireFeature("costs"), requireAdmin, async (req, res): Promise<void> => {
|
router.post("/tools/:id/costs", requireAuth, requireFeature("costs"), async (req, res): Promise<void> => {
|
||||||
const toolId = Number(req.params.id);
|
const toolId = Number(req.params.id);
|
||||||
if (isNaN(toolId)) { res.status(400).json({ error: "Invalid id" }); return; }
|
if (isNaN(toolId)) { res.status(400).json({ error: "Invalid id" }); return; }
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ router.post("/tools/:id/costs", requireAuth, requireFeature("costs"), requireAdm
|
|||||||
res.status(201).json(entry);
|
res.status(201).json(entry);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.patch("/costs/:id", requireAuth, requireFeature("costs"), requireAdmin, async (req, res): Promise<void> => {
|
router.patch("/costs/:id", requireAuth, requireFeature("costs"), async (req, res): Promise<void> => {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
if (isNaN(id)) { res.status(400).json({ error: "Invalid id" }); return; }
|
if (isNaN(id)) { res.status(400).json({ error: "Invalid id" }); return; }
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ router.patch("/costs/:id", requireAuth, requireFeature("costs"), requireAdmin, a
|
|||||||
res.json(updated);
|
res.json(updated);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete("/costs/:id", requireAuth, requireFeature("costs"), requireAdmin, async (req, res): Promise<void> => {
|
router.delete("/costs/:id", requireAuth, requireFeature("costs"), async (req, res): Promise<void> => {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
if (isNaN(id)) { res.status(400).json({ error: "Invalid id" }); return; }
|
if (isNaN(id)) { res.status(400).json({ error: "Invalid id" }); return; }
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ export function useAuth() {
|
|||||||
const isLocalMode = authMode?.mode === "local";
|
const isLocalMode = authMode?.mode === "local";
|
||||||
const tier = isAuthenticated ? user?.tier ?? "free" : "free";
|
const tier = isAuthenticated ? user?.tier ?? "free" : "free";
|
||||||
|
|
||||||
|
function hasFeature(feature: string): boolean {
|
||||||
|
if (!isAuthenticated) return false;
|
||||||
|
if (user?.role === "admin") return true;
|
||||||
|
return !!user?.entitlements?.includes(feature);
|
||||||
|
}
|
||||||
|
|
||||||
function login(returnTo?: string) {
|
function login(returnTo?: string) {
|
||||||
if (isLocalMode) {
|
if (isLocalMode) {
|
||||||
const path = returnTo
|
const path = returnTo
|
||||||
@@ -47,6 +53,7 @@ export function useAuth() {
|
|||||||
isAdmin,
|
isAdmin,
|
||||||
isLocalMode,
|
isLocalMode,
|
||||||
tier,
|
tier,
|
||||||
|
hasFeature,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -74,7 +74,8 @@ export default function ToolDetail() {
|
|||||||
const [isReviewFormOpen, setIsReviewFormOpen] = useState(false);
|
const [isReviewFormOpen, setIsReviewFormOpen] = useState(false);
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
|
||||||
const { user, isAdmin } = useAuth();
|
const { user, isAdmin, hasFeature } = useAuth();
|
||||||
|
const canManageCosts = hasFeature("costs");
|
||||||
const deleteTool = useDeleteTool();
|
const deleteTool = useDeleteTool();
|
||||||
|
|
||||||
const [similarData, setSimilarData] = useState<{ manual: any[]; auto: any[] } | null>(null);
|
const [similarData, setSimilarData] = useState<{ manual: any[]; auto: any[] } | null>(null);
|
||||||
@@ -535,7 +536,7 @@ export default function ToolDetail() {
|
|||||||
<div className="space-y-4 mt-8">
|
<div className="space-y-4 mt-8">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-xl font-bold">Costs</h3>
|
<h3 className="text-xl font-bold">Costs</h3>
|
||||||
{isAdmin && (
|
{canManageCosts && (
|
||||||
<Button variant="outline" size="sm" onClick={() => { resetCostForm(); setCostDialogOpen(true); }} className="gap-2">
|
<Button variant="outline" size="sm" onClick={() => { resetCostForm(); setCostDialogOpen(true); }} className="gap-2">
|
||||||
<Plus className="w-4 h-4" /> Add Cost
|
<Plus className="w-4 h-4" /> Add Cost
|
||||||
</Button>
|
</Button>
|
||||||
@@ -560,7 +561,7 @@ export default function ToolDetail() {
|
|||||||
</div>
|
</div>
|
||||||
{c.notes && <p className="text-xs text-muted-foreground mt-1 italic">{c.notes}</p>}
|
{c.notes && <p className="text-xs text-muted-foreground mt-1 italic">{c.notes}</p>}
|
||||||
</div>
|
</div>
|
||||||
{isAdmin && (
|
{canManageCosts && (
|
||||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => openEditCost(c)}>
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => openEditCost(c)}>
|
||||||
<Pencil className="w-3.5 h-3.5" />
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
|||||||
Reference in New Issue
Block a user