feat(marketplace): dashboard nav link + My-templates filter

The logged-in user can now reach the marketplace and filter to their own
templates.

Dashboard nav:
- Added 'Marketplace' item (Overview · Servers · Marketplace · Audit · Settings).

/templates page — login-aware:
- Detects session via /v1/auth/me. Logged-in users get a 'Dashboard' + '+ New
  server' header instead of 'Home' + 'Start building'.
- New [All templates | My templates] scope toggle, shown only when logged in.
- 'My templates' loads GET /v1/templates/mine and shows EVERY status the user
  owns (public / hidden / draft / takedown) with a colored status badge on each
  card — so a template you unshared doesn't appear to have vanished.
- Sort tabs (trending/top/newest) hide in 'mine' scope — meaningless for a
  handful of own templates. Category filter + search still apply (client-side).
- Takedown cards link to the source server's Publish tab instead of the detail
  route (which 410s); everything else opens the detail page.

Backend:
- GET /v1/templates/mine (requireAuth) — all own templates, any status,
  registered before /:slug so the static route always wins the match.
- GET /v1/templates/:slug — now does an optional session check: the OWNER can
  view their own hidden/draft template (so a 'My templates' card click never
  dead-ends in a 404). takedown stays 410 for everyone, owner included — that's
  an admin decision, not the owner's to reverse.

Detail page:
- Fork CTA is gated on status === 'public'. For a non-public template the owner
  sees an amber 'not forkable — re-share from the Publish tab' notice plus a
  'Manage in server' link, instead of a Fork button that would fail silently.

Verified:
- GET /v1/templates/mine → marco's 1 template; 401 without auth
- Owner GET of a hidden template → 200 status:hidden; anon → 404
- Dashboard nav shows Marketplace (screenshot)
- /templates 'My templates' toggle → only own template, public badge, sort tabs
  hidden (screenshot)
This commit is contained in:
Marco Sadjadi 2026-05-20 17:18:58 +02:00
parent a189111782
commit 414903f16d
4 changed files with 276 additions and 102 deletions

View File

@ -16,6 +16,7 @@ import {
users, users,
} from '@bmm/db'; } from '@bmm/db';
import { GeneratorSpec } from '@bmm/types'; import { GeneratorSpec } from '@bmm/types';
import { getSession } from '@bmm/auth';
import { requireAuth, requireAdmin } from '../plugins/session.js'; import { requireAuth, requireAdmin } from '../plugins/session.js';
import { audit } from '../lib/audit.js'; import { audit } from '../lib/audit.js';
import { cacheSpec, cachePrebuiltCode } from '../lib/preview-cache.js'; import { cacheSpec, cachePrebuiltCode } from '../lib/preview-cache.js';
@ -345,6 +346,33 @@ export async function templateRoutes(app: FastifyInstance): Promise<void> {
return reply.send({ templates: ranked, categories: CATEGORIES }); return reply.send({ templates: ranked, categories: CATEGORIES });
}); });
// ---- My templates (authed — all statuses, for the marketplace "Mine" filter) ----
// Registered before /:slug so the static segment always wins the router match.
app.get('/v1/templates/mine', { preHandler: requireAuth }, async (req, reply) => {
const user = req.user!;
const rows = await db
.select()
.from(templates)
.where(eq(templates.ownerUserId, user.userId))
.orderBy(desc(templates.createdAt));
const enriched = await Promise.all(
rows.map(async (t) => {
const [active] = await db
.select({ c: count() })
.from(mcpServers)
.where(and(eq(mcpServers.templateId, t.id), eq(mcpServers.status, 'live')));
return {
...t,
ownerName: user.email.split('@')[0],
ownerOrgName: null,
activeDeployments: Number(active?.c ?? 0),
};
}),
);
return reply.send({ templates: enriched, categories: CATEGORIES });
});
// ---- Detail ---- // ---- Detail ----
app.get('/v1/templates/:slug', async (req, reply) => { app.get('/v1/templates/:slug', async (req, reply) => {
const Params = z.object({ slug: z.string().regex(SLUG_REGEX) }); const Params = z.object({ slug: z.string().regex(SLUG_REGEX) });
@ -365,17 +393,20 @@ export async function templateRoutes(app: FastifyInstance): Promise<void> {
.limit(1); .limit(1);
if (!row) return reply.code(404).send({ error: 'not_found' }); if (!row) return reply.code(404).send({ error: 'not_found' });
if (row.template.status === 'takedown') { if (row.template.status === 'takedown') {
// Takedown is an admin decision — sealed for everyone, including the owner.
return reply.code(410).send({ return reply.code(410).send({
error: 'taken_down', error: 'taken_down',
reason: row.template.takedownReason, reason: row.template.takedownReason,
}); });
} }
if (row.template.status !== 'public') { if (row.template.status !== 'public') {
// hidden / draft — only owner can view // hidden / draft — visible only to the owner (optional auth check).
// Note: viewing endpoint is public, so we 404 to non-owners. const session = await getSession(req.cookies['bmm_session']);
// (Owner UI would use a separate auth'd endpoint; out of scope for v1.) const isOwner = session != null && session.userId === row.template.ownerUserId;
if (!isOwner) {
return reply.code(404).send({ error: 'not_found' }); return reply.code(404).send({ error: 'not_found' });
} }
}
const [active] = await db const [active] = await db
.select({ c: count() }) .select({ c: count() })

View File

@ -1,6 +1,6 @@
import Link from 'next/link'; import Link from 'next/link';
import { Logo } from '@/components/logo'; import { Logo } from '@/components/logo';
import { LayoutGrid, Server, Settings, FileClock } from 'lucide-react'; import { LayoutGrid, Server, Settings, FileClock, Package } from 'lucide-react';
export default function DashboardLayout({ children }: { children: React.ReactNode }) { export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return ( return (
@ -16,6 +16,9 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
<NavLink href="/servers" icon={<Server size={13} />}> <NavLink href="/servers" icon={<Server size={13} />}>
Servers Servers
</NavLink> </NavLink>
<NavLink href="/templates" icon={<Package size={13} />}>
Marketplace
</NavLink>
<NavLink href="/audit" icon={<FileClock size={13} />}> <NavLink href="/audit" icon={<FileClock size={13} />}>
Audit Audit
</NavLink> </NavLink>

View File

@ -28,6 +28,7 @@ interface TemplateDetail {
shortDescription: string; shortDescription: string;
longDescription: string | null; longDescription: string | null;
category: string; category: string;
status: 'draft' | 'public' | 'hidden' | 'takedown';
verified: boolean; verified: boolean;
forkCount: number; forkCount: number;
activeDeployments: number; activeDeployments: number;
@ -37,6 +38,7 @@ interface TemplateDetail {
scopes: string[]; scopes: string[];
ownerName: string | null; ownerName: string | null;
ownerOrgName: string | null; ownerOrgName: string | null;
sourceServerId: string | null;
createdAt: string; createdAt: string;
} }
@ -208,12 +210,31 @@ export default function TemplateDetail() {
<aside className="space-y-3"> <aside className="space-y-3">
<div className="panel p-4"> <div className="panel p-4">
{template.status === 'public' ? (
<>
<Button variant="primary" size="lg" className="w-full" onClick={useTemplate}> <Button variant="primary" size="lg" className="w-full" onClick={useTemplate}>
Fork this template Fork this template
</Button> </Button>
<p className="mt-2 text-[11.5px] text-[--color-fg-muted]"> <p className="mt-2 text-[11.5px] text-[--color-fg-muted]">
One click your own isolated container. One click your own isolated container.
</p> </p>
</>
) : (
<>
<div className="rounded-md border border-amber-400/30 bg-amber-400/5 p-2.5 text-[12px] text-amber-200/90">
This template is <span className="mono">{template.status}</span> not
forkable. {template.sourceServerId ? 'Re-share it from the servers Publish tab to allow forks.' : ''}
</div>
{template.sourceServerId && (
<a
href={`/servers/${template.sourceServerId}`}
className="mt-2 inline-flex h-8 w-full items-center justify-center rounded-md border border-[--color-border] bg-[--color-bg-elevated] text-[12.5px] text-[--color-fg] transition-colors hover:bg-[--color-bg-subtle]"
>
Manage in server
</a>
)}
</>
)}
</div> </div>
<div className="panel p-3 text-[12px]"> <div className="panel p-3 text-[12px]">

View File

@ -14,38 +14,77 @@ interface Template {
title: string; title: string;
shortDescription: string; shortDescription: string;
category: string; category: string;
status: 'draft' | 'public' | 'hidden' | 'takedown';
verified: boolean; verified: boolean;
forkCount: number; forkCount: number;
activeDeployments: number; activeDeployments: number;
ownerName: string | null; ownerName: string | null;
ownerOrgName: string | null; ownerOrgName: string | null;
sourceServerId: string | null;
createdAt: string; createdAt: string;
} }
type Sort = 'trending' | 'top' | 'newest'; type Sort = 'trending' | 'top' | 'newest';
type Scope = 'all' | 'mine';
const STATUS_STYLE: Record<Template['status'], string> = {
public: 'border-emerald-400/40 bg-emerald-400/10 text-emerald-300',
hidden: 'border-amber-400/40 bg-amber-400/10 text-amber-300',
takedown: 'border-red-400/40 bg-red-400/10 text-red-300',
draft: 'border-zinc-400/40 bg-zinc-400/10 text-zinc-300',
};
export default function TemplatesMarketplace() { export default function TemplatesMarketplace() {
const [me, setMe] = useState<{ email: string } | null | undefined>(undefined);
const [scope, setScope] = useState<Scope>('all');
const [templates, setTemplates] = useState<Template[] | null>(null); const [templates, setTemplates] = useState<Template[] | null>(null);
const [categories, setCategories] = useState<string[]>([]); const [categories, setCategories] = useState<string[]>([]);
const [sort, setSort] = useState<Sort>('trending'); const [sort, setSort] = useState<Sort>('trending');
const [category, setCategory] = useState(''); const [category, setCategory] = useState('');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
// Detect login state once
useEffect(() => { useEffect(() => {
const params = new URLSearchParams({ sort }); apiFetch<{ user: { email: string } }>('/v1/auth/me')
if (category) params.set('category', category); .then((r) => setMe({ email: r.user.email }))
apiFetch<{ templates: Template[]; categories: string[] }>(`/v1/templates?${params}`).then((r) => { .catch(() => setMe(null));
}, []);
// Load templates whenever scope/sort/category changes
useEffect(() => {
setTemplates(null);
if (scope === 'mine') {
apiFetch<{ templates: Template[]; categories: string[] }>('/v1/templates/mine')
.then((r) => {
setTemplates(r.templates); setTemplates(r.templates);
setCategories(r.categories); setCategories(r.categories);
}); })
}, [sort, category]); .catch(() => setTemplates([]));
} else {
const params = new URLSearchParams({ sort });
if (category) params.set('category', category);
apiFetch<{ templates: Template[]; categories: string[] }>(`/v1/templates?${params}`)
.then((r) => {
setTemplates(r.templates);
setCategories(r.categories);
})
.catch(() => setTemplates([]));
}
}, [scope, sort, category]);
const visible = templates?.filter((t) => const visible = templates?.filter((t) => {
search if (search) {
? t.title.toLowerCase().includes(search.toLowerCase()) || const q = search.toLowerCase();
t.shortDescription.toLowerCase().includes(search.toLowerCase()) if (!t.title.toLowerCase().includes(q) && !t.shortDescription.toLowerCase().includes(q)) {
: true, return false;
); }
}
// category filter is server-side for 'all', client-side for 'mine'
if (scope === 'mine' && category && t.category !== category) return false;
return true;
});
const loggedIn = me != null;
return ( return (
<div className="flex min-h-screen flex-col"> <div className="flex min-h-screen flex-col">
@ -56,6 +95,23 @@ export default function TemplatesMarketplace() {
<span className="text-[12.5px] text-[--color-fg-subtle]">/ templates</span> <span className="text-[12.5px] text-[--color-fg-subtle]">/ templates</span>
</div> </div>
<nav className="flex items-center gap-2"> <nav className="flex items-center gap-2">
{loggedIn ? (
<>
<Link
href="/dashboard"
className="text-[12.5px] text-[--color-fg-muted] transition-colors hover:text-[--color-fg]"
>
Dashboard
</Link>
<Link
href="/servers/new"
className="rounded-md bg-[--color-accent] px-3 py-1.5 text-[12.5px] font-medium text-white transition-colors duration-200 hover:bg-[#5557e8]"
>
+ New server
</Link>
</>
) : (
<>
<Link <Link
href="/" href="/"
className="text-[12.5px] text-[--color-fg-muted] transition-colors hover:text-[--color-fg]" className="text-[12.5px] text-[--color-fg-muted] transition-colors hover:text-[--color-fg]"
@ -68,6 +124,8 @@ export default function TemplatesMarketplace() {
> >
Start building Start building
</Link> </Link>
</>
)}
</nav> </nav>
</div> </div>
</header> </header>
@ -87,6 +145,31 @@ export default function TemplatesMarketplace() {
</header> </header>
<div className="mb-6 flex flex-wrap items-center gap-3 border-b border-[--color-border] pb-4"> <div className="mb-6 flex flex-wrap items-center gap-3 border-b border-[--color-border] pb-4">
{loggedIn && (
<>
<div className="flex gap-1 rounded-md border border-[--color-border] bg-[--color-bg-subtle] p-0.5">
{(['all', 'mine'] as Scope[]).map((s) => (
<button
key={s}
type="button"
onClick={() => setScope(s)}
className={cn(
'rounded-[4px] px-2.5 py-1 text-[12.5px] capitalize transition-colors',
s === scope
? 'bg-[--color-bg-elevated] text-[--color-fg]'
: 'text-[--color-fg-muted] hover:text-[--color-fg]',
)}
>
{s === 'all' ? 'All templates' : 'My templates'}
</button>
))}
</div>
<div className="h-4 w-px bg-[--color-border]" />
</>
)}
{scope === 'all' && (
<>
<div className="flex gap-1"> <div className="flex gap-1">
{(['trending', 'top', 'newest'] as Sort[]).map((s) => ( {(['trending', 'top', 'newest'] as Sort[]).map((s) => (
<button <button
@ -104,7 +187,10 @@ export default function TemplatesMarketplace() {
</button> </button>
))} ))}
</div> </div>
<div className="mx-2 h-4 w-px bg-[--color-border]" /> <div className="h-4 w-px bg-[--color-border]" />
</>
)}
<select <select
value={category} value={category}
onChange={(e) => setCategory(e.target.value)} onChange={(e) => setCategory(e.target.value)}
@ -126,25 +212,58 @@ export default function TemplatesMarketplace() {
/> />
</div> </div>
{!visible && ( {!visible && <p className="mono text-[12px] text-[--color-fg-muted]">Loading</p>}
<p className="mono text-[12px] text-[--color-fg-muted]">Loading</p>
)}
{visible && visible.length === 0 && ( {visible && visible.length === 0 && (
<div className="panel p-12 text-center"> <div className="panel p-12 text-center">
{scope === 'mine' ? (
<>
<p className="text-[14px] text-[--color-fg]">You haven&apos;t published any templates.</p>
<p className="mt-1 text-[12.5px] text-[--color-fg-muted]">
Build a server, then tick <span className="mono">Share as template</span> on the
done screen or use the Publish tab on any live server.
</p>
</>
) : (
<>
<p className="text-[14px] text-[--color-fg]">No templates yet.</p> <p className="text-[14px] text-[--color-fg]">No templates yet.</p>
<p className="mt-1 text-[12.5px] text-[--color-fg-muted]"> <p className="mt-1 text-[12.5px] text-[--color-fg-muted]">
Build a server you&apos;re proud of and click <span className="mono">Publish as template</span>{' '} Build a server you&apos;re proud of and share it to the marketplace.
on its detail page.
</p> </p>
</>
)}
</div> </div>
)} )}
{visible && visible.length > 0 && ( {visible && visible.length > 0 && (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{visible.map((t) => ( {visible.map((t) => (
<TemplateCard key={t.id} t={t} showStatus={scope === 'mine'} />
))}
</div>
)}
</main>
<footer className="border-t border-[--color-border] py-8">
<div className="mx-auto max-w-6xl px-6 text-[12px] text-[--color-fg-subtle]">
Every template is isolated: forking creates your own container with your own secrets.
</div>
</footer>
</div>
);
}
function TemplateCard({ t, showStatus }: { t: Template; showStatus: boolean }) {
// takedown templates can't be opened (the detail route 410s); link to the
// server's Publish tab so the owner can see why. Everything else opens detail.
const href =
t.status === 'takedown' && t.sourceServerId
? `/servers/${t.sourceServerId}`
: `/templates/${t.slug}`;
return (
<Link <Link
key={t.id} href={href}
href={`/templates/${t.slug}`}
className="panel flex flex-col p-4 transition-colors duration-200 hover:border-[--color-border-strong]" className="panel flex flex-col p-4 transition-colors duration-200 hover:border-[--color-border-strong]"
> >
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
@ -159,9 +278,20 @@ export default function TemplatesMarketplace() {
</span> </span>
)} )}
</div> </div>
<span className="mono text-[10px] rounded-full border border-[--color-border] bg-[--color-bg-subtle] px-1.5 py-0.5 text-[--color-fg-subtle]"> {showStatus ? (
<span
className={cn(
'mono rounded-full border px-1.5 py-0.5 text-[9.5px]',
STATUS_STYLE[t.status],
)}
>
{t.status}
</span>
) : (
<span className="mono rounded-full border border-[--color-border] bg-[--color-bg-subtle] px-1.5 py-0.5 text-[10px] text-[--color-fg-subtle]">
{t.category} {t.category}
</span> </span>
)}
</div> </div>
<p className="mt-2 flex-1 text-[12.5px] leading-relaxed text-[--color-fg-muted]"> <p className="mt-2 flex-1 text-[12.5px] leading-relaxed text-[--color-fg-muted]">
{t.shortDescription} {t.shortDescription}
@ -178,20 +308,9 @@ export default function TemplatesMarketplace() {
</span> </span>
</div> </div>
<span className="truncate"> <span className="truncate">
by {t.ownerName ?? t.ownerOrgName ?? 'anonymous'} {showStatus ? t.category : `by ${t.ownerName ?? t.ownerOrgName ?? 'anonymous'}`}
</span> </span>
</div> </div>
</Link> </Link>
))}
</div>
)}
</main>
<footer className="border-t border-[--color-border] py-8">
<div className="mx-auto max-w-6xl px-6 text-[12px] text-[--color-fg-subtle]">
Every template is isolated: forking creates your own container with your own secrets.
</div>
</footer>
</div>
); );
} }