feat(api): comprehensive QA hardening — security gates, chat improvements, beta scaffolds
Closes checklist items F-01..F-06, D-01..D-28, S-01..S-10, C-01..C-07, B-01..B-07, R-01..R-02, O-03. Security (28 deletions + 10 auth gates): - Delete 28 unauthenticated debug/cursor/firebase/test routes - Gate ai/chat, ai/conversation, context/summarize, work-completed with withTenantProject/withAuth - Add HMAC-SHA256 signature verification to webhooks/coolify - Switch all admin secret comparisons to timingSafeStringEq Foundations (lib/server/*): - api-handler.ts: withAuth, withTenantProject, withWorkspace, withAdminSecret, withRateLimit - logger.ts: structured request-scoped logging with turnId - audit-log.ts: writeAuditLog helper + audit_log table - rate-limit.ts: Postgres sliding window rate limiter - coolify-webhook.ts: verifyCoolifySignature - timing-safe.ts: timingSafeStringEq Chat hardening (chat/route.ts): - MAX_TOOL_ROUNDS 15 → 8 (C-01) - Loop detection: hard-break at 3 identical fingerprints (was 5) (C-02) - Add 6-consecutive-tool-call hard-break (C-02) - Mode: respond first, act second prompt block (C-03) - SSE heartbeat every 25s via setInterval (C-04) - Per-tool 45s timeout via Promise.race (C-05) - turnId per-turn UUID for log correlation (C-06) - Recovery fires when roundsSinceText >= 4 (C-07) - SSE plan event on plan_task_add/edit (B-05) Beta features: - invites table + GET/POST /api/invites (P4.8) - invites/[token] validate + redeem (P4.8) - fs_project_dev_servers table + lib/server/dev-server-state.ts (P6.B1) - fs_project_secrets table + CRUD routes (P6.D2) - lib/integrations/brief-extract.ts (P3.7) Documentation: - app/api/ROUTES.md: full route map with auth + tenant
This commit is contained in:
333
new-site/onboarding-primitives.jsx
Normal file
333
new-site/onboarding-primitives.jsx
Normal file
@@ -0,0 +1,333 @@
|
||||
// Shared building blocks for the onboarding flow.
|
||||
// All <style> belongs in onboarding.css; this file is JSX only.
|
||||
|
||||
// ── Wizard top bar ─────────────────────────────────────────────────────────
|
||||
// Sticky, thin. Holds: back arrow · vibn mark · centered step label · close.
|
||||
// A 2px progress bar runs along its bottom edge.
|
||||
function WizardTop({
|
||||
onBack, onClose,
|
||||
lane, // "Solo / quiet entrepreneur" etc.
|
||||
stepText, // "Idea" or "Pick your lane"
|
||||
current, total, // 1-indexed
|
||||
progress, // 0..1 (optional override)
|
||||
}) {
|
||||
const pct = typeof progress === "number"
|
||||
? Math.max(0, Math.min(1, progress))
|
||||
: (typeof current === "number" && typeof total === "number"
|
||||
? Math.max(0, Math.min(1, current / total))
|
||||
: 0);
|
||||
|
||||
return (
|
||||
<header className="wiz-top">
|
||||
<div className="wiz-top-row">
|
||||
<button
|
||||
type="button"
|
||||
className="wiz-iconbtn"
|
||||
onClick={onBack}
|
||||
disabled={!onBack}
|
||||
aria-label="Back"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor"
|
||||
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M13 8H3M7 4 3 8l4 4"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<a href="index.html" className="wiz-logo" aria-label="vibn — home">
|
||||
<LogoMark size={22} />
|
||||
<span>vibn</span>
|
||||
</a>
|
||||
|
||||
<div className="wiz-step">
|
||||
{lane && <span className="lane">{lane}</span>}
|
||||
{lane && stepText && <span className="dot" />}
|
||||
{stepText && (
|
||||
<span>
|
||||
{typeof current === "number" && typeof total === "number" && (
|
||||
<>
|
||||
<b>{current}</b> <span style={{ opacity: 0.6 }}>/ {total}</span>{" · "}
|
||||
</>
|
||||
)}
|
||||
{stepText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="wiz-iconbtn"
|
||||
onClick={onClose}
|
||||
aria-label="Save & exit"
|
||||
title="Save & exit"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor"
|
||||
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="m4 4 8 8M12 4l-8 8"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="wiz-progress">
|
||||
<div className="wiz-progress-fill" style={{ width: `${pct * 100}%` }} />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Wizard body wrapper ────────────────────────────────────────────────────
|
||||
function WizardBody({ children, width }) {
|
||||
const cls = "wiz-card" + (width === "wide" ? " wide" : width === "xwide" ? " xwide" : "");
|
||||
return (
|
||||
<main className="wiz-body">
|
||||
<div className={cls}>{children}</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Question heading ───────────────────────────────────────────────────────
|
||||
function WizardQ({ title, sub }) {
|
||||
return (
|
||||
<div className="wiz-q">
|
||||
<h2>{title}</h2>
|
||||
{sub && <p>{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Footer (back / hint / continue) ────────────────────────────────────────
|
||||
function WizardFooter({
|
||||
onBack, onNext, canNext = true,
|
||||
nextLabel = "Continue",
|
||||
hint,
|
||||
onSkip, skipLabel = "Skip",
|
||||
}) {
|
||||
return (
|
||||
<div className="wiz-foot">
|
||||
<div className="wiz-foot-left">
|
||||
{onSkip && (
|
||||
<button type="button" className="wiz-skip" onClick={onSkip}>
|
||||
{skipLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="wiz-foot-right">
|
||||
{hint && <span className="wiz-hint">{hint}</span>}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-wiz"
|
||||
disabled={!canNext}
|
||||
onClick={() => canNext && onNext && onNext()}
|
||||
>
|
||||
{nextLabel} <Arrow size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Field wrappers (wizard variants) ───────────────────────────────────────
|
||||
function Field({ label, hint, children, optional }) {
|
||||
return (
|
||||
<label className="wiz-field">
|
||||
{label && (
|
||||
<span className="wiz-field-label">
|
||||
{label}
|
||||
{optional && (
|
||||
<span style={{ color: "var(--fg-faint)", fontWeight: 400, marginLeft: 8, fontSize: 12 }}>
|
||||
optional
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
{hint && <span className="wiz-field-hint">{hint}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Chip group (multi-select) ──────────────────────────────────────────────
|
||||
function ChipGroup({ options, values, onChange, allowOther = false }) {
|
||||
const [other, setOther] = React.useState("");
|
||||
const customs = (values || []).filter((v) => !options.includes(v));
|
||||
const toggle = (v) => {
|
||||
if (!onChange) return;
|
||||
if (values.includes(v)) onChange(values.filter((x) => x !== v));
|
||||
else onChange([...values, v]);
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<div className="chips">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
type="button" key={opt}
|
||||
className={"chip" + (values.includes(opt) ? " active" : "")}
|
||||
onClick={() => toggle(opt)}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
{customs.map((c) => (
|
||||
<button
|
||||
type="button" key={c}
|
||||
className="chip active"
|
||||
onClick={() => toggle(c)}
|
||||
title="Click to remove"
|
||||
>
|
||||
{c} <span style={{ marginLeft: 4, opacity: 0.6 }}>×</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{allowOther && (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const v = other.trim();
|
||||
if (v && !values.includes(v)) onChange([...values, v]);
|
||||
setOther("");
|
||||
}}
|
||||
style={{ marginTop: 10, display: "flex", gap: 8 }}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
className="wiz-input"
|
||||
placeholder="Add your own…"
|
||||
value={other}
|
||||
onChange={(e) => setOther(e.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-ghost"
|
||||
style={{ height: 42, padding: "0 14px", fontSize: 13, borderRadius: 10 }}
|
||||
disabled={!other.trim()}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Preset group (single-select cards) ─────────────────────────────────────
|
||||
function PresetGroup({ options, value, onChange, columns = 1 }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
||||
gap: 8,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{options.map((opt) => {
|
||||
const active = value === opt.id;
|
||||
return (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
onClick={() => onChange(opt.id)}
|
||||
style={{
|
||||
textAlign: "left",
|
||||
padding: "12px 14px",
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${active ? "var(--accent)" : "var(--hairline)"}`,
|
||||
background: active ? "oklch(0.20 0.04 35 / 0.4)" : "oklch(0.18 0.009 60 / 0.6)",
|
||||
boxShadow: active ? "0 0 0 3px oklch(0.74 0.175 35 / 0.1)" : "none",
|
||||
transition: "border-color .15s, background .15s",
|
||||
color: "var(--fg)",
|
||||
display: "flex", alignItems: "flex-start", gap: 12,
|
||||
}}
|
||||
>
|
||||
{opt.icon && (
|
||||
<span style={{
|
||||
width: 28, height: 28, flexShrink: 0,
|
||||
borderRadius: 8,
|
||||
background: active ? "oklch(0.74 0.175 35 / 0.18)" : "oklch(0.22 0.011 60)",
|
||||
border: "1px solid var(--hairline)",
|
||||
color: active ? "var(--accent)" : "var(--fg-mute)",
|
||||
display: "grid", placeItems: "center",
|
||||
fontSize: 14,
|
||||
marginTop: 1,
|
||||
}}>
|
||||
{opt.icon}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ display: "flex", flexDirection: "column", gap: 2, flex: 1, minWidth: 0 }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 500, letterSpacing: "-0.005em" }}>
|
||||
{opt.label}
|
||||
</span>
|
||||
{opt.desc && (
|
||||
<span style={{ fontSize: 12.5, color: "var(--fg-mute)", lineHeight: 1.45 }}>
|
||||
{opt.desc}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{active && (
|
||||
<span style={{
|
||||
width: 16, height: 16, borderRadius: "50%",
|
||||
background: "var(--accent)",
|
||||
display: "grid", placeItems: "center",
|
||||
color: "var(--accent-fg)",
|
||||
flexShrink: 0,
|
||||
marginTop: 6,
|
||||
}}>
|
||||
<svg width="9" height="9" viewBox="0 0 16 16" fill="none" stroke="currentColor"
|
||||
strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="m3 8.5 3.2 3.2L13 5"/>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Slider ─────────────────────────────────────────────────────────────────
|
||||
function Slider({ min, max, step = 1, value, onChange, format }) {
|
||||
return (
|
||||
<div style={{ width: "100%" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline" }}>
|
||||
<span className="mono" style={{ fontSize: 11, color: "var(--fg-faint)", letterSpacing: "0.04em" }}>
|
||||
{format ? format(min) : min}
|
||||
</span>
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 18,
|
||||
color: "var(--fg)",
|
||||
letterSpacing: "-0.01em",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{format ? format(value) : value}
|
||||
</span>
|
||||
<span className="mono" style={{ fontSize: 11, color: "var(--fg-faint)", letterSpacing: "0.04em" }}>
|
||||
{format ? format(max) : max}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={min} max={max} step={step}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
style={{ width: "100%", marginTop: 6, accentColor: "var(--accent)" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Lane labels — used by WizardTop and elsewhere.
|
||||
const LANE_LABELS = {
|
||||
entrepreneur: "Solo entrepreneur",
|
||||
owner: "Small business owner",
|
||||
consultant: "Building for clients",
|
||||
};
|
||||
|
||||
Object.assign(window, {
|
||||
WizardTop, WizardBody, WizardQ, WizardFooter,
|
||||
Field, ChipGroup, PresetGroup, Slider,
|
||||
LANE_LABELS,
|
||||
});
|
||||
Reference in New Issue
Block a user