diff --git a/.cspell/names.txt b/.cspell/names.txt index 56ae105e1e..385f71ca08 100644 --- a/.cspell/names.txt +++ b/.cspell/names.txt @@ -27,3 +27,4 @@ beka guilhermejansen iamjasonkendrick ejirocodes +0-Sandy diff --git a/docs/content/docs/plugins/2fa.mdx b/docs/content/docs/plugins/2fa.mdx index 3e1676bbc7..b03c655644 100644 --- a/docs/content/docs/plugins/2fa.mdx +++ b/docs/content/docs/plugins/2fa.mdx @@ -159,10 +159,15 @@ const authClient = createAuthClient({ When you call `auth.api.signInEmail` on the server, and the user has 2FA enabled, it will return an object where `twoFactorRedirect` is set to `true`. This behavior isn’t inferred in TypeScript, which can be misleading. You can check using `in` instead to check if `twoFactorRedirect` is set to `true`. +`authClient.twoFactor.*` handles cookies automatically in the browser. If you continue the 2FA flow with `auth.api.*` on the server, you must pass the relevant headers so Better Auth can read the current 2FA state and set the resulting 2FA/session cookies. The generated examples below use `await headers()` for this, but in other frameworks you should pass the equivalent incoming request headers. + +If you chain multiple `auth.api.*` calls in the same server flow, make sure you forward the cookies from the previous auth response into the next call. + ```ts title="sign-in.ts" import { auth } from "@/lib/auth" -const response = await auth.api.signInEmail({ +const { headers: responseHeaders, response } = await auth.api.signInEmail({ + returnHeaders: true, body: { email: "test@test.com", password: "test", @@ -170,6 +175,7 @@ const response = await auth.api.signInEmail({ }); if ("twoFactorRedirect" in response) { + // Forward the cookies from responseHeaders into the next auth.api 2FA call. // Handle the 2FA verification in place } ``` @@ -252,7 +258,12 @@ By default the issuer for TOTP is set to the app name provided in the auth confi After the user has entered their 2FA code, you can verify it using `twoFactor.verifyTotp` method. `Better Auth` follows standard practice by accepting TOTP codes from one period before and one after the current code, ensuring users can authenticate even with minor time delays on their end. - + ```ts type verifyTOTP = { /** @@ -292,11 +303,16 @@ export const auth = betterAuth({ #### Sending OTP -Sending an OTP is done by calling the `twoFactor.sendOtp` function. This function will trigger your sendOTP implementation that you provided in the Better Auth configuration. +Sending an OTP is done by calling the `authClient.twoFactor.sendOtp` function on the client or `auth.api.sendTwoFactorOTP` on the server. This function will trigger your sendOTP implementation that you provided in the Better Auth configuration. - + ```ts -type send2FaOTP = { +type sendTwoFactorOTP = { /** * If true, the device will be trusted for 30 days. It'll be refreshed on every sign in request within this time. */ @@ -311,11 +327,16 @@ if (data) { #### Verifying OTP -After the user has entered their OTP code, you can verify it +After the user has entered their OTP code, you can verify it using `authClient.twoFactor.verifyOtp` on the client or `auth.api.verifyTwoFactorOTP` on the server. - + ```ts -type verifyOTP = { +type verifyTwoFactorOTP = { /** * The otp code to verify. */ @@ -364,7 +385,12 @@ When you generate backup codes, the old backup codes will be deleted and new one You can now allow users to provide a backup code as an account recovery method. - + ```ts type verifyBackupCode = { /** diff --git a/landing/.env.example b/landing/.env.example index 8f83961f71..9abf53a3f2 100644 --- a/landing/.env.example +++ b/landing/.env.example @@ -1,8 +1,13 @@ # GitHub API - community stats GITHUB_TOKEN= -# Inkeep API - docs search & AI chat +# Inkeep API - AI chat INKEEP_API_KEY= +# Typesense - Docs search +NEXT_PUBLIC_TYPESENSE_SERVER_URL= +NEXT_PUBLIC_TYPESENSE_SEARCH_API_KEY= +TYPESENSE_ADMIN_API_KEY= + # Next.js - public URL -NEXT_PUBLIC_URL=http://localhost:3000 \ No newline at end of file +NEXT_PUBLIC_URL=http://localhost:3000 diff --git a/landing/app/api/docs/search/route.ts b/landing/app/api/docs/search/route.ts deleted file mode 100644 index fd70234260..0000000000 --- a/landing/app/api/docs/search/route.ts +++ /dev/null @@ -1,180 +0,0 @@ -interface SearchResult { - id: string; - url: string; - type: "page" | "heading" | "text"; - content: string; - pageName?: string; -} - -interface InkeepDocument { - type: string; - record_type: string; - url: string; - title: string; - source?: { - type: string; - content: { type: string; text?: string }[]; - }; -} - -function toSlug(text: string): string { - return text - .toLowerCase() - .replace(/[^\w\s-]/g, "") - .replace(/\s+/g, "-") - .replace(/-+/g, "-") - .trim(); -} - -function toRelativePath(fullUrl: string): string { - try { - const parsed = new URL(fullUrl); - return parsed.pathname; - } catch { - return fullUrl; - } -} - -function extractHeadings(text: string): { level: number; title: string }[] { - const headings: { level: number; title: string }[] = []; - const regex = /^(#{1,6})\s+(.+)$/gm; - let match: RegExpExecArray | null; - while ((match = regex.exec(text)) !== null) { - headings.push({ - level: match[1].length, - title: match[2].trim(), - }); - } - return headings; -} - -function extractMatchingSnippet(text: string, query: string): string | null { - const terms = query.toLowerCase().split(/\s+/).filter(Boolean); - - // Find the first paragraph-like block that contains a query term - const blocks = text.split(/\n{2,}/); - for (const block of blocks) { - const blockLower = block.toLowerCase(); - // Skip headings and very short blocks - if (block.startsWith("#") || block.trim().length < 20) continue; - if (terms.some((t) => blockLower.includes(t))) { - // Return a trimmed snippet - const clean = block.replace(/[#*_`>\[\]]/g, "").trim(); - if (clean.length > 120) return `${clean.slice(0, 120)}...`; - return clean; - } - } - return null; -} - -export async function GET(request: Request) { - const url = new URL(request.url); - const query = url.searchParams.get("query"); - if (!query) return Response.json([]); - - try { - const response = await fetch("https://api.inkeep.com/v1/chat/completions", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${process.env.INKEEP_API_KEY}`, - }, - body: JSON.stringify({ - model: "inkeep-rag", - messages: [{ role: "user", content: query }], - response_format: { type: "json_object" }, - }), - }); - - if (!response.ok) { - console.error("Inkeep RAG API error:", response.status); - return Response.json([]); - } - - const data = await response.json(); - const content = data.choices?.[0]?.message?.content; - if (!content) return Response.json([]); - - const parsed = JSON.parse(content); - const documents: InkeepDocument[] = parsed.content ?? []; - - const results: SearchResult[] = []; - const seenUrls = new Set(); - - for (const doc of documents) { - const docUrl = toRelativePath(doc.url); - - // Add page-level result (dedupe by base URL) - const baseUrl = docUrl.split("#")[0]; - if (!seenUrls.has(baseUrl)) { - seenUrls.add(baseUrl); - results.push({ - id: baseUrl, - url: baseUrl, - type: "page", - content: doc.title, - }); - } - - // Extract full text from source content blocks - const fullText = (doc.source?.content ?? []) - .filter((c) => c.type === "text" && c.text) - .map((c) => c.text!) - .join("\n\n"); - - if (!fullText) continue; - - // Extract headings and add as section results - const headings = extractHeadings(fullText); - const queryTerms = query.toLowerCase().split(/\s+/).filter(Boolean); - - for (const heading of headings) { - const headingLower = heading.title.toLowerCase(); - const matches = queryTerms.some((t) => headingLower.includes(t)); - if (!matches) continue; - - const slug = toSlug(heading.title); - const headingUrl = `${baseUrl}#${slug}`; - if (seenUrls.has(headingUrl)) continue; - seenUrls.add(headingUrl); - - results.push({ - id: headingUrl, - url: headingUrl, - type: "heading", - content: heading.title, - pageName: doc.title, - }); - } - - // Add a text snippet match if we find relevant content - const snippet = extractMatchingSnippet(fullText, query); - if (snippet) { - const snippetId = `${baseUrl}:snippet`; - if (!seenUrls.has(snippetId)) { - seenUrls.add(snippetId); - - // Link to the closest heading above the snippet, or the page - let bestAnchor = baseUrl; - for (const heading of headings) { - const slug = toSlug(heading.title); - bestAnchor = `${baseUrl}#${slug}`; - } - - results.push({ - id: snippetId, - url: bestAnchor, - type: "text", - content: snippet, - pageName: doc.title, - }); - } - } - } - - return Response.json(results.slice(0, 20)); - } catch (error) { - console.error("Search error:", error); - return Response.json([]); - } -} diff --git a/landing/app/api/docs/static.json/route.ts b/landing/app/api/docs/static.json/route.ts new file mode 100644 index 0000000000..54bea910d1 --- /dev/null +++ b/landing/app/api/docs/static.json/route.ts @@ -0,0 +1,7 @@ +import { exportSearchIndexes } from "@/lib/export-search-indexes"; + +export const revalidate = false; + +export async function GET() { + return Response.json(await exportSearchIndexes()); +} diff --git a/landing/app/blog/rss.xml/route.ts b/landing/app/blog/rss.xml/route.ts new file mode 100644 index 0000000000..2c7245cdf9 --- /dev/null +++ b/landing/app/blog/rss.xml/route.ts @@ -0,0 +1,11 @@ +import { getRSS } from "@/lib/rss"; + +export const revalidate = false; + +export function GET() { + return new Response(getRSS(), { + headers: { + "Content-Type": "application/rss+xml; charset=utf-8", + }, + }); +} diff --git a/landing/app/careers/careers-client.tsx b/landing/app/careers/careers-client.tsx index db6143054a..feb6cee4c5 100644 --- a/landing/app/careers/careers-client.tsx +++ b/landing/app/careers/careers-client.tsx @@ -54,7 +54,7 @@ function ApplyDialog({ onClick={onClose} > {/* Backdrop */} -
+
{/* Dialog */} e.stopPropagation()} - className="relative w-full max-w-md border border-dashed border-foreground/[0.08] bg-background overflow-hidden" + className="relative w-full max-w-md overflow-hidden border border-foreground/[0.14] bg-background/95 shadow-2xl shadow-black/12 ring-1 ring-black/5 backdrop-blur dark:border-white/[0.08] dark:bg-[#050505]/95 dark:shadow-black/65 dark:ring-white/[0.04]" > {/* Corner marks */} - + + - + + - + + - + + @@ -84,13 +84,15 @@ function ApplyDialog({ @@ -122,15 +126,17 @@ function ApplyDialog({ ) : ( <>
-

+

# Apply

-

{role.title}

+

+ {role.title} +

- + {role.type} - + {role.location}
@@ -146,7 +152,7 @@ function ApplyDialog({
@@ -154,7 +160,7 @@ function ApplyDialog({ id="careers-name" type="text" required - className="w-full bg-transparent border border-dashed border-foreground/[0.1] px-3 py-2 text-[12px] text-foreground/70 placeholder:text-foreground/20 focus:outline-none focus:border-foreground/25 transition-colors" + className="w-full border border-foreground/[0.15] bg-foreground/[0.025] px-3 py-2 text-[12px] text-foreground/88 placeholder:text-foreground/38 focus:outline-none focus:border-foreground/38 dark:border-white/[0.1] dark:bg-white/[0.03] dark:text-foreground/78 dark:placeholder:text-foreground/28 dark:focus:border-white/[0.2] transition-colors" placeholder="Your full name" />
@@ -162,7 +168,7 @@ function ApplyDialog({
@@ -170,7 +176,7 @@ function ApplyDialog({ id="careers-email" type="email" required - className="w-full bg-transparent border border-dashed border-foreground/[0.1] px-3 py-2 text-[12px] text-foreground/70 placeholder:text-foreground/20 focus:outline-none focus:border-foreground/25 transition-colors" + className="w-full border border-foreground/[0.15] bg-foreground/[0.025] px-3 py-2 text-[12px] text-foreground/88 placeholder:text-foreground/38 focus:outline-none focus:border-foreground/38 dark:border-white/[0.1] dark:bg-white/[0.03] dark:text-foreground/78 dark:placeholder:text-foreground/28 dark:focus:border-white/[0.2] transition-colors" placeholder="you@example.com" />
@@ -178,7 +184,7 @@ function ApplyDialog({
@@ -186,7 +192,7 @@ function ApplyDialog({ id="careers-portfolio" type="url" required - className="w-full bg-transparent border border-dashed border-foreground/[0.1] px-3 py-2 text-[12px] text-foreground/70 placeholder:text-foreground/20 focus:outline-none focus:border-foreground/25 transition-colors" + className="w-full border border-foreground/[0.15] bg-foreground/[0.025] px-3 py-2 text-[12px] text-foreground/88 placeholder:text-foreground/38 focus:outline-none focus:border-foreground/38 dark:border-white/[0.1] dark:bg-white/[0.03] dark:text-foreground/78 dark:placeholder:text-foreground/28 dark:focus:border-white/[0.2] transition-colors" placeholder="https://" />
@@ -194,7 +200,7 @@ function ApplyDialog({
@@ -202,7 +208,7 @@ function ApplyDialog({ id="careers-why" required rows={3} - className="w-full bg-transparent border border-dashed border-foreground/[0.1] px-3 py-2 text-[12px] text-foreground/70 placeholder:text-foreground/20 focus:outline-none focus:border-foreground/25 transition-colors resize-none" + className="w-full border border-foreground/[0.15] bg-foreground/[0.025] px-3 py-2 text-[12px] text-foreground/88 placeholder:text-foreground/38 focus:outline-none focus:border-foreground/38 dark:border-white/[0.1] dark:bg-white/[0.03] dark:text-foreground/78 dark:placeholder:text-foreground/28 dark:focus:border-white/[0.2] transition-colors resize-none" placeholder="Tell us why you want to join..." />
@@ -210,13 +216,13 @@ function ApplyDialog({
+
+ ); +} + +// ─── Panel Messages ────────────────────────────────────────────────────────── + +function PanelMessages({ className, ...props }: ComponentProps<"div">) { + const { messages } = useChatContext(); + const containerRef = useRef(null); + const filtered = messages.filter((msg) => msg.role !== "system"); + + useEffect(() => { + if (!containerRef.current) return; + const container = containerRef.current; + + const scrollToBottom = () => { + container.scrollTo({ + top: container.scrollHeight, + behavior: "instant", + }); + }; + + // Observe size changes on all current and future children + const resizeObserver = new ResizeObserver(scrollToBottom); + const observeChildren = () => { + resizeObserver.disconnect(); + for (const child of container.children) { + resizeObserver.observe(child); + } + }; + + observeChildren(); + + // Re-attach when children are added/removed + const mutationObserver = new MutationObserver(observeChildren); + mutationObserver.observe(container, { childList: true }); + + // Prevent scroll chaining to body on desktop + const onWheel = (e: WheelEvent) => { + const { scrollTop, scrollHeight, clientHeight } = container; + const atTop = scrollTop <= 0 && e.deltaY < 0; + const atBottom = scrollTop + clientHeight >= scrollHeight && e.deltaY > 0; + if (atTop || atBottom) { + e.preventDefault(); + } + }; + container.addEventListener("wheel", onWheel, { passive: false }); + + return () => { + resizeObserver.disconnect(); + mutationObserver.disconnect(); + container.removeEventListener("wheel", onWheel); + }; + }, []); + + return ( +
+ {filtered.length === 0 ? ( +
+ +

Start a new chat below.

+
+ ) : ( +
+ {filtered.map((item, index) => ( + + ))} +
+ )} +
+ ); +} + +// ─── Panel Input ───────────────────────────────────────────────────────────── + +function PanelInput({ autoFocus = false }: { autoFocus?: boolean }) { + const { status, sendMessage, stop, setMessages, messages, regenerate } = + useChatContext(); + const [input, setInput] = useState(""); + const textareaRef = useRef(null); + const isLoading = status === "streaming" || status === "submitted"; + const inputDragRef = useRef<{ startY: number; startHeight: number } | null>( + null, + ); + const [inputMinHeight, setInputMinHeight] = useState(38); + + const adjustHeight = useCallback(() => { + const el = textareaRef.current; + if (!el) return; + const maxH = window.innerHeight * 0.35; + el.style.height = "auto"; + el.style.height = `${Math.min(maxH, Math.max(el.scrollHeight, inputMinHeight))}px`; + }, [inputMinHeight]); + + useEffect(() => { + adjustHeight(); + }, [adjustHeight]); + + const onSubmit = (e?: SyntheticEvent) => { + e?.preventDefault(); + if (!input.trim() || isLoading) return; + void sendMessage({ text: input }); + setInput(""); + requestAnimationFrame(adjustHeight); + }; + + const handleDragStart = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + inputDragRef.current = { startY: e.clientY, startHeight: inputMinHeight }; + const onMove = (ev: MouseEvent) => { + if (!inputDragRef.current) return; + const delta = inputDragRef.current.startY - ev.clientY; + setInputMinHeight( + Math.max(38, Math.min(400, inputDragRef.current.startHeight + delta)), + ); + }; + const onUp = () => { + inputDragRef.current = null; + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + document.body.style.userSelect = ""; + document.body.style.cursor = ""; + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + document.body.style.userSelect = "none"; + document.body.style.cursor = "row-resize"; + }, + [inputMinHeight], + ); + + return ( +
+ {/* Drag handle to resize input */} +
+
+
+
+
+
+