feat(web): real 3-step wizard, settings, audit, docs, marketing pages
Sprint 3.5: close every dead link and replace the single-step wizard with the spec-mandated 3-step flow. Wizard: - Step 1 collects prompt + name + slug, calls /v1/servers/preview. - Step 2 renders parsed tools (name, description, input schema as copyable JSON) + a credential field per requiredSecret Claude actually identified. Self-contained servers see 'No credentials needed' instead of generic Notion placeholders. - Step 3 streams the live build over WebSocket and shows install snippets. New dashboard pages: - /settings — org, plan/usage, members table, API keys + billing stubs (Sprint 4), encryption status. Reads /v1/me/org. - /audit — filterable table over /v1/audit with action pills, resource refs, IP, metadata JSON. Docs site (/docs + 6 sub-pages): - Sticky 240px sidebar, max-w-prose article column, shared DocsTitle/H2/Code primitives. - Quickstart, MCP concepts, OAuth 2.1 flow (full walkthrough with curl), Authoring tools, Self-hosting, API reference, FAQ. Marketing pages: - /changelog with tagged release timeline. - /security with 8 pillars + disclosure. - /privacy with GDPR-aware sections. - /terms (10 clauses). - /pricing full page (nav now points here instead of /#pricing anchor). - /status with live 10s probes against /api/health and /login. Footer 'system status' badge now links to /status. All 20 routes 200 OK in smoke crawl. Typecheck clean across packages.
This commit is contained in:
120
apps/web/app/(dashboard)/audit/page.tsx
Normal file
120
apps/web/app/(dashboard)/audit/page.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { Input } from '@/components/input';
|
||||
|
||||
interface AuditEntry {
|
||||
id: string;
|
||||
action: string;
|
||||
resourceType: string | null;
|
||||
resourceId: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
ipAddress: string | null;
|
||||
userId: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const ACTION_FILTERS = [
|
||||
{ value: '', label: 'All actions' },
|
||||
{ value: 'auth.login', label: 'Logins' },
|
||||
{ value: 'auth.logout', label: 'Logouts' },
|
||||
{ value: 'server.create', label: 'Server created' },
|
||||
{ value: 'server.iterate', label: 'Server iterated' },
|
||||
{ value: 'server.delete', label: 'Server deleted' },
|
||||
];
|
||||
|
||||
export default function AuditPage() {
|
||||
const [entries, setEntries] = useState<AuditEntry[] | null>(null);
|
||||
const [action, setAction] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const q = action ? `?action=${encodeURIComponent(action)}` : '';
|
||||
apiFetch<{ entries: AuditEntry[] }>(`/v1/audit${q}`).then((r) => setEntries(r.entries));
|
||||
}, [action]);
|
||||
|
||||
const visible = entries?.filter((e) =>
|
||||
search
|
||||
? e.action.includes(search) ||
|
||||
e.resourceId?.includes(search) ||
|
||||
e.ipAddress?.includes(search)
|
||||
: true,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-6 py-8">
|
||||
<div>
|
||||
<h1 className="text-[22px] font-semibold tracking-tight">Audit log</h1>
|
||||
<p className="mt-1 text-[13px] text-[--color-fg-muted]">
|
||||
Every privileged action in your workspace, with IP and metadata.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-2">
|
||||
<select
|
||||
value={action}
|
||||
onChange={(e) => setAction(e.target.value)}
|
||||
className="h-8 rounded-md border border-[--color-border] bg-[--color-bg-subtle] px-2 text-[13px] focus:border-[--color-accent] focus:outline-none"
|
||||
>
|
||||
{ACTION_FILTERS.map((f) => (
|
||||
<option key={f.value} value={f.value}>
|
||||
{f.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Filter by resource id or ip…"
|
||||
className="w-72"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="panel mt-4">
|
||||
{!visible && (
|
||||
<div className="px-4 py-4 text-[12.5px] text-[--color-fg-muted]">Loading…</div>
|
||||
)}
|
||||
{visible && visible.length === 0 && (
|
||||
<div className="px-4 py-12 text-center text-[13px] text-[--color-fg-muted]">
|
||||
No matching entries.
|
||||
</div>
|
||||
)}
|
||||
{visible && visible.length > 0 && (
|
||||
<table className="w-full text-[12px]">
|
||||
<thead className="border-b border-[--color-border] text-[--color-fg-subtle]">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-medium">When</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Action</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Resource</th>
|
||||
<th className="px-4 py-2 text-left font-medium">IP</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Metadata</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visible.map((e) => (
|
||||
<tr key={e.id} className="border-b border-[--color-border] last:border-0">
|
||||
<td className="px-4 py-2 mono text-[--color-fg-muted]">
|
||||
{new Date(e.createdAt).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className="mono rounded-full border border-[--color-border] bg-[--color-bg-subtle] px-2 py-0.5 text-[11px]">
|
||||
{e.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 mono text-[--color-fg-muted]">
|
||||
{e.resourceType ? `${e.resourceType}/${e.resourceId?.slice(0, 8) ?? '—'}` : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2 mono text-[--color-fg-muted]">{e.ipAddress ?? '—'}</td>
|
||||
<td className="px-4 py-2 mono text-[10.5px] text-[--color-fg-subtle]">
|
||||
{e.metadata ? JSON.stringify(e.metadata) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user