mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-19 11:24:57 -05:00
2329 lines
101 KiB
TypeScript
2329 lines
101 KiB
TypeScript
"use client";
|
|
|
|
import { AnimatePresence, motion } from "framer-motion";
|
|
import Link from "next/link";
|
|
import { useTheme } from "next-themes";
|
|
import {
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import type { ContributorInfo } from "@/lib/community-stats";
|
|
import { cn } from "@/lib/utils";
|
|
import { Icons } from "../icons";
|
|
import {
|
|
DatabaseSection,
|
|
IntegrationsSection,
|
|
PluginEcosystem,
|
|
ServerClientTabs,
|
|
SocialProvidersSection,
|
|
} from "./framework-sections";
|
|
import { TrustedBy } from "./trusted-by";
|
|
|
|
const mcpCommands = [
|
|
{ name: "Cursor", command: "npx auth mcp --cursor" },
|
|
{ name: "Claude Code", command: "npx auth mcp --claude-code" },
|
|
{ name: "Open Code", command: "npx auth mcp --open-code" },
|
|
{ name: "Manual", command: "npx auth mcp --manual" },
|
|
];
|
|
|
|
const aiPromptText = `Set up authentication in my project using Better Auth (better-auth npm package).
|
|
|
|
1. Install better-auth. If I already have a database configured in this project, use that — don't set up a new one.
|
|
|
|
2. Create lib/auth.ts — call betterAuth() with:
|
|
- My existing database connection (or a new SQLite/Postgres setup if none exists)
|
|
- emailAndPassword enabled
|
|
- Any social providers if I have OAuth credentials in my env
|
|
|
|
3. Create lib/auth-client.ts — use the correct framework import:
|
|
- React/Next.js: "better-auth/react"
|
|
- Vue: "better-auth/vue"
|
|
- Svelte: "better-auth/svelte"
|
|
- Vanilla: "better-auth/client"
|
|
|
|
4. Add the API route handler for my framework (e.g. app/api/auth/[...all]/route.ts for Next.js App Router).
|
|
|
|
5. Add BETTER_AUTH_SECRET to my .env if it doesn't exist (generate a 32+ char secret).
|
|
|
|
6. Run npx auth migrate to apply database migrations.
|
|
|
|
Refer to better-auth.com/docs for exact API and plugin syntax.`;
|
|
|
|
function CredentialFields() {
|
|
const emailText = "user@email.com";
|
|
const passwordDots = "••••••••";
|
|
const [emailDisplay, setEmailDisplay] = useState(emailText);
|
|
const [passwordDisplay, setPasswordDisplay] = useState(passwordDots);
|
|
const [isTyping, setIsTyping] = useState(false);
|
|
const timeoutsRef = useRef<NodeJS.Timeout[]>([]);
|
|
const isTypingRef = useRef(false);
|
|
|
|
const startTyping = useCallback(() => {
|
|
if (isTypingRef.current) return;
|
|
isTypingRef.current = true;
|
|
setIsTyping(true);
|
|
|
|
// Clear previous timeouts
|
|
for (const t of timeoutsRef.current) clearTimeout(t);
|
|
timeoutsRef.current = [];
|
|
|
|
// Reset to empty
|
|
setEmailDisplay("");
|
|
setPasswordDisplay("");
|
|
|
|
// Type email character by character
|
|
for (let i = 0; i <= emailText.length; i++) {
|
|
const t = setTimeout(() => {
|
|
setEmailDisplay(emailText.slice(0, i));
|
|
}, i * 60);
|
|
timeoutsRef.current.push(t);
|
|
}
|
|
|
|
// Type password dots after email finishes
|
|
const passwordStart = (emailText.length + 2) * 60;
|
|
for (let i = 0; i <= passwordDots.length; i++) {
|
|
const t = setTimeout(
|
|
() => {
|
|
setPasswordDisplay(passwordDots.slice(0, i));
|
|
if (i === passwordDots.length) {
|
|
isTypingRef.current = false;
|
|
setIsTyping(false);
|
|
}
|
|
},
|
|
passwordStart + i * 50,
|
|
);
|
|
timeoutsRef.current.push(t);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
for (const t of timeoutsRef.current) clearTimeout(t);
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<div className="mt-3 flex items-center gap-1.5" onMouseEnter={startTyping}>
|
|
<div className="flex items-center h-5 px-2 border border-foreground/[0.08] bg-foreground/[0.02] flex-1 min-w-0">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="8"
|
|
height="8"
|
|
viewBox="0 0 24 24"
|
|
className="text-foreground/45 dark:text-foreground/30 shrink-0 mr-1.5"
|
|
>
|
|
<path
|
|
fill="currentColor"
|
|
d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2m0 4-8 5-8-5V6l8 5 8-5z"
|
|
/>
|
|
</svg>
|
|
<span className="text-[9px] font-mono text-foreground/50 dark:text-foreground/35 truncate">
|
|
{emailDisplay}
|
|
{isTyping && emailDisplay.length < emailText.length && (
|
|
<span className="inline-block w-px h-2.5 bg-foreground/50 ml-px animate-[blink_0.8s_step-end_infinite] align-middle" />
|
|
)}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center h-5 px-2 border border-foreground/[0.08] bg-foreground/[0.02] flex-1 min-w-0">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="8"
|
|
height="8"
|
|
viewBox="0 0 24 24"
|
|
className="text-foreground/45 dark:text-foreground/30 shrink-0 mr-1.5"
|
|
>
|
|
<path
|
|
fill="currentColor"
|
|
d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2m-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2M9 8V6c0-1.66 1.34-3 3-3s3 1.34 3 3v2z"
|
|
/>
|
|
</svg>
|
|
<span className="text-[9px] font-mono text-foreground/50 dark:text-foreground/35 tracking-[0.1em]">
|
|
{passwordDisplay}
|
|
{isTyping &&
|
|
emailDisplay.length >= emailText.length &&
|
|
passwordDisplay.length < passwordDots.length && (
|
|
<span className="inline-block w-px h-2.5 bg-foreground/50 ml-px animate-[blink_0.8s_step-end_infinite] align-middle" />
|
|
)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function InstallBlock() {
|
|
const [mode, setMode] = useState<"cli" | "prompt" | "mcp" | "skills">("cli");
|
|
const [copied, setCopied] = useState(false);
|
|
const [pmOpen, setPmOpen] = useState(false);
|
|
const [promptOpen, setPromptOpen] = useState(false);
|
|
const contentRef = useRef<HTMLDivElement>(null);
|
|
const [contentHeight, setContentHeight] = useState<number | "auto">("auto");
|
|
const [overflow, setOverflow] = useState<"hidden" | "visible">("visible");
|
|
|
|
useEffect(() => {
|
|
const el = contentRef.current;
|
|
if (!el) return;
|
|
const ro = new ResizeObserver(() => {
|
|
setContentHeight(el.offsetHeight);
|
|
});
|
|
ro.observe(el);
|
|
return () => ro.disconnect();
|
|
}, []);
|
|
|
|
useLayoutEffect(() => {
|
|
setOverflow("hidden");
|
|
}, [mode]);
|
|
|
|
useLayoutEffect(() => {
|
|
if (pmOpen) {
|
|
setOverflow("visible");
|
|
}
|
|
}, [pmOpen]);
|
|
|
|
const copy = (text: string) => {
|
|
navigator.clipboard.writeText(text);
|
|
setCopied(true);
|
|
setPmOpen(false);
|
|
setTimeout(() => setCopied(false), 1500);
|
|
};
|
|
|
|
return (
|
|
<div className="mb-6 rounded-md border border-foreground/[0.1] relative">
|
|
{/* Tabs */}
|
|
<div className="flex items-center border-b border-foreground/[0.1]">
|
|
<button
|
|
onClick={() => {
|
|
setMode("cli");
|
|
setCopied(false);
|
|
setPmOpen(false);
|
|
}}
|
|
className={cn(
|
|
"px-4 py-2 text-[12px] transition-colors duration-150 relative",
|
|
mode === "cli"
|
|
? "text-neutral-800 dark:text-neutral-200"
|
|
: "text-neutral-400 dark:text-neutral-500 hover:text-neutral-600 dark:hover:text-neutral-400",
|
|
)}
|
|
>
|
|
CLI
|
|
{mode === "cli" && (
|
|
<div className="absolute bottom-0 left-4 right-4 h-[1.5px] bg-neutral-600 dark:bg-neutral-400" />
|
|
)}
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setMode("prompt");
|
|
setCopied(false);
|
|
setPmOpen(false);
|
|
}}
|
|
className={cn(
|
|
"px-4 py-2 text-[12px] transition-colors duration-150 relative",
|
|
mode === "prompt"
|
|
? "text-neutral-800 dark:text-neutral-200"
|
|
: "text-neutral-400 dark:text-neutral-500 hover:text-neutral-600 dark:hover:text-neutral-400",
|
|
)}
|
|
>
|
|
Prompt
|
|
{mode === "prompt" && (
|
|
<div className="absolute bottom-0 left-4 right-4 h-[1.5px] bg-neutral-600 dark:bg-neutral-400" />
|
|
)}
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setMode("mcp");
|
|
setCopied(false);
|
|
setPmOpen(false);
|
|
}}
|
|
className={cn(
|
|
"px-4 py-2 text-[12px] transition-colors duration-150 relative",
|
|
mode === "mcp"
|
|
? "text-neutral-800 dark:text-neutral-200"
|
|
: "text-neutral-400 dark:text-neutral-500 hover:text-neutral-600 dark:hover:text-neutral-400",
|
|
)}
|
|
>
|
|
MCP
|
|
{mode === "mcp" && (
|
|
<div className="absolute bottom-0 left-4 right-4 h-[1.5px] bg-neutral-600 dark:bg-neutral-400" />
|
|
)}
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setMode("skills");
|
|
setCopied(false);
|
|
setPmOpen(false);
|
|
}}
|
|
className={cn(
|
|
"px-4 py-2 text-[12px] transition-colors duration-150 relative",
|
|
mode === "skills"
|
|
? "text-neutral-800 dark:text-neutral-200"
|
|
: "text-neutral-400 dark:text-neutral-500 hover:text-neutral-600 dark:hover:text-neutral-400",
|
|
)}
|
|
>
|
|
Skills
|
|
{mode === "skills" && (
|
|
<div className="absolute bottom-0 left-4 right-4 h-[1.5px] bg-neutral-600 dark:bg-neutral-400" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<motion.div
|
|
animate={{ height: contentHeight }}
|
|
initial={false}
|
|
transition={{ duration: 0.3, ease: [0.25, 0.1, 0.25, 1] }}
|
|
onAnimationComplete={() => setOverflow("visible")}
|
|
style={{ overflow }}
|
|
>
|
|
<div ref={contentRef}>
|
|
<AnimatePresence mode="wait" initial={false}>
|
|
<div>
|
|
{mode === "cli" || mode === "skills" ? (
|
|
<div className="flex items-center justify-between bg-neutral-100/50 dark:bg-[#050505] px-4 py-3">
|
|
<code
|
|
className="text-[13px]"
|
|
style={{ fontFamily: "var(--font-geist-pixel-square)" }}
|
|
>
|
|
{mode === "skills" ? (
|
|
<>
|
|
<span className="text-purple-600/90 dark:text-purple-400/90">
|
|
npx
|
|
</span>{" "}
|
|
<span className="text-neutral-700 dark:text-neutral-300">
|
|
skills add better-auth/skills
|
|
</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<span className="text-purple-600/90 dark:text-purple-400/90">
|
|
npx
|
|
</span>{" "}
|
|
<span className="text-neutral-700 dark:text-neutral-300">
|
|
auth init
|
|
</span>
|
|
</>
|
|
)}
|
|
</code>
|
|
<div className="relative">
|
|
{mode === "skills" ? (
|
|
<button
|
|
onClick={() =>
|
|
copy("npx skills add better-auth/skills")
|
|
}
|
|
className="text-neutral-400 dark:text-neutral-500 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors p-1"
|
|
aria-label="Copy command"
|
|
>
|
|
{copied ? (
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
className="h-4 w-4"
|
|
>
|
|
<path
|
|
fill="currentColor"
|
|
d="M9 16.17L4.83 12l-1.42 1.41L9 19L21 7l-1.41-1.41z"
|
|
/>
|
|
</svg>
|
|
) : (
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
className="h-4 w-4"
|
|
>
|
|
<path
|
|
fill="currentColor"
|
|
d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2m0 16H8V7h11z"
|
|
/>
|
|
</svg>
|
|
)}
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={() => copy("npx auth init")}
|
|
className="text-neutral-400 dark:text-neutral-500 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors p-1"
|
|
aria-label="Copy command"
|
|
>
|
|
{copied ? (
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
className="h-4 w-4"
|
|
>
|
|
<path
|
|
fill="currentColor"
|
|
d="M9 16.17L4.83 12l-1.42 1.41L9 19L21 7l-1.41-1.41z"
|
|
/>
|
|
</svg>
|
|
) : (
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
className="h-4 w-4"
|
|
>
|
|
<path
|
|
fill="currentColor"
|
|
d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2m0 16H8V7h11z"
|
|
/>
|
|
</svg>
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : mode === "mcp" ? (
|
|
<div className="flex items-center justify-between bg-neutral-100/50 dark:bg-[#050505] px-4 py-3">
|
|
<code
|
|
className="text-[13px] truncate"
|
|
style={{ fontFamily: "var(--font-geist-pixel-square)" }}
|
|
>
|
|
<span className="text-purple-600/90 dark:text-purple-400/90">
|
|
npx
|
|
</span>{" "}
|
|
<span className="text-neutral-700 dark:text-neutral-300">
|
|
auth mcp
|
|
</span>
|
|
</code>
|
|
<div className="relative">
|
|
<button
|
|
onClick={() => {
|
|
if (copied) return;
|
|
setPmOpen(!pmOpen);
|
|
}}
|
|
className="text-neutral-400 dark:text-neutral-500 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors p-1"
|
|
aria-label="Add MCP"
|
|
>
|
|
{copied ? (
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
className="h-4 w-4"
|
|
>
|
|
<path
|
|
fill="currentColor"
|
|
d="M9 16.17L4.83 12l-1.42 1.41L9 19L21 7l-1.41-1.41z"
|
|
/>
|
|
</svg>
|
|
) : (
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
className="h-4 w-4"
|
|
>
|
|
<path
|
|
fill="currentColor"
|
|
d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6z"
|
|
/>
|
|
</svg>
|
|
)}
|
|
</button>
|
|
{pmOpen && (
|
|
<>
|
|
<div
|
|
className="fixed inset-0 z-40"
|
|
role="button"
|
|
tabIndex={-1}
|
|
aria-label="Close dropdown"
|
|
onClick={() => setPmOpen(false)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Escape") setPmOpen(false);
|
|
}}
|
|
/>
|
|
<div className="absolute right-0 top-full mt-2 w-[160px] bg-white dark:bg-[#050505] border border-neutral-200 dark:border-white/[0.07] shadow-2xl shadow-black/10 dark:shadow-black/80 z-50 rounded-sm">
|
|
{mcpCommands.map((mc, i) => (
|
|
<button
|
|
key={mc.name}
|
|
onClick={() => copy(mc.command)}
|
|
className={cn(
|
|
"flex items-center gap-2.5 w-full px-3 py-2 text-[12px] text-neutral-500 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-white/[0.05] transition-all text-left",
|
|
i < mcpCommands.length - 1 &&
|
|
"border-b border-neutral-100 dark:border-white/[0.06]",
|
|
)}
|
|
>
|
|
<span className="flex items-center justify-center w-3.5 h-3.5 shrink-0">
|
|
{mc.name === "Cursor" && (
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
className="h-3.5 w-3.5"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
fill="currentColor"
|
|
d="M11.503.131L1.891 5.678a.84.84 0 0 0-.42.726v11.188c0 .3.162.575.42.724l9.609 5.55a1 1 0 0 0 .998 0l9.61-5.55a.84.84 0 0 0 .42-.724V6.404a.84.84 0 0 0-.42-.726L12.497.131a1.01 1.01 0 0 0-.996 0M2.657 6.338h18.55c.263 0 .43.287.297.515L12.23 22.918c-.062.107-.229.064-.229-.06V12.335a.59.59 0 0 0-.295-.51l-9.11-5.257c-.109-.063-.064-.23.061-.23"
|
|
/>
|
|
</svg>
|
|
)}
|
|
{mc.name === "Claude Code" && (
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
className="h-3.5 w-3.5"
|
|
viewBox="0 0 16 16"
|
|
>
|
|
<path
|
|
fill="currentColor"
|
|
d="m6.96 15.2l.224-.992l.256-1.28l.208-1.024l.192-1.264l.112-.416l-.016-.032l-.08.016l-.96 1.312l-1.456 1.968l-1.152 1.216l-.272.112l-.48-.24l.048-.448l.272-.384l1.584-2.032l.96-1.264l.624-.72l-.016-.096h-.032l-4.224 2.752L2 12.48l-.336-.304l.048-.496l.16-.16l1.264-.88l3.152-1.76l.048-.16l-.048-.08h-.16L5.6 8.608L3.808 8.56l-1.552-.064l-1.52-.08l-.384-.08L0 7.856l.032-.24l.32-.208l.464.032l1.008.08l1.52.096l1.104.064l1.632.176h.256l.032-.112l-.08-.064l-.064-.064L4.64 6.56L2.944 5.44l-.896-.656l-.48-.336l-.24-.304l-.096-.672l.432-.48l.592.048l.144.032l.592.464l1.264.976L5.92 5.744l.24.192l.112-.064v-.048l-.112-.176l-.896-1.632l-.96-1.664l-.432-.688l-.112-.416a1.7 1.7 0 0 1-.064-.48l.496-.672L4.464 0l.672.096l.272.24l.416.944l.656 1.488l1.04 2.016l.304.608l.16.544l.064.176h.112v-.096l.08-1.152l.16-1.392l.16-1.792l.048-.512l.256-.608l.496-.32l.384.176l.32.464l-.048.288L9.84 2.4l-.384 1.936l-.24 1.312h.144l.16-.176l.656-.864l1.104-1.376l.48-.544l.576-.608l.368-.288h.688l.496.752l-.224.784l-.704.896l-.592.752l-.848 1.136l-.512.912l.048.064h.112l1.904-.416l1.04-.176l1.216-.208l.56.256l.064.256l-.224.544l-1.312.32l-1.536.304l-2.288.544l-.032.016l.032.048l1.024.096l.448.032h1.088l2.016.144l.528.352l.304.416l-.048.336l-.816.4l-1.088-.256l-2.56-.608l-.864-.208h-.128v.064l.736.72l1.328 1.2l1.68 1.552l.08.384l-.208.32l-.224-.032l-1.472-1.12l-.576-.496l-1.28-1.072h-.08v.112l.288.432l1.568 2.352l.08.72l-.112.224l-.416.144l-.432-.08l-.928-1.28l-.944-1.456l-.768-1.296l-.08.064l-.464 4.832l-.208.24l-.48.192l-.4-.304z"
|
|
/>
|
|
</svg>
|
|
)}
|
|
{mc.name === "Open Code" && (
|
|
<svg
|
|
className="h-3.5 w-3.5"
|
|
viewBox="0 0 32 40"
|
|
fill="none"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
>
|
|
<g clipPath="url(#oc)">
|
|
<path
|
|
d="M24 32H8V16H24V32Z"
|
|
fill="currentColor"
|
|
opacity="0.5"
|
|
/>
|
|
<path
|
|
d="M24 8H8V32H24V8ZM32 40H0V0H32V40Z"
|
|
fill="currentColor"
|
|
/>
|
|
</g>
|
|
<defs>
|
|
<clipPath id="oc">
|
|
<rect
|
|
width="32"
|
|
height="40"
|
|
fill="white"
|
|
/>
|
|
</clipPath>
|
|
</defs>
|
|
</svg>
|
|
)}
|
|
{mc.name === "Manual" && (
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
className="h-3.5 w-3.5"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth="2"
|
|
d="M12 19h8M4 17l6-6l-6-6"
|
|
/>
|
|
</svg>
|
|
)}
|
|
</span>
|
|
<span className="font-mono text-[11px]">
|
|
{mc.name}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="bg-neutral-100/50 dark:bg-[#050505] px-5 py-4">
|
|
<p className="text-[13px] font-medium text-neutral-700 dark:text-neutral-200 leading-relaxed">
|
|
Set up authentication in my project using Better Auth.
|
|
</p>
|
|
<div className="relative mt-1.5">
|
|
<p className="text-[11px] text-neutral-400 dark:text-neutral-500 leading-relaxed line-clamp-2">
|
|
Install better-auth. If I already have a database
|
|
configured, use that. Create lib/auth.ts with{" "}
|
|
<code className="text-neutral-500 dark:text-neutral-400">
|
|
betterAuth()
|
|
</code>
|
|
, create auth-client.ts, add the route handler, run
|
|
migrations...
|
|
</p>
|
|
<div className="absolute bottom-0 left-0 right-0 h-6 bg-gradient-to-t from-neutral-100/50 dark:from-[#050505] to-transparent pointer-events-none" />
|
|
</div>
|
|
<div className="flex items-center justify-between mt-3 pt-2 border-t border-foreground/[0.04]">
|
|
<button
|
|
onClick={() => setPromptOpen(true)}
|
|
className="flex items-center gap-1 text-[11px] text-neutral-400 dark:text-neutral-500 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
className="h-3 w-3"
|
|
>
|
|
<path
|
|
fill="currentColor"
|
|
d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5M12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5s5 2.24 5 5s-2.24 5-5 5m0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3s3-1.34 3-3s-1.34-3-3-3"
|
|
/>
|
|
</svg>
|
|
View full prompt
|
|
</button>
|
|
<button
|
|
onClick={() => copy(aiPromptText)}
|
|
className="flex items-center gap-1.5 text-[11px] text-neutral-400 dark:text-neutral-500 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors"
|
|
>
|
|
{copied ? (
|
|
<>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
className="h-3.5 w-3.5"
|
|
>
|
|
<path
|
|
fill="currentColor"
|
|
d="M9 16.17L4.83 12l-1.42 1.41L9 19L21 7l-1.41-1.41z"
|
|
/>
|
|
</svg>
|
|
Copied
|
|
</>
|
|
) : (
|
|
<>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
className="h-3.5 w-3.5"
|
|
>
|
|
<path
|
|
fill="currentColor"
|
|
d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2m0 16H8V7h11z"
|
|
/>
|
|
</svg>
|
|
Copy prompt
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</AnimatePresence>
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* Prompt dialog */}
|
|
<AnimatePresence>
|
|
{promptOpen && (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="fixed inset-0 lg:left-[40%] z-50 flex items-center justify-center"
|
|
onClick={() => setPromptOpen(false)}
|
|
>
|
|
{/* Backdrop - only covers right/content side */}
|
|
<div className="absolute inset-0 bg-black/50 dark:bg-black/70 backdrop-blur-sm" />
|
|
|
|
{/* Dialog */}
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 8, scale: 0.98 }}
|
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
exit={{ opacity: 0, y: 8, scale: 0.98 }}
|
|
transition={{ duration: 0.2, ease: "easeOut" }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="relative w-[calc(100%-2rem)] max-w-lg mx-4 bg-neutral-50 dark:bg-[#0a0a0a] border border-neutral-200 dark:border-white/[0.06] rounded-sm shadow-2xl"
|
|
>
|
|
{/* Close */}
|
|
<button
|
|
onClick={() => setPromptOpen(false)}
|
|
className="absolute top-3 right-3 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors z-10"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
className="h-4 w-4"
|
|
>
|
|
<path
|
|
fill="currentColor"
|
|
d="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
|
|
{/* Content */}
|
|
<div className="px-5 py-5 max-h-[60vh] overflow-y-auto">
|
|
<p className="text-[12px] font-mono text-neutral-600 dark:text-neutral-400 leading-[1.9] whitespace-pre-line">
|
|
{aiPromptText}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="flex justify-end px-5 py-3 border-t border-neutral-200 dark:border-white/[0.06]">
|
|
<button
|
|
onClick={() => copy(aiPromptText)}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] rounded-sm border border-neutral-200 dark:border-white/[0.08] text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-white/[0.04] transition-colors"
|
|
>
|
|
{copied ? (
|
|
<>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
className="h-3.5 w-3.5"
|
|
>
|
|
<path
|
|
fill="currentColor"
|
|
d="M9 16.17L4.83 12l-1.42 1.41L9 19L21 7l-1.41-1.41z"
|
|
/>
|
|
</svg>
|
|
Copied
|
|
</>
|
|
) : (
|
|
<>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
className="h-3.5 w-3.5"
|
|
>
|
|
<path
|
|
fill="currentColor"
|
|
d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2m0 16H8V7h11z"
|
|
/>
|
|
</svg>
|
|
Copy prompt
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const EMPTY_CONTRIBUTORS: ContributorInfo[] = [];
|
|
|
|
type CommunityHeroStats = {
|
|
npmDownloads: number;
|
|
npmWeeklyHistory: number[];
|
|
githubStars: number;
|
|
contributors: number;
|
|
};
|
|
|
|
/**
|
|
* Below-the-fold media (contributor avatars, the demo video) never competes
|
|
* with the initial load. Once the page has loaded, a fast connection fetches
|
|
* it right away so it's ready before anyone scrolls to it; slow or
|
|
* data-saver connections wait until it's close to the viewport.
|
|
*/
|
|
function useBelowFoldMedia() {
|
|
const [state, setState] = useState({ loaded: false, prefetch: false });
|
|
|
|
useEffect(() => {
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
const onLoad = () => {
|
|
const prefetch = !isSlowConnection();
|
|
// Give the browser a beat after `load` before starting any fetches.
|
|
timer = setTimeout(() => setState({ loaded: true, prefetch }), 300);
|
|
};
|
|
if (document.readyState === "complete") onLoad();
|
|
else window.addEventListener("load", onLoad, { once: true });
|
|
return () => {
|
|
window.removeEventListener("load", onLoad);
|
|
clearTimeout(timer);
|
|
};
|
|
}, []);
|
|
|
|
return state;
|
|
}
|
|
|
|
function isSlowConnection() {
|
|
// Chromium only; Safari and Firefox don't expose the Network Information API.
|
|
const connection = (
|
|
navigator as Navigator & {
|
|
connection?: { saveData?: boolean; effectiveType?: string };
|
|
}
|
|
).connection;
|
|
if (connection?.saveData) return true;
|
|
if (connection?.effectiveType) {
|
|
return /^(slow-2g|2g|3g)$/.test(connection.effectiveType);
|
|
}
|
|
// Everywhere else, judge by how long this page took to load.
|
|
const [navigation] = performance.getEntriesByType(
|
|
"navigation",
|
|
) as PerformanceNavigationTiming[];
|
|
return (navigation?.loadEventStart || performance.now()) > 4000;
|
|
}
|
|
|
|
function ContributorsSection({
|
|
contributors = EMPTY_CONTRIBUTORS,
|
|
contributorCount,
|
|
}: {
|
|
contributors: ContributorInfo[];
|
|
contributorCount: number;
|
|
}) {
|
|
const wallRef = useRef<HTMLDivElement>(null);
|
|
const [wallNearViewport, setWallNearViewport] = useState(false);
|
|
const { loaded, prefetch } = useBelowFoldMedia();
|
|
const showAvatars = prefetch || (loaded && wallNearViewport);
|
|
|
|
// Hundreds of avatars scroll through the wall; the links always render,
|
|
// but the avatar images are only mounted after the page has loaded: right
|
|
// away on fast connections, or once the wall is about a screen away on
|
|
// slow ones.
|
|
useEffect(() => {
|
|
const wall = wallRef.current;
|
|
if (!wall) return;
|
|
const observer = new IntersectionObserver(
|
|
([entry]) => {
|
|
if (entry.isIntersecting) {
|
|
setWallNearViewport(true);
|
|
observer.disconnect();
|
|
}
|
|
},
|
|
// About a screen ahead; the images themselves stay `loading="lazy"`.
|
|
{ rootMargin: "100% 0px" },
|
|
);
|
|
observer.observe(wall);
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
if (contributors.length === 0) return null;
|
|
|
|
const colCount = 18;
|
|
const columns = Array.from({ length: colCount }, (_, i) => {
|
|
const perCol = Math.ceil(contributors.length / colCount);
|
|
return contributors.slice(i * perCol, (i + 1) * perCol);
|
|
});
|
|
|
|
const speeds = [
|
|
160, 190, 140, 176, 150, 184, 164, 144, 180, 156, 170, 136, 186, 152, 174,
|
|
146, 182, 158,
|
|
];
|
|
|
|
return (
|
|
<div className="mt-10 pt-8">
|
|
<div className="flex items-center gap-4 mb-2">
|
|
<span className="text-lg font-medium text-foreground/90 dark:text-foreground/80 tracking-tight shrink-0">
|
|
Contributors
|
|
</span>
|
|
<div className="flex-1 border-t border-foreground/10" />
|
|
</div>
|
|
<p className="text-[13px] text-foreground/50 dark:text-foreground/40 mb-5 leading-relaxed">
|
|
Built by a community of{" "}
|
|
<span className="text-foreground/70 dark:text-foreground/60 font-medium tabular-nums">
|
|
{contributorCount}+
|
|
</span>{" "}
|
|
contributors.
|
|
</p>
|
|
|
|
{contributors.length > 0 && (
|
|
<div
|
|
ref={wallRef}
|
|
className="relative overflow-hidden h-[220px] rounded-md"
|
|
style={{
|
|
perspective: "600px",
|
|
maskImage:
|
|
"linear-gradient(to bottom, transparent 0%, black 20%, black 80%, transparent 100%)",
|
|
WebkitMaskImage:
|
|
"linear-gradient(to bottom, transparent 0%, black 20%, black 80%, transparent 100%)",
|
|
}}
|
|
>
|
|
<div
|
|
className="absolute inset-0 pointer-events-none"
|
|
style={{
|
|
backgroundImage:
|
|
"radial-gradient(circle, currentColor 0.5px, transparent 0.5px)",
|
|
backgroundSize: "12px 12px",
|
|
opacity: 0.04,
|
|
}}
|
|
/>
|
|
<div
|
|
className="grid h-full relative"
|
|
style={{
|
|
gridTemplateColumns: `repeat(${colCount}, 1fr)`,
|
|
transform: "rotateX(18deg)",
|
|
transformOrigin: "center center",
|
|
}}
|
|
>
|
|
{columns.map((col, i) => (
|
|
<div key={i} className="relative overflow-hidden h-full">
|
|
<div
|
|
className="flex flex-col gap-1 items-center"
|
|
style={{
|
|
animation: `vertical-marquee ${speeds[i]}s linear infinite`,
|
|
}}
|
|
>
|
|
{[...col, ...col].map((c, j) => (
|
|
<a
|
|
key={`${c.login}-${j}`}
|
|
href={c.html_url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
title={c.login}
|
|
aria-label={c.login}
|
|
className="relative group shrink-0"
|
|
>
|
|
{showAvatars ? (
|
|
// Eager once prefetching: Safari's native lazy-loading
|
|
// only starts right at the viewport, so avatars would
|
|
// still pop in one by one as the marquee scrolls. Low
|
|
// priority keeps them behind the demo video, and the
|
|
// tile background covers any that land a beat late.
|
|
<img
|
|
src={`${c.avatar_url}&s=64`}
|
|
alt={c.login}
|
|
width={32}
|
|
height={32}
|
|
loading={prefetch ? "eager" : "lazy"}
|
|
fetchPriority="low"
|
|
className="rounded-sm bg-foreground/10 grayscale opacity-50 hover:grayscale-0 hover:opacity-100 transition-all duration-200 hover:scale-125 hover:z-10 relative"
|
|
/>
|
|
) : (
|
|
<span className="block size-8 rounded-sm bg-foreground/5" />
|
|
)}
|
|
<div className="absolute -top-7 left-1/2 -translate-x-1/2 px-1.5 py-0.5 bg-foreground text-background text-[8px] font-mono rounded-sm opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-20">
|
|
{c.login}
|
|
</div>
|
|
</a>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="absolute inset-y-0 left-0 w-8 bg-linear-to-r from-background to-transparent pointer-events-none z-10" />
|
|
<div className="absolute inset-y-0 right-0 w-8 bg-linear-to-l from-background to-transparent pointer-events-none z-10" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function formatCount(num: number | null | undefined): string {
|
|
if (num == null) return "—";
|
|
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
|
|
if (num >= 1_000) return `${(num / 1_000).toFixed(num >= 10_000 ? 0 : 1)}k`;
|
|
return num.toString();
|
|
}
|
|
|
|
function _NpmSparkline({ data: raw }: { data: number[] }) {
|
|
// Drop the last bucket — it's the incomplete current week
|
|
const data = raw.length > 2 ? raw.slice(0, -1) : raw;
|
|
const w = 120;
|
|
const h = 32;
|
|
const pad = 1;
|
|
const max = Math.max(...data);
|
|
const min = Math.min(...data);
|
|
const range = max - min || 1;
|
|
const points = data.map((v, i) => {
|
|
const x = pad + (i / (data.length - 1)) * (w - pad * 2);
|
|
const y = h - pad - ((v - min) / range) * (h - pad * 2);
|
|
return `${x},${y}`;
|
|
});
|
|
const line = points.join(" ");
|
|
const areaPath = `M${points[0]} ${points.map((p) => `L${p}`).join(" ")} L${w - pad},${h} L${pad},${h} Z`;
|
|
|
|
return (
|
|
<svg
|
|
width={w}
|
|
height={h}
|
|
viewBox={`0 0 ${w} ${h}`}
|
|
className="shrink-0 ml-auto"
|
|
>
|
|
<defs>
|
|
<linearGradient id="npm-spark-fill" x1="0" y1="0" x2="0" y2="1">
|
|
<stop
|
|
offset="0%"
|
|
className="[stop-color:theme(colors.emerald.500)]"
|
|
stopOpacity="0.15"
|
|
/>
|
|
<stop
|
|
offset="100%"
|
|
className="[stop-color:theme(colors.emerald.500)]"
|
|
stopOpacity="0"
|
|
/>
|
|
</linearGradient>
|
|
</defs>
|
|
<path d={areaPath} fill="url(#npm-spark-fill)" />
|
|
<polyline
|
|
points={line}
|
|
fill="none"
|
|
className="stroke-emerald-500/50"
|
|
strokeWidth="1.5"
|
|
strokeLinejoin="round"
|
|
strokeLinecap="round"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function ReadmeFooter({ stats }: { stats: CommunityHeroStats }) {
|
|
return (
|
|
<div className="relative mt-10 pt-8 pb-16 overflow-hidden">
|
|
{/* Watermark logo */}
|
|
<div
|
|
className="absolute -right-10 top-1/2 -translate-y-1/2 pointer-events-none select-none opacity-[0.03] dark:opacity-[0.04]"
|
|
aria-hidden="true"
|
|
>
|
|
<svg
|
|
width="300"
|
|
height="225"
|
|
viewBox="0 0 60 45"
|
|
fill="none"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
>
|
|
<path
|
|
fillRule="evenodd"
|
|
clipRule="evenodd"
|
|
d="M0 0H15V15H30V30H15V45H0V30V15V0ZM45 30V15H30V0H45H60V15V30V45H45H30V30H45Z"
|
|
className="fill-foreground"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
|
|
{/* Dot grid */}
|
|
<div
|
|
className="absolute inset-0 pointer-events-none select-none"
|
|
aria-hidden="true"
|
|
style={{
|
|
backgroundImage:
|
|
"radial-gradient(circle, currentColor 0.5px, transparent 0.5px)",
|
|
backgroundSize: "24px 24px",
|
|
opacity: 0.03,
|
|
}}
|
|
/>
|
|
|
|
{/* CTA */}
|
|
<div className="relative space-y-6">
|
|
<p className="text-center text-lg text-balance text-foreground/60 dark:text-foreground/50 tracking-tight">
|
|
Roll your own auth with confidence in minutes.
|
|
</p>
|
|
|
|
<div className="flex items-center justify-center gap-2">
|
|
{stats.npmDownloads > 0 && (
|
|
<a
|
|
href="https://www.npmjs.com/package/better-auth"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
<div className="flex items-center gap-1.5 px-2.5 hover:bg-foreground/4 rounded-sm transition-colors text-foreground/50 dark:text-foreground/50">
|
|
<Icons.npm className="size-[11px] -translate-y-px" />
|
|
<span className="text-xs font-mono">
|
|
{formatCount(stats.npmDownloads)} / week
|
|
</span>
|
|
</div>
|
|
</a>
|
|
)}
|
|
{stats.githubStars > 0 && (
|
|
<a
|
|
href="https://github.com/better-auth/better-auth"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
<div className="flex items-center gap-1.5 px-2.5 hover:bg-foreground/4 rounded-sm transition-colors text-foreground/50 dark:text-foreground/50">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="11"
|
|
height="11"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className="size-[11px] -translate-y-px"
|
|
>
|
|
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
|
</svg>
|
|
<span className="text-xs font-mono">
|
|
{formatCount(stats.githubStars)} stars
|
|
</span>
|
|
</div>
|
|
</a>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center justify-center gap-4 pt-1">
|
|
<Link
|
|
href="/docs/installation"
|
|
className="inline-flex items-center gap-1.5 px-4 sm:px-5 py-2 bg-neutral-900 text-neutral-100 dark:bg-neutral-100 dark:text-neutral-900 text-xs sm:text-sm font-medium hover:opacity-90 transition-colors"
|
|
>
|
|
Get Started
|
|
</Link>
|
|
<Link
|
|
href="https://dash.better-auth.com/sign-in"
|
|
className="relative inline-flex items-center gap-1.5 px-4 sm:px-5 py-2 text-neutral-600 dark:text-neutral-300 text-xs sm:text-sm font-medium transition-colors group"
|
|
>
|
|
<span
|
|
className="absolute inset-0 opacity-[0.04] group-hover:opacity-[0.08] transition-opacity"
|
|
style={{
|
|
backgroundImage: `repeating-linear-gradient(
|
|
-45deg,
|
|
transparent,
|
|
transparent 4px,
|
|
currentColor 4px,
|
|
currentColor 5px
|
|
)`,
|
|
}}
|
|
/>
|
|
<span className="absolute top-0 -left-[6px] -right-[6px] h-px bg-foreground/20 group-hover:bg-foreground/30 transition-colors" />
|
|
<span className="absolute bottom-0 -left-[6px] -right-[6px] h-px bg-foreground/20 group-hover:bg-foreground/30 transition-colors" />
|
|
<span className="absolute left-0 -top-[6px] -bottom-[6px] w-px bg-foreground/20 group-hover:bg-foreground/30 transition-colors" />
|
|
<span className="absolute right-0 -top-[6px] -bottom-[6px] w-px bg-foreground/20 group-hover:bg-foreground/30 transition-colors" />
|
|
<span className="absolute -bottom-[6px] -right-[6px] font-mono text-[8px] text-foreground/40 dark:text-foreground/50 leading-none select-none translate-x-1/2 translate-y-1/2">
|
|
+
|
|
</span>
|
|
<span className="relative">Sign In </span>
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Both theme variants are rendered so CSS picks the right one without a
|
|
* hydration flash, but neither autoplays or preloads up front. Only the
|
|
* visible variant is fetched, after the page has loaded: right away on fast
|
|
* connections, or once it's about to scroll into view on slow ones.
|
|
*/
|
|
function DemoVideo() {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const { resolvedTheme } = useTheme();
|
|
const { loaded, prefetch } = useBelowFoldMedia();
|
|
|
|
// On fast connections, buffer the visible variant once the page has
|
|
// loaded so it's already playing by the time it scrolls into view.
|
|
useEffect(() => {
|
|
if (!prefetch || !containerRef.current) return;
|
|
for (const video of containerRef.current.querySelectorAll("video")) {
|
|
if (video.offsetParent === null || !video.paused) continue;
|
|
video.preload = "auto";
|
|
// Safari won't resume a `preload="none"` video on its own.
|
|
if (video.readyState === 0) video.load();
|
|
}
|
|
}, [prefetch, resolvedTheme]);
|
|
|
|
useEffect(() => {
|
|
const container = containerRef.current;
|
|
if (!loaded || !container) return;
|
|
const videos = Array.from(container.querySelectorAll("video"));
|
|
const observer = new IntersectionObserver(
|
|
([entry]) => {
|
|
for (const video of videos) {
|
|
const visible = video.offsetParent !== null;
|
|
if (entry.isIntersecting && visible) {
|
|
video.play().catch(() => {});
|
|
} else {
|
|
video.pause();
|
|
}
|
|
}
|
|
},
|
|
// Only start playing when it's close: on slow connections that's
|
|
// also when the (multi-MB) video starts downloading.
|
|
{ rootMargin: "200px" },
|
|
);
|
|
observer.observe(container);
|
|
return () => observer.disconnect();
|
|
}, [loaded, resolvedTheme]);
|
|
|
|
return (
|
|
<div ref={containerRef}>
|
|
{/* The poster is a CSS background rather than the `poster` attribute:
|
|
hidden <video>s still fetch their poster, but browsers skip
|
|
backgrounds on display:none elements, so only the active theme's
|
|
poster loads and it's there on first paint. */}
|
|
<video
|
|
src="/demo-dark.mp4"
|
|
preload="none"
|
|
loop
|
|
muted
|
|
playsInline
|
|
className="w-full h-auto aspect-[2528/1440] -mt-[2px] bg-[url(/demo-dark-poster.webp)] bg-cover dark:block hidden"
|
|
/>
|
|
<video
|
|
src="/demo-light.mp4"
|
|
preload="none"
|
|
loop
|
|
muted
|
|
playsInline
|
|
className="w-full h-auto aspect-[2544/1440] -mt-[2px] bg-[url(/demo-light-poster.webp)] bg-cover dark:hidden"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function HeroReadMe({
|
|
contributors,
|
|
stats,
|
|
}: {
|
|
contributors: ContributorInfo[];
|
|
stats: CommunityHeroStats;
|
|
}) {
|
|
const [socialHovered, setSocialHovered] = useState(false);
|
|
const [frameworkTab, setFrameworkTab] = useState<
|
|
"declarative" | "database" | "oauth" | "integrations"
|
|
>("declarative");
|
|
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
transition={{ duration: 0.5, delay: 0.15, ease: "easeOut" }}
|
|
className="flex flex-col w-full"
|
|
>
|
|
{/* Markdown content */}
|
|
<div className="flex-1 overflow-x-hidden no-scrollbar">
|
|
<div className="p-5 lg:px-8 lg:pt-20">
|
|
<motion.article
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
transition={{ duration: 0.4, delay: 0.3 }}
|
|
className="no-scrollbar pb-0"
|
|
>
|
|
<h1 className="flex items-center gap-3 text-sm sm:text-[15px] font-mono text-neutral-900 dark:text-neutral-100 mb-4 sm:mb-5">
|
|
README
|
|
<span className="flex-1 h-px bg-foreground/15" />
|
|
</h1>
|
|
|
|
<p className="text-sm sm:text-[15px] text-foreground/80 mb-6 sm:mb-8 leading-relaxed">
|
|
Auth that lives{" "}
|
|
<span className="font-medium text-foreground/90 dark:text-foreground/80">
|
|
inside your app
|
|
</span>
|
|
. Composable, plugin-based, and built to scale — powering from
|
|
weekend projects to the biggest{" "}
|
|
<span className="font-medium text-foreground/90 dark:text-foreground/80">
|
|
consumer and enterprise apps
|
|
</span>{" "}
|
|
on the planet.
|
|
</p>
|
|
|
|
<InstallBlock />
|
|
|
|
<div className="flex items-center gap-3 my-4">
|
|
<div className="flex-1 border-t border-foreground/6"></div>
|
|
<span className="text-[11px] sm:text-xs text-foreground/50 dark:text-foreground/50 font-mono tracking-wider uppercase shrink-0">
|
|
Trusted By
|
|
</span>
|
|
</div>
|
|
|
|
<TrustedBy />
|
|
|
|
<div className="flex items-center gap-4 my-4">
|
|
<span className="text-lg font-medium text-foreground/90 dark:text-foreground/80 tracking-tight shrink-0">
|
|
Features
|
|
</span>
|
|
<div className="flex-1 border-t border-foreground/10" />
|
|
</div>
|
|
|
|
<div className="relative grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 mb-2 border border-foreground/[0.08] overflow-hidden">
|
|
{[
|
|
{
|
|
label: "Framework Agnostic",
|
|
headline: "Works with your stack.",
|
|
desc: "Next.js, Nuxt, SvelteKit, Astro, Hono, and 20+ more.",
|
|
logos: true,
|
|
href: "/docs",
|
|
},
|
|
{
|
|
label: "Email & Password",
|
|
headline: "Built-in credential auth.",
|
|
desc: "Sessions, email verification, and password reset included.",
|
|
credential: true,
|
|
href: "/docs",
|
|
},
|
|
{
|
|
label: "Social Sign-on",
|
|
headline: "Social sign-on.",
|
|
desc: "Google, GitHub, Apple, Discord, and more.",
|
|
social: true,
|
|
href: "/docs",
|
|
},
|
|
{
|
|
label: "Organizations",
|
|
headline: "Multi-tenancy built in.",
|
|
desc: "Teams, roles, invitations, and access control.",
|
|
org: true,
|
|
href: "/docs",
|
|
},
|
|
{
|
|
label: "Enterprise",
|
|
headline: "Enterprise ready.",
|
|
desc: "SSO, SAML 2.0, SCIM, and directory sync.",
|
|
enterprise: true,
|
|
href: "/docs",
|
|
},
|
|
{
|
|
label: "Plugins",
|
|
headline: "50+ and growing.",
|
|
desc: "Passkeys, magic links, API keys, JWTs, and more.",
|
|
plugins: true,
|
|
href: "/docs",
|
|
},
|
|
{
|
|
label: "Agent Auth",
|
|
headline: "Auth for AI agents.",
|
|
desc: "MCP auth, token exchange, and agent delegation.",
|
|
agent: true,
|
|
href: "/docs",
|
|
},
|
|
{
|
|
label: "Infrastructure",
|
|
headline: "Security & observability.",
|
|
desc: "Bot detection, IP blocking, and email validation.",
|
|
security: true,
|
|
href: "/pricing",
|
|
managed: true,
|
|
},
|
|
{
|
|
label: "Dashboard",
|
|
headline: "User management.",
|
|
desc: "Manage users, sessions, and organizations.",
|
|
dashboard: true,
|
|
href: "/pricing",
|
|
managed: true,
|
|
},
|
|
].map((feature, i) => (
|
|
<Link
|
|
key={feature.label}
|
|
href={"href" in feature ? feature.href : "#"}
|
|
className="contents"
|
|
>
|
|
<motion.div
|
|
whileHover={{
|
|
y: -2,
|
|
transition: { duration: 0.2, ease: "easeOut" },
|
|
}}
|
|
onMouseEnter={() => {
|
|
if ("social" in feature && feature.social) {
|
|
setSocialHovered(true);
|
|
}
|
|
}}
|
|
onMouseLeave={() => {
|
|
if ("social" in feature && feature.social) {
|
|
setSocialHovered(false);
|
|
}
|
|
}}
|
|
className={cn(
|
|
"group/card relative p-4 lg:p-5 border-foreground/[0.08] min-h-[100px] transition-all duration-200 hover:bg-foreground/[0.02] hover:shadow-[inset_0_1px_0_0_rgba(128,128,128,0.1)] hover:z-10",
|
|
// Bottom border: all except last; 3-col last row starts at 6
|
|
i < 8 && "border-b",
|
|
i >= 6 && "md:border-b-0",
|
|
// Right border: none on mobile
|
|
// 2-col: left column (even indices) gets right border
|
|
i % 2 === 0 && i < 8 && "sm:border-r",
|
|
// 3-col: remove right border on 3rd column, add on odd indices that need it
|
|
i % 3 === 2 && "md:border-r-0",
|
|
i % 2 !== 0 && i % 3 !== 2 && "md:border-r",
|
|
)}
|
|
>
|
|
{/* Arrow icon — top right, visible on hover */}
|
|
<span className="absolute top-3 right-3 lg:top-4 lg:right-4 opacity-0 -translate-y-0.5 group-hover/card:opacity-100 group-hover/card:translate-y-0 transition-all duration-200">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className="text-foreground/40 dark:text-foreground/50"
|
|
>
|
|
<line x1="7" y1="17" x2="17" y2="7" />
|
|
<polyline points="7 7 17 7 17 17" />
|
|
</svg>
|
|
</span>
|
|
<div className="mb-1">
|
|
<div className="text-[11px] font-mono text-foreground/45 dark:text-foreground/30 tracking-wider transition-colors duration-200 group-hover/card:text-foreground/60 dark:group-hover/card:text-foreground/40">
|
|
{String(i + 1).padStart(2, "0")}
|
|
</div>
|
|
<div className="text-[13px] font-medium text-foreground/80 dark:text-neutral-100 transition-colors duration-200">
|
|
{feature.headline}
|
|
</div>
|
|
</div>
|
|
<div className="text-[13px] text-neutral-500 dark:text-neutral-400 leading-relaxed transition-colors duration-200 group-hover/card:text-neutral-400 dark:group-hover/card:text-neutral-300">
|
|
{feature.desc}
|
|
</div>
|
|
{"logos" in feature && feature.logos && (
|
|
<div className="flex items-center gap-3.5 mt-3">
|
|
{/* Next.js */}
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="15"
|
|
height="15"
|
|
viewBox="0 0 24 24"
|
|
className="text-neutral-800 dark:text-neutral-200 opacity-90 transition-all duration-300 group-hover/card:opacity-100 group-hover/card:animate-[icon-bounce_0.4s_ease-out_0s]"
|
|
>
|
|
<path
|
|
fill="currentColor"
|
|
d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10s-4.477 10-10 10m4-14h-1.35v4H16zM9.346 9.71l6.059 7.828l1.054-.809L9.683 8H8v7.997h1.346z"
|
|
/>
|
|
</svg>
|
|
{/* Nuxt */}
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="15"
|
|
height="15"
|
|
viewBox="0 0 24 24"
|
|
className="text-[#00DC82] opacity-90 transition-all duration-300 group-hover/card:opacity-100 group-hover/card:animate-[icon-bounce_0.4s_ease-out_0.05s]"
|
|
>
|
|
<path
|
|
fill="currentColor"
|
|
d="M13.2 18.666h7.4c.236 0 .462-.083.667-.2c.204-.117.415-.264.533-.466c.118-.203.2-.433.2-.667s-.082-.464-.2-.667l-5-8.6a1.2 1.2 0 0 0-.467-.466a1.6 1.6 0 0 0-.733-.2c-.236 0-.462.083-.667.2a1.2 1.2 0 0 0-.466.466l-1.267 2.2L10.667 6c-.118-.203-.262-.417-.467-.534s-.43-.133-.667-.133c-.236 0-.462.016-.666.133s-.416.33-.534.534l-6.2 10.666c-.118.203-.133.433-.133.667s.015.464.133.667c.118.202.33.35.534.466s.43.2.666.2H8c1.85 0 3.195-.83 4.133-2.4l2.267-3.933l1.2-2.067l3.667 6.267H14.4zm-5.267-2.133H4.667l4.866-8.4l2.467 4.2l-1.634 2.848c-.623 1.02-1.333 1.352-2.433 1.352"
|
|
/>
|
|
</svg>
|
|
{/* SvelteKit */}
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="13"
|
|
height="15"
|
|
viewBox="0 0 426 512"
|
|
className="text-[#FF3E00] opacity-90 transition-all duration-300 group-hover/card:opacity-100 group-hover/card:animate-[icon-bounce_0.4s_ease-out_0.1s]"
|
|
>
|
|
<path
|
|
fill="currentColor"
|
|
d="M403.508 229.23C491.235 87.7 315.378-58.105 190.392 23.555L71.528 99.337c-57.559 37.487-82.55 109.513-47.45 183.53c-87.761 133.132 83.005 289.03 213.116 205.762l118.864-75.782c64.673-42.583 79.512-116.018 47.45-183.616m-297.592-80.886l118.69-75.739c77.973-46.679 167.756 34.942 135.388 110.992c-19.225-15.274-40.65-24.665-56.923-28.894c6.186-24.57-22.335-42.796-42.174-30.106l-118.95 75.48c-29.411 20.328 1.946 62.138 31.014 44.596l45.33-28.895c101.725-57.403 198 80.425 103.38 147.975l-118.692 75.739C131.455 485.225 34.11 411.96 67.592 328.5c17.786 13.463 36.677 23.363 56.923 28.894c-4.47 28.222 24.006 41.943 42.476 30.365L285.64 312.02c29.28-21.955-2.149-61.692-30.97-44.595l-45.504 28.894c-100.56 58.77-199.076-80.42-103.25-147.975"
|
|
/>
|
|
</svg>
|
|
{/* Solid Start */}
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="15"
|
|
height="15"
|
|
viewBox="0 0 128 128"
|
|
className="text-[#2C4F7C] dark:text-[#66AAEE] opacity-90 transition-all duration-300 group-hover/card:opacity-100 group-hover/card:animate-[icon-bounce_0.4s_ease-out_0.2s]"
|
|
>
|
|
<path
|
|
fill="currentColor"
|
|
d="M61.832 4.744c-3.205.058-6.37.395-9.45 1.07l-2.402.803c-4.806 1.603-8.813 4.005-11.216 7.21l-1.602 2.404l-12.017 20.828l.166.031c-4.785 5.823-5.007 14.07-.166 21.6c1.804 2.345 4.073 4.431 6.634 6.234l-15.445 4.982L.311 97.946s42.46 32.044 75.306 24.033l2.403-.801c5.322-1.565 9.292-4.48 11.683-8.068l.334.056l16.022-28.84c3.204-5.608 2.404-12.016-1.602-18.425a36 36 0 0 0-7.059-6.643l15.872-5.375l14.42-24.033S92.817 4.19 61.831 4.744z"
|
|
/>
|
|
</svg>
|
|
{/* TanStack Start */}
|
|
<svg
|
|
height="15"
|
|
viewBox="0 0 64.04 83.84"
|
|
width="12"
|
|
className="text-neutral-800 dark:text-neutral-200 opacity-90 transition-all duration-300 group-hover/card:opacity-100 group-hover/card:animate-[icon-bounce_0.4s_ease-out_0.15s]"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
>
|
|
<path
|
|
fill="currentColor"
|
|
d="M32.02,0c23.15,0,32.02,14.33,32.02,41.92s-8.87,41.92-32.02,41.92S0,69.53,0,41.92C0,14.33,8.87,0,32.02,0ZM42.61,61.08c-3.01,0-4.65.82-5.97,1.48-1.14.57-2.03,1.02-4.01,1.02s-2.87-.45-4.01-1.02c-1.32-.66-2.96-1.48-5.97-1.48s-4.65.82-5.97,1.48c-1.14.57-2.03,1.02-4.01,1.02v4.37c3.01,0,4.65-.82,5.97-1.48,1.14-.57,2.03-1.02,4.01-1.02s2.87.45,4.01,1.02c1.32.66,2.96,1.48,5.97,1.48s4.65-.82,5.97-1.48c1.14-.57,2.03-1.02,4.01-1.02s2.87.45,4.01,1.02c1.32.66,2.96,1.48,5.97,1.48v-4.37c-1.97,0-2.87-.45-4.01-1.02-1.32-.66-2.96-1.48-5.97-1.48ZM44.04,17.29c2.27-2.05,0-5.76-2.84-4.64-1.57.63-3.05,1.53-4.37,2.72-2.06,1.87-3.45,4.22-4.2,6.78-.75-2.56-2.14-4.91-4.2-6.78-1.32-1.18-2.8-2.09-4.37-2.72-2.84-1.12-5.11,2.59-2.84,4.64l8.33,7.52c-2.72-1.84-6-2.93-9.54-2.93-1.93,0-3.78.31-5.52.91-2.92.99-2.11,5.35.96,5.35h11.62c-2.56.49-4.99,1.63-7.03,3.45-1.32,1.18-2.36,2.56-3.14,4.07-1.41,2.71,2.05,5.34,4.32,3.31l9.38-8.45-.7,23.25c0,.28-.16.55-.37.78-.3-.13-.58-.27-.9-.43-1.32-.66-2.96-1.48-5.97-1.48s-4.65.82-5.97,1.48c-1.14.57-2.03,1.02-4.01,1.02v4.37c3.01,0,4.65-.82,5.97-1.48,1.14-.57,2.03-1.02,4.01-1.02s2.87.45,4.01,1.02c1.32.66,2.96,1.48,5.97,1.48s4.65-.82,5.97-1.48c1.14-.57,2.03-1.02,4.01-1.02s2.87.45,4.01,1.02c1.32.66,2.96,1.48,5.97,1.48v-4.37c-1.97,0-2.87-.45-4.01-1.02v-.02c-1.32-.66-2.96-1.48-5.97-1.48s-4.65.82-5.97,1.48c-.34.16-.66.33-.99.46-.25-.22-.4-.51-.42-.81l-.7-23.37,9.51,8.58c2.27,2.05,5.73-.58,4.32-3.31-.78-1.5-1.82-2.89-3.14-4.07-2.03-1.84-4.46-2.96-7.03-3.45h11.62c3.08,0,3.87-4.35.96-5.35-1.73-.58-3.59-.91-5.52-.91-3.53,0-6.8,1.09-9.54,2.93l8.33-7.52Z"
|
|
/>
|
|
</svg>
|
|
{/* Expo */}
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="15"
|
|
height="15"
|
|
viewBox="0 0 32 32"
|
|
className="text-neutral-800 dark:text-neutral-200 opacity-90 transition-all duration-300 group-hover/card:opacity-100 group-hover/card:animate-[icon-bounce_0.4s_ease-out_0.25s]"
|
|
>
|
|
<path
|
|
fill="currentColor"
|
|
d="M24.292 15.547a3.93 3.93 0 0 0 4.115-3.145a2.57 2.57 0 0 0-2.161-1.177c-2.272-.052-3.491 2.651-1.953 4.323zm-9.177-10.85l5.359-3.104L18.766.63l-7.391 4.281l.589.328l1.119.629l2.032-1.176zm6.046-3.39c.089.027.161.1.188.188l2.484 7.593a.285.285 0 0 1-.125.344a5.06 5.06 0 0 0-2.317 5.693a5.066 5.066 0 0 0 5.401 3.703a.3.3 0 0 1 .307.203l2.563 7.803a.3.3 0 0 1-.125.344l-7.859 4.771a.3.3 0 0 1-.131.036a.26.26 0 0 1-.203-.041l-2.765-1.797a.3.3 0 0 1-.109-.129l-5.396-12.896l-8.219 4.875c-.016.011-.037.021-.052.032a.3.3 0 0 1-.261-.021l-1.859-1.093a.283.283 0 0 1-.115-.381l7.953-15.749a.27.27 0 0 1 .135-.131L18.615.045a.29.29 0 0 1 .292-.005zm-8.322 5.1l-1.932-1.089l-7.693 15.229l1.396.823l6.631-9.015a.28.28 0 0 1 .271-.12a.29.29 0 0 1 .235.177l7.228 17.296l1.933 1.251l-8.063-24.552zm13.406 10.557c-2.256 0-3.787-2.292-2.923-4.376c.86-2.083 3.563-2.619 5.156-1.025c.595.593.928 1.396.928 2.235a3.16 3.16 0 0 1-3.161 3.167z"
|
|
/>
|
|
</svg>
|
|
{/* +14 more */}
|
|
<div className="flex items-center justify-center size-[20px] border border-dashed border-foreground/[0.1] text-foreground/35 dark:text-foreground/20 transition-all duration-300 group-hover/card:text-foreground/60 dark:group-hover/card:text-foreground/40 group-hover/card:border-foreground/20 group-hover/card:animate-[icon-bounce_0.4s_ease-out_0.3s]">
|
|
<span className="text-[7px] font-mono leading-none">
|
|
+14
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{"social" in feature && feature.social && (
|
|
<div className="mt-3 relative overflow-hidden">
|
|
<div className="flex items-center gap-2.5">
|
|
<div className="relative flex items-center gap-2.5">
|
|
<div className="absolute left-3 right-3 top-1/2 h-px -translate-y-1/2 bg-foreground/[0.08]" />
|
|
{/* Google — stretches to "Sign in with Google" on hover */}
|
|
<motion.div
|
|
animate={{ width: socialHovered ? 120 : 24 }}
|
|
transition={{
|
|
duration: 0.3,
|
|
ease: [0.4, 0, 0.2, 1],
|
|
}}
|
|
className="relative flex items-center h-6 border border-foreground/8 bg-background shrink-0 overflow-hidden opacity-60 transition-opacity duration-300 group-hover/card:opacity-100"
|
|
>
|
|
<div className="flex items-center gap-1.5 px-[7px]">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="10"
|
|
height="10"
|
|
viewBox="0 0 48 48"
|
|
className="shrink-0"
|
|
>
|
|
<path
|
|
fill="#FFC107"
|
|
d="M43.611 20.083H42V20H24v8h11.303c-1.649 4.657-6.08 8-11.303 8c-6.627 0-12-5.373-12-12s5.373-12 12-12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4C12.955 4 4 12.955 4 24s8.955 20 20 20s20-8.955 20-20c0-1.341-.138-2.65-.389-3.917"
|
|
/>
|
|
<path
|
|
fill="#FF3D00"
|
|
d="m6.306 14.691l6.571 4.819C14.655 15.108 18.961 12 24 12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4C16.318 4 9.656 8.337 6.306 14.691"
|
|
/>
|
|
<path
|
|
fill="#4CAF50"
|
|
d="M24 44c5.166 0 9.86-1.977 13.409-5.192l-6.19-5.238A11.9 11.9 0 0 1 24 36c-5.202 0-9.619-3.317-11.283-7.946l-6.522 5.025C9.505 39.556 16.227 44 24 44"
|
|
/>
|
|
<path
|
|
fill="#1976D2"
|
|
d="M43.611 20.083H42V20H24v8h11.303a12.04 12.04 0 0 1-4.087 5.571l.003-.002l6.19 5.238C36.971 39.205 44 34 44 24c0-1.341-.138-2.65-.389-3.917"
|
|
/>
|
|
</svg>
|
|
<motion.span
|
|
animate={{ opacity: socialHovered ? 1 : 0 }}
|
|
transition={{
|
|
duration: 0.2,
|
|
delay: socialHovered ? 0.1 : 0,
|
|
}}
|
|
className="text-[8px] font-mono text-foreground/60 dark:text-foreground/40 whitespace-nowrap"
|
|
>
|
|
Sign in with Google
|
|
</motion.span>
|
|
</div>
|
|
</motion.div>
|
|
{/* GitHub */}
|
|
<div className="relative flex items-center justify-center size-6 border border-foreground/[0.08] bg-background text-neutral-800 dark:text-neutral-200 shrink-0 opacity-60 transition-opacity duration-300 group-hover/card:opacity-100">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="10"
|
|
height="10"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
fill="currentColor"
|
|
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
{/* Apple */}
|
|
<div className="relative flex items-center justify-center size-6 border border-foreground/[0.08] bg-background text-neutral-800 dark:text-neutral-200 shrink-0 opacity-60 transition-opacity duration-300 group-hover/card:opacity-100">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="10"
|
|
height="10"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
fill="currentColor"
|
|
d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
{/* Discord */}
|
|
<div className="relative flex items-center justify-center size-6 border border-foreground/[0.08] bg-background text-[#5865F2] shrink-0 opacity-60 transition-opacity duration-300 group-hover/card:opacity-100">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="10"
|
|
height="10"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
fill="currentColor"
|
|
d="M20.317 4.37a19.8 19.8 0 0 0-4.885-1.515.07.07 0 0 0-.073.036c-.21.375-.444.864-.608 1.25a18.3 18.3 0 0 0-5.487 0 13 13 0 0 0-.617-1.25.07.07 0 0 0-.073-.036A19.7 19.7 0 0 0 3.69 4.37a.06.06 0 0 0-.032.025C.533 9.046-.32 13.58.099 18.057a.08.08 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.08.08 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.08.08 0 0 0-.041-.106 13 13 0 0 1-1.872-.892.08.08 0 0 1-.008-.128q.188-.141.372-.287a.08.08 0 0 1 .078-.01c3.928 1.793 8.18 1.793 12.062 0a.08.08 0 0 1 .079.01q.183.149.372.288a.08.08 0 0 1-.006.127c-.598.35-1.22.645-1.873.892a.08.08 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.08.08 0 0 0 .084.029 19.8 19.8 0 0 0 6.002-3.03.08.08 0 0 0 .032-.056c.5-5.177-.838-9.674-3.549-13.66a.06.06 0 0 0-.031-.026M8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419s.955-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418m7.975 0c-1.183 0-2.157-1.085-2.157-2.419s.955-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
{/* Microsoft */}
|
|
<div className="relative flex items-center justify-center size-6 border border-foreground/[0.08] bg-background shrink-0 opacity-60 transition-opacity duration-300 group-hover/card:opacity-100">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="9"
|
|
height="9"
|
|
viewBox="0 0 256 256"
|
|
>
|
|
<path
|
|
fill="#F1511B"
|
|
d="M121.666 121.666H0V0h121.666z"
|
|
/>
|
|
<path
|
|
fill="#80CC28"
|
|
d="M256 121.666H134.335V0H256z"
|
|
/>
|
|
<path
|
|
fill="#00ADEF"
|
|
d="M121.663 256.002H0V134.336h121.663z"
|
|
/>
|
|
<path
|
|
fill="#FBBC09"
|
|
d="M256 256.002H134.335V134.336H256z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
{/* X/Twitter */}
|
|
<div className="relative flex items-center justify-center size-6 border border-foreground/[0.08] bg-background text-neutral-800 dark:text-neutral-200 shrink-0 opacity-60 transition-opacity duration-300 group-hover/card:opacity-100">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="9"
|
|
height="9"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
fill="currentColor"
|
|
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
{/* +34 */}
|
|
<div className="flex items-center justify-center size-6 border border-dashed border-foreground/[0.1] text-foreground/35 dark:text-foreground/20 shrink-0">
|
|
<span className="text-[8px] font-mono leading-none">
|
|
+34
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="absolute inset-y-0 right-0 w-12 bg-gradient-to-l from-background to-transparent pointer-events-none" />
|
|
</div>
|
|
)}
|
|
{"credential" in feature && feature.credential && (
|
|
<CredentialFields />
|
|
)}
|
|
{"org" in feature && feature.org && (
|
|
<div className="mt-3 flex items-center gap-2.5">
|
|
{/* Overlapping member avatars */}
|
|
<div className="flex -space-x-1.5">
|
|
<div className="relative size-5 rounded-full border border-foreground/[0.08] bg-neutral-100 dark:bg-neutral-900 flex items-center justify-center z-[3]">
|
|
<span className="text-[8px] font-mono text-foreground/55 dark:text-foreground/35 leading-none">
|
|
A
|
|
</span>
|
|
</div>
|
|
<div className="relative size-5 rounded-full border border-foreground/[0.08] bg-neutral-100 dark:bg-neutral-900 flex items-center justify-center z-[2]">
|
|
<span className="text-[8px] font-mono text-foreground/50 dark:text-foreground/30 leading-none">
|
|
B
|
|
</span>
|
|
</div>
|
|
<div className="relative size-5 rounded-full border border-foreground/[0.08] bg-neutral-100 dark:bg-neutral-900 flex items-center justify-center z-[1]">
|
|
<span className="text-[8px] font-mono text-foreground/40 dark:text-foreground/50 leading-none">
|
|
C
|
|
</span>
|
|
</div>
|
|
<div className="relative size-5 rounded-full border border-dashed border-foreground/[0.1] bg-background flex items-center justify-center z-[0]">
|
|
<span className="text-[8px] font-mono text-foreground/35 dark:text-foreground/20 leading-none">
|
|
+
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{/* Role badges */}
|
|
<div className="flex items-center gap-1">
|
|
<span className="text-[8px] font-mono text-foreground/50 dark:text-foreground/30 px-1.5 py-0.5 border border-foreground/[0.08] bg-foreground/[0.015]">
|
|
owner
|
|
</span>
|
|
<span className="text-[8px] font-mono text-foreground/35 dark:text-foreground/20 px-1.5 py-0.5 border border-foreground/[0.06] bg-foreground/[0.015]">
|
|
admin
|
|
</span>
|
|
<span className="text-[8px] font-mono text-foreground/30 px-1.5 py-0.5 border border-dashed border-foreground/[0.08]">
|
|
member
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{"plugins" in feature && feature.plugins && (
|
|
<div className="mt-3 relative overflow-hidden">
|
|
<div className="flex items-center gap-1 overflow-hidden">
|
|
{[
|
|
"passkeys",
|
|
"2fa",
|
|
"magic-link",
|
|
"jwt",
|
|
"api-keys",
|
|
"anonymous",
|
|
"oidc",
|
|
"otp",
|
|
"bearer",
|
|
"multi-session",
|
|
].map((plugin, i) => (
|
|
<span
|
|
key={plugin}
|
|
className={`text-[8px] font-mono whitespace-nowrap px-1.5 py-0.5 border shrink-0 ${i < 2 ? "text-foreground/50 dark:text-foreground/30 border-foreground/[0.08] bg-foreground/[0.02]" : i < 4 ? "text-foreground/40 dark:text-foreground/22 border-foreground/[0.06] bg-foreground/[0.015]" : "text-foreground/30 border-foreground/[0.05]"}`}
|
|
>
|
|
{plugin}
|
|
</span>
|
|
))}
|
|
</div>
|
|
{/* Fade-out gradient on the right to imply "there's more" */}
|
|
<div className="absolute inset-y-0 right-0 w-12 bg-gradient-to-l from-background to-transparent pointer-events-none" />
|
|
</div>
|
|
)}
|
|
{"enterprise" in feature && feature.enterprise && (
|
|
<div className="mt-3 flex items-center gap-2.5">
|
|
<div className="relative flex items-center gap-2.5">
|
|
<div className="absolute left-3 right-3 top-1/2 h-px -translate-y-1/2 bg-foreground/[0.08]" />
|
|
{/* Okta */}
|
|
<div className="relative flex items-center justify-center size-6 border border-foreground/[0.08] bg-background text-neutral-800 dark:text-neutral-200 opacity-60 transition-opacity duration-300 group-hover/card:opacity-100">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="10"
|
|
height="10"
|
|
viewBox="0 0 256 256"
|
|
>
|
|
<path
|
|
fill="currentColor"
|
|
d="m140.844 1.778l-5.266 64.853a66 66 0 0 0-7.542-.427c-3.203 0-6.334.214-9.393.712l-2.99-31.432a1.72 1.72 0 0 1 1.709-1.848h5.337l-2.562-31.787C120.066.853 120.848 0 121.774 0h17.434c.996 0 1.779.853 1.636 1.849zm-43.976 3.2c-.285-.925-1.281-1.494-2.206-1.138L78.295 9.813c-.925.356-1.352 1.423-.925 2.276l13.307 29.013l-5.052 1.85c-.926.355-1.352 1.421-.926 2.275l13.592 28.515a61 61 0 0 1 15.868-6.044L96.94 4.978zM56.734 23.04l37.643 53.049c-4.768 3.129-9.108 6.827-12.809 11.093L59.011 64.996a1.72 1.72 0 0 1 .071-2.49l4.127-3.413L40.794 36.41c-.711-.711-.64-1.849.142-2.489l13.307-11.164c.783-.64 1.85-.498 2.42.284zM25.139 53.76c-.783-.569-1.921-.284-2.42.569l-8.68 15.075c-.499.854-.143 1.92.71 2.347L43.64 85.404l-2.704 4.623c-.498.853-.142 1.99.783 2.346l28.749 13.156a60.2 60.2 0 0 1 8.254-14.791zM3.862 94.72c.143-.996 1.139-1.564 2.064-1.351l62.976 16.427a62.3 62.3 0 0 0-2.704 16.782l-31.524-2.56a1.642 1.642 0 0 1-1.494-1.991l.925-5.263l-31.808-2.986c-.996-.071-1.637-.996-1.495-1.991l2.99-17.138zm-2.348 42.524c-.996.072-1.637.996-1.494 1.992l3.06 17.137c.142.996 1.138 1.565 2.063 1.351l30.883-8.035l.925 5.262c.143.996 1.139 1.565 2.064 1.351l30.456-8.39c-1.779-5.263-2.917-10.88-3.202-16.64l-64.826 5.972zM11.62 182.33c-.498-.853-.143-1.92.711-2.347l58.778-27.875c2.206 5.262 5.195 10.169 8.753 14.577L54.1 185.031c-.783.569-1.921.356-2.42-.498l-2.704-4.693l-26.257 18.133c-.783.57-1.922.285-2.42-.569l-8.752-15.075zm71.23-12.231L37.094 216.39c-.712.711-.64 1.849.142 2.489l13.378 11.164c.783.64 1.85.498 2.42-.284l18.501-26.027l4.127 3.485c.783.64 1.922.498 2.49-.356l17.933-26.026c-4.839-2.987-9.322-6.614-13.165-10.738zm-9.037 74.31c-.925-.355-1.352-1.421-.925-2.275L100 182.97c4.98 2.56 10.389 4.48 16.01 5.547l-7.97 30.577c-.213.925-1.28 1.494-2.205 1.138l-5.052-1.849l-8.468 30.791c-.285.925-1.281 1.494-2.206 1.138l-16.367-5.973zm46.68-55.11l-5.265 64.853c-.071.996.711 1.849 1.637 1.849h17.434c.996 0 1.779-.853 1.636-1.849l-2.561-31.787h5.336a1.72 1.72 0 0 0 1.708-1.848l-2.988-31.432c-3.06.498-6.191.712-9.393.712c-2.562 0-5.053-.143-7.543-.498m62.763-175.574c.427-.924 0-1.92-.925-2.275l-16.366-5.973c-.926-.356-1.922.213-2.206 1.137l-8.468 30.791l-5.053-1.848c-.925-.356-1.921.213-2.206 1.137l-7.97 30.578c5.693 1.138 11.03 3.058 16.011 5.547zm35.722 25.814L173.222 85.83a62 62 0 0 0-13.165-10.738l17.933-26.026c.569-.783 1.707-.996 2.49-.356l4.127 3.485l18.502-26.027c.57-.782 1.708-.925 2.42-.285l13.377 11.165c.783.64.783 1.778.143 2.489zm24.764 36.409c.925-.427 1.21-1.494.711-2.347L235.7 58.524c-.498-.853-1.637-1.066-2.42-.568l-26.257 18.133l-2.704-4.622c-.499-.854-1.637-1.138-2.42-.498l-25.76 18.347c3.558 4.408 6.476 9.315 8.753 14.577l58.778-27.875zm9.25 23.609l2.99 17.137c.142.996-.499 1.85-1.495 1.991l-64.826 6.045c-.285-5.831-1.424-11.378-3.203-16.64l30.457-8.391c.925-.285 1.921.355 2.063 1.35l.925 5.263l30.884-8.035c.925-.214 1.92.355 2.063 1.35zm-2.917 62.933c.925.213 1.921-.356 2.064-1.351L255.126 144c.143-.996-.498-1.849-1.494-1.991l-31.808-2.987l.925-5.262c.142-.996-.498-1.849-1.495-1.991l-31.523-2.56a62.3 62.3 0 0 1-2.704 16.782l62.976 16.427zM233.28 201.6c-.498.853-1.636 1.067-2.419.569l-53.583-36.978a60.2 60.2 0 0 0 8.254-14.791l28.749 13.156c.925.426 1.28 1.493.783 2.346l-2.704 4.622l28.89 13.654c.854.426 1.21 1.493.712 2.346zm-71.657-21.831l37.643 53.049c.57.782 1.708.924 2.42.284l13.306-11.164c.783-.64.783-1.778.143-2.49l-22.415-22.684l4.127-3.413c.783-.64.783-1.778.07-2.489l-22.557-22.186c-3.771 4.266-8.04 8.035-12.808 11.093zm-.356 72.249c-.925.355-1.921-.214-2.206-1.138l-17.22-62.72a61 61 0 0 0 15.868-6.044l13.592 28.515c.426.925 0 1.991-.926 2.276l-5.052 1.849l13.307 29.013c.427.924 0 1.92-.925 2.275l-16.367 5.974z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
{/* Microsoft Entra / Azure AD */}
|
|
<div className="relative flex items-center justify-center size-6 border border-foreground/[0.08] bg-background shrink-0 opacity-60 transition-opacity duration-300 group-hover/card:opacity-100">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="10"
|
|
height="10"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
fill="#0078D4"
|
|
d="M13.05 4.24L6.56 18.05L2 18l5.09-8.76zm.7 1.09L22 19.76H6.74l9.3-1.66l-4.87-5.79z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
{/* SCIM / Directory Sync */}
|
|
<div className="relative flex items-center justify-center size-6 border border-foreground/[0.08] bg-background text-[#10B981] shrink-0 opacity-60 transition-opacity duration-300 group-hover/card:opacity-100">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="10"
|
|
height="10"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
|
<circle cx="9" cy="7" r="4" />
|
|
<polyline points="16 11 18 13 22 9" />
|
|
</svg>
|
|
</div>
|
|
{/* Generic IdP / Building */}
|
|
<div className="relative flex items-center justify-center size-6 border border-foreground/[0.08] bg-background text-neutral-800 dark:text-neutral-200 opacity-60 transition-opacity duration-300 group-hover/card:opacity-100">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="10"
|
|
height="10"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
fill="currentColor"
|
|
d="M12 7V3H2v18h20V7zM6 19H4v-2h2zm0-4H4v-2h2zm0-4H4V9h2zm0-4H4V5h2zm4 12H8v-2h2zm0-4H8v-2h2zm0-4H8V9h2zm0-4H8V5h2zm10 12h-8v-2h2v-2h-2v-2h2v-2h-2V9h8zm-2-8h-2v2h2zm0 4h-2v2h2z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
{/* +more */}
|
|
<div className="flex items-center justify-center size-6 border border-dashed border-foreground/[0.1] text-foreground/35 dark:text-foreground/20">
|
|
<span className="text-[8px] font-mono leading-none">
|
|
+
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{"agent" in feature && feature.agent && (
|
|
<div className="mt-3 flex items-center h-5 px-2.5 border border-foreground/[0.06] bg-foreground/[0.015] font-mono text-[8px] gap-1">
|
|
<span className="text-foreground/30 ">$</span>
|
|
<span className="text-foreground/50 dark:text-foreground/30">
|
|
agent
|
|
<span className="text-foreground/30 ">.</span>
|
|
auth
|
|
<span className="text-foreground/30 ">()</span>
|
|
</span>
|
|
<span className="text-foreground/50 dark:text-foreground/10 mx-0.5">
|
|
→
|
|
</span>
|
|
<span className="text-foreground/35 dark:text-foreground/20">
|
|
sk-<span className="tracking-[0.08em]">••••</span>
|
|
</span>
|
|
<span className="text-foreground/40 dark:text-foreground/50">
|
|
✓
|
|
</span>
|
|
<span className="inline-block w-px h-2.5 bg-foreground/30 animate-[blink_1s_steps(2)_infinite]" />
|
|
</div>
|
|
)}
|
|
{"security" in feature && feature.security && (
|
|
<div className="mt-3 relative overflow-hidden">
|
|
<div className="flex items-center gap-1.5 font-mono text-[8px]">
|
|
{/* Shield icon */}
|
|
<div className="flex items-center justify-center size-5 border border-foreground/[0.08] bg-foreground/[0.02] shrink-0">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="10"
|
|
height="10"
|
|
viewBox="0 0 24 24"
|
|
className="text-foreground/50 dark:text-foreground/30"
|
|
>
|
|
<path
|
|
fill="currentColor"
|
|
d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12c5.16-1.26 9-6.45 9-12V5zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
{/* Blocked */}
|
|
<div className="flex items-center gap-1 px-1.5 py-0.5 border border-red-500/15 bg-red-500/[0.03] shrink-0">
|
|
<span className="inline-block size-1 rounded-full bg-red-500/40" />
|
|
<span className="text-red-500/40">blocked</span>
|
|
</div>
|
|
{/* Challenged */}
|
|
<div className="flex items-center gap-1 px-1.5 py-0.5 border border-yellow-600/15 bg-yellow-600/[0.03] shrink-0">
|
|
<span className="inline-block size-1 rounded-full bg-yellow-600/40" />
|
|
<span className="text-yellow-600/40">
|
|
challenged
|
|
</span>
|
|
</div>
|
|
{/* Allowed */}
|
|
<div className="flex items-center gap-1 px-1.5 py-0.5 border border-green-500/15 bg-green-500/[0.03] shrink-0">
|
|
<span className="inline-block size-1 rounded-full bg-green-500/50" />
|
|
<span className="text-green-500/50">allowed</span>
|
|
</div>
|
|
</div>
|
|
<div className="absolute inset-y-0 right-0 w-12 bg-gradient-to-l from-background to-transparent pointer-events-none" />
|
|
</div>
|
|
)}
|
|
{"dashboard" in feature && feature.dashboard && (
|
|
<div className="mt-3 relative overflow-hidden h-5">
|
|
<div className="flex animate-[marquee_20s_linear_infinite] gap-4">
|
|
{[...Array(2)].map((_, setIdx) => (
|
|
<div key={setIdx} className="flex gap-4 shrink-0">
|
|
{[
|
|
{
|
|
time: "10:50 AM",
|
|
user: "John",
|
|
action: "created a session",
|
|
},
|
|
{
|
|
time: "10:48 AM",
|
|
user: "Sarah",
|
|
action: "updated profile",
|
|
},
|
|
{
|
|
time: "10:45 AM",
|
|
user: "Alex",
|
|
action: "joined organization",
|
|
},
|
|
{
|
|
time: "10:42 AM",
|
|
user: "Emma",
|
|
action: "revoked token",
|
|
},
|
|
{
|
|
time: "10:38 AM",
|
|
user: "Mike",
|
|
action: "enabled 2FA",
|
|
},
|
|
].map((event) => (
|
|
<div
|
|
key={`${setIdx}-${event.time}-${event.user}`}
|
|
className="flex items-center gap-1.5 shrink-0 h-5 whitespace-nowrap"
|
|
>
|
|
<span className="text-[8px] font-mono text-foreground/30 ">
|
|
{event.time}
|
|
</span>
|
|
<span className="text-[8px] font-mono text-foreground/50 dark:text-foreground/30 border-b border-dashed border-foreground/20">
|
|
{event.user}
|
|
</span>
|
|
<span className="text-[8px] font-mono text-foreground/35 dark:text-foreground/20">
|
|
{event.action}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-background to-transparent pointer-events-none" />
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
</Link>
|
|
))}
|
|
{/* + marks at grid intersections */}
|
|
<span className="hidden md:block absolute top-1/3 left-1/3 -translate-x-1/2 -translate-y-1/2 font-mono -mt-[1px] -ml-[.5px] text-[10px] text-foreground/35 dark:text-foreground/20 select-none z-10">
|
|
+
|
|
</span>
|
|
<span className="hidden md:block absolute top-1/3 left-2/3 -translate-x-1/2 -translate-y-1/2 font-mono -mt-[1px] -ml-[.5px] text-[10px] text-foreground/35 dark:text-foreground/20 select-none z-10">
|
|
+
|
|
</span>
|
|
<span className="hidden md:block absolute top-2/3 left-1/3 -translate-x-1/2 -translate-y-1/2 font-mono -mt-[1px] -ml-[.5px] text-[10px] text-foreground/35 dark:text-foreground/20 select-none z-10">
|
|
+
|
|
</span>
|
|
<span className="hidden md:block absolute top-2/3 left-2/3 -translate-x-1/2 -translate-y-1/2 font-mono -mt-[1px] -ml-[.5px] text-[10px] text-foreground/35 dark:text-foreground/20 select-none z-10">
|
|
+
|
|
</span>
|
|
</div>
|
|
|
|
<div className="my-4">
|
|
<div className="flex items-center gap-4">
|
|
<span className="text-lg font-medium text-foreground/90 dark:text-foreground/80 tracking-tight shrink-0">
|
|
Framework
|
|
</span>
|
|
<div className="flex-1 border-t border-foreground/10"></div>
|
|
</div>
|
|
<p className="text-[15px] sm:text-base text-foreground/50 mt-1">
|
|
The most comprehensive authentication framework for TypeScript.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="mt-8 mb-10">
|
|
<div className="border-r border-foreground/[0.1] bg-foreground/[0.01] overflow-hidden">
|
|
<div className="flex flex-col lg:flex-row">
|
|
<div className="min-w-0 flex-1 min-h-[320px] sm:min-h-[360px] lg:h-[400px] overflow-hidden">
|
|
<AnimatePresence mode="wait" initial={false}>
|
|
<motion.div
|
|
key={frameworkTab}
|
|
initial={{ opacity: 0, y: 6 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -4 }}
|
|
transition={{ duration: 0.2, ease: "easeOut" }}
|
|
className="pr-3 sm:pr-5 pb-5 h-full"
|
|
>
|
|
{frameworkTab === "declarative" && <ServerClientTabs />}
|
|
{frameworkTab === "database" && <DatabaseSection />}
|
|
{frameworkTab === "oauth" && <SocialProvidersSection />}
|
|
{frameworkTab === "integrations" && (
|
|
<IntegrationsSection />
|
|
)}
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
</div>
|
|
|
|
<div className="flex flex-row lg:flex-col lg:w-56 lg:shrink-0 border-t lg:border-t-0 lg:border-l border-foreground/[0.1] bg-neutral-50 dark:bg-black overflow-x-auto lg:overflow-visible">
|
|
{[
|
|
{ id: "declarative", label: "Declarative Config" },
|
|
{ id: "database", label: "Bring Your Own Database" },
|
|
{ id: "oauth", label: "OAuth Providers" },
|
|
{ id: "integrations", label: "Integrations" },
|
|
].map((tab) => (
|
|
<button
|
|
key={tab.id}
|
|
type="button"
|
|
onClick={() =>
|
|
setFrameworkTab(
|
|
tab.id as
|
|
| "declarative"
|
|
| "database"
|
|
| "oauth"
|
|
| "integrations",
|
|
)
|
|
}
|
|
className={cn(
|
|
"relative flex-1 lg:flex-none text-left px-3 sm:px-4 py-2.5 sm:py-3 text-[10px] sm:text-[11px] lg:text-xs font-mono tracking-wider uppercase transition-colors border-r lg:border-r-0 lg:border-b last:border-r-0 lg:last:border-b-0 border-foreground/[0.08] whitespace-nowrap lg:whitespace-normal",
|
|
frameworkTab === tab.id
|
|
? "text-foreground/85 bg-foreground/[0.04]"
|
|
: "text-foreground/45 hover:text-foreground/70",
|
|
)}
|
|
>
|
|
{tab.id === "database" ? (
|
|
<>
|
|
Bring Your Own{" "}
|
|
<span className="text-amber-600 dark:text-amber-400">
|
|
Database
|
|
</span>
|
|
</>
|
|
) : (
|
|
tab.label
|
|
)}
|
|
{frameworkTab === tab.id && (
|
|
<span className="absolute inset-y-0 right-0 w-[1.5px] bg-foreground/65 hidden lg:block" />
|
|
)}
|
|
</button>
|
|
))}
|
|
<div className="hidden lg:flex flex-1 items-end p-4">
|
|
<p className="text-[13px] leading-relaxed text-foreground/60 dark:text-foreground/50">
|
|
{frameworkTab === "declarative" &&
|
|
"No dashboard clicks. Your auth lives in code — version controlled, type-safe, and reviewable in PRs."}
|
|
{frameworkTab === "database" &&
|
|
"Use any database. Connect with a connection string or your favorite ORM. Your data stays yours."}
|
|
{frameworkTab === "oauth" &&
|
|
"40+ preconfigured providers. Add Google, GitHub, Apple, and more in seconds."}
|
|
{frameworkTab === "integrations" &&
|
|
"Works with every major framework. First-class support for 20+ integrations."}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-8">
|
|
<PluginEcosystem />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Infrastructure */}
|
|
<div className="relative mt-8 pt-6 pb-2">
|
|
{/* Grain noise background — full bleed with fade edges */}
|
|
<div
|
|
className="absolute top-0 bottom-0 z-0 pointer-events-none"
|
|
style={{
|
|
left: "50%",
|
|
transform: "translateX(-50%)",
|
|
width: "100vw",
|
|
maskImage:
|
|
"linear-gradient(to bottom, transparent 0%, black 8%, black 92%, transparent 100%)",
|
|
WebkitMaskImage:
|
|
"linear-gradient(to bottom, transparent 0%, black 8%, black 92%, transparent 100%)",
|
|
}}
|
|
>
|
|
<div className="absolute inset-0 bg-neutral-200/20 dark:bg-black/30" />
|
|
<div className="absolute inset-0 opacity-[0.03] dark:opacity-[0.07]">
|
|
<svg className="w-full h-full">
|
|
<filter id="infra-grain">
|
|
<feTurbulence
|
|
type="fractalNoise"
|
|
baseFrequency="0.85"
|
|
numOctaves="4"
|
|
stitchTiles="stitch"
|
|
/>
|
|
<feColorMatrix type="saturate" values="0" />
|
|
</filter>
|
|
<rect
|
|
width="100%"
|
|
height="100%"
|
|
filter="url(#infra-grain)"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="relative z-10 mb-6">
|
|
<div className="flex items-center gap-4 mb-2">
|
|
<span className="text-lg font-medium text-foreground/90 dark:text-foreground/80 tracking-tight shrink-0">
|
|
Infrastructure
|
|
</span>
|
|
<div className="flex-1 border-t border-foreground/10" />
|
|
</div>
|
|
<p className="text-[15px] sm:text-base text-foreground/75 dark:text-foreground/65 leading-relaxed">
|
|
Connect to our infrastructure and power your self-hosted
|
|
Better Auth with a dashboard, audit logs, security detection,
|
|
enterprise features, and more.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Dashboard video */}
|
|
<div
|
|
className="relative z-10 overflow-hidden border border-foreground/[0.08]"
|
|
style={{
|
|
maskImage:
|
|
"linear-gradient(to bottom, black 60%, transparent 100%)",
|
|
WebkitMaskImage:
|
|
"linear-gradient(to bottom, black 60%, transparent 100%)",
|
|
}}
|
|
>
|
|
<div className="flex items-center justify-between px-4 py-2 bg-foreground/[0.02] border-b border-foreground/[0.06]">
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="size-2 rounded-full bg-foreground/10" />
|
|
<span className="size-2 rounded-full bg-foreground/10" />
|
|
<span className="size-2 rounded-full bg-foreground/10" />
|
|
</div>
|
|
<span className="text-[10px] font-mono text-foreground/30 ml-2">
|
|
dash.better-auth.com
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
{["Overview", "Users", "Orgs", "Events"].map((tab, i) => (
|
|
<span
|
|
key={tab}
|
|
className={cn(
|
|
"text-[9px] font-mono uppercase tracking-wider",
|
|
i === 0 ? "text-foreground/50" : "text-foreground/20",
|
|
)}
|
|
>
|
|
{tab}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="overflow-hidden" suppressHydrationWarning>
|
|
<DemoVideo />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Feature grid — 3 columns */}
|
|
<div className="relative z-10 grid grid-cols-1 sm:grid-cols-3 mt-4 -mx-px">
|
|
{[
|
|
{
|
|
title: "Dashboard",
|
|
features: [
|
|
"User management",
|
|
"Session monitoring",
|
|
"Organization oversight",
|
|
"User analytics",
|
|
],
|
|
},
|
|
{
|
|
title: "Audit Logs",
|
|
features: [
|
|
"Auto-captured events",
|
|
"Filter & search",
|
|
"Configurable retention",
|
|
"Log drain to SIEM",
|
|
],
|
|
},
|
|
{
|
|
title: "Enterprise",
|
|
features: [
|
|
"Self-service SSO",
|
|
"SCIM provisioning",
|
|
"Directory sync",
|
|
"RBAC",
|
|
],
|
|
},
|
|
].map((group) => (
|
|
<div
|
|
key={group.title}
|
|
className="relative overflow-hidden border-t border-r border-b border-dashed border-foreground/[0.06] first:border-l -mt-px p-4"
|
|
>
|
|
{group.title === "Dashboard" && (
|
|
<div
|
|
className="absolute inset-0 pointer-events-none"
|
|
style={{
|
|
backgroundImage:
|
|
"radial-gradient(circle, rgb(180 160 130 / 0.3) 1.2px, transparent 1.2px)",
|
|
backgroundSize: "6px 6px",
|
|
maskImage:
|
|
"linear-gradient(135deg, rgba(0,0,0,0.5) 0%, rgba(0,0,0,0) 45%)",
|
|
WebkitMaskImage:
|
|
"linear-gradient(135deg, rgba(0,0,0,0.5) 0%, rgba(0,0,0,0) 45%)",
|
|
}}
|
|
/>
|
|
)}
|
|
<h4 className="relative text-[11px] sm:text-xs font-mono font-semibold uppercase tracking-widest text-foreground/90 dark:text-foreground/75 mb-3">
|
|
{group.title}
|
|
</h4>
|
|
<ul className="space-y-1.5">
|
|
{group.features.map((f) => (
|
|
<li
|
|
key={f}
|
|
className="flex items-start gap-2 text-[13px] sm:text-[14px] text-foreground/70 dark:text-foreground/55"
|
|
>
|
|
<span className="text-foreground/35 mt-0.5 font-mono text-[11px] leading-none select-none shrink-0">
|
|
+
|
|
</span>
|
|
<span>{f}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
{/* Enterprise half-circle with provider icons */}
|
|
{group.title === "Enterprise" && (
|
|
<div className="absolute right-0 top-1/2 -translate-y-1/2 translate-x-1/2 w-[160px] h-[160px] pointer-events-none">
|
|
{/* Half-circle arcs */}
|
|
<svg
|
|
className="absolute inset-0 w-full h-full"
|
|
viewBox="0 0 160 160"
|
|
>
|
|
<circle
|
|
cx="80"
|
|
cy="80"
|
|
r="68"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="0.75"
|
|
className="text-foreground/20"
|
|
strokeDasharray="3 3"
|
|
/>
|
|
<circle
|
|
cx="80"
|
|
cy="80"
|
|
r="44"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="0.75"
|
|
className="text-foreground/12"
|
|
strokeDasharray="2 4"
|
|
/>
|
|
</svg>
|
|
{/* Okta — 270° top of outer arc */}
|
|
<div className="absolute size-6 flex items-center justify-center left-[68px] top-0">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="15"
|
|
height="15"
|
|
viewBox="0 0 256 256"
|
|
className="text-foreground/60"
|
|
>
|
|
<path
|
|
fill="currentColor"
|
|
d="m140.844 1.778l-5.266 64.853a66 66 0 0 0-7.542-.427c-3.203 0-6.334.214-9.393.712l-2.99-31.432a1.72 1.72 0 0 1 1.709-1.848h5.337l-2.562-31.787C120.066.853 120.848 0 121.774 0h17.434c.996 0 1.779.853 1.636 1.849zm-43.976 3.2c-.285-.925-1.281-1.494-2.206-1.138L78.295 9.813c-.925.356-1.352 1.423-.925 2.276l13.307 29.013l-5.052 1.85c-.926.355-1.352 1.421-.926 2.275l13.592 28.515a61 61 0 0 1 15.868-6.044L96.94 4.978zM56.734 23.04l37.643 53.049c-4.768 3.129-9.108 6.827-12.809 11.093L59.011 64.996a1.72 1.72 0 0 1 .071-2.49l4.127-3.413L40.794 36.41c-.711-.711-.64-1.849.142-2.489l13.307-11.164c.783-.64 1.85-.498 2.42.284zM25.139 53.76c-.783-.569-1.921-.284-2.42.569l-8.68 15.075c-.499.854-.143 1.92.71 2.347L43.64 85.404l-2.704 4.623c-.498.853-.142 1.99.783 2.346l28.749 13.156a60.2 60.2 0 0 1 8.254-14.791zM3.862 94.72c.143-.996 1.139-1.564 2.064-1.351l62.976 16.427a62.3 62.3 0 0 0-2.704 16.782l-31.524-2.56a1.642 1.642 0 0 1-1.494-1.991l.925-5.263l-31.808-2.986c-.996-.071-1.637-.996-1.495-1.991l2.99-17.138zm-2.348 42.524c-.996.072-1.637.996-1.494 1.992l3.06 17.137c.142.996 1.138 1.565 2.063 1.351l30.883-8.035l.925 5.262c.143.996 1.139 1.565 2.064 1.351l30.456-8.39c-1.779-5.263-2.917-10.88-3.202-16.64l-64.826 5.972zM11.62 182.33c-.498-.853-.143-1.92.711-2.347l58.778-27.875c2.206 5.262 5.195 10.169 8.753 14.577L54.1 185.031c-.783.569-1.921.356-2.42-.498l-2.704-4.693l-26.257 18.133c-.783.57-1.922.285-2.42-.569l-8.752-15.075zm71.23-12.231L37.094 216.39c-.712.711-.64 1.849.142 2.489l13.378 11.164c.783.64 1.85.498 2.42-.284l18.501-26.027l4.127 3.485c.783.64 1.922.498 2.49-.356l17.933-26.026c-4.839-2.987-9.322-6.614-13.165-10.738zm-9.037 74.31c-.925-.355-1.352-1.421-.925-2.275L100 182.97c4.98 2.56 10.389 4.48 16.01 5.547l-7.97 30.577c-.213.925-1.28 1.494-2.205 1.138l-5.052-1.849l-8.468 30.791c-.285.925-1.281 1.494-2.206 1.138l-16.367-5.973zm46.68-55.11l-5.265 64.853c-.071.996.711 1.849 1.637 1.849h17.434c.996 0 1.779-.853 1.636-1.849l-2.561-31.787h5.336a1.72 1.72 0 0 0 1.708-1.848l-2.988-31.432c-3.06.498-6.191.712-9.393.712c-2.562 0-5.053-.143-7.543-.498m62.763-175.574c.427-.924 0-1.92-.925-2.275l-16.366-5.973c-.926-.356-1.922.213-2.206 1.137l-8.468 30.791l-5.053-1.848c-.925-.356-1.921.213-2.206 1.137l-7.97 30.578c5.693 1.138 11.03 3.058 16.011 5.547zm35.722 25.814L173.222 85.83a62 62 0 0 0-13.165-10.738l17.933-26.026c.569-.783 1.707-.996 2.49-.356l4.127 3.485l18.502-26.027c.57-.782 1.708-.925 2.42-.285l13.377 11.165c.783.64.783 1.778.143 2.489zm24.764 36.409c.925-.427 1.21-1.494.711-2.347L235.7 58.524c-.498-.853-1.637-1.066-2.42-.568l-26.257 18.133l-2.704-4.622c-.499-.854-1.637-1.138-2.42-.498l-25.76 18.347c3.558 4.408 6.476 9.315 8.753 14.577l58.778-27.875zm9.25 23.609l2.99 17.137c.142.996-.499 1.85-1.495 1.991l-64.826 6.045c-.285-5.831-1.424-11.378-3.203-16.64l30.457-8.391c.925-.285 1.921.355 2.063 1.35l.925 5.263l30.884-8.035c.925-.214 1.92.355 2.063 1.35zm-2.917 62.933c.925.213 1.921-.356 2.064-1.351L255.126 144c.143-.996-.498-1.849-1.494-1.991l-31.808-2.987l.925-5.262c.142-.996-.498-1.849-1.495-1.991l-31.523-2.56a62.3 62.3 0 0 1-2.704 16.782l62.976 16.427zM233.28 201.6c-.498.853-1.636 1.067-2.419.569l-53.583-36.978a60.2 60.2 0 0 0 8.254-14.791l28.749 13.156c.925.426 1.28 1.493.783 2.346l-2.704 4.622l28.89 13.654c.854.426 1.21 1.493.712 2.346zm-71.657-21.831l37.643 53.049c.57.782 1.708.924 2.42.284l13.306-11.164c.783-.64.783-1.778.143-2.49l-22.415-22.684l4.127-3.413c.783-.64.783-1.778.07-2.489l-22.557-22.186c-3.771 4.266-8.04 8.035-12.808 11.093zm-.356 72.249c-.925.355-1.921-.214-2.206-1.138l-17.22-62.72a61 61 0 0 0 15.868-6.044l13.592 28.515c.426.925 0 1.991-.926 2.276l-5.052 1.849l13.307 29.013c.427.924 0 1.92-.925 2.275l-16.367 5.974z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
{/* Microsoft — 225° upper-left of outer arc */}
|
|
<div className="absolute size-6 flex items-center justify-center left-[20px] top-[20px]">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="13"
|
|
height="13"
|
|
viewBox="0 0 256 256"
|
|
>
|
|
<path
|
|
fill="#F1511B"
|
|
d="M121.666 121.666H0V0h121.666z"
|
|
/>
|
|
<path
|
|
fill="#80CC28"
|
|
d="M256 121.666H134.335V0H256z"
|
|
/>
|
|
<path
|
|
fill="#00ADEF"
|
|
d="M121.663 256.002H0V134.336h121.663z"
|
|
/>
|
|
<path
|
|
fill="#FBBC09"
|
|
d="M256 256.002H134.335V134.336H256z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
{/* Google — 180° leftmost of outer arc */}
|
|
<div className="absolute size-6 flex items-center justify-center left-0 top-[68px]">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="13"
|
|
height="13"
|
|
viewBox="0 0 16 16"
|
|
>
|
|
<g
|
|
fill="none"
|
|
fillRule="evenodd"
|
|
clipRule="evenodd"
|
|
>
|
|
<path
|
|
fill="#f44336"
|
|
d="M7.209 1.061c.725-.081 1.154-.081 1.933 0a6.57 6.57 0 0 1 3.65 1.82a100 100 0 0 0-1.986 1.93q-1.876-1.59-4.188-.734q-1.696.78-2.362 2.528a78 78 0 0 1-2.148-1.658a.26.26 0 0 0-.16-.027q1.683-3.245 5.26-3.86"
|
|
opacity=".987"
|
|
/>
|
|
<path
|
|
fill="#ffc107"
|
|
d="M1.946 4.92q.085-.013.161.027a78 78 0 0 0 2.148 1.658A7.6 7.6 0 0 0 4.04 7.99q.037.678.215 1.331L2 11.116Q.527 8.038 1.946 4.92"
|
|
opacity=".997"
|
|
/>
|
|
<path
|
|
fill="#448aff"
|
|
d="M12.685 13.29a26 26 0 0 0-2.202-1.74q1.15-.812 1.396-2.228H8.122V6.713q3.25-.027 6.497.055q.616 3.345-1.423 6.032a7 7 0 0 1-.51.49"
|
|
opacity=".999"
|
|
/>
|
|
<path
|
|
fill="#43a047"
|
|
d="M4.255 9.322q1.23 3.057 4.51 2.854a3.94 3.94 0 0 0 1.718-.626q1.148.812 2.202 1.74a6.62 6.62 0 0 1-4.027 1.684a6.4 6.4 0 0 1-1.02 0Q3.82 14.524 2 11.116z"
|
|
opacity=".993"
|
|
/>
|
|
</g>
|
|
</svg>
|
|
</div>
|
|
{/* Keycloak — 135° lower-left of outer arc */}
|
|
<div className="absolute size-6 flex items-center justify-center left-[20px] top-[116px]">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="13"
|
|
height="13"
|
|
viewBox="0 0 24 24"
|
|
className="text-foreground/60"
|
|
>
|
|
<path
|
|
fill="currentColor"
|
|
d="m18.742 1.182l-12.493.002C4.155 4.784 2.079 8.393 0 12.002c2.071 3.612 4.162 7.214 6.252 10.816l12.49-.004l3.089-5.404h2.158v-.002H24L23.996 6.59h-2.168zM8.327 4.792h2.081l1.04 1.8l-3.12 5.413l3.117 5.403l-1.035 1.81H8.327a2048 2048 0 0 0-4.168-7.204zm6.241 0l2.086.003q2.088 3.608 4.166 7.222l-4.167 7.2h-2.08c-.382-.562-1.038-1.808-1.038-1.808l3.123-5.405l-3.124-5.413z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
{/* Ping Identity — 90° bottom of outer arc */}
|
|
<div className="absolute size-6 flex items-center justify-center left-[68px] top-[136px]">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="12"
|
|
height="12"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
d="M23.7476 0H0V23.3473H23.7476V0Z"
|
|
fill="#D20E0F"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Sentinel row */}
|
|
<div className="relative z-10 border border-dashed border-foreground/[0.06] -mt-px -mx-px p-4">
|
|
<div className="flex flex-col sm:flex-row sm:items-start gap-4">
|
|
<div className="sm:w-1/3">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="12"
|
|
height="12"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.5"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className="text-foreground/70"
|
|
>
|
|
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
|
|
</svg>
|
|
<h4 className="text-[11px] sm:text-xs font-mono font-semibold uppercase tracking-widest text-foreground/90 dark:text-foreground/75">
|
|
Sentinel
|
|
</h4>
|
|
</div>
|
|
<p className="text-[13px] sm:text-[14px] text-foreground/60 dark:text-foreground/50 leading-relaxed">
|
|
Real-time threat detection before it reaches your users.
|
|
</p>
|
|
</div>
|
|
<div className="flex-1 flex flex-wrap gap-1.5">
|
|
{[
|
|
"Bot Detection",
|
|
"Brute Force",
|
|
"Breached Passwords",
|
|
"Impossible Travel",
|
|
"Rate Limiting",
|
|
"Geo Blocking",
|
|
"Suspicious IPs",
|
|
"Disposable Emails",
|
|
"Email Abuse",
|
|
"Free Trial Abuse",
|
|
].map((tag) => (
|
|
<span
|
|
key={tag}
|
|
className="inline-flex items-center px-2 py-1 text-[10px] sm:text-[11px] font-mono uppercase tracking-wider text-foreground/70 dark:text-foreground/55 border border-foreground/[0.12] bg-foreground/[0.03] hover:bg-foreground/[0.06] hover:text-foreground/80 dark:hover:text-foreground/65 transition-colors"
|
|
>
|
|
{tag}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* CTA */}
|
|
<div className="relative z-10 flex items-center justify-between mt-4 px-6 py-5 border border-dashed border-foreground/[0.08] bg-foreground/[0.01]">
|
|
<div className="flex flex-col gap-0.5">
|
|
<span className="text-[13px] sm:text-[14px] font-medium text-foreground/90 dark:text-foreground/85">
|
|
Explore plans
|
|
</span>
|
|
<span className="text-[11px] sm:text-[12px] text-foreground/75 dark:text-foreground/60">
|
|
Dashboard, audit logs, security detection, transactional
|
|
comms, and more.
|
|
</span>
|
|
</div>
|
|
<Link
|
|
href="/pricing"
|
|
className="inline-flex items-center gap-1.5 shrink-0 ml-4 px-4 py-2.5 bg-foreground text-background hover:opacity-90 transition-all font-mono text-[11px] uppercase tracking-widest group"
|
|
>
|
|
View Plans
|
|
<svg
|
|
className="h-2.5 w-2.5 opacity-70 group-hover:translate-x-0.5 transition-transform"
|
|
viewBox="0 0 10 10"
|
|
fill="none"
|
|
>
|
|
<path
|
|
d="M1 5H9M9 5L5 1M9 5L5 9"
|
|
stroke="currentColor"
|
|
strokeWidth="1.2"
|
|
/>
|
|
</svg>
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
|
|
<ContributorsSection
|
|
contributors={contributors}
|
|
contributorCount={stats.contributors}
|
|
/>
|
|
|
|
<ReadmeFooter stats={stats} />
|
|
</motion.article>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
}
|