feat: Swiss-compliant launch — Impressum/AGB/Contact, support panel, DSG exports, cookie banner
All checks were successful
Deploy to Production / deploy (push) Successful in 57s

Legal (Swiss minimum, no individual named):
- Impressum page (UWG Art. 3 lit. s) — provider, contact via support panel,
  no email required, jurisdiction = Switzerland
- AGB page — subscription terms, payment, cancellation, suspension on payment
  fail, 14-day money-back, AI-processing-per-tier disclosure, Swiss law +
  Swiss venue, modeled after typical Schweizer SaaS terms
- Privacy: Stripe added as subprocessor with full data-flow disclosure

Support panel replaces email contact entirely:
- @bmm/db: support_status enum + support_tickets + support_messages tables,
  migration applied to prod DB
- @bmm/api: support routes (user create/list/view/reply, admin list/view/reply
  /set-status), public /v1/contact for logged-out visitors with per-IP rate
  limit of 3 submissions/day to prevent spam-flood
- Web: /settings/support (list + new), /settings/support/[id] (conversation),
  /admin/support, /admin/support/[id]
- Public /contact form with email collection for guest tickets

Data rights (DSG Art. 25 / GDPR Art. 15+20):
- /v1/account/export returns user-scoped JSON of profile, org, servers,
  builds, audit, support tickets and messages — excludes hashes, encrypted
  secrets, other-user data
- /settings/account: download button + deletion-via-ticket workflow

Production-readiness gaps closed:
- org.suspended now blocks /v1/servers POST and /v1/servers/preview (402);
  webhook flagged this state but enforcement was missing
- Cookie banner: minimal, essential-cookies-only disclosure (Swiss DSG +
  GDPR compliant without dark-pattern consent UI), mounts on both layouts

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marco Sadjadi 2026-05-25 17:12:06 +02:00
parent c2a21fc3cd
commit ef30baf52a
18 changed files with 1692 additions and 6 deletions

View File

@ -6,12 +6,14 @@ import Fastify from 'fastify';
import { config } from './config.js';
import { ensureActiveKey } from './lib/crypto.js';
import { validateStripePriceConfig } from './lib/stripe.js';
import { accountRoutes } from './routes/account.js';
import { adminRoutes } from './routes/admin.js';
import { authRoutes } from './routes/auth.js';
import { billingRoutes } from './routes/billing.js';
import { oauthRoutes } from './routes/oauth.js';
import { serverRoutes } from './routes/servers.js';
import { settingsRoutes } from './routes/settings.js';
import { supportRoutes } from './routes/support.js';
import { templateRoutes } from './routes/templates.js';
// Stripe webhook signature verification requires the raw request body, so we
@ -72,6 +74,8 @@ await app.register(settingsRoutes);
await app.register(adminRoutes);
await app.register(templateRoutes);
await app.register(billingRoutes);
await app.register(supportRoutes);
await app.register(accountRoutes);
// Loud warning if STRIPE_PRICE_* env vars are set to product ids (prod_…)
// instead of price ids (price_…). Stripe Checkout would silently 400 — easier

View File

@ -14,6 +14,31 @@ export async function getOrgPlan(orgId: string): Promise<Plan> {
return (row?.plan ?? 'hobby') as Plan;
}
export interface OrgBilling {
plan: Plan;
suspended: boolean;
suspendedReason: string | null;
}
/** Like getOrgPlan but also reports suspension state. Use in routes that
* should refuse new work when a subscription is past-due / unpaid. */
export async function getOrgBilling(orgId: string): Promise<OrgBilling> {
const [row] = await db
.select({
plan: organizations.plan,
suspended: organizations.suspended,
suspendedReason: organizations.suspendedReason,
})
.from(organizations)
.where(eq(organizations.id, orgId))
.limit(1);
return {
plan: (row?.plan ?? 'hobby') as Plan,
suspended: row?.suspended ?? false,
suspendedReason: row?.suspendedReason ?? null,
};
}
/** Max MCP servers per org by plan. Enforced at POST /v1/servers. */
export const SERVER_LIMITS: Record<Plan, number> = {
hobby: 1,

View File

@ -0,0 +1,137 @@
import {
auditLog,
builds,
createDb,
desc,
eq,
inArray,
mcpServers,
organizations,
supportMessages,
supportTickets,
users,
} from '@bmm/db';
import type { FastifyInstance } from 'fastify';
import { audit } from '../lib/audit.js';
import { requireAuth } from '../plugins/session.js';
const db = createDb();
export async function accountRoutes(app: FastifyInstance): Promise<void> {
/**
* GDPR Art. 15 / Swiss DSG Art. 25 right of access. Returns every record
* we hold that belongs to the calling user. Excludes hashed passwords,
* encrypted secret payloads, and any other user's data. Streamed as JSON
* attachment so the browser downloads it directly.
*/
app.get('/v1/account/export', { preHandler: requireAuth }, async (req, reply) => {
const user = req.user!;
const [userRow] = await db.select().from(users).where(eq(users.id, user.userId)).limit(1);
const [org] = await db
.select()
.from(organizations)
.where(eq(organizations.id, user.orgId))
.limit(1);
const orgServers = await db
.select()
.from(mcpServers)
.where(eq(mcpServers.orgId, user.orgId));
const serverIds = orgServers.map((s) => s.id);
const orgBuilds =
serverIds.length > 0
? await db.select().from(builds).where(inArray(builds.serverId, serverIds))
: [];
const userAudit = await db
.select()
.from(auditLog)
.where(eq(auditLog.userId, user.userId))
.orderBy(desc(auditLog.createdAt))
.limit(1000);
const userTickets = await db
.select()
.from(supportTickets)
.where(eq(supportTickets.userId, user.userId));
const ticketIds = userTickets.map((t) => t.id);
const userTicketMessages =
ticketIds.length > 0
? await db
.select()
.from(supportMessages)
.where(inArray(supportMessages.ticketId, ticketIds))
: [];
await audit({
orgId: user.orgId,
userId: user.userId,
action: 'account.export',
resourceType: 'account',
ipAddress: req.ip,
});
reply
.header('Content-Type', 'application/json; charset=utf-8')
.header(
'Content-Disposition',
`attachment; filename="buildmymcpserver-export-${Date.now()}.json"`,
);
return reply.send({
exportedAt: new Date().toISOString(),
_format: 'BuildMyMCPServer Account Export v1',
_excluded: [
'password hashes',
'encrypted secret payloads',
'session tokens',
'other users in the same organization',
],
user: userRow
? {
id: userRow.id,
email: userRow.email,
name: userRow.name,
phone: userRow.phone,
isAdmin: userRow.isAdmin,
createdAt: userRow.createdAt,
}
: null,
organization: org
? {
id: org.id,
slug: org.slug,
name: org.name,
plan: org.plan,
createdAt: org.createdAt,
}
: null,
servers: orgServers.map((s) => ({
id: s.id,
slug: s.slug,
name: s.name,
status: s.status,
publicUrl: s.publicUrl,
toolsSchema: s.toolsSchema,
createdAt: s.createdAt,
})),
builds: orgBuilds.map((b) => ({
id: b.id,
serverId: b.serverId,
version: b.version,
prompt: b.prompt,
status: b.status,
createdAt: b.createdAt,
})),
audit: userAudit.map((a) => ({
id: a.id,
action: a.action,
resourceType: a.resourceType,
resourceId: a.resourceId,
metadata: a.metadata,
ipAddress: a.ipAddress,
createdAt: a.createdAt,
})),
supportTickets: userTickets,
supportMessages: userTicketMessages,
});
});
}

View File

@ -32,7 +32,7 @@ import { config } from '../config.js';
import { audit } from '../lib/audit.js';
import { encryptSecret } from '../lib/crypto.js';
import { stopContainer } from '../lib/docker.js';
import { SERVER_LIMITS, getOrgPlan } from '../lib/plan.js';
import { SERVER_LIMITS, getOrgBilling } from '../lib/plan.js';
import { cacheSpec, loadSpec, overwriteSpec } from '../lib/preview-cache.js';
import { getBuildQueue } from '../lib/queue.js';
import { BUILD_DAILY_LIMIT, PREVIEW_DAILY_LIMIT, checkDailyLimit } from '../lib/rate-limit.js';
@ -60,7 +60,18 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
return reply.code(400).send({ error: 'invalid_input', issues: parsed.error.flatten() });
}
const plan = await getOrgPlan(user.orgId);
const billing = await getOrgBilling(user.orgId);
if (billing.suspended) {
return reply.code(402).send({
error: 'subscription_suspended',
detail:
billing.suspendedReason === 'payment_failed'
? 'Your subscription is paused due to a payment issue. Update your payment method in /settings/billing.'
: 'Your subscription is paused. Visit /settings/billing for details.',
suspendedReason: billing.suspendedReason,
});
}
const plan = billing.plan;
// Daily preview rate-limit per user. Free is tight (5/day) because every
// preview is a paid LLM call; paid tiers have headroom for real iteration.
@ -142,7 +153,18 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
} = parsed.data;
// ---- Plan enforcement (must happen before any DB write) ----
const plan = await getOrgPlan(user.orgId);
const billing = await getOrgBilling(user.orgId);
if (billing.suspended) {
return reply.code(402).send({
error: 'subscription_suspended',
detail:
billing.suspendedReason === 'payment_failed'
? 'Your subscription is paused due to a payment issue. Update your payment method in /settings/billing.'
: 'Your subscription is paused. Visit /settings/billing for details.',
suspendedReason: billing.suspendedReason,
});
}
const plan = billing.plan;
// Daily build rate-limit.
const rl = await checkDailyLimit('build', user.userId, BUILD_DAILY_LIMIT[plan]);

View File

@ -0,0 +1,290 @@
import {
and,
createDb,
desc,
eq,
supportMessages,
supportTickets,
users,
} from '@bmm/db';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { audit } from '../lib/audit.js';
import { checkDailyLimit } from '../lib/rate-limit.js';
import { requireAdmin, requireAuth } from '../plugins/session.js';
const db = createDb();
const NewTicketBody = z.object({
subject: z.string().min(3).max(200),
body: z.string().min(10).max(10_000),
});
const GuestTicketBody = z.object({
email: z.string().email(),
subject: z.string().min(3).max(200),
body: z.string().min(10).max(10_000),
});
const NewMessageBody = z.object({
body: z.string().min(1).max(10_000),
});
const StatusBody = z.object({
status: z.enum(['awaiting_admin', 'awaiting_user', 'closed']),
});
export async function supportRoutes(app: FastifyInstance): Promise<void> {
// ─── User-side ──────────────────────────────────────────────────────────
app.post('/v1/support/tickets', { preHandler: requireAuth }, async (req, reply) => {
const user = req.user!;
const parsed = NewTicketBody.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' });
const [ticket] = await db
.insert(supportTickets)
.values({
userId: user.userId,
orgId: user.orgId,
subject: parsed.data.subject,
status: 'awaiting_admin',
})
.returning();
if (!ticket) return reply.code(500).send({ error: 'ticket_create_failed' });
await db.insert(supportMessages).values({
ticketId: ticket.id,
authorUserId: user.userId,
authorIsAdmin: false,
body: parsed.data.body,
});
await audit({
orgId: user.orgId,
userId: user.userId,
action: 'support.ticket_created',
resourceType: 'support_ticket',
resourceId: ticket.id,
metadata: { subject: parsed.data.subject },
ipAddress: req.ip,
});
return reply.send({ ticket });
});
app.get('/v1/support/tickets', { preHandler: requireAuth }, async (req, reply) => {
const user = req.user!;
const rows = await db
.select()
.from(supportTickets)
.where(eq(supportTickets.userId, user.userId))
.orderBy(desc(supportTickets.lastMessageAt));
return reply.send({ tickets: rows });
});
app.get('/v1/support/tickets/:id', { preHandler: requireAuth }, async (req, reply) => {
const user = req.user!;
const Params = z.object({ id: z.string().uuid() });
const parsed = Params.safeParse(req.params);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_id' });
const [ticket] = await db
.select()
.from(supportTickets)
.where(
and(eq(supportTickets.id, parsed.data.id), eq(supportTickets.userId, user.userId)),
)
.limit(1);
if (!ticket) return reply.code(404).send({ error: 'not_found' });
const messages = await db
.select()
.from(supportMessages)
.where(eq(supportMessages.ticketId, ticket.id))
.orderBy(supportMessages.createdAt);
return reply.send({ ticket, messages });
});
app.post(
'/v1/support/tickets/:id/messages',
{ preHandler: requireAuth },
async (req, reply) => {
const user = req.user!;
const Params = z.object({ id: z.string().uuid() });
const parsed = Params.safeParse(req.params);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_id' });
const body = NewMessageBody.safeParse(req.body);
if (!body.success) return reply.code(400).send({ error: 'invalid_input' });
const [ticket] = await db
.select()
.from(supportTickets)
.where(
and(eq(supportTickets.id, parsed.data.id), eq(supportTickets.userId, user.userId)),
)
.limit(1);
if (!ticket) return reply.code(404).send({ error: 'not_found' });
await db.insert(supportMessages).values({
ticketId: ticket.id,
authorUserId: user.userId,
authorIsAdmin: false,
body: body.data.body,
});
await db
.update(supportTickets)
.set({
status: 'awaiting_admin',
lastMessageAt: new Date(),
updatedAt: new Date(),
})
.where(eq(supportTickets.id, ticket.id));
return reply.send({ ok: true });
},
);
// ─── Public contact form (no auth) ─────────────────────────────────────
// Satisfies UWG Art. 3 lit. s ("easy electronic contact") for non-logged-in
// visitors. Rate-limited per IP to prevent spam-flood of admin queue.
app.post('/v1/contact', async (req, reply) => {
const parsed = GuestTicketBody.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' });
const rl = await checkDailyLimit('contact', req.ip, 3);
if (!rl.ok) {
return reply.code(429).send({
error: 'rate_limited',
detail: 'Too many contact submissions from this IP. Try again tomorrow.',
});
}
const [ticket] = await db
.insert(supportTickets)
.values({
guestEmail: parsed.data.email,
subject: parsed.data.subject,
status: 'awaiting_admin',
})
.returning();
if (!ticket) return reply.code(500).send({ error: 'ticket_create_failed' });
await db.insert(supportMessages).values({
ticketId: ticket.id,
authorUserId: null,
authorIsAdmin: false,
body: parsed.data.body,
});
return reply.send({ ok: true });
});
// ─── Admin-side ────────────────────────────────────────────────────────
app.get(
'/v1/admin/support/tickets',
{ preHandler: requireAdmin },
async (_req, reply) => {
const rows = await db
.select({
ticket: supportTickets,
userEmail: users.email,
userName: users.name,
})
.from(supportTickets)
.leftJoin(users, eq(users.id, supportTickets.userId))
.orderBy(desc(supportTickets.lastMessageAt))
.limit(200);
return reply.send({ tickets: rows });
},
);
app.get(
'/v1/admin/support/tickets/:id',
{ preHandler: requireAdmin },
async (req, reply) => {
const Params = z.object({ id: z.string().uuid() });
const parsed = Params.safeParse(req.params);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_id' });
const [row] = await db
.select({ ticket: supportTickets, userEmail: users.email, userName: users.name })
.from(supportTickets)
.leftJoin(users, eq(users.id, supportTickets.userId))
.where(eq(supportTickets.id, parsed.data.id))
.limit(1);
if (!row) return reply.code(404).send({ error: 'not_found' });
const messages = await db
.select()
.from(supportMessages)
.where(eq(supportMessages.ticketId, parsed.data.id))
.orderBy(supportMessages.createdAt);
return reply.send({ ticket: row.ticket, userEmail: row.userEmail, userName: row.userName, messages });
},
);
app.post(
'/v1/admin/support/tickets/:id/messages',
{ preHandler: requireAdmin },
async (req, reply) => {
const user = req.user!;
const Params = z.object({ id: z.string().uuid() });
const parsed = Params.safeParse(req.params);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_id' });
const body = NewMessageBody.safeParse(req.body);
if (!body.success) return reply.code(400).send({ error: 'invalid_input' });
await db.insert(supportMessages).values({
ticketId: parsed.data.id,
authorUserId: user.userId,
authorIsAdmin: true,
body: body.data.body,
});
await db
.update(supportTickets)
.set({
status: 'awaiting_user',
lastMessageAt: new Date(),
updatedAt: new Date(),
})
.where(eq(supportTickets.id, parsed.data.id));
await audit({
orgId: user.orgId,
userId: user.userId,
action: 'support.admin_reply',
resourceType: 'support_ticket',
resourceId: parsed.data.id,
});
return reply.send({ ok: true });
},
);
app.post(
'/v1/admin/support/tickets/:id/status',
{ preHandler: requireAdmin },
async (req, reply) => {
const Params = z.object({ id: z.string().uuid() });
const parsed = Params.safeParse(req.params);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_id' });
const body = StatusBody.safeParse(req.body);
if (!body.success) return reply.code(400).send({ error: 'invalid_input' });
await db
.update(supportTickets)
.set({
status: body.data.status,
closedAt: body.data.status === 'closed' ? new Date() : null,
updatedAt: new Date(),
})
.where(eq(supportTickets.id, parsed.data.id));
return reply.send({ ok: true });
},
);
}

View File

@ -1,3 +1,4 @@
import { CookieBanner } from '@/components/cookie-banner';
import { Logo } from '@/components/logo';
import { MobileActionBar } from '@/components/mobile-action-bar';
import { FileClock, LayoutGrid, Package, Server, Settings } from 'lucide-react';
@ -38,6 +39,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
</header>
<main className="flex-1 bg-[--color-bg] pb-20 sm:pb-0">{children}</main>
<MobileActionBar />
<CookieBanner />
</div>
);
}

View File

@ -0,0 +1,80 @@
'use client';
import { Button } from '@/components/ui/button';
import { apiUrl } from '@/lib/api';
import Link from 'next/link';
import { useState } from 'react';
export default function AccountPage() {
const [downloading, setDownloading] = useState(false);
async function downloadExport() {
setDownloading(true);
try {
// Trigger a same-origin attachment download. The cookie ships with the
// request because we're same-credentials with the API origin via CORS.
window.location.href = apiUrl('/v1/account/export');
} finally {
setTimeout(() => setDownloading(false), 1500);
}
}
return (
<div className="mx-auto max-w-3xl px-6 py-10">
<h1 className="text-[22px] font-semibold tracking-tight">Account</h1>
<p className="mt-1 text-[13px] text-[--color-fg-muted]">
Your data, your rights. Swiss DSG Art. 25 / GDPR Art. 15 + 20.
</p>
<div className="mt-8 space-y-4">
<section className="panel p-5">
<h2 className="text-[14px] font-semibold tracking-tight">Download your data</h2>
<p className="mt-2 text-[12.5px] leading-relaxed text-[--color-fg-muted]">
One JSON file with everything we hold for your account: profile, organization, MCP
servers, build history (last 1000 entries), audit log (last 1000 events) and your
support-ticket history. Excludes password hashes, encrypted secrets and other
users&apos; data.
</p>
<div className="mt-4">
<Button variant="primary" size="md" onClick={downloadExport} disabled={downloading}>
{downloading ? 'Preparing…' : 'Download .json'}
</Button>
</div>
</section>
<section className="panel p-5">
<h2 className="text-[14px] font-semibold tracking-tight">Delete account</h2>
<p className="mt-2 text-[12.5px] leading-relaxed text-[--color-fg-muted]">
We don&apos;t do one-click account deletion yet too easy to fat-finger and lose
paid-tier server configs. Open a ticket and we&apos;ll wipe everything within 30
days (servers, secrets, audit, tickets) per Swiss DSG Art. 32 / GDPR Art. 17.
</p>
<div className="mt-4">
<Link href="/settings/support">
<Button variant="secondary" size="md">
Open deletion ticket
</Button>
</Link>
</div>
</section>
<section className="panel p-5">
<h2 className="text-[14px] font-semibold tracking-tight">Cookies on this site</h2>
<p className="mt-2 text-[12.5px] leading-relaxed text-[--color-fg-muted]">
We use only strictly-necessary cookies: a session cookie (
<span className="mono">bmm_session</span>, httpOnly, 30 days) and a short-lived
OAuth-CSRF state cookie (<span className="mono">bmm_oauth_state</span>, 10 minutes
during a third-party login flow). No analytics, no tracking, no third-party cookies on
this domain.
</p>
</section>
</div>
<div className="mt-10 text-[12px] text-[--color-fg-subtle]">
<Link href="/privacy" className="hover:text-[--color-fg]">
Privacy policy
</Link>
</div>
</div>
);
}

View File

@ -0,0 +1,145 @@
'use client';
import { Textarea } from '@/components/input';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api';
import { Loader2 } from 'lucide-react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useEffect, useState } from 'react';
interface Ticket {
id: string;
subject: string;
status: 'awaiting_admin' | 'awaiting_user' | 'closed';
createdAt: string;
lastMessageAt: string;
}
interface Message {
id: string;
authorIsAdmin: boolean;
body: string;
createdAt: string;
}
export default function TicketDetail() {
const params = useParams<{ id: string }>();
const [data, setData] = useState<{ ticket: Ticket; messages: Message[] } | null>(null);
const [reply, setReply] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
function load() {
if (!params?.id) return;
apiFetch<{ ticket: Ticket; messages: Message[] }>(`/v1/support/tickets/${params.id}`)
.then(setData)
.catch((e) => setError((e as Error).message));
}
useEffect(load, [params?.id]);
async function sendReply(e: React.FormEvent) {
e.preventDefault();
if (!params?.id || reply.trim().length === 0) return;
setBusy(true);
setError(null);
try {
await apiFetch(`/v1/support/tickets/${params.id}/messages`, {
method: 'POST',
body: JSON.stringify({ body: reply }),
});
setReply('');
load();
} catch (err) {
setError((err as Error).message);
} finally {
setBusy(false);
}
}
if (!data && !error) {
return (
<div className="mx-auto max-w-3xl px-6 py-12 text-center">
<Loader2 className="mx-auto animate-spin text-[--color-fg-muted]" size={20} />
</div>
);
}
if (error || !data) {
return (
<div className="mx-auto max-w-3xl px-6 py-12">
<p className="text-[13px] text-[--color-danger]">{error ?? 'Ticket not found.'}</p>
<Link href="/settings/support" className="mt-3 inline-block text-[12px] text-[--color-fg-muted] hover:text-[--color-fg]">
Back to support
</Link>
</div>
);
}
const { ticket, messages } = data;
const isClosed = ticket.status === 'closed';
return (
<div className="mx-auto max-w-3xl px-6 py-10">
<Link
href="/settings/support"
className="text-[12px] text-[--color-fg-muted] hover:text-[--color-fg]"
>
All tickets
</Link>
<div className="mt-3 flex items-baseline justify-between gap-3">
<h1 className="text-[22px] font-semibold tracking-tight">{ticket.subject}</h1>
<span className="mono text-[10.5px] uppercase tracking-wider text-[--color-fg-subtle]">
{ticket.status.replace('_', ' ')}
</span>
</div>
<div className="mt-6 space-y-3">
{messages.map((m) => (
<div
key={m.id}
className={`panel p-4 ${m.authorIsAdmin ? 'border-[--color-accent]/40' : ''}`}
>
<div className="flex items-baseline justify-between">
<span
className={`text-[11.5px] font-medium ${m.authorIsAdmin ? 'text-[--color-accent]' : 'text-[--color-fg]'}`}
>
{m.authorIsAdmin ? 'Support' : 'You'}
</span>
<span className="text-[10.5px] text-[--color-fg-subtle]">
{new Date(m.createdAt).toLocaleString()}
</span>
</div>
<p className="mt-2 whitespace-pre-wrap text-[13px] leading-relaxed text-[--color-fg-muted]">
{m.body}
</p>
</div>
))}
</div>
{!isClosed && (
<form onSubmit={sendReply} className="panel mt-6 space-y-3 p-4">
<Textarea
value={reply}
onChange={(e) => setReply(e.target.value)}
rows={4}
maxLength={10_000}
placeholder="Your reply…"
/>
{error && <p className="text-[12.5px] text-[--color-danger]">{error}</p>}
<div className="flex justify-end">
<Button
variant="primary"
size="md"
type="submit"
disabled={busy || reply.trim().length === 0}
>
{busy ? 'Sending…' : 'Send reply'}
</Button>
</div>
</form>
)}
</div>
);
}

View File

@ -0,0 +1,166 @@
'use client';
import { Input, Label, Textarea } from '@/components/input';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api';
import { Loader2 } from 'lucide-react';
import Link from 'next/link';
import { useEffect, useState } from 'react';
interface Ticket {
id: string;
subject: string;
status: 'awaiting_admin' | 'awaiting_user' | 'closed';
createdAt: string;
lastMessageAt: string;
}
const STATUS_LABEL: Record<Ticket['status'], string> = {
awaiting_admin: 'Open — awaiting support',
awaiting_user: 'Reply received',
closed: 'Closed',
};
const STATUS_COLOR: Record<Ticket['status'], string> = {
awaiting_admin: 'text-amber-300',
awaiting_user: 'text-emerald-300',
closed: 'text-[--color-fg-subtle]',
};
export default function SupportPage() {
const [tickets, setTickets] = useState<Ticket[] | null>(null);
const [showNew, setShowNew] = useState(false);
const [subject, setSubject] = useState('');
const [body, setBody] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
function load() {
apiFetch<{ tickets: Ticket[] }>('/v1/support/tickets')
.then((r) => setTickets(r.tickets))
.catch((e) => setError((e as Error).message));
}
useEffect(load, []);
async function createTicket(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
setError(null);
try {
await apiFetch('/v1/support/tickets', {
method: 'POST',
body: JSON.stringify({ subject, body }),
});
setSubject('');
setBody('');
setShowNew(false);
load();
} catch (err) {
setError((err as Error).message);
} finally {
setBusy(false);
}
}
return (
<div className="mx-auto max-w-3xl px-6 py-10">
<div className="flex items-baseline justify-between">
<div>
<h1 className="text-[22px] font-semibold tracking-tight">Support</h1>
<p className="mt-1 text-[13px] text-[--color-fg-muted]">
Open a ticket and we&apos;ll get back to you within one business day.
</p>
</div>
{!showNew && (
<Button variant="primary" size="md" onClick={() => setShowNew(true)}>
+ New ticket
</Button>
)}
</div>
{showNew && (
<form onSubmit={createTicket} className="panel mt-6 space-y-4 p-5">
<div className="space-y-1.5">
<Label htmlFor="t-subject">Subject</Label>
<Input
id="t-subject"
required
minLength={3}
maxLength={200}
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="Briefly — what's up?"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="t-body" hint={`${body.length} / 10000`}>
Message
</Label>
<Textarea
id="t-body"
required
rows={6}
minLength={10}
maxLength={10_000}
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="The more context the better — server slug, error messages, what you expected."
/>
</div>
{error && <p className="text-[12.5px] text-[--color-danger]">{error}</p>}
<div className="flex justify-end gap-2">
<Button variant="ghost" size="md" type="button" onClick={() => setShowNew(false)}>
Cancel
</Button>
<Button
variant="primary"
size="md"
type="submit"
disabled={busy || subject.length < 3 || body.length < 10}
>
{busy ? 'Sending…' : 'Open ticket'}
</Button>
</div>
</form>
)}
<div className="mt-8">
{tickets === null && (
<div className="panel p-6 text-center">
<Loader2 className="mx-auto animate-spin text-[--color-fg-muted]" size={18} />
</div>
)}
{tickets && tickets.length === 0 && !showNew && (
<div className="panel p-6 text-center text-[13px] text-[--color-fg-muted]">
No tickets yet.
</div>
)}
{tickets && tickets.length > 0 && (
<div className="panel divide-y divide-[--color-border]">
{tickets.map((t) => (
<Link
key={t.id}
href={`/settings/support/${t.id}`}
className="flex items-center justify-between px-4 py-3 transition-colors hover:bg-[--color-bg-subtle]"
>
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-[--color-fg]">
{t.subject}
</div>
<div className={`mt-0.5 text-[11.5px] ${STATUS_COLOR[t.status]}`}>
{STATUS_LABEL[t.status]} ·{' '}
<span className="text-[--color-fg-subtle]">
{new Date(t.lastMessageAt).toLocaleString()}
</span>
</div>
</div>
<span className="ml-3 text-[--color-fg-subtle]"></span>
</Link>
))}
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,129 @@
import { pageMetadata } from '@/lib/seo';
import Link from 'next/link';
export const metadata = pageMetadata({
title: 'AGB',
description:
'Allgemeine Geschäftsbedingungen für die Nutzung von BuildMyMCPServer (Schweiz).',
path: '/agb',
});
const SECTIONS: Array<{ h: string; p: string[] }> = [
{
h: '1. Geltungsbereich',
p: [
'Diese AGB regeln die Nutzung der über buildmymcpserver.com bereitgestellten Dienste durch natürliche und juristische Personen ("Kund:in"). Mit Erstellung eines Accounts oder Abschluss eines kostenpflichtigen Abonnements bestätigt die Kund:in, diese AGB gelesen, verstanden und akzeptiert zu haben.',
'Abweichende Bedingungen der Kund:in gelten nur, wenn schriftlich bestätigt.',
],
},
{
h: '2. Vertragsgegenstand',
p: [
'BuildMyMCPServer ist ein Software-as-a-Service-Angebot zur Generierung und zum Betrieb von Model-Context-Protocol-Servern (MCP-Server). Der Funktionsumfang ergibt sich aus dem jeweils gewählten Tarif gemäss Pricing-Seite.',
'Wir liefern den Service "as-is" mit angestrebter Verfügbarkeit gemäss tariflicher SLA (Hobby/Pro: keine SLA; Team: 99.9% monatlich; Enterprise: vertraglich vereinbart).',
],
},
{
h: '3. Account und Sicherheit',
p: [
'Die Kund:in ist verpflichtet, Zugangsdaten vertraulich zu behandeln. Bei Verdacht auf unberechtigten Zugriff sind wir unverzüglich über das Support-Panel zu informieren.',
'Wir behalten uns vor, Accounts bei schwerwiegenden Verstössen gegen diese AGB oder geltendes Recht zu suspendieren.',
],
},
{
h: '4. Tarife und Bezahlung',
p: [
'Bezahlung erfolgt im Voraus über unseren Zahlungsdienstleister Stripe Payments Europe Ltd. (Irland). Akzeptierte Zahlungsmethoden umfassen Kreditkarte und SEPA-Lastschrift.',
'Monatliche Tarife werden monatlich, Jahres-Tarife jährlich abgerechnet. Bei Jahres-Tarif werden zwei Monate gratis gewährt.',
'Preise verstehen sich vorbehältlich gesetzlicher Mehrwertsteuer. Die anwendbare MwSt. wird durch Stripe Tax automatisch nach Sitz der Kund:in berechnet und ausgewiesen.',
'Nutzungs-Overage (Tool-Calls über das tarifliche Kontingent hinaus) wird zu €0.02 / 1000 Calls am Folgemonat in Rechnung gestellt.',
],
},
{
h: '5. Laufzeit, Kündigung und Rückerstattung',
p: [
'Monats-Abos verlängern sich automatisch um einen Monat, Jahres-Abos um ein Jahr. Eine Kündigung ist jederzeit über das Kundenportal (Stripe) zur nächsten Periode möglich.',
'Bereits gezahlte Beträge werden bei Kündigung nicht anteilig rückerstattet; der Service bleibt bis Periodenende aktiv.',
'Wir gewähren eine 14-tägige Geld-zurück-Garantie ab Erst-Buchung (nicht bei Verlängerungen). Anfragen über das Support-Panel.',
],
},
{
h: '6. Aussetzung bei Zahlungsverzug',
p: [
'Bei fehlgeschlagener Zahlung versucht Stripe automatisch Nachzahlungen. Nach drei erfolglosen Versuchen wird der Account in den "suspended"-Status versetzt: Bestehende MCP-Server laufen weiter, jedoch können keine neuen Server angelegt oder Builds gestartet werden.',
'Nach erfolgreicher Aktualisierung der Zahlungsmethode wird der Account automatisch reaktiviert.',
],
},
{
h: '7. Daten der Kund:in',
p: [
'Die Kund:in behält alle Rechte an ihren Inhalten (Prompts, Konfigurationen, Secrets, generierter Code). Wir nutzen diese ausschliesslich zur Erbringung des Dienstes.',
'Eine Datenexport-Funktion ist über das Einstellungsmenü verfügbar und entspricht Art. 25 Schweizer Datenschutzgesetz (DSG) sowie Art. 15 DSGVO.',
'Details zur Datenverarbeitung siehe unsere Datenschutzerklärung.',
],
},
{
h: '8. KI-Verarbeitung',
p: [
'Zur Spec-Generierung übermitteln wir Prompt-Texte an unsere KI-Anbieter: Hobby-Tarif → Zhipu AI (China); Pro/Team/Enterprise → Anthropic (USA). Vor Versand keiner sensiblen Daten gilt: Die Kund:in ist verantwortlich, welche Informationen sie in Prompts einfügt.',
'Der generierte Code wird statisch auf gefährliche Patterns (eval, child_process, Prompt-Injection-Marker) geprüft, jedoch nicht funktional verifiziert. Die Kund:in prüft den Code vor Produktivnutzung selbst.',
],
},
{
h: '9. Haftung',
p: [
'Wir haften nur für Schäden, die auf vorsätzlichem oder grob fahrlässigem Verhalten beruhen. Die Haftung für leichte Fahrlässigkeit, Mangelfolgeschäden, entgangenen Gewinn und Drittansprüche ist im gesetzlich zulässigen Umfang ausgeschlossen.',
'Wir haften nicht für Inhalte oder Verhalten von Drittanbietern (Anthropic, Zhipu, Stripe, Hetzner u.a.), an die personenbezogene Daten gemäss Datenschutzerklärung übermittelt werden.',
],
},
{
h: '10. Änderungen',
p: [
'Wir behalten uns vor, diese AGB sowie Preise mit Wirkung für die Zukunft anzupassen. Änderungen werden mindestens 30 Tage vor Inkrafttreten per E-Mail oder im Dashboard angekündigt. Bei Preisanhebung steht der Kund:in ein ausserordentliches Kündigungsrecht zum Wirkungsdatum zu.',
],
},
{
h: '11. Anwendbares Recht und Gerichtsstand',
p: [
'Es gilt schweizerisches Recht unter Ausschluss kollisionsrechtlicher Bestimmungen sowie des UN-Kaufrechts. Ausschliesslicher Gerichtsstand ist der Sitz des Anbieters; zwingende Verbraucher-Gerichtsstände bleiben vorbehalten.',
],
},
];
export default function Agb() {
return (
<div className="mx-auto max-w-3xl px-6 py-16">
<header className="mb-12">
<div className="text-[11px] uppercase tracking-[0.16em] text-[--color-fg-subtle]">
Allgemeine Geschäftsbedingungen
</div>
<h1 className="mt-2 text-[32px] font-semibold tracking-tight">AGB</h1>
<p className="mt-3 text-[14px] leading-relaxed text-[--color-fg-muted]">
Stand: 2026-05-25. Bei Fragen zur Auslegung erreichst du uns über das{' '}
<Link href="/contact" className="text-[--color-accent] underline">
Support-Panel
</Link>
.
</p>
</header>
<div className="space-y-9">
{SECTIONS.map((s) => (
<section key={s.h}>
<h2 className="text-[16px] font-semibold tracking-tight">{s.h}</h2>
<div className="mt-2 space-y-2">
{s.p.map((p) => (
<p
key={p.slice(0, 32)}
className="text-[13.5px] leading-relaxed text-[--color-fg-muted]"
>
{p}
</p>
))}
</div>
</section>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,133 @@
'use client';
import { Input, Label, Textarea } from '@/components/input';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api';
import Link from 'next/link';
import { useState } from 'react';
export default function ContactPage() {
const [email, setEmail] = useState('');
const [subject, setSubject] = useState('');
const [body, setBody] = useState('');
const [state, setState] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
const [error, setError] = useState<string | null>(null);
async function submit(e: React.FormEvent) {
e.preventDefault();
setState('sending');
setError(null);
try {
await apiFetch('/v1/contact', {
method: 'POST',
body: JSON.stringify({ email, subject, body }),
});
setState('sent');
} catch (err) {
setState('error');
const detail = (err as { detail?: { detail?: string; error?: string } }).detail;
setError(detail?.detail ?? detail?.error ?? (err as Error).message);
}
}
if (state === 'sent') {
return (
<div className="mx-auto max-w-2xl px-6 py-16">
<div className="panel p-6 text-center">
<h1 className="text-[20px] font-semibold tracking-tight">Message received</h1>
<p className="mt-2 text-[13.5px] text-[--color-fg-muted]">
Thank you we got your message. We&apos;ll reply to{' '}
<span className="text-[--color-fg]">{email}</span> within one business day.
</p>
<p className="mt-4 text-[12px] text-[--color-fg-subtle]">
<Link href="/" className="hover:text-[--color-fg]">
Back to home
</Link>
</p>
</div>
</div>
);
}
return (
<div className="mx-auto max-w-2xl px-6 py-14">
<header className="mb-8">
<div className="text-[11px] uppercase tracking-[0.16em] text-[--color-fg-subtle]">
Contact
</div>
<h1 className="mt-2 text-[28px] font-semibold tracking-tight">Talk to us</h1>
<p className="mt-3 text-[14px] leading-relaxed text-[--color-fg-muted]">
We don&apos;t do public email every conversation runs through our internal support
panel so nothing gets lost. Already have an account?{' '}
<Link href="/settings/support" className="text-[--color-accent] hover:underline">
Open a ticket from inside
</Link>
.
</p>
</header>
<form onSubmit={submit} className="panel space-y-4 p-5">
<div className="space-y-1.5">
<Label htmlFor="contact-email">Your email</Label>
<Input
id="contact-email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@company.com"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="contact-subject">Subject</Label>
<Input
id="contact-subject"
required
minLength={3}
maxLength={200}
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="Briefly — what's this about?"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="contact-body" hint={`${body.length} / 10000`}>
Message
</Label>
<Textarea
id="contact-body"
required
rows={7}
minLength={10}
maxLength={10_000}
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Tell us what's going on. We answer within one business day."
/>
</div>
{error && <p className="text-[12.5px] text-[--color-danger]">{error}</p>}
<div className="flex items-center justify-between pt-1">
<p className="text-[11px] text-[--color-fg-subtle]">
Submitting creates a support ticket see{' '}
<Link href="/privacy" className="hover:text-[--color-fg]">
privacy
</Link>
.
</p>
<Button
variant="primary"
size="md"
type="submit"
disabled={state === 'sending' || !email || subject.length < 3 || body.length < 10}
>
{state === 'sending' ? 'Sending…' : 'Send'}
</Button>
</div>
</form>
</div>
);
}

View File

@ -0,0 +1,95 @@
import { pageMetadata } from '@/lib/seo';
import Link from 'next/link';
export const metadata = pageMetadata({
title: 'Impressum',
description: 'Legal information for BuildMyMCPServer (Switzerland).',
path: '/impressum',
});
export default function Impressum() {
return (
<div className="mx-auto max-w-3xl px-6 py-16">
<header className="mb-10">
<div className="text-[11px] uppercase tracking-[0.16em] text-[--color-fg-subtle]">
Impressum
</div>
<h1 className="mt-2 text-[32px] font-semibold tracking-tight">Impressum</h1>
<p className="mt-3 text-[14px] leading-relaxed text-[--color-fg-muted]">
Angaben gemäss UWG Art. 3 Abs. 1 lit. s (Schweiz).
</p>
</header>
<div className="space-y-8">
<section>
<h2 className="text-[16px] font-semibold tracking-tight">Anbieter</h2>
<div className="mt-2 text-[13.5px] leading-relaxed text-[--color-fg-muted]">
<p>BuildMyMCPServer</p>
<p>Schweiz</p>
<p className="mt-2 text-[12px] text-[--color-fg-subtle]">
Postanschrift auf Anfrage über das Support-Panel.
</p>
</div>
</section>
<section>
<h2 className="text-[16px] font-semibold tracking-tight">Kontakt</h2>
<p className="mt-2 text-[13.5px] leading-relaxed text-[--color-fg-muted]">
Sämtliche Kontaktanfragen laufen über unser integriertes Support-Panel ohne
Account erreichbar unter{' '}
<Link href="/contact" className="text-[--color-accent] underline">
/contact
</Link>
. Eingeloggte Nutzer:innen verwenden{' '}
<Link href="/settings/support" className="text-[--color-accent] underline">
/settings/support
</Link>
. Wir antworten in der Regel innerhalb von einem Werktag.
</p>
</section>
<section>
<h2 className="text-[16px] font-semibold tracking-tight">Mehrwertsteuer</h2>
<p className="mt-2 text-[13.5px] leading-relaxed text-[--color-fg-muted]">
UID-Nummer wird im ausgestellten Beleg geführt. Bei steuerrechtlichen Anfragen
kontaktiere uns über das Support-Panel.
</p>
</section>
<section>
<h2 className="text-[16px] font-semibold tracking-tight">Haftungsausschluss</h2>
<p className="mt-2 text-[13.5px] leading-relaxed text-[--color-fg-muted]">
Inhalte dieser Webseite werden mit grösstmöglicher Sorgfalt erstellt. Für Richtigkeit,
Vollständigkeit und Aktualität wird jedoch keine Gewähr übernommen. Für Inhalte
externer Links sind ausschliesslich deren Betreiber verantwortlich.
</p>
</section>
<section>
<h2 className="text-[16px] font-semibold tracking-tight">Anwendbares Recht</h2>
<p className="mt-2 text-[13.5px] leading-relaxed text-[--color-fg-muted]">
Es gilt schweizerisches Recht unter Ausschluss kollisionsrechtlicher Bestimmungen.
Gerichtsstand ist der Sitz des Anbieters.
</p>
</section>
<section>
<h2 className="text-[16px] font-semibold tracking-tight">Weiterführend</h2>
<p className="mt-2 text-[13.5px] leading-relaxed text-[--color-fg-muted]">
<Link href="/privacy" className="text-[--color-accent] underline">
Datenschutzerklärung
</Link>{' '}
·{' '}
<Link href="/agb" className="text-[--color-accent] underline">
AGB
</Link>{' '}
·{' '}
<Link href="/security" className="text-[--color-accent] underline">
Security
</Link>
</p>
</section>
</div>
</div>
);
}

View File

@ -1,3 +1,4 @@
import { CookieBanner } from '@/components/cookie-banner';
import { Logo } from '@/components/logo';
import { MarketingAuthButtons } from '@/components/marketing-auth-buttons';
import { MarketingMobileMenu } from '@/components/marketing-mobile-menu';
@ -48,12 +49,21 @@ export default function MarketingLayout({ children }: { children: React.ReactNod
<Link href="/docs" className="transition-colors hover:text-[--color-fg]">
Docs
</Link>
<Link href="/contact" className="transition-colors hover:text-[--color-fg]">
Contact
</Link>
<Link href="/security" className="transition-colors hover:text-[--color-fg]">
Security
</Link>
<Link href="/privacy" className="transition-colors hover:text-[--color-fg]">
Privacy
</Link>
<Link href="/agb" className="transition-colors hover:text-[--color-fg]">
AGB
</Link>
<Link href="/impressum" className="transition-colors hover:text-[--color-fg]">
Impressum
</Link>
<Link href="/terms" className="transition-colors hover:text-[--color-fg]">
Terms
</Link>
@ -61,6 +71,7 @@ export default function MarketingLayout({ children }: { children: React.ReactNod
<div>&copy; {new Date().getFullYear()} BuildMyMCPServer</div>
</div>
</footer>
<CookieBanner />
</div>
);
}

View File

@ -38,10 +38,10 @@ const SECTIONS = [
p: [
"Anthropic, USA (Claude AI — used for prompt analysis and code generation on Pro / Team / Enterprise tiers). Only the prompt text and resulting spec are sent. Anthropic's data-retention policy applies.",
'Zhipu AI, China (GLM model — used for prompt analysis on the free Hobby tier only). Only the prompt text and resulting spec are sent. Upgrade to a paid tier to keep all AI processing within Anthropic (US).',
'Hetzner, Germany (compute).',
'Stripe Payments Europe Ltd., Ireland (billing, invoicing, payment processing, automatic VAT). Stripe receives: email, billing address, payment method details. Card numbers are tokenised by Stripe and never reach our servers. Stripe is GDPR-compliant and Swiss-DSG-aligned via the EU-Swiss adequacy decision.',
'Hetzner, Germany (compute, Postgres, Redis, runner containers).',
'Backblaze, EU (encrypted backups).',
'Stripe, Ireland (billing).',
'Cloudflare (DNS + DDoS protection).',
'Cloudflare (DNS + DDoS protection + TLS termination).',
],
},
{

View File

@ -0,0 +1,197 @@
'use client';
import { Textarea } from '@/components/input';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api';
import { Loader2 } from 'lucide-react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useEffect, useState } from 'react';
interface Ticket {
id: string;
subject: string;
status: 'awaiting_admin' | 'awaiting_user' | 'closed';
guestEmail: string | null;
createdAt: string;
lastMessageAt: string;
}
interface Message {
id: string;
authorIsAdmin: boolean;
body: string;
createdAt: string;
}
interface Detail {
ticket: Ticket;
userEmail: string | null;
userName: string | null;
messages: Message[];
}
export default function AdminTicketDetail() {
const params = useParams<{ id: string }>();
const [data, setData] = useState<Detail | null>(null);
const [reply, setReply] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
function load() {
if (!params?.id) return;
apiFetch<Detail>(`/v1/admin/support/tickets/${params.id}`)
.then(setData)
.catch((e) => setError((e as Error).message));
}
useEffect(load, [params?.id]);
async function sendReply(e: React.FormEvent) {
e.preventDefault();
if (!params?.id || reply.trim().length === 0) return;
setBusy(true);
setError(null);
try {
await apiFetch(`/v1/admin/support/tickets/${params.id}/messages`, {
method: 'POST',
body: JSON.stringify({ body: reply }),
});
setReply('');
load();
} catch (err) {
setError((err as Error).message);
} finally {
setBusy(false);
}
}
async function setStatus(status: Ticket['status']) {
if (!params?.id) return;
setBusy(true);
setError(null);
try {
await apiFetch(`/v1/admin/support/tickets/${params.id}/status`, {
method: 'POST',
body: JSON.stringify({ status }),
});
load();
} catch (err) {
setError((err as Error).message);
} finally {
setBusy(false);
}
}
if (!data && !error) {
return (
<div className="mx-auto max-w-3xl px-6 py-12 text-center">
<Loader2 className="mx-auto animate-spin text-[--color-fg-muted]" size={20} />
</div>
);
}
if (error || !data) {
return (
<div className="mx-auto max-w-3xl px-6 py-12">
<p className="text-[13px] text-[--color-danger]">{error ?? 'Ticket not found.'}</p>
<Link href="/admin/support" className="mt-3 inline-block text-[12px] text-[--color-fg-muted] hover:text-[--color-fg]">
Back to tickets
</Link>
</div>
);
}
const { ticket, messages, userEmail, userName } = data;
const fromLabel = userEmail
? `${userName ? `${userName} · ` : ''}${userEmail}`
: ticket.guestEmail
? `${ticket.guestEmail} (guest)`
: 'unknown';
return (
<div className="mx-auto max-w-3xl px-6 py-10">
<Link
href="/admin/support"
className="text-[12px] text-[--color-fg-muted] hover:text-[--color-fg]"
>
All tickets
</Link>
<div className="mt-3 flex items-baseline justify-between gap-3">
<div className="min-w-0 flex-1">
<h1 className="text-[22px] font-semibold tracking-tight">{ticket.subject}</h1>
<p className="mt-1 text-[12px] text-[--color-fg-muted]">From: {fromLabel}</p>
</div>
<span className="mono text-[10.5px] uppercase tracking-wider text-[--color-fg-subtle]">
{ticket.status.replace('_', ' ')}
</span>
</div>
<div className="mt-6 space-y-3">
{messages.map((m) => (
<div
key={m.id}
className={`panel p-4 ${m.authorIsAdmin ? 'border-[--color-accent]/40' : ''}`}
>
<div className="flex items-baseline justify-between">
<span
className={`text-[11.5px] font-medium ${m.authorIsAdmin ? 'text-[--color-accent]' : 'text-[--color-fg]'}`}
>
{m.authorIsAdmin ? 'Admin' : 'User'}
</span>
<span className="text-[10.5px] text-[--color-fg-subtle]">
{new Date(m.createdAt).toLocaleString()}
</span>
</div>
<p className="mt-2 whitespace-pre-wrap text-[13px] leading-relaxed text-[--color-fg-muted]">
{m.body}
</p>
</div>
))}
</div>
<form onSubmit={sendReply} className="panel mt-6 space-y-3 p-4">
<Textarea
value={reply}
onChange={(e) => setReply(e.target.value)}
rows={4}
maxLength={10_000}
placeholder="Reply to user…"
/>
{error && <p className="text-[12.5px] text-[--color-danger]">{error}</p>}
<div className="flex items-center justify-between gap-2">
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
type="button"
onClick={() => setStatus('closed')}
disabled={busy || ticket.status === 'closed'}
>
Mark closed
</Button>
{ticket.status === 'closed' && (
<Button
variant="ghost"
size="sm"
type="button"
onClick={() => setStatus('awaiting_admin')}
disabled={busy}
>
Reopen
</Button>
)}
</div>
<Button
variant="primary"
size="md"
type="submit"
disabled={busy || reply.trim().length === 0}
>
{busy ? 'Sending…' : 'Send reply'}
</Button>
</div>
</form>
</div>
);
}

View File

@ -0,0 +1,132 @@
'use client';
import { apiFetch } from '@/lib/api';
import { Loader2 } from 'lucide-react';
import Link from 'next/link';
import { useEffect, useState } from 'react';
interface AdminTicketRow {
ticket: {
id: string;
subject: string;
status: 'awaiting_admin' | 'awaiting_user' | 'closed';
guestEmail: string | null;
createdAt: string;
lastMessageAt: string;
};
userEmail: string | null;
userName: string | null;
}
const STATUS_BADGE: Record<AdminTicketRow['ticket']['status'], string> = {
awaiting_admin: 'bg-amber-500/20 text-amber-300',
awaiting_user: 'bg-emerald-500/20 text-emerald-300',
closed: 'bg-[--color-bg-subtle] text-[--color-fg-subtle]',
};
export default function AdminSupport() {
const [rows, setRows] = useState<AdminTicketRow[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState<'all' | 'awaiting_admin' | 'awaiting_user' | 'closed'>(
'awaiting_admin',
);
useEffect(() => {
apiFetch<{ tickets: AdminTicketRow[] }>('/v1/admin/support/tickets')
.then((r) => setRows(r.tickets))
.catch((e) => setError((e as Error).message));
}, []);
const filtered = (rows ?? []).filter(
(r) => filter === 'all' || r.ticket.status === filter,
);
return (
<div className="mx-auto max-w-6xl px-6 py-8">
<div className="flex items-baseline justify-between">
<div>
<Link href="/admin" className="text-[12px] text-[--color-fg-muted] hover:text-[--color-fg]">
Admin
</Link>
<h1 className="mt-1 text-[22px] font-semibold tracking-tight">Support tickets</h1>
</div>
<div className="flex gap-1">
{(['awaiting_admin', 'awaiting_user', 'closed', 'all'] as const).map((s) => (
<button
type="button"
key={s}
onClick={() => setFilter(s)}
className={`rounded-md px-2.5 py-1 text-[11.5px] transition-colors ${
filter === s
? 'bg-[--color-bg-subtle] text-[--color-fg]'
: 'text-[--color-fg-muted] hover:text-[--color-fg]'
}`}
>
{s.replace('_', ' ')}
</button>
))}
</div>
</div>
{error && <p className="mt-4 text-[12.5px] text-[--color-danger]">{error}</p>}
<div className="panel mt-6">
{rows === null && (
<div className="p-6 text-center">
<Loader2 className="mx-auto animate-spin text-[--color-fg-muted]" size={18} />
</div>
)}
{rows && filtered.length === 0 && (
<div className="p-6 text-center text-[13px] text-[--color-fg-muted]">
No tickets in this view.
</div>
)}
{rows && filtered.length > 0 && (
<table className="w-full text-[12.5px]">
<thead className="border-b border-[--color-border] text-[--color-fg-subtle]">
<tr>
<th className="px-4 py-2 text-left font-medium">Subject</th>
<th className="px-4 py-2 text-left font-medium">From</th>
<th className="px-4 py-2 text-left font-medium">Status</th>
<th className="px-4 py-2 text-left font-medium">Last activity</th>
</tr>
</thead>
<tbody>
{filtered.map((r) => (
<tr
key={r.ticket.id}
className="border-b border-[--color-border] last:border-0 hover:bg-[--color-bg-subtle]"
>
<td className="px-4 py-2.5">
<Link
href={`/admin/support/${r.ticket.id}`}
className="font-medium hover:text-[--color-accent]"
>
{r.ticket.subject}
</Link>
</td>
<td className="mono px-4 py-2.5 text-[--color-fg-muted]">
{r.userEmail ??
(r.ticket.guestEmail
? `${r.ticket.guestEmail} (guest)`
: 'unknown')}
</td>
<td className="px-4 py-2.5">
<span
className={`rounded-full px-2 py-0.5 text-[10.5px] ${STATUS_BADGE[r.ticket.status]}`}
>
{r.ticket.status.replace('_', ' ')}
</span>
</td>
<td className="px-4 py-2.5 text-[--color-fg-muted]">
{new Date(r.ticket.lastMessageAt).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,69 @@
'use client';
import Link from 'next/link';
import { useEffect, useState } from 'react';
const STORAGE_KEY = 'bmm-cookie-ack-v1';
/**
* BMM uses only strictly-necessary cookies (session + OAuth CSRF state). Under
* Swiss DSG and GDPR strictly-necessary cookies do not require opt-in consent,
* only clear disclosure this banner satisfies the disclosure obligation
* without dark-pattern cookie walls or false-choice "Reject all" UIs.
*/
export function CookieBanner() {
const [show, setShow] = useState(false);
useEffect(() => {
try {
const ack = window.localStorage.getItem(STORAGE_KEY);
if (!ack) setShow(true);
} catch {
// localStorage blocked (private mode etc.) — show banner anyway, it's
// dismissable via a single click and never persists if storage fails.
setShow(true);
}
}, []);
function acknowledge() {
try {
window.localStorage.setItem(STORAGE_KEY, new Date().toISOString());
} catch {
/* ignore — re-shown on next visit */
}
setShow(false);
}
if (!show) return null;
return (
<div
role="dialog"
aria-label="Cookie notice"
className="fixed inset-x-0 bottom-0 z-50 border-t border-[--color-border] backdrop-blur-md"
style={{
backgroundColor: 'color-mix(in oklab, var(--color-bg-elevated) 92%, transparent)',
paddingBottom: 'max(env(safe-area-inset-bottom), 0.75rem)',
}}
>
<div className="mx-auto flex max-w-6xl flex-col gap-3 px-5 pt-3 sm:flex-row sm:items-center sm:gap-4 sm:px-6">
<p className="flex-1 text-[12.5px] leading-relaxed text-[--color-fg-muted]">
We use strictly-necessary cookies for login (session token) and CSRF
protection. No tracking, no analytics, no third-party cookies on this
domain. Details:{' '}
<Link href="/privacy" className="text-[--color-accent] hover:underline">
privacy policy
</Link>
.
</p>
<button
type="button"
onClick={acknowledge}
className="inline-flex h-9 shrink-0 items-center justify-center rounded-md bg-[--color-accent] px-4 text-[13px] font-medium text-white transition-colors hover:bg-[#5557e8]"
>
OK
</button>
</div>
</div>
);
}

View File

@ -330,6 +330,53 @@ export const auditLog = pgTable('audit_log', {
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// In-app support ticketing replaces the email contact channel. Anonymous
// (logged-out) tickets are allowed via the public /contact form so we still
// satisfy UWG Art. 3 lit. s (Swiss "easy electronic contact" requirement).
export const supportStatusEnum = pgEnum('support_status', [
'awaiting_admin',
'awaiting_user',
'closed',
]);
export const supportTickets = pgTable(
'support_tickets',
{
id: uuid('id').defaultRandom().primaryKey(),
userId: uuid('user_id').references(() => users.id, { onDelete: 'set null' }),
orgId: uuid('org_id').references(() => organizations.id, { onDelete: 'set null' }),
// For anonymous /contact submissions: collect email so admin can reply.
guestEmail: varchar('guest_email', { length: 255 }),
subject: varchar('subject', { length: 200 }).notNull(),
status: supportStatusEnum('status').default('awaiting_admin').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
lastMessageAt: timestamp('last_message_at').defaultNow().notNull(),
closedAt: timestamp('closed_at'),
},
(t) => ({
userIdx: index('idx_support_tickets_user').on(t.userId),
statusIdx: index('idx_support_tickets_status').on(t.status, t.lastMessageAt),
}),
);
export const supportMessages = pgTable(
'support_messages',
{
id: uuid('id').defaultRandom().primaryKey(),
ticketId: uuid('ticket_id')
.references(() => supportTickets.id, { onDelete: 'cascade' })
.notNull(),
authorUserId: uuid('author_user_id').references(() => users.id, { onDelete: 'set null' }),
authorIsAdmin: boolean('author_is_admin').default(false).notNull(),
body: text('body').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
},
(t) => ({
ticketIdx: index('idx_support_messages_ticket').on(t.ticketId, t.createdAt),
}),
);
export type Organization = typeof organizations.$inferSelect;
export type User = typeof users.$inferSelect;
export type Session = typeof sessions.$inferSelect;
@ -340,3 +387,5 @@ export type Secret = typeof secrets.$inferSelect;
export type OAuthClient = typeof oauthClients.$inferSelect;
export type Template = typeof templates.$inferSelect;
export type EncryptionKey = typeof encryptionKeys.$inferSelect;
export type SupportTicket = typeof supportTickets.$inferSelect;
export type SupportMessage = typeof supportMessages.$inferSelect;