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
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:
parent
c2a21fc3cd
commit
ef30baf52a
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
137
apps/api/src/routes/account.ts
Normal file
137
apps/api/src/routes/account.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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]);
|
||||
|
||||
290
apps/api/src/routes/support.ts
Normal file
290
apps/api/src/routes/support.ts
Normal 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 });
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
80
apps/web/app/(dashboard)/settings/account/page.tsx
Normal file
80
apps/web/app/(dashboard)/settings/account/page.tsx
Normal 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' 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't do one-click account deletion yet — too easy to fat-finger and lose
|
||||
paid-tier server configs. Open a ticket and we'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>
|
||||
);
|
||||
}
|
||||
145
apps/web/app/(dashboard)/settings/support/[id]/page.tsx
Normal file
145
apps/web/app/(dashboard)/settings/support/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
166
apps/web/app/(dashboard)/settings/support/page.tsx
Normal file
166
apps/web/app/(dashboard)/settings/support/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
129
apps/web/app/(marketing)/agb/page.tsx
Normal file
129
apps/web/app/(marketing)/agb/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
133
apps/web/app/(marketing)/contact/page.tsx
Normal file
133
apps/web/app/(marketing)/contact/page.tsx
Normal 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'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'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>
|
||||
);
|
||||
}
|
||||
95
apps/web/app/(marketing)/impressum/page.tsx
Normal file
95
apps/web/app/(marketing)/impressum/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>© {new Date().getFullYear()} BuildMyMCPServer</div>
|
||||
</div>
|
||||
</footer>
|
||||
<CookieBanner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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).',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
197
apps/web/app/admin/support/[id]/page.tsx
Normal file
197
apps/web/app/admin/support/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
132
apps/web/app/admin/support/page.tsx
Normal file
132
apps/web/app/admin/support/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
apps/web/components/cookie-banner.tsx
Normal file
69
apps/web/components/cookie-banner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user