2 Commits

6 changed files with 186 additions and 146 deletions

View File

@@ -886,6 +886,16 @@ footer .logo-svg > svg { width: var(--logo-width, 8rem); }
-------------------------------------------------- */
#gridView { row-gap: 1.5rem; }
@media (min-width: 1200px) {
.catalog-sidebar {
position: sticky;
top: var(--navbar-height, 4rem);
height: calc(100vh - var(--navbar-height, 4rem));
overflow-y: auto;
z-index: 1;
}
}
.inv-grid-card {
position: relative;
height: 100%;

View File

@@ -1,4 +1,8 @@
---
// auth check for inventory management features
const { canAddInventory } = Astro.locals;
const { isAdmin } = Astro.locals;
// const canAddInventory = false;
---
<button
class="navbar-toggler ms-4 p-1 btn btn-purple border-0"
@@ -30,9 +34,16 @@
<li class="nav-item">
<a class="nav-link py-3 border-bottom border-secondary" href="/pokemon">Browse Cards</a>
</li>
<!--<li class="nav-item">
<a class="nav-link py-3" href="/dashboard">Dashboard</a>
</li> -->
{canAddInventory && (
<li class="nav-item">
<a class="nav-link py-3 border-bottom border-secondary" href="/dashboard">Dashboard</a>
</li>
)}
{isAdmin && (
<li class="nav-item">
<a class="nav-link py-3" href="/admin">Admin Panel</a>
</li>
)}
</ul>
</div>
</div>

View File

@@ -25,6 +25,35 @@ import { Show } from '@clerk/astro/components'
</script>
<Show when="signed-in">
{Astro.url.pathname === '/dashboard' ? (
<div class="input-group">
<input
type="search"
name="q"
id="inventorySearchInput"
class="form-control search-input"
placeholder="Search your inventory"
hx-post="/partials/inventory-cards"
hx-trigger="keyup[key=='Enter']"
hx-target="#gridView"
hx-swap="innerHTML"
hx-vals="js:{start: 0, catalog: document.querySelector('#catalogList li[data-catalog].active')?.dataset.catalog || 'all'}"
/>
<button
type="button"
id="inventorySearchBtn"
class="btn btn-purple border-start-0"
aria-label="search"
hx-post="/partials/inventory-cards"
hx-include="#inventorySearchInput"
hx-target="#gridView"
hx-swap="innerHTML"
hx-vals="js:{start: 0, catalog: document.querySelector('#catalogList li[data-catalog].active')?.dataset.catalog || 'all'}"
>
<svg aria-hidden="true" class="search-button d-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M503.7 304.9C520.3 80.3 214-44 100.9 169.4C-14.1 383.9 203.9 614.6 419.8 466.3C459.7 500.3 494.8 542.3 531.5 578.2C561.1 607.7 606.3 562.8 576.8 533L540 496.1C520.2 471.6 495.7 449.1 473.7 428.9C471.1 426.5 468.5 424.2 466 421.9C491.9 385.4 500.1 341 503.7 304.8zM236.1 129C334 92.1 452.1 198.1 440 298.6C440.5 404.9 335.6 462.2 244 445.8C99 407.1 100.3 178.9 236.2 129z"/></svg>
</button>
</div>
) : (
<form
class="d-flex align-items-center"
role="search"
@@ -68,4 +97,5 @@ import { Show } from '@clerk/astro/components'
</button>
</div>
</form>
)}
</Show>

View File

@@ -5,6 +5,7 @@ declare global {
namespace App {
interface Locals {
canAddInventory: boolean;
isAdmin: boolean;
}
}
}
@@ -26,54 +27,32 @@ export const onRequest = clerkMiddleware(async (auth, context, next) => {
}
// ── Inventory visibility check ──────────────────────────────────────────────
// Resolves to true if the user belongs to the target org OR has the feature
const canAddInventory = process.env.INVENTORY_ACCESS === 'true' ||
(
isAuthenticated &&
userId &&
(
!!has({ permission: "org:feature:inventory_add" }) || // Clerk feature flag
!!has({ permission: "org:feature:inventory_add" }) ||
(await getUserOrgIds(context, userId)).includes(TARGET_ORG_ID)
)
);
// Expose the flag to your Astro pages via locals
context.locals.canAddInventory = Boolean(canAddInventory);
// ── Admin check ──────────────────────────────────────────────────────────
// Computed on every request (not just /admin routes) so pages can use
// Astro.locals.isAdmin to conditionally show admin-only UI anywhere.
context.locals.isAdmin = isAuthenticated && userId
? await checkIsAdmin(context, userId)
: false;
// ── Admin route guard ───────────────────────────────────────────
if (isAdminRoute(context.request)) {
if (!isAuthenticated || !userId) {
return redirectToSignIn();
}
try {
const client = await clerkClient(context);
const userOrgIds = await getUserOrgIds(context, userId);
const matchingOrgIds = userOrgIds.filter((id) => ADMIN_ORG_IDS.has(id));
if (matchingOrgIds.length === 0) {
if (!context.locals.isAdmin) {
return new Response(null, { status: 404 });
}
const membershipLists = await Promise.all(
matchingOrgIds.map((orgId) =>
client.organizations.getOrganizationMembershipList({ organizationId: orgId })
)
);
const isAdmin = membershipLists.some((list) =>
list.data.some(
(m) => m.publicUserData?.userId === userId && m.role === "org:admin"
)
);
if (!isAdmin) {
return new Response(null, { status: 404 });
}
} catch (e) {
console.error("Clerk membership check failed:", e);
return context.redirect("/");
}
}
return next();
@@ -90,3 +69,28 @@ async function getUserOrgIds(context: any, userId: string): Promise<string[]> {
return [];
}
}
// ── Helper: is this user an org:admin in any of ADMIN_ORG_IDS? ─────────────
async function checkIsAdmin(context: any, userId: string): Promise<boolean> {
try {
const userOrgIds = await getUserOrgIds(context, userId);
const matchingOrgIds = userOrgIds.filter((id) => ADMIN_ORG_IDS.has(id));
if (matchingOrgIds.length === 0) return false;
const client = await clerkClient(context);
const membershipLists = await Promise.all(
matchingOrgIds.map((orgId) =>
client.organizations.getOrganizationMembershipList({ organizationId: orgId })
)
);
return membershipLists.some((list) =>
list.data.some(
(m) => m.publicUserData?.userId === userId && m.role === "org:admin"
)
);
} catch (e) {
console.error("Clerk membership check failed:", e);
return false;
}
}

View File

@@ -1,8 +1,6 @@
---
import Layout from "../layouts/Main.astro";
import Footer from "../components/Footer.astro";
import BackToTop from "../components/BackToTop.astro";
import FirstEditionIcon from "../components/FirstEditionIcon.astro";
export const prerender = false;
import Layout from '../layouts/Main.astro';
import { db } from '../db/index';
import { inventory, skus } from '../db/schema';
import { sql, sum, eq } from "drizzle-orm";
@@ -41,11 +39,21 @@ const catalogs = catalogRows
---
<Layout title="Inventory Dashboard">
<div class="container-fluid container-sm mt-3" slot="page">
<BackToTop />
<div class="container-fluid container-sm" slot="page">
<div class="row mb-4">
<aside class="col-12 col-md-2 border-end border-secondary bg-dark p-3 d-flex flex-column gap-3">
<div class="d-flex justify-content-between align-items-center">
<aside
class="offcanvas-xl offcanvas-start col-12 col-xl-2 border-end border-secondary bg-dark p-3 d-flex flex-column gap-3 catalog-sidebar"
tabindex="-1"
id="catalogSidebar"
aria-labelledby="catalogSidebarLabel"
>
<div class="offcanvas-header d-xl-none">
<h6 class="offcanvas-title text-uppercase text-secondary fw-bold ls-wide" id="catalogSidebarLabel" style="letter-spacing:.08em">Catalogs</h6>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="offcanvas" data-bs-target="#catalogSidebar" aria-label="Close"></button>
</div>
<div class="offcanvas-body d-xl-flex flex-column gap-3 p-0">
<div class="d-none d-xl-flex justify-content-between align-items-center">
<h6 class="mb-0 text-uppercase text-secondary fw-bold ls-wide" style="letter-spacing:.08em">Catalogs</h6>
</div>
@@ -61,7 +69,7 @@ const catalogs = catalogRows
hx-swap="innerHTML"
>
<span class="d-flex align-items-center gap-2">
View all cards
All cards
</span>
<span class="badge rounded-pill text-bg-secondary small ms-auto">{totalQty}</span>
</li>
@@ -87,53 +95,33 @@ const catalogs = catalogRows
<div class="d-flex justify-content-between mb-1"><span>Market Value</span><span class="text-success fw-semibold">${totalValue.toFixed(0)}</span></div>
<div class="d-flex justify-content-between"><span>Gain/Loss</span><span class={`fw-semibold ${totalGain >= 0 ? "text-success" : "text-danger"}`}>{totalGain >= 0 ? "+" : ""}${Math.abs(totalGain).toFixed(0)}</span></div>
</div>
</div>
</aside>
<main class="col-12 col-md-10 mt-4">
<div class="d-flex flex-wrap gap-2 mb-4">
<a href="/pokemon" class="btn btn-vendor">Add cards</a>
<main class="col-12 col-xl-10 mt-4">
<div class="d-flex flex-wrap flex-row gap-2 mb-4">
<button
type="button"
class="btn btn-secondary d-xl-none me-auto"
data-bs-toggle="offcanvas"
data-bs-target="#catalogSidebar"
aria-controls="catalogSidebar"
>
Catalogs
</button>
<a href="/pokemon" class="btn btn-vendor ms-auto">Add cards</a>
<button
type="button"
class="btn btn-secondary"
class="btn btn-outline-secondary"
data-bs-toggle="modal"
data-bs-target="#bulkImportModal"
>Bulk Import</button>
<div class="ms-auto position-relative">
<div class="input-group">
<input
type="search"
name="q"
id="inventorySearchInput"
class="form-control search-input"
placeholder="Search your inventory"
hx-post="/partials/inventory-cards"
hx-trigger="keyup[key=='Enter']"
hx-target="#gridView"
hx-swap="innerHTML"
hx-vals="js:{start: 0, catalog: document.querySelector('#catalogList li[data-catalog].active')?.dataset.catalog || 'all'}"
/>
<button
type="button"
id="inventorySearchBtn"
class="btn btn-purple border-start-0"
aria-label="search"
hx-post="/partials/inventory-cards"
hx-include="#inventorySearchInput"
hx-target="#gridView"
hx-swap="innerHTML"
hx-vals="js:{start: 0, catalog: document.querySelector('#catalogList li[data-catalog].active')?.dataset.catalog || 'all'}"
>
<svg aria-hidden="true" class="search-button d-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M503.7 304.9C520.3 80.3 214-44 100.9 169.4C-14.1 383.9 203.9 614.6 419.8 466.3C459.7 500.3 494.8 542.3 531.5 578.2C561.1 607.7 606.3 562.8 576.8 533L540 496.1C520.2 471.6 495.7 449.1 473.7 428.9C471.1 426.5 468.5 424.2 466 421.9C491.9 385.4 500.1 341 503.7 304.8zM236.1 129C334 92.1 452.1 198.1 440 298.6C440.5 404.9 335.6 462.2 244 445.8C99 407.1 100.3 178.9 236.2 129z"/></svg>
</button>
</div>
</div>
</div>
<div id="inventoryView">
<div id="gridView" class="row g-4 row-cols-2 row-cols-md-3 row-cols-xl-4 row-cols-xxxl-5" hx-post="/partials/inventory-cards" hx-trigger="load">
<div id="gridView" class="row g-4 row-cols-2 row-cols-md-3 row-cols-lg-4 row-cols-xxxl-5" hx-post="/partials/inventory-cards" hx-trigger="load">
</div>
</div>
</main>
@@ -547,5 +535,4 @@ const catalogs = catalogRows
})();
</script>
</div>
<Footer slot="footer" />
</Layout>

View File

@@ -197,9 +197,7 @@ const altSearchUrl = (card: any) => {
</datalist>
<label for="note" class="form-label mt-2">Note</label>
<textarea class="form-control" id="note" name="note" rows="5" maxlength="255" placeholder="e.g. bought at local shop, gift, graded copy…">
{inv?.note ?? ''}
</textarea>
<textarea class="form-control" id="note" name="note" rows="5" maxlength="255" placeholder="e.g. bought at local shop, gift, graded copy…">{inv?.note ?? ''}</textarea>
</form>
</div>