mirror of
https://github.com/LukeHagar/better-auth.git
synced 2025-12-09 20:27:44 +00:00
Merge branch 'canary' into v1.3.8-staging
# Conflicts: # demo/nextjs/package.json # docs/package.json # packages/better-auth/package.json # pnpm-lock.yaml
This commit is contained in:
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -1,3 +1,3 @@
|
||||
# https://help.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners
|
||||
|
||||
* @Bekacru
|
||||
* @Bekacru @himself65
|
||||
70
.github/renovate.json5
vendored
70
.github/renovate.json5
vendored
@@ -1,46 +1,46 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended",
|
||||
"schedule:weekly",
|
||||
"group:allNonMajor",
|
||||
":disablePeerDependencies",
|
||||
"regexManagers:biomeVersions",
|
||||
"helpers:pinGitHubActionDigestsToSemver"
|
||||
$schema: 'https://docs.renovatebot.com/renovate-schema.json',
|
||||
extends: [
|
||||
'config:recommended',
|
||||
'schedule:weekly',
|
||||
'group:allNonMajor',
|
||||
':disablePeerDependencies',
|
||||
'customManagers:biomeVersions',
|
||||
'helpers:pinGitHubActionDigestsToSemver',
|
||||
],
|
||||
"labels": [
|
||||
"dependencies"
|
||||
labels: [
|
||||
'dependencies',
|
||||
],
|
||||
"rangeStrategy": "bump",
|
||||
"postUpdateOptions": [
|
||||
"pnpmDedupe"
|
||||
rangeStrategy: 'bump',
|
||||
postUpdateOptions: [
|
||||
'pnpmDedupe',
|
||||
],
|
||||
"ignorePaths": [
|
||||
"**/node_modules/**"
|
||||
ignorePaths: [
|
||||
'**/node_modules/**',
|
||||
],
|
||||
"packageRules": [
|
||||
packageRules: [
|
||||
{
|
||||
"groupName": "github-actions",
|
||||
"matchManagers": [
|
||||
"github-actions"
|
||||
]
|
||||
groupName: 'github-actions',
|
||||
matchManagers: [
|
||||
'github-actions',
|
||||
],
|
||||
},
|
||||
{
|
||||
"groupName": "better-auth dependencies",
|
||||
"matchManagers": [
|
||||
"npm"
|
||||
groupName: 'better-auth dependencies',
|
||||
matchManagers: [
|
||||
'npm',
|
||||
],
|
||||
"matchFileNames": [
|
||||
"packages/better-auth/**"
|
||||
]
|
||||
}
|
||||
matchFileNames: [
|
||||
'packages/better-auth/**',
|
||||
],
|
||||
},
|
||||
],
|
||||
"ignoreDeps": [
|
||||
"@biomejs/biome",
|
||||
"@types/node",
|
||||
"drizzle-orm",
|
||||
"node",
|
||||
"npm",
|
||||
"pnpm",
|
||||
ignoreDeps: [
|
||||
'@biomejs/biome',
|
||||
'@types/node',
|
||||
'drizzle-orm',
|
||||
'node',
|
||||
'npm',
|
||||
'pnpm',
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -17,9 +17,9 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node-version: [20.x, 22.x]
|
||||
node-version: [22.x, 24.x]
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
115
.github/workflows/e2e.yml
vendored
Normal file
115
.github/workflows/e2e.yml
vendored
Normal file
@@ -0,0 +1,115 @@
|
||||
name: E2E
|
||||
on:
|
||||
push:
|
||||
branches: [ main, canary ]
|
||||
pull_request:
|
||||
jobs:
|
||||
smoke:
|
||||
name: Smoke test
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Cache turbo build setup
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
with:
|
||||
path: .turbo
|
||||
key: ${{ runner.os }}-turbo-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-turbo-
|
||||
|
||||
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 22.x
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
cache: pnpm
|
||||
|
||||
- name: Install
|
||||
run: pnpm install
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM || github.repository_owner }}
|
||||
TURBO_REMOTE_ONLY: true
|
||||
run: pnpm build
|
||||
|
||||
- name: Start Docker Containers
|
||||
run: |
|
||||
docker compose up -d
|
||||
# Wait for services to be ready (optional)
|
||||
sleep 10
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
|
||||
- uses: denoland/setup-deno@e95548e56dfa95d4e1a28d6f422fafe75c4c26fb # v2.0.3
|
||||
with:
|
||||
deno-version: v2.x
|
||||
|
||||
- name: Smoke
|
||||
env:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM || github.repository_owner }}
|
||||
TURBO_REMOTE_ONLY: true
|
||||
run: pnpm e2e:smoke
|
||||
|
||||
- name: Stop Docker Containers
|
||||
run: docker compose down
|
||||
integration:
|
||||
name: Integration test
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Cache turbo build setup
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
with:
|
||||
path: .turbo
|
||||
key: ${{ runner.os }}-turbo-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-turbo-
|
||||
|
||||
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 22.x
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
cache: pnpm
|
||||
|
||||
- name: Install
|
||||
run: pnpm install
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: pnpx playwright install --with-deps
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM || github.repository_owner }}
|
||||
TURBO_REMOTE_ONLY: true
|
||||
run: pnpm build
|
||||
|
||||
- name: Start Docker Containers
|
||||
run: |
|
||||
docker compose up -d
|
||||
# Wait for services to be ready (optional)
|
||||
sleep 10
|
||||
|
||||
- name: Integration
|
||||
env:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM || github.repository_owner }}
|
||||
TURBO_REMOTE_ONLY: true
|
||||
run: pnpm e2e:integration
|
||||
|
||||
- name: Stop Docker Containers
|
||||
run: docker compose down
|
||||
4
.github/workflows/preview.yml
vendored
4
.github/workflows/preview.yml
vendored
@@ -6,7 +6,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 20.x
|
||||
node-version: 22.x
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
cache: pnpm
|
||||
|
||||
|
||||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
@@ -12,13 +12,13 @@ jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 20.x
|
||||
node-version: 22.x
|
||||
|
||||
- run: npx changelogithub
|
||||
env:
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 20.x
|
||||
node-version: 22.x
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- run: pnpm install
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -194,3 +194,6 @@ android/
|
||||
.vinxi
|
||||
# Turborepo
|
||||
.turbo
|
||||
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -24,6 +24,6 @@
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[mdx]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
"editor.defaultFormatter": "unifiedjs.vscode-mdx"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,9 +21,9 @@
|
||||
<a href="https://github.com/better-auth/better-auth/issues">Issues</a>
|
||||
</p>
|
||||
|
||||
[](https://npm.chart.dev/better-auth?primary=neutral&gray=neutral&theme=dark)
|
||||
[](https://www.npmjs.com/package/better-auth)
|
||||
[](https://github.com/better-auth/better-auth/stargazers)
|
||||
[](https://npm.chart.dev/better-auth?primary=neutral&gray=neutral&theme=dark)
|
||||
[](https://www.npmjs.com/package/better-auth)
|
||||
[](https://github.com/better-auth/better-auth/stargazers)
|
||||
</p>
|
||||
|
||||
## About the Project
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
"recommended": false,
|
||||
"suspicious": {
|
||||
"noImplicitAnyLet": "warn",
|
||||
"noDuplicateObjectKeys": "warn"
|
||||
"noDuplicateObjectKeys": "warn",
|
||||
"noTsIgnore": "error"
|
||||
},
|
||||
"performance": {
|
||||
"noDelete": "error"
|
||||
@@ -25,7 +26,8 @@
|
||||
"noUnusedImports": "warn"
|
||||
},
|
||||
"nursery": {
|
||||
"noMisusedPromises": "error"
|
||||
"noMisusedPromises": "error",
|
||||
"noFloatingPromises": "error"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -61,7 +63,8 @@
|
||||
"!**/.source",
|
||||
"!**/.expo",
|
||||
"!**/.cache",
|
||||
"!**/dev/cloudflare/drizzle"
|
||||
"!**/dev/cloudflare/drizzle",
|
||||
"!**/playwright-report"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,3 +21,6 @@ FACEBOOK_CLIENT_SECRET=
|
||||
NODE_ENV=
|
||||
STRIPE_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
PAYPAL_CLIENT_ID=
|
||||
PAYPAL_CLIENT_SECRET=
|
||||
|
||||
|
||||
10
demo/nextjs/app/(auth)/sign-in/loading.tsx
Normal file
10
demo/nextjs/app/(auth)/sign-in/loading.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
export default function SignInLoading() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-gray-900 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,12 +4,14 @@ import SignIn from "@/components/sign-in";
|
||||
import { SignUp } from "@/components/sign-up";
|
||||
import { Tabs } from "@/components/ui/tabs2";
|
||||
import { client } from "@/lib/auth-client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { getCallbackURL } from "@/lib/shared";
|
||||
|
||||
export default function Page() {
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
useEffect(() => {
|
||||
client.oneTap({
|
||||
fetchOptions: {
|
||||
@@ -18,7 +20,7 @@ export default function Page() {
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Successfully signed in");
|
||||
router.push("/dashboard");
|
||||
router.push(getCallbackURL(params));
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -208,8 +208,9 @@ export default function UserCard(props: {
|
||||
) : (
|
||||
<Laptop size={16} />
|
||||
)}
|
||||
{new UAParser(session.userAgent || "").getOS().name},{" "}
|
||||
{new UAParser(session.userAgent || "").getBrowser().name}
|
||||
{new UAParser(session.userAgent || "").getOS().name ||
|
||||
session.userAgent}
|
||||
, {new UAParser(session.userAgent || "").getBrowser().name}
|
||||
<button
|
||||
className="text-red-500 opacity-80 cursor-pointer text-xs border-muted-foreground border-red-600 underline "
|
||||
onClick={async () => {
|
||||
@@ -392,7 +393,6 @@ export default function UserCard(props: {
|
||||
setIsPendingTwoFa(true);
|
||||
if (session?.user.twoFactorEnabled) {
|
||||
const res = await client.twoFactor.disable({
|
||||
//@ts-ignore
|
||||
password: twoFaPassword,
|
||||
fetchOptions: {
|
||||
onError(context) {
|
||||
|
||||
122
demo/nextjs/app/device/approve/page.tsx
Normal file
122
demo/nextjs/app/device/approve/page.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { client, useSession } from "@/lib/auth-client";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Loader2, Check, X } from "lucide-react";
|
||||
|
||||
export default function DeviceApprovalPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const userCode = searchParams.get("user_code");
|
||||
const { data: session } = useSession();
|
||||
const [isApprovePending, startApproveTransition] = useTransition();
|
||||
const [isDenyPending, startDenyTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleApprove = () => {
|
||||
if (!userCode) return;
|
||||
|
||||
setError(null);
|
||||
|
||||
startApproveTransition(async () => {
|
||||
try {
|
||||
await client.device.approve({
|
||||
userCode,
|
||||
});
|
||||
router.push("/device/success");
|
||||
} catch (err: any) {
|
||||
setError(err.error?.message || "Failed to approve device");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeny = () => {
|
||||
if (!userCode) return;
|
||||
|
||||
setError(null);
|
||||
|
||||
startDenyTransition(async () => {
|
||||
try {
|
||||
await client.device.deny({
|
||||
userCode,
|
||||
});
|
||||
router.push("/device/denied");
|
||||
} catch (err: any) {
|
||||
setError(err.error?.message || "Failed to deny device");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold">Approve Device</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
A device is requesting access to your account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg bg-muted p-4">
|
||||
<p className="text-sm font-medium">Device Code</p>
|
||||
<p className="font-mono text-lg">{userCode}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-muted p-4">
|
||||
<p className="text-sm font-medium">Signed in as</p>
|
||||
<p>{session.user.email}</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={handleDeny}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
disabled={isDenyPending}
|
||||
>
|
||||
{isDenyPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Deny
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleApprove}
|
||||
className="flex-1"
|
||||
disabled={isApprovePending}
|
||||
>
|
||||
{isApprovePending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Approve
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
demo/nextjs/app/device/denied/page.tsx
Normal file
35
demo/nextjs/app/device/denied/page.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function DeviceDeniedPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md p-6">
|
||||
<div className="space-y-4 text-center">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
|
||||
<X className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Device Denied</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
The device authorization request has been denied.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The device will not be able to access your account.
|
||||
</p>
|
||||
|
||||
<Button asChild className="w-full">
|
||||
<Link href="/">Return to Home</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
demo/nextjs/app/device/layout.tsx
Normal file
17
demo/nextjs/app/device/layout.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { auth } from "@/lib/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function DevicePage({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
});
|
||||
if (session === null) {
|
||||
throw redirect("/sign-in?callbackUrl=/device");
|
||||
}
|
||||
return children;
|
||||
}
|
||||
94
demo/nextjs/app/device/page.tsx
Normal file
94
demo/nextjs/app/device/page.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { client } from "@/lib/auth-client";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export default function DeviceAuthorizationPage() {
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const user_code = params.get("user_code");
|
||||
const [userCode, setUserCode] = useState<string>(user_code ? user_code : "");
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const finalCode = userCode.trim().replaceAll(/-/g, "").toUpperCase();
|
||||
// Get the device authorization status
|
||||
const response = await client.device({
|
||||
query: {
|
||||
user_code: finalCode,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
router.push(`/device/approve?user_code=${finalCode}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(
|
||||
err.error?.message || "Invalid code. Please check and try again.",
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold">Device Authorization</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Enter the code displayed on your device
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="userCode">Device Code</Label>
|
||||
<Input
|
||||
id="userCode"
|
||||
type="text"
|
||||
placeholder="XXXX-XXXX"
|
||||
value={userCode}
|
||||
onChange={(e) => setUserCode(e.target.value)}
|
||||
className="text-center text-lg font-mono uppercase"
|
||||
maxLength={9}
|
||||
disabled={isPending}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isPending}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Verifying...
|
||||
</>
|
||||
) : (
|
||||
"Continue"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
demo/nextjs/app/device/success/page.tsx
Normal file
36
demo/nextjs/app/device/success/page.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Check } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function DeviceSuccessPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md p-6">
|
||||
<div className="space-y-4 text-center">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
|
||||
<Check className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Device Approved</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
The device has been successfully authorized to access your
|
||||
account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You can now return to your device to continue.
|
||||
</p>
|
||||
|
||||
<Button asChild className="w-full">
|
||||
<Link href="/">Return to Home</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export default async function AuthorizePage({
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
});
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
const clientDetails = await auth.api.getOAuthClient({
|
||||
params: {
|
||||
id: client_id,
|
||||
|
||||
@@ -14,10 +14,12 @@ import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { signIn } from "@/lib/auth-client";
|
||||
import { client, signIn } from "@/lib/auth-client";
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { getCallbackURL } from "@/lib/shared";
|
||||
|
||||
export default function SignIn() {
|
||||
const [email, setEmail] = useState("");
|
||||
@@ -25,6 +27,13 @@ export default function SignIn() {
|
||||
const [loading, startTransition] = useTransition();
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
|
||||
const LastUsedIndicator = () => (
|
||||
<span className="ml-auto absolute top-0 right-0 px-2 py-1 text-xs bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300 rounded-md font-medium">
|
||||
Last Used
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="max-w-md rounded-none">
|
||||
@@ -80,7 +89,7 @@ export default function SignIn() {
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
className="w-full flex items-center justify-center"
|
||||
disabled={loading}
|
||||
onClick={async () => {
|
||||
startTransition(async () => {
|
||||
@@ -88,14 +97,24 @@ export default function SignIn() {
|
||||
{ email, password, rememberMe },
|
||||
{
|
||||
onSuccess(context) {
|
||||
router.push("/dashboard");
|
||||
toast.success("Successfully signed in");
|
||||
router.push(getCallbackURL(params));
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{loading ? <Loader2 size={16} className="animate-spin" /> : "Login"}
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<span className="flex-1">
|
||||
{loading ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
"Login"
|
||||
)}
|
||||
</span>
|
||||
{client.isLastUsedLoginMethod("email") && <LastUsedIndicator />}
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<div
|
||||
@@ -106,7 +125,7 @@ export default function SignIn() {
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn("w-full gap-2")}
|
||||
className={cn("w-full gap-2 flex relative")}
|
||||
onClick={async () => {
|
||||
await signIn.social({
|
||||
provider: "google",
|
||||
@@ -137,11 +156,12 @@ export default function SignIn() {
|
||||
d="M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0C79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251"
|
||||
></path>
|
||||
</svg>
|
||||
Sign in with Google
|
||||
<span>Sign in with Google</span>
|
||||
{client.isLastUsedLoginMethod("google") && <LastUsedIndicator />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn("w-full gap-2")}
|
||||
className={cn("w-full gap-2 flex items-center relative")}
|
||||
onClick={async () => {
|
||||
await signIn.social({
|
||||
provider: "github",
|
||||
@@ -160,11 +180,12 @@ export default function SignIn() {
|
||||
d="M12 2A10 10 0 0 0 2 12c0 4.42 2.87 8.17 6.84 9.5c.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34c-.46-1.16-1.11-1.47-1.11-1.47c-.91-.62.07-.6.07-.6c1 .07 1.53 1.03 1.53 1.03c.87 1.52 2.34 1.07 2.91.83c.09-.65.35-1.09.63-1.34c-2.22-.25-4.55-1.11-4.55-4.92c0-1.11.38-2 1.03-2.71c-.1-.25-.45-1.29.1-2.64c0 0 .84-.27 2.75 1.02c.79-.22 1.65-.33 2.5-.33s1.71.11 2.5.33c1.91-1.29 2.75-1.02 2.75-1.02c.55 1.35.2 2.39.1 2.64c.65.71 1.03 1.6 1.03 2.71c0 3.82-2.34 4.66-4.57 4.91c.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2"
|
||||
></path>
|
||||
</svg>
|
||||
Sign in with GitHub
|
||||
<span>Sign in with GitHub</span>
|
||||
{client.isLastUsedLoginMethod("github") && <LastUsedIndicator />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn("w-full gap-2")}
|
||||
className={cn("w-full gap-2 flex items-center relative")}
|
||||
onClick={async () => {
|
||||
await signIn.social({
|
||||
provider: "microsoft",
|
||||
@@ -183,7 +204,10 @@ export default function SignIn() {
|
||||
d="M2 3h9v9H2zm9 19H2v-9h9zM21 3v9h-9V3zm0 19h-9v-9h9z"
|
||||
></path>
|
||||
</svg>
|
||||
Sign in with Microsoft
|
||||
<span>Sign in with Microsoft</span>
|
||||
{client.isLastUsedLoginMethod("microsoft") && (
|
||||
<LastUsedIndicator />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,13 +11,13 @@ import {
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Loader2, X } from "lucide-react";
|
||||
import { signUp } from "@/lib/auth-client";
|
||||
import { toast } from "sonner";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { getCallbackURL } from "@/lib/shared";
|
||||
|
||||
export function SignUp() {
|
||||
const [firstName, setFirstName] = useState("");
|
||||
@@ -28,17 +28,19 @@ export function SignUp() {
|
||||
const [image, setImage] = useState<File | null>(null);
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const params = useSearchParams();
|
||||
const [loading, startTransition] = useTransition();
|
||||
|
||||
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setImage(file);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setImagePreview(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
setImagePreview((preview) => {
|
||||
if (preview) {
|
||||
URL.revokeObjectURL(preview);
|
||||
}
|
||||
return URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -118,11 +120,10 @@ export function SignUp() {
|
||||
<div className="flex items-end gap-4">
|
||||
{imagePreview && (
|
||||
<div className="relative w-16 h-16 rounded-sm overflow-hidden">
|
||||
<Image
|
||||
<img
|
||||
src={imagePreview}
|
||||
alt="Profile preview"
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
className="object-cover w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -151,26 +152,23 @@ export function SignUp() {
|
||||
className="w-full"
|
||||
disabled={loading}
|
||||
onClick={async () => {
|
||||
await signUp.email({
|
||||
email,
|
||||
password,
|
||||
name: `${firstName} ${lastName}`,
|
||||
image: image ? await convertImageToBase64(image) : "",
|
||||
callbackURL: "/dashboard",
|
||||
fetchOptions: {
|
||||
onResponse: () => {
|
||||
setLoading(false);
|
||||
startTransition(async () => {
|
||||
await signUp.email({
|
||||
email,
|
||||
password,
|
||||
name: `${firstName} ${lastName}`,
|
||||
image: image ? await convertImageToBase64(image) : "",
|
||||
callbackURL: "/dashboard",
|
||||
fetchOptions: {
|
||||
onError: (ctx) => {
|
||||
toast.error(ctx.error.message);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success("Successfully signed up");
|
||||
router.push(getCallbackURL(params));
|
||||
},
|
||||
},
|
||||
onRequest: () => {
|
||||
setLoading(true);
|
||||
},
|
||||
onError: (ctx) => {
|
||||
toast.error(ctx.error.message);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
router.push("/dashboard");
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -156,7 +156,7 @@ function toast({ ...props }: Toast) {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss();
|
||||
},
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
oneTapClient,
|
||||
oidcClient,
|
||||
genericOAuthClient,
|
||||
deviceAuthorizationClient,
|
||||
lastLoginMethodClient,
|
||||
} from "better-auth/client/plugins";
|
||||
import { toast } from "sonner";
|
||||
import { stripeClient } from "@better-auth/stripe/client";
|
||||
@@ -34,6 +36,8 @@ export const client = createAuthClient({
|
||||
stripeClient({
|
||||
subscription: true,
|
||||
}),
|
||||
deviceAuthorizationClient(),
|
||||
lastLoginMethodClient(),
|
||||
],
|
||||
fetchOptions: {
|
||||
onError(e) {
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
oAuthProxy,
|
||||
openAPI,
|
||||
customSession,
|
||||
deviceAuthorization,
|
||||
lastLoginMethod,
|
||||
} from "better-auth/plugins";
|
||||
import { reactInvitationEmail } from "./email/invitation";
|
||||
import { LibsqlDialect } from "@libsql/kysely-libsql";
|
||||
@@ -24,33 +26,52 @@ import { Stripe } from "stripe";
|
||||
const from = process.env.BETTER_AUTH_EMAIL || "delivered@resend.dev";
|
||||
const to = process.env.TEST_EMAIL || "";
|
||||
|
||||
const libsql = new LibsqlDialect({
|
||||
url: process.env.TURSO_DATABASE_URL || "",
|
||||
authToken: process.env.TURSO_AUTH_TOKEN || "",
|
||||
});
|
||||
|
||||
const mysql = process.env.USE_MYSQL
|
||||
? new MysqlDialect(createPool(process.env.MYSQL_DATABASE_URL || ""))
|
||||
: null;
|
||||
|
||||
const dialect = process.env.USE_MYSQL ? mysql : libsql;
|
||||
const dialect = (() => {
|
||||
if (process.env.USE_MYSQL) {
|
||||
if (!process.env.MYSQL_DATABASE_URL) {
|
||||
throw new Error(
|
||||
"Using MySQL dialect without MYSQL_DATABASE_URL. Please set it in your environment variables.",
|
||||
);
|
||||
}
|
||||
return new MysqlDialect(createPool(process.env.MYSQL_DATABASE_URL || ""));
|
||||
} else {
|
||||
if (process.env.TURSO_DATABASE_URL && process.env.TURSO_AUTH_TOKEN) {
|
||||
return new LibsqlDialect({
|
||||
url: process.env.TURSO_DATABASE_URL,
|
||||
authToken: process.env.TURSO_AUTH_TOKEN,
|
||||
});
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
if (!dialect) {
|
||||
throw new Error("No dialect found");
|
||||
}
|
||||
|
||||
const PRO_PRICE_ID = {
|
||||
default: "price_1RoxnRHmTADgihIt4y8c0lVE",
|
||||
annual: "price_1RoxnoHmTADgihItzFvVP8KT",
|
||||
};
|
||||
const PLUS_PRICE_ID = {
|
||||
default: "price_1RoxnJHmTADgihIthZTLmrPn",
|
||||
annual: "price_1Roxo5HmTADgihItEbJu5llL",
|
||||
};
|
||||
const baseURL: string | undefined =
|
||||
process.env.VERCEL === "1"
|
||||
? process.env.VERCEL_ENV === "production"
|
||||
? process.env.BETTER_AUTH_URL
|
||||
: process.env.VERCEL_ENV === "preview"
|
||||
? `https://${process.env.VERCEL_URL}`
|
||||
: undefined
|
||||
: undefined;
|
||||
|
||||
const cookieDomain: string | undefined =
|
||||
process.env.VERCEL === "1"
|
||||
? process.env.VERCEL_ENV === "production"
|
||||
? ".better-auth.com"
|
||||
: process.env.VERCEL_ENV === "preview"
|
||||
? `.${process.env.VERCEL_URL}`
|
||||
: undefined
|
||||
: undefined;
|
||||
|
||||
export const auth = betterAuth({
|
||||
appName: "Better Auth Demo",
|
||||
baseURL,
|
||||
database: {
|
||||
dialect: libsql,
|
||||
dialect,
|
||||
type: "sqlite",
|
||||
},
|
||||
emailVerification: {
|
||||
@@ -112,6 +133,10 @@ export const auth = betterAuth({
|
||||
clientId: process.env.TWITTER_CLIENT_ID || "",
|
||||
clientSecret: process.env.TWITTER_CLIENT_SECRET || "",
|
||||
},
|
||||
paypal: {
|
||||
clientId: process.env.PAYPAL_CLIENT_ID || "",
|
||||
clientSecret: process.env.PAYPAL_CLIENT_SECRET || "",
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
organization({
|
||||
@@ -174,32 +199,56 @@ export const auth = betterAuth({
|
||||
subscription: {
|
||||
enabled: true,
|
||||
allowReTrialsForDifferentPlans: true,
|
||||
plans: [
|
||||
{
|
||||
name: "Plus",
|
||||
priceId: PLUS_PRICE_ID.default,
|
||||
annualDiscountPriceId: PLUS_PRICE_ID.annual,
|
||||
freeTrial: {
|
||||
days: 7,
|
||||
plans: () => {
|
||||
const PRO_PRICE_ID = {
|
||||
default:
|
||||
process.env.STRIPE_PRO_PRICE_ID ??
|
||||
"price_1RoxnRHmTADgihIt4y8c0lVE",
|
||||
annual:
|
||||
process.env.STRIPE_PRO_ANNUAL_PRICE_ID ??
|
||||
"price_1RoxnoHmTADgihItzFvVP8KT",
|
||||
};
|
||||
const PLUS_PRICE_ID = {
|
||||
default:
|
||||
process.env.STRIPE_PLUS_PRICE_ID ??
|
||||
"price_1RoxnJHmTADgihIthZTLmrPn",
|
||||
annual:
|
||||
process.env.STRIPE_PLUS_ANNUAL_PRICE_ID ??
|
||||
"price_1Roxo5HmTADgihItEbJu5llL",
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
name: "Plus",
|
||||
priceId: PLUS_PRICE_ID.default,
|
||||
annualDiscountPriceId: PLUS_PRICE_ID.annual,
|
||||
freeTrial: {
|
||||
days: 7,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Pro",
|
||||
priceId: PRO_PRICE_ID.default,
|
||||
annualDiscountPriceId: PRO_PRICE_ID.annual,
|
||||
freeTrial: {
|
||||
days: 7,
|
||||
{
|
||||
name: "Pro",
|
||||
priceId: PRO_PRICE_ID.default,
|
||||
annualDiscountPriceId: PRO_PRICE_ID.annual,
|
||||
freeTrial: {
|
||||
days: 7,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
];
|
||||
},
|
||||
},
|
||||
}),
|
||||
deviceAuthorization({
|
||||
expiresIn: "3min",
|
||||
interval: "5s",
|
||||
}),
|
||||
lastLoginMethod(),
|
||||
],
|
||||
trustedOrigins: ["exp://"],
|
||||
advanced: {
|
||||
crossSubDomainCookies: {
|
||||
enabled: process.env.NODE_ENV === "production",
|
||||
domain: ".better-auth.com",
|
||||
domain: cookieDomain,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
19
demo/nextjs/lib/shared.ts
Normal file
19
demo/nextjs/lib/shared.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { ReadonlyURLSearchParams } from "next/navigation";
|
||||
|
||||
const allowedCallbackSet: ReadonlySet<string> = new Set([
|
||||
"/dashboard",
|
||||
"/device",
|
||||
]);
|
||||
|
||||
export const getCallbackURL = (
|
||||
queryParams: ReadonlyURLSearchParams,
|
||||
): string => {
|
||||
const callbackUrl = queryParams.get("callbackUrl");
|
||||
if (callbackUrl) {
|
||||
if (allowedCallbackSet.has(callbackUrl)) {
|
||||
return callbackUrl;
|
||||
}
|
||||
return "/dashboard";
|
||||
}
|
||||
return "/dashboard";
|
||||
};
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
webpack: (config) => {
|
||||
config.externals.push("@libsql/client");
|
||||
return config;
|
||||
|
||||
@@ -13,98 +13,93 @@
|
||||
"dependencies": {
|
||||
"@better-auth/stripe": "workspace:*",
|
||||
"@better-fetch/fetch": "catalog:",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@libsql/client": "^0.12.0",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@libsql/client": "^0.15.14",
|
||||
"@libsql/kysely-libsql": "^0.4.1",
|
||||
"@number-flow/react": "^0.5.5",
|
||||
"@number-flow/react": "^0.5.10",
|
||||
"@prisma/adapter-libsql": "^5.22.0",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"@radix-ui/react-accordion": "^1.2.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.2",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.0",
|
||||
"@radix-ui/react-avatar": "^1.1.1",
|
||||
"@radix-ui/react-checkbox": "^1.1.2",
|
||||
"@radix-ui/react-collapsible": "^1.1.1",
|
||||
"@radix-ui/react-context-menu": "^2.2.2",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||
"@radix-ui/react-hover-card": "^1.1.2",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-menubar": "^1.1.2",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.1",
|
||||
"@radix-ui/react-popover": "^1.1.2",
|
||||
"@radix-ui/react-progress": "^1.1.0",
|
||||
"@radix-ui/react-radio-group": "^1.2.1",
|
||||
"@radix-ui/react-scroll-area": "^1.2.1",
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slider": "^1.2.1",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.1",
|
||||
"@radix-ui/react-tabs": "^1.1.1",
|
||||
"@radix-ui/react-toast": "^1.2.2",
|
||||
"@radix-ui/react-toggle": "^1.1.0",
|
||||
"@radix-ui/react-toggle-group": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.4",
|
||||
"@react-email/components": "^0.0.25",
|
||||
"@react-three/fiber": "^8.17.10",
|
||||
"@tanstack/react-query": "^5.62.3",
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-menubar": "^1.1.16",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@react-email/components": "^0.5.1",
|
||||
"@react-three/fiber": "^8.18.0",
|
||||
"@tanstack/react-query": "^5.85.9",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"better-auth": "workspace:*",
|
||||
"better-call": "catalog:",
|
||||
"better-sqlite3": "^11.6.0",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "1.0.0",
|
||||
"consola": "^3.2.3",
|
||||
"crypto": "^1.0.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"embla-carousel-react": "^8.5.1",
|
||||
"framer-motion": "^11.13.1",
|
||||
"geist": "^1.3.1",
|
||||
"input-otp": "^1.4.1",
|
||||
"kysely": "^0.28.2",
|
||||
"lucide-react": "^0.477.0",
|
||||
"cmdk": "1.1.1",
|
||||
"consola": "^3.4.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"framer-motion": "^12.23.12",
|
||||
"geist": "^1.4.2",
|
||||
"input-otp": "^1.4.2",
|
||||
"kysely": "^0.28.5",
|
||||
"lucide-react": "^0.542.0",
|
||||
"mini-svg-data-uri": "^1.4.4",
|
||||
"mysql2": "^3.11.5",
|
||||
"next": "^15.5.0",
|
||||
"next-themes": "^0.3.0",
|
||||
"mysql2": "^3.14.4",
|
||||
"next": "^15.5.2",
|
||||
"next-themes": "^0.4.6",
|
||||
"prisma": "^5.22.0",
|
||||
"react": "^19.0.0",
|
||||
"react-day-picker": "8.10.1",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.54.0",
|
||||
"react-qr-code": "^2.0.15",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"recharts": "^2.14.1",
|
||||
"resend": "^4.0.1",
|
||||
"react": "^19.1.1",
|
||||
"react-day-picker": "9.9.0",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-hook-form": "^7.62.0",
|
||||
"react-qr-code": "^2.0.18",
|
||||
"react-resizable-panels": "^3.0.5",
|
||||
"recharts": "^3.1.2",
|
||||
"resend": "^6.0.2",
|
||||
"server-only": "^0.0.1",
|
||||
"shiki": "^1.24.0",
|
||||
"sonner": "^1.7.0",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"shiki": "^3.12.2",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"three": "^0.168.0",
|
||||
"ua-parser-js": "^0.7.39",
|
||||
"vaul": "^0.9.9",
|
||||
"zod": "^3.23.8"
|
||||
"three": "^0.180.0",
|
||||
"ua-parser-js": "^2.0.4",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@types/three": "^0.168.0",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@types/three": "^0.179.0",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"dotenv": "^16.4.7",
|
||||
"dotenv": "^16.6.1",
|
||||
"dotenv-cli": "^7.4.4",
|
||||
"eslint-config-next": "15.0.0-canary.149",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "3.4.16",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"overrides": {
|
||||
"whatwg-url": "^14.0.0"
|
||||
"eslint-config-next": "15.5.2",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "3.4.17"
|
||||
}
|
||||
}
|
||||
|
||||
26
demo/nextjs/turbo.json
Normal file
26
demo/nextjs/turbo.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"$schema": "https://turborepo.org/schema.json",
|
||||
"extends": ["//"],
|
||||
"tasks": {
|
||||
"build": {
|
||||
"env": [
|
||||
"TURSO_DATABASE_URL",
|
||||
"TURSO_AUTH_TOKEN",
|
||||
"RESEND_API_KEY",
|
||||
"BETTER_AUTH_EMAIL",
|
||||
"BETTER_AUTH_SECRET",
|
||||
"BETTER_AUTH_URL",
|
||||
"GITHUB_CLIENT_SECRET",
|
||||
"GITHUB_CLIENT_ID",
|
||||
"GOOGLE_CLIENT_ID",
|
||||
"GOOGLE_CLIENT_SECRET",
|
||||
"DISCORD_CLIENT_ID",
|
||||
"DISCORD_CLIENT_SECRET",
|
||||
"MICROSOFT_CLIENT_ID",
|
||||
"MICROSOFT_CLIENT_SECRET",
|
||||
"STRIPE_KEY",
|
||||
"STRIPE_WEBHOOK_SECRET"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1,7 @@
|
||||
NEXT_PUBLIC_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_URL=http://localhost:3000
|
||||
|
||||
# Orama Search Configuration
|
||||
ORAMA_PRIVATE_API_KEY=
|
||||
NEXT_PUBLIC_ORAMA_PUBLIC_API_KEY=
|
||||
NEXT_PUBLIC_ORAMA_ENDPOINT=
|
||||
ORAMA_INDEX_ID=
|
||||
|
||||
@@ -45,19 +45,22 @@ const ChangelogPage = async () => {
|
||||
if (line.startsWith("- ")) {
|
||||
const mainContent = line.split(";")[0];
|
||||
const context = line.split(";")[2];
|
||||
const mentions = context
|
||||
?.split(" ")
|
||||
.filter((word) => word.startsWith("@"))
|
||||
.map((mention) => {
|
||||
const username = mention.replace("@", "");
|
||||
const avatarUrl = `https://github.com/${username}.png`;
|
||||
return `[](https://github.com/${username})`;
|
||||
});
|
||||
if (!mentions) {
|
||||
return line;
|
||||
const mentionMatches =
|
||||
(context ?? line)?.match(/@([A-Za-z0-9-]+)/g) ?? [];
|
||||
if (mentionMatches.length === 0) {
|
||||
return (mainContent || line).replace(/ /g, "");
|
||||
}
|
||||
const mentions = mentionMatches.map((match) => {
|
||||
const username = match.slice(1);
|
||||
const avatarUrl = `https://github.com/${username}.png`;
|
||||
return `[](https://github.com/${username})`;
|
||||
});
|
||||
// Remove  
|
||||
return mainContent.replace(/ /g, "") + " – " + mentions.join(" ");
|
||||
return (
|
||||
(mainContent || line).replace(/ /g, "") +
|
||||
" – " +
|
||||
mentions.join(" ")
|
||||
);
|
||||
}
|
||||
return line;
|
||||
});
|
||||
|
||||
@@ -34,7 +34,6 @@ export default async function Page({
|
||||
const { slug } = await params;
|
||||
const page = changelogs.getPage(slug);
|
||||
if (!slug) {
|
||||
//@ts-ignore
|
||||
return <ChangelogPage />;
|
||||
}
|
||||
if (!page) {
|
||||
|
||||
@@ -45,19 +45,22 @@ const ChangelogPage = async () => {
|
||||
if (line.trim().startsWith("- ")) {
|
||||
const mainContent = line.split(";")[0];
|
||||
const context = line.split(";")[2];
|
||||
const mentions = context
|
||||
?.split(" ")
|
||||
.filter((word) => word.startsWith("@"))
|
||||
.map((mention) => {
|
||||
const username = mention.replace("@", "");
|
||||
const avatarUrl = `https://github.com/${username}.png`;
|
||||
return `[](https://github.com/${username})`;
|
||||
});
|
||||
if (!mentions) {
|
||||
return line;
|
||||
const mentionMatches =
|
||||
(context ?? line)?.match(/@([A-Za-z0-9-]+)/g) ?? [];
|
||||
if (mentionMatches.length === 0) {
|
||||
return (mainContent || line).replace(/ /g, "");
|
||||
}
|
||||
const mentions = mentionMatches.map((match) => {
|
||||
const username = match.slice(1);
|
||||
const avatarUrl = `https://github.com/${username}.png`;
|
||||
return `[](https://github.com/${username})`;
|
||||
});
|
||||
// Remove  
|
||||
return mainContent.replace(/ /g, "") + " – " + mentions.join(" ");
|
||||
return (
|
||||
(mainContent || line).replace(/ /g, "") +
|
||||
" – " +
|
||||
mentions.join(" ")
|
||||
);
|
||||
}
|
||||
return line;
|
||||
});
|
||||
|
||||
@@ -8,6 +8,250 @@ import { remarkAutoTypeTable } from "fumadocs-typescript";
|
||||
import { remarkInclude } from "fumadocs-mdx/config";
|
||||
import { readFile } from "fs/promises";
|
||||
|
||||
function extractAPIMethods(rawContent: string): string {
|
||||
const apiMethodRegex = /<APIMethod\s+([^>]+)>([\s\S]*?)<\/APIMethod>/g;
|
||||
|
||||
return rawContent.replace(apiMethodRegex, (match, attributes, content) => {
|
||||
// Parse attributes by matching
|
||||
const pathMatch = attributes.match(/path="([^"]+)"/);
|
||||
const methodMatch = attributes.match(/method="([^"]+)"/);
|
||||
const requireSessionMatch = attributes.match(/requireSession/);
|
||||
const isServerOnlyMatch = attributes.match(/isServerOnly/);
|
||||
const isClientOnlyMatch = attributes.match(/isClientOnly/);
|
||||
const noResultMatch = attributes.match(/noResult/);
|
||||
const resultVariableMatch = attributes.match(/resultVariable="([^"]+)"/);
|
||||
const forceAsBodyMatch = attributes.match(/forceAsBody/);
|
||||
const forceAsQueryMatch = attributes.match(/forceAsQuery/);
|
||||
|
||||
const path = pathMatch ? pathMatch[1] : "";
|
||||
const method = methodMatch ? methodMatch[1] : "GET";
|
||||
const requireSession = !!requireSessionMatch;
|
||||
const isServerOnly = !!isServerOnlyMatch;
|
||||
const isClientOnly = !!isClientOnlyMatch;
|
||||
const noResult = !!noResultMatch;
|
||||
const resultVariable = resultVariableMatch
|
||||
? resultVariableMatch[1]
|
||||
: "data";
|
||||
const forceAsBody = !!forceAsBodyMatch;
|
||||
const forceAsQuery = !!forceAsQueryMatch;
|
||||
|
||||
const typeMatch = content.match(/type\s+(\w+)\s*=\s*\{([\s\S]*?)\}/);
|
||||
if (!typeMatch) {
|
||||
return match; // Return original if no type found
|
||||
}
|
||||
|
||||
const functionName = typeMatch[1];
|
||||
const typeBody = typeMatch[2];
|
||||
|
||||
const properties = parseTypeBody(typeBody);
|
||||
|
||||
const clientCode = generateClientCode(functionName, properties, path);
|
||||
const serverCode = generateServerCode(
|
||||
functionName,
|
||||
properties,
|
||||
method,
|
||||
requireSession,
|
||||
forceAsBody,
|
||||
forceAsQuery,
|
||||
noResult,
|
||||
resultVariable,
|
||||
);
|
||||
|
||||
return `
|
||||
### Client Side
|
||||
|
||||
\`\`\`ts
|
||||
${clientCode}
|
||||
\`\`\`
|
||||
|
||||
### Server Side
|
||||
|
||||
\`\`\`ts
|
||||
${serverCode}
|
||||
\`\`\`
|
||||
|
||||
### Type Definition
|
||||
|
||||
\`\`\`ts
|
||||
type ${functionName} = {${typeBody}
|
||||
}
|
||||
\`\`\`
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
function parseTypeBody(typeBody: string) {
|
||||
const properties: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
description: string;
|
||||
exampleValue: string;
|
||||
isServerOnly: boolean;
|
||||
isClientOnly: boolean;
|
||||
}> = [];
|
||||
|
||||
const lines = typeBody.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (!trimmed || trimmed.startsWith("//") || trimmed.startsWith("/*"))
|
||||
continue;
|
||||
const propMatch = trimmed.match(
|
||||
/^(\w+)(\?)?:\s*(.+?)(\s*=\s*["']([^"']+)["'])?(\s*\/\/\s*(.+))?$/,
|
||||
);
|
||||
if (propMatch) {
|
||||
const [, name, optional, type, , exampleValue, , description] = propMatch;
|
||||
|
||||
let cleanType = type.trim();
|
||||
let cleanExampleValue = exampleValue || "";
|
||||
|
||||
cleanType = cleanType.replace(/,$/, "");
|
||||
|
||||
properties.push({
|
||||
name,
|
||||
type: cleanType,
|
||||
required: !optional,
|
||||
description: description || "",
|
||||
exampleValue: cleanExampleValue,
|
||||
isServerOnly: false,
|
||||
isClientOnly: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return properties;
|
||||
}
|
||||
|
||||
// Generate client code example
|
||||
function generateClientCode(
|
||||
functionName: string,
|
||||
properties: any[],
|
||||
path: string,
|
||||
) {
|
||||
if (!functionName || !path) {
|
||||
return "// Unable to generate client code - missing function name or path";
|
||||
}
|
||||
|
||||
const clientMethodPath = pathToDotNotation(path);
|
||||
const body = createClientBody(properties);
|
||||
|
||||
return `const { data, error } = await authClient.${clientMethodPath}(${body});`;
|
||||
}
|
||||
|
||||
// Generate server code example
|
||||
function generateServerCode(
|
||||
functionName: string,
|
||||
properties: any[],
|
||||
method: string,
|
||||
requireSession: boolean,
|
||||
forceAsBody: boolean,
|
||||
forceAsQuery: boolean,
|
||||
noResult: boolean,
|
||||
resultVariable: string,
|
||||
) {
|
||||
if (!functionName) {
|
||||
return "// Unable to generate server code - missing function name";
|
||||
}
|
||||
|
||||
const body = createServerBody(
|
||||
properties,
|
||||
method,
|
||||
requireSession,
|
||||
forceAsBody,
|
||||
forceAsQuery,
|
||||
);
|
||||
|
||||
return `${noResult ? "" : `const ${resultVariable} = `}await auth.api.${functionName}(${body});`;
|
||||
}
|
||||
|
||||
function pathToDotNotation(input: string): string {
|
||||
return input
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map((segment) =>
|
||||
segment
|
||||
.split("-")
|
||||
.map((word, i) =>
|
||||
i === 0
|
||||
? word.toLowerCase()
|
||||
: word.charAt(0).toUpperCase() + word.slice(1),
|
||||
)
|
||||
.join(""),
|
||||
)
|
||||
.join(".");
|
||||
}
|
||||
|
||||
// Helper function to create client body (simplified version)
|
||||
function createClientBody(props: any[]) {
|
||||
if (props.length === 0) return "{}";
|
||||
|
||||
let body = "{\n";
|
||||
|
||||
for (const prop of props) {
|
||||
if (prop.isServerOnly) continue;
|
||||
|
||||
let comment = "";
|
||||
if (!prop.required || prop.description) {
|
||||
const comments = [];
|
||||
if (!prop.required) comments.push("required");
|
||||
if (prop.description) comments.push(prop.description);
|
||||
comment = ` // ${comments.join(", ")}`;
|
||||
}
|
||||
|
||||
body += ` ${prop.name}${prop.exampleValue ? `: ${prop.exampleValue}` : ""}${prop.type === "Object" ? ": {}" : ""},${comment}\n`;
|
||||
}
|
||||
|
||||
body += "}";
|
||||
return body;
|
||||
}
|
||||
|
||||
function createServerBody(
|
||||
props: any[],
|
||||
method: string,
|
||||
requireSession: boolean,
|
||||
forceAsBody: boolean,
|
||||
forceAsQuery: boolean,
|
||||
) {
|
||||
const relevantProps = props.filter((x) => !x.isClientOnly);
|
||||
|
||||
if (relevantProps.length === 0 && !requireSession) {
|
||||
return "{}";
|
||||
}
|
||||
|
||||
let serverBody = "{\n";
|
||||
|
||||
if (relevantProps.length > 0) {
|
||||
const bodyKey =
|
||||
(method === "POST" || forceAsBody) && !forceAsQuery ? "body" : "query";
|
||||
serverBody += ` ${bodyKey}: {\n`;
|
||||
|
||||
for (const prop of relevantProps) {
|
||||
let comment = "";
|
||||
if (!prop.required || prop.description) {
|
||||
const comments = [];
|
||||
if (!prop.required) comments.push("required");
|
||||
if (prop.description) comments.push(prop.description);
|
||||
comment = ` // ${comments.join(", ")}`;
|
||||
}
|
||||
|
||||
serverBody += ` ${prop.name}${prop.exampleValue ? `: ${prop.exampleValue}` : ""}${prop.type === "Object" ? ": {}" : ""},${comment}\n`;
|
||||
}
|
||||
|
||||
serverBody += " }";
|
||||
}
|
||||
|
||||
if (requireSession) {
|
||||
if (relevantProps.length > 0) serverBody += ",";
|
||||
serverBody +=
|
||||
"\n // This endpoint requires session cookies.\n headers: await headers()";
|
||||
}
|
||||
|
||||
serverBody += "\n}";
|
||||
return serverBody;
|
||||
}
|
||||
|
||||
const processor = remark()
|
||||
.use(remarkMdx)
|
||||
.use(remarkInclude)
|
||||
@@ -23,9 +267,12 @@ export async function getLLMText(docPage: any) {
|
||||
// Read the raw file content
|
||||
const rawContent = await readFile(docPage.data._file.absolutePath, "utf-8");
|
||||
|
||||
// Extract APIMethod components & other nested wrapper before processing
|
||||
const processedContent = extractAPIMethods(rawContent);
|
||||
|
||||
const processed = await processor.process({
|
||||
path: docPage.data._file.absolutePath,
|
||||
value: rawContent,
|
||||
value: processedContent,
|
||||
});
|
||||
|
||||
return `# ${category}: ${docPage.data.title}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { baseUrl, createMetadata } from "@/lib/metadata";
|
||||
import { Analytics } from "@vercel/analytics/react";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { CustomSearchDialog } from "@/components/search-dialog";
|
||||
|
||||
export const metadata = createMetadata({
|
||||
title: {
|
||||
@@ -50,6 +51,10 @@ export default function Layout({ children }: { children: ReactNode }) {
|
||||
enableSystem: true,
|
||||
defaultTheme: "dark",
|
||||
}}
|
||||
search={{
|
||||
enabled: true,
|
||||
SearchDialog: CustomSearchDialog,
|
||||
}}
|
||||
>
|
||||
<NavbarProvider>
|
||||
<Navbar />
|
||||
|
||||
7
docs/app/static.json/route.ts
Normal file
7
docs/app/static.json/route.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { exportSearchIndexes } from "@/lib/export-search-indexes";
|
||||
|
||||
export const revalidate = false;
|
||||
|
||||
export async function GET() {
|
||||
return Response.json(await exportSearchIndexes());
|
||||
}
|
||||
@@ -230,6 +230,42 @@ export const socialProviders = {
|
||||
/>
|
||||
</svg>`,
|
||||
},
|
||||
kakao: {
|
||||
Icon: (props: SVGProps<any>) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 512 512"
|
||||
{...props}
|
||||
>
|
||||
<g>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M 511.5,203.5 C 511.5,215.5 511.5,227.5 511.5,239.5C 504.002,286.989 482.002,326.489 445.5,358C 390.216,402.375 326.882,424.209 255.5,423.5C 239.751,423.476 224.085,422.643 208.5,421C 174.34,444.581 140.006,467.914 105.5,491C 95.6667,493.167 91.8333,489.333 94,479.5C 101.833,450.667 109.667,421.833 117.5,393C 85.5639,376.077 58.0639,353.577 35,325.5C 15.8353,299.834 4.00193,271.167 -0.5,239.5C -0.5,227.5 -0.5,215.5 -0.5,203.5C 7.09119,155.407 29.4245,115.574 66.5,84C 121.53,39.9708 184.53,18.4708 255.5,19.5C 326.47,18.4708 389.47,39.9708 444.5,84C 481.575,115.574 503.909,155.407 511.5,203.5 Z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
),
|
||||
stringIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512"><g><path fill="currentColor" d="M 511.5,203.5 C 511.5,215.5 511.5,227.5 511.5,239.5C 504.002,286.989 482.002,326.489 445.5,358C 390.216,402.375 326.882,424.209 255.5,423.5C 239.751,423.476 224.085,422.643 208.5,421C 174.34,444.581 140.006,467.914 105.5,491C 95.6667,493.167 91.8333,489.333 94,479.5C 101.833,450.667 109.667,421.833 117.5,393C 85.5639,376.077 58.0639,353.577 35,325.5C 15.8353,299.834 4.00193,271.167 -0.5,239.5C -0.5,227.5 -0.5,215.5 -0.5,203.5C 7.09119,155.407 29.4245,115.574 66.5,84C 121.53,39.9708 184.53,18.4708 255.5,19.5C 326.47,18.4708 389.47,39.9708 444.5,84C 481.575,115.574 503.909,155.407 511.5,203.5 Z"/></g></svg>`,
|
||||
},
|
||||
naver: {
|
||||
Icon: (props: SVGProps<any>) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M16.273 12.845 7.376 0H0v24h7.726V11.156L16.624 24H24V0h-7.727v12.845Z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
stringIcon: `<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill="currentColor" d="M16.273 12.845 7.376 0H0v24h7.726V11.156L16.624 24H24V0h-7.727v12.845Z"/></svg>`,
|
||||
},
|
||||
linkedin: {
|
||||
Icon: (props: SVGProps<any>) => (
|
||||
<svg
|
||||
|
||||
@@ -33,7 +33,6 @@ export const NavbarProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const toggleDocsNavbar = () => {
|
||||
setIsDocsOpen((prevIsOpen) => !prevIsOpen);
|
||||
};
|
||||
// @ts-ignore
|
||||
return (
|
||||
<NavbarContext.Provider
|
||||
value={{ isOpen, toggleNavbar, isDocsOpen, toggleDocsNavbar }}
|
||||
|
||||
59
docs/components/search-dialog.tsx
Normal file
59
docs/components/search-dialog.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SearchDialog,
|
||||
SearchDialogClose,
|
||||
SearchDialogContent,
|
||||
SearchDialogFooter,
|
||||
SearchDialogHeader,
|
||||
SearchDialogIcon,
|
||||
SearchDialogInput,
|
||||
SearchDialogList,
|
||||
SearchDialogOverlay,
|
||||
type SharedProps,
|
||||
} from "fumadocs-ui/components/dialog/search";
|
||||
import { useDocsSearch } from "fumadocs-core/search/client";
|
||||
import { OramaClient } from "@oramacloud/client";
|
||||
import { useI18n } from "fumadocs-ui/contexts/i18n";
|
||||
|
||||
const client = new OramaClient({
|
||||
endpoint: process.env.NEXT_PUBLIC_ORAMA_ENDPOINT!,
|
||||
api_key: process.env.NEXT_PUBLIC_ORAMA_PUBLIC_API_KEY!,
|
||||
});
|
||||
|
||||
export function CustomSearchDialog(props: SharedProps) {
|
||||
const { locale } = useI18n();
|
||||
const { search, setSearch, query } = useDocsSearch({
|
||||
type: "orama-cloud",
|
||||
client,
|
||||
locale,
|
||||
});
|
||||
|
||||
return (
|
||||
<SearchDialog
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
isLoading={query.isLoading}
|
||||
{...props}
|
||||
>
|
||||
<SearchDialogOverlay />
|
||||
<SearchDialogContent>
|
||||
<SearchDialogHeader>
|
||||
<SearchDialogIcon />
|
||||
<SearchDialogInput />
|
||||
<SearchDialogClose />
|
||||
</SearchDialogHeader>
|
||||
<SearchDialogList items={query.data !== "empty" ? query.data : null} />
|
||||
<SearchDialogFooter>
|
||||
<a
|
||||
href="https://orama.com"
|
||||
rel="noreferrer noopener"
|
||||
className="ms-auto text-xs text-fd-muted-foreground"
|
||||
>
|
||||
Search powered by Orama
|
||||
</a>
|
||||
</SearchDialogFooter>
|
||||
</SearchDialogContent>
|
||||
</SearchDialog>
|
||||
);
|
||||
}
|
||||
@@ -492,6 +492,43 @@ export const contents: Content[] = [
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Atlassian",
|
||||
href: "/docs/authentication/atlassian",
|
||||
isNew: true,
|
||||
icon: () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1.2em"
|
||||
height="1.2em"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M7.45 10.54c-.15-.26-.44-.26-.59 0L3.12 17.8c-.15.26-.02.47.29.47h3.68c.21 0 .4-.11.49-.29l1.77-3.07c.8-1.38.87-3.04.1-4.37m1.14-6.91c-.8 1.33-.73 2.98.1 4.37l4.84 8.41c.09.18.28.29.49.29h3.68c.31 0 .44-.21.29-.47L8.73 3.34c-.15-.26-.44-.26-.59 0l.45.29z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Cognito",
|
||||
href: "/docs/authentication/cognito",
|
||||
isNew: true,
|
||||
icon: () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1.2em"
|
||||
height="1.2em"
|
||||
viewBox="0 0 48 48"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M24 4L6 14v20l18 10 18-10V14L24 4zm0 4.62l13.6 7.86v15.04L24 39.38 10.4 31.52V16.48L24 8.62z"
|
||||
/>
|
||||
<path fill="currentColor" d="M22 14h4v20h-4zM14 22h20v4H14z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Discord",
|
||||
href: "/docs/authentication/discord",
|
||||
@@ -526,6 +563,27 @@ export const contents: Content[] = [
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Figma",
|
||||
href: "/docs/authentication/figma",
|
||||
isNew: true,
|
||||
icon: () => (
|
||||
<svg
|
||||
width="1.2em"
|
||||
height="1.2em"
|
||||
viewBox="0 0 15 15"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M7.00005 2.04999H5.52505C4.71043 2.04999 4.05005 2.71037 4.05005 3.52499C4.05005 4.33961 4.71043 4.99999 5.52505 4.99999H7.00005V2.04999ZM7.00005 1.04999H8.00005H9.47505C10.842 1.04999 11.95 2.15808 11.95 3.52499C11.95 4.33163 11.5642 5.04815 10.9669 5.49999C11.5642 5.95184 11.95 6.66836 11.95 7.475C11.95 8.8419 10.842 9.95 9.47505 9.95C8.92236 9.95 8.41198 9.76884 8.00005 9.46266V9.95L8.00005 11.425C8.00005 12.7919 6.89195 13.9 5.52505 13.9C4.15814 13.9 3.05005 12.7919 3.05005 11.425C3.05005 10.6183 3.43593 9.90184 4.03317 9.44999C3.43593 8.99814 3.05005 8.28163 3.05005 7.475C3.05005 6.66836 3.43594 5.95184 4.03319 5.5C3.43594 5.04815 3.05005 4.33163 3.05005 3.52499C3.05005 2.15808 4.15814 1.04999 5.52505 1.04999H7.00005ZM8.00005 2.04999V4.99999H9.47505C10.2897 4.99999 10.95 4.33961 10.95 3.52499C10.95 2.71037 10.2897 2.04999 9.47505 2.04999H8.00005ZM5.52505 8.94998H7.00005L7.00005 7.4788L7.00005 7.475L7.00005 7.4712V6H5.52505C4.71043 6 4.05005 6.66038 4.05005 7.475C4.05005 8.28767 4.70727 8.94684 5.5192 8.94999L5.52505 8.94998ZM4.05005 11.425C4.05005 10.6123 4.70727 9.95315 5.5192 9.94999L5.52505 9.95H7.00005L7.00005 11.425C7.00005 12.2396 6.33967 12.9 5.52505 12.9C4.71043 12.9 4.05005 12.2396 4.05005 11.425ZM8.00005 7.47206C8.00164 6.65879 8.66141 6 9.47505 6C10.2897 6 10.95 6.66038 10.95 7.475C10.95 8.28962 10.2897 8.95 9.47505 8.95C8.66141 8.95 8.00164 8.29121 8.00005 7.47794V7.47206Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "GitHub",
|
||||
href: "/docs/authentication/github",
|
||||
@@ -563,6 +621,24 @@ export const contents: Content[] = [
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "LINE",
|
||||
href: "/docs/authentication/line",
|
||||
isNew: true,
|
||||
icon: () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1.2em"
|
||||
height="1.2em"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M19.365 9.863c.349 0 .63.285.63.631 0 .345-.281.63-.63.63H17.61v1.125h1.755c.349 0 .63.283.63.63 0 .344-.281.629-.63.629h-2.386c-.345 0-.627-.285-.627-.629V8.108c0-.345.282-.63.63-.63h2.386c.346 0 .627.285.627.63 0 .349-.281.63-.63.63H17.61v1.125h1.755zm-3.855 3.016c0 .27-.174.51-.432.596-.064.021-.133.031-.199.031-.211 0-.391-.09-.51-.25l-2.443-3.317v2.94c0 .344-.279.629-.631.629-.346 0-.626-.285-.626-.629V8.108c0-.27.173-.51.43-.595.06-.023.136-.033.194-.033.195 0 .375.104.495.254l2.462 3.33V8.108c0-.345.282-.63.63-.63.345 0 .63.285.63.63v4.771zm-5.741 0c0 .344-.282.629-.631.629-.345 0-.627-.285-.627-.629V8.108c0-.345.282-.63.63-.63.346 0 .628.285.628.63v4.771zm-2.466.629H4.917c-.345 0-.63-.285-.63-.629V8.108c0-.345.285-.63.63-.63.348 0 .63.285.63.63v4.141h1.756c.348 0 .629.283.629.63 0 .344-.282.629-.629.629M24 10.314C24 4.943 18.615.572 12 .572S0 4.943 0 10.314c0 4.811 4.27 8.842 10.035 9.608.391.082.923.258 1.058.59.12.301.079.766.038 1.08l-.164 1.02c-.045.301-.24 1.186 1.049.645 1.291-.539 6.916-4.078 9.436-6.975C23.176 14.393 24 12.458 24 10.314"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Hugging Face",
|
||||
href: "/docs/authentication/huggingface",
|
||||
@@ -580,6 +656,26 @@ export const contents: Content[] = [
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Kakao",
|
||||
isNew: true,
|
||||
href: "/docs/authentication/kakao",
|
||||
icon: (props?: SVGProps<any>) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1.2em"
|
||||
height="1.2em"
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<g>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M 511.5,203.5 C 511.5,215.5 511.5,227.5 511.5,239.5C 504.002,286.989 482.002,326.489 445.5,358C 390.216,402.375 326.882,424.209 255.5,423.5C 239.751,423.476 224.085,422.643 208.5,421C 174.34,444.581 140.006,467.914 105.5,491C 95.6667,493.167 91.8333,489.333 94,479.5C 101.833,450.667 109.667,421.833 117.5,393C 85.5639,376.077 58.0639,353.577 35,325.5C 15.8353,299.834 4.00193,271.167 -0.5,239.5C -0.5,227.5 -0.5,215.5 -0.5,203.5C 7.09119,155.407 29.4245,115.574 66.5,84C 121.53,39.9708 184.53,18.4708 255.5,19.5C 326.47,18.4708 389.47,39.9708 444.5,84C 481.575,115.574 503.909,155.407 511.5,203.5 Z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Kick",
|
||||
href: "/docs/authentication/kick",
|
||||
@@ -614,6 +710,51 @@ export const contents: Content[] = [
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "PayPal",
|
||||
href: "/docs/authentication/paypal",
|
||||
isNew: true,
|
||||
icon: () => (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
viewBox="-2 -2 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
preserveAspectRatio="xMinYMin"
|
||||
width="1.4em"
|
||||
height="1.4em"
|
||||
>
|
||||
<g id="SVGRepo_bgCarrier" strokeWidth="0"></g>
|
||||
<g
|
||||
id="SVGRepo_tracerCarrier"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
></g>
|
||||
<g id="SVGRepo_iconCarrier">
|
||||
<path d="M4.328 16.127l-.011.07a.899.899 0 0 1-.887.744H.9a.892.892 0 0 1-.88-1.04L2.57.745A.892.892 0 0 1 3.45 0h6.92a4.141 4.141 0 0 1 4.142 4.141c0 .273-.017.54-.05.804a3.629 3.629 0 0 1 1.53 2.962 5.722 5.722 0 0 1-5.72 5.722h-.583c-.653 0-1.211.472-1.32 1.117l-.314 1.87.314-1.87a1.339 1.339 0 0 1 1.32-1.117h.582a5.722 5.722 0 0 0 5.722-5.722 3.629 3.629 0 0 0-1.53-2.962 6.52 6.52 0 0 1-6.47 5.716H6.06a.969.969 0 0 0-.93.701l-1.155 6.862c-.08.48.289.916.775.916h2.214a.786.786 0 0 0 .775-.655l.315-1.87-.315 1.87a.786.786 0 0 1-.775.655H4.751a.782.782 0 0 1-.6-.278.782.782 0 0 1-.175-.638l.352-2.097z"></path>
|
||||
<path d="M15.45 5.995c.365.567.578 1.242.578 1.967a5.722 5.722 0 0 1-5.722 5.722h-.581c-.654 0-1.212.472-1.32 1.117l-.63 3.739a.786.786 0 0 1-.774.655H4.973l1.15-6.833c.118-.41.495-.7.93-.7h1.932a6.52 6.52 0 0 0 6.464-5.667zm-10.477 13.2h-.187a.786.786 0 0 1-.775-.916l.057-.338h.355a.899.899 0 0 0 .886-.743l.012-.07-.348 2.067z"></path>
|
||||
</g>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Salesforce",
|
||||
href: "/docs/authentication/salesforce",
|
||||
isNew: true,
|
||||
icon: () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
width="1.2em"
|
||||
height="1.2em"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M8.5 3.5c-1.4 0-2.5 1.1-2.5 2.5 0 .4.1.7.2 1-1.7.3-3 1.7-3 3.5 0 2 1.6 3.6 3.6 3.6h10.2c1.6 0 2.9-1.3 2.9-2.9 0-1.2-.7-2.2-1.7-2.6 0-.3 0-.5 0-.8-.3-2-1.9-3.5-4-3.3-.4 0-.7.1-1 .2-.5-.8-1.4-1.3-2.4-1.3-.9 0-1.7.4-2.2 1.1zm7.7 7.1c-.5-.3-1.1-.5-1.7-.5-.6 0-1.2.2-1.7.5-.1-2-1.7-3.6-3.8-3.6-1.3 0-2.4.6-3.1 1.6-.4-.2-.8-.3-1.3-.3-1.8 0-3.3 1.5-3.3 3.3 0 .2 0 .5.1.7-1.6.4-2.7 1.8-2.7 3.5 0 2 1.6 3.6 3.6 3.6h10.6c2 0 3.6-1.6 3.6-3.6 0-1.9-1.4-3.4-3.2-3.5z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
title: "Slack",
|
||||
href: "/docs/authentication/slack",
|
||||
@@ -652,6 +793,24 @@ export const contents: Content[] = [
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Naver",
|
||||
href: "/docs/authentication/naver",
|
||||
isNew: true,
|
||||
icon: () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1.2em"
|
||||
height="1.2em"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M16.273 12.845 7.376 0H0v24h7.726V11.156L16.624 24H24V0h-7.727v12.845Z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Tiktok",
|
||||
href: "/docs/authentication/tiktok",
|
||||
@@ -726,6 +885,7 @@ export const contents: Content[] = [
|
||||
{
|
||||
title: "Linear",
|
||||
href: "/docs/authentication/linear",
|
||||
isNew: true,
|
||||
icon: () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -1504,6 +1664,24 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2,
|
||||
icon: () => <Key className="w-4 h-4" />,
|
||||
href: "/docs/plugins/bearer",
|
||||
},
|
||||
{
|
||||
title: "Device Authorization",
|
||||
icon: () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1.2em"
|
||||
height="1.2em"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M20 18c1.1 0 1.99-.9 1.99-2L22 6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2H0v2h24v-2zM4 6h16v10H4z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
href: "/docs/plugins/device-authorization",
|
||||
isNew: true,
|
||||
},
|
||||
{
|
||||
title: "Captcha",
|
||||
href: "/docs/plugins/captcha",
|
||||
@@ -1526,6 +1704,24 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2,
|
||||
href: "/docs/plugins/have-i-been-pwned",
|
||||
icon: () => <p className="text-xs">';--</p>,
|
||||
},
|
||||
{
|
||||
title: "Last Login Method",
|
||||
href: "/docs/plugins/last-login-method",
|
||||
icon: () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1.2em"
|
||||
height="1.2em"
|
||||
viewBox="0 0 256 256"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="m141.66 133.66l-40 40A8 8 0 0 1 88 168v-32H24a8 8 0 0 1 0-16h64V88a8 8 0 0 1 13.66-5.66l40 40a8 8 0 0 1 0 11.32M200 32h-64a8 8 0 0 0 0 16h56v160h-56a8 8 0 0 0 0 16h64a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
isNew: true,
|
||||
},
|
||||
{
|
||||
title: "Multi Session",
|
||||
icon: () => (
|
||||
|
||||
@@ -42,6 +42,7 @@ export const SparklesCore = (props: ParticlesProps) => {
|
||||
const particlesLoaded = async (container?: Container) => {
|
||||
if (container) {
|
||||
console.log(container);
|
||||
// biome-ignore lint/nursery/noFloatingPromises: add error handling is not important
|
||||
controls.start({
|
||||
opacity: 1,
|
||||
transition: {
|
||||
|
||||
59
docs/content/docs/authentication/atlassian.mdx
Normal file
59
docs/content/docs/authentication/atlassian.mdx
Normal file
@@ -0,0 +1,59 @@
|
||||
---
|
||||
title: Atlassian
|
||||
description: Atlassian provider setup and usage.
|
||||
---
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
### Get your Credentials
|
||||
1. Sign in to your Atlassian account and go to the [Atlassian Developer Console](https://developer.atlassian.com/console/myapps/)
|
||||
2. Click "Create new app"
|
||||
3. Fill out the app details
|
||||
4. Configure your redirect URI (e.g., `https://yourdomain.com/api/auth/callback/atlassian`)
|
||||
5. Note your Client ID and Client Secret
|
||||
|
||||
<Callout type="info">
|
||||
- The default scope is `read:jira-user` and `offline_access`. For additional scopes, refer to the [Atlassian OAuth documentation](https://developer.atlassian.com/cloud/confluence/oauth-2-3lo-apps/).
|
||||
</Callout>
|
||||
|
||||
Make sure to set the redirect URI to match your application's callback URL. If you change the base path of the auth routes, you should update the redirect URI accordingly.
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Configure the provider
|
||||
To configure the provider, you need to import the provider and pass it to the `socialProviders` option of the auth instance.
|
||||
|
||||
```ts title="auth.ts"
|
||||
import { betterAuth } from "better-auth"
|
||||
|
||||
export const auth = betterAuth({
|
||||
socialProviders: {
|
||||
atlassian: { // [!code highlight]
|
||||
clientId: process.env.ATLASSIAN_CLIENT_ID as string, // [!code highlight]
|
||||
clientSecret: process.env.ATLASSIAN_CLIENT_SECRET as string, // [!code highlight]
|
||||
}, // [!code highlight]
|
||||
},
|
||||
})
|
||||
```
|
||||
</Step>
|
||||
<Step>
|
||||
### Sign In with Atlassian
|
||||
To sign in with Atlassian, you can use the `signIn.social` function provided by the client. The `signIn` function takes an object with the following properties:
|
||||
- `provider`: The provider to use. It should be set to `atlassian`.
|
||||
|
||||
```ts title="auth-client.ts"
|
||||
import { createAuthClient } from "better-auth/client"
|
||||
const authClient = createAuthClient()
|
||||
|
||||
const signIn = async () => {
|
||||
const data = await authClient.signIn.social({
|
||||
provider: "atlassian"
|
||||
})
|
||||
}
|
||||
```
|
||||
<Callout type="info">
|
||||
For more information about Atlassian's OAuth scopes and API capabilities, refer to the [official Atlassian OAuth 2.0 (3LO) apps documentation](https://developer.atlassian.com/cloud/confluence/oauth-2-3lo-apps/).
|
||||
</Callout>
|
||||
</Step>
|
||||
|
||||
</Steps>
|
||||
78
docs/content/docs/authentication/cognito.mdx
Normal file
78
docs/content/docs/authentication/cognito.mdx
Normal file
@@ -0,0 +1,78 @@
|
||||
---
|
||||
title: Cognito
|
||||
description: Amazon Cognito provider setup and usage.
|
||||
---
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
### Get your Cognito Credentials
|
||||
To integrate with Cognito, you need to set up a **User Pool** and an **App client** in the [Amazon Cognito Console](https://console.aws.amazon.com/cognito/).
|
||||
|
||||
Follow these steps:
|
||||
1. Go to the **Cognito Console** and create a **User Pool**.
|
||||
2. Under **App clients**, create a new **App client** (note the Client ID and Client Secret if enabled).
|
||||
3. Go to **Domain** and set a Cognito Hosted UI domain (e.g., `your-app.auth.us-east-1.amazoncognito.com`).
|
||||
4. In **App client settings**, enable:
|
||||
- Allowed OAuth flows: `Authorization code grant`
|
||||
- Allowed OAuth scopes: `openid`, `profile`, `email`
|
||||
5. Add your callback URL (e.g., `http://localhost:3000/api/auth/callback/cognito`).
|
||||
|
||||
<Callout type="info">
|
||||
- **User Pool is required** for Cognito authentication.
|
||||
- Make sure the callback URL matches exactly what you configure in Cognito.
|
||||
</Callout>
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Configure the provider
|
||||
Import the Cognito provider and pass it to the `providers` option of your `auth` instance.
|
||||
|
||||
```ts title="auth.ts"
|
||||
import { betterAuth } from "better-auth"
|
||||
|
||||
export const auth = betterAuth({
|
||||
socialProvider: {
|
||||
cognito({
|
||||
clientId: process.env.COGNITO_CLIENT_ID as string, // [!code highlight]
|
||||
clientSecret: process.env.COGNITO_CLIENT_SECRET as string, // [!code highlight]
|
||||
domain: process.env.COGNITO_DOMAIN as string, // e.g. "your-app.auth.us-east-1.amazoncognito.com" [!code highlight]
|
||||
region: process.env.COGNITO_REGION as string, // e.g. "us-east-1" [!code highlight]
|
||||
userPoolId: process.env.COGNITO_USERPOOL_ID as string, // [!code highlight]
|
||||
}),
|
||||
},
|
||||
})
|
||||
```
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Sign In with Cognito
|
||||
To sign in with Cognito, use the `signIn.social` function from the client.
|
||||
|
||||
```ts title="auth-client.ts"
|
||||
import { createAuthClient } from "better-auth/client"
|
||||
|
||||
const authClient = createAuthClient()
|
||||
|
||||
const signIn = async () => {
|
||||
const data = await authClient.signIn.social({
|
||||
provider: "cognito"
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Additional Options:
|
||||
- `scope`: Additional OAuth2 scopes to request (combined with default permissions).
|
||||
- Default: `"openid" "profile" "email"`
|
||||
- Common Cognito scopes:
|
||||
- `openid`: Required for OpenID Connect authentication
|
||||
- `profile`: Access to basic profile info
|
||||
- `email`: Access to user’s email
|
||||
- `phone`: Access to user’s phone number
|
||||
- `aws.cognito.signin.user.admin`: Grants access to Cognito-specific APIs
|
||||
- Note: You must configure the scopes in your Cognito App Client settings. [available scopes](https://docs.aws.amazon.com/cognito/latest/developerguide/token-endpoint.html#token-endpoint-userinfo)
|
||||
- `getUserInfo`: Custom function to retrieve user information from the Cognito UserInfo endpoint.
|
||||
<Callout type="info">
|
||||
For more information about Amazon Cognito's scopes and API capabilities, refer to the [official documentation](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-define-resource-servers.html?utm_source).
|
||||
</Callout>
|
||||
</Step>
|
||||
</Steps>
|
||||
60
docs/content/docs/authentication/figma.mdx
Normal file
60
docs/content/docs/authentication/figma.mdx
Normal file
@@ -0,0 +1,60 @@
|
||||
---
|
||||
title: Figma
|
||||
description: Figma provider setup and usage.
|
||||
---
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
### Get your Credentials
|
||||
1. Sign in to your Figma account and go to the [Developer Apps page](https://www.figma.com/developers/apps)
|
||||
2. Click "Create new app"
|
||||
3. Fill out the app details (name, description, etc.)
|
||||
4. Configure your redirect URI (e.g., `https://yourdomain.com/api/auth/callback/figma`)
|
||||
5. Note your Client ID and Client Secret
|
||||
|
||||
<Callout type="info">
|
||||
- The default scope is `file_read`. For additional scopes like `file_write`, refer to the [Figma OAuth documentation](https://www.figma.com/developers/api#oauth2).
|
||||
</Callout>
|
||||
|
||||
Make sure to set the redirect URI to match your application's callback URL. If you change the base path of the auth routes, you should update the redirect URI accordingly.
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Configure the provider
|
||||
To configure the provider, you need to import the provider and pass it to the `socialProviders` option of the auth instance.
|
||||
|
||||
```ts title="auth.ts"
|
||||
import { betterAuth } from "better-auth"
|
||||
|
||||
export const auth = betterAuth({
|
||||
socialProviders: {
|
||||
figma: { // [!code highlight]
|
||||
clientId: process.env.FIGMA_CLIENT_ID as string, // [!code highlight]
|
||||
clientSecret: process.env.FIGMA_CLIENT_SECRET as string, // [!code highlight]
|
||||
clientKey: process.env.FIGMA_CLIENT_KEY as string, // [!code highlight]
|
||||
}, // [!code highlight]
|
||||
},
|
||||
})
|
||||
```
|
||||
</Step>
|
||||
<Step>
|
||||
### Sign In with Figma
|
||||
To sign in with Figma, you can use the `signIn.social` function provided by the client. The `signIn` function takes an object with the following properties:
|
||||
- `provider`: The provider to use. It should be set to `figma`.
|
||||
|
||||
```ts title="auth-client.ts"
|
||||
import { createAuthClient } from "better-auth/client"
|
||||
const authClient = createAuthClient()
|
||||
|
||||
const signIn = async () => {
|
||||
const data = await authClient.signIn.social({
|
||||
provider: "figma"
|
||||
})
|
||||
}
|
||||
```
|
||||
<Callout type="info">
|
||||
For more information about Figma's OAuth scopes and API capabilities, refer to the [official Figma API documentation](https://www.figma.com/developers/api).
|
||||
</Callout>
|
||||
</Step>
|
||||
|
||||
</Steps>
|
||||
47
docs/content/docs/authentication/kakao.mdx
Normal file
47
docs/content/docs/authentication/kakao.mdx
Normal file
@@ -0,0 +1,47 @@
|
||||
---
|
||||
title: Kakao
|
||||
description: Kakao provider setup and usage.
|
||||
---
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
### Get your Kakao Credentials
|
||||
To use Kakao sign in, you need a client ID and client secret. You can get them from the [Kakao Developer Portal](https://developers.kakao.com).
|
||||
|
||||
Make sure to set the redirect URL to `http://localhost:3000/api/auth/callback/kakao` for local development. For production, you should set it to the URL of your application. If you change the base path of the auth routes, you should update the redirect URL accordingly.
|
||||
</Step>
|
||||
<Step>
|
||||
### Configure the provider
|
||||
To configure the provider, you need to import the provider and pass it to the `socialProviders` option of the auth instance.
|
||||
|
||||
```ts title="auth.ts"
|
||||
import { betterAuth } from "better-auth"
|
||||
|
||||
export const auth = betterAuth({
|
||||
socialProviders: {
|
||||
kakao: { // [!code highlight]
|
||||
clientId: process.env.KAKAO_CLIENT_ID as string, // [!code highlight]
|
||||
clientSecret: process.env.KAKAO_CLIENT_SECRET as string, // [!code highlight]
|
||||
}, // [!code highlight]
|
||||
}
|
||||
})
|
||||
```
|
||||
</Step>
|
||||
<Step>
|
||||
### Sign In with Kakao
|
||||
To sign in with Kakao, you can use the `signIn.social` function provided by the client. The `signIn` function takes an object with the following properties:
|
||||
- `provider`: The provider to use. It should be set to `kakao`.
|
||||
|
||||
```ts title="auth-client.ts"
|
||||
import { createAuthClient } from "better-auth/client"
|
||||
const authClient = createAuthClient()
|
||||
|
||||
const signIn = async () => {
|
||||
const data = await authClient.signIn.social({
|
||||
provider: "kakao"
|
||||
})
|
||||
}
|
||||
```
|
||||
</Step>
|
||||
|
||||
</Steps>
|
||||
77
docs/content/docs/authentication/line.mdx
Normal file
77
docs/content/docs/authentication/line.mdx
Normal file
@@ -0,0 +1,77 @@
|
||||
---
|
||||
title: LINE
|
||||
description: LINE provider setup and usage.
|
||||
---
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
### Get your LINE credentials
|
||||
|
||||
1. Create a channel in the LINE Developers Console.
|
||||
2. Note your Channel ID (client_id) and Channel secret (client_secret).
|
||||
3. In the channel settings, add your Redirect URI, e.g. `http://localhost:3000/api/auth/callback/line` for local development.
|
||||
4. Enable required scopes (at least `openid`; add `profile`, `email` if you need name, avatar, email).
|
||||
|
||||
See LINE Login v2.1 reference for details: [`https://developers.line.biz/en/reference/line-login/#issue-access-token`]
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Configure the provider
|
||||
|
||||
Add your LINE credentials to `socialProviders.line` in your auth configuration.
|
||||
|
||||
```ts title="auth.ts"
|
||||
import { betterAuth } from "better-auth";
|
||||
|
||||
export const auth = betterAuth({
|
||||
socialProviders: {
|
||||
line: {
|
||||
clientId: process.env.LINE_CLIENT_ID as string,
|
||||
clientSecret: process.env.LINE_CLIENT_SECRET as string,
|
||||
// Optional: override redirect if needed
|
||||
// redirectURI: "https://your.app/api/auth/callback/line",
|
||||
// scopes are prefilled: ["openid","profile","email"]. Append if needed
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Usage
|
||||
|
||||
### Sign In with LINE
|
||||
|
||||
Use the client `signIn.social` with `provider: "line"`.
|
||||
|
||||
```ts title="auth-client.ts"
|
||||
import { createAuthClient } from "better-auth/client";
|
||||
const authClient = createAuthClient();
|
||||
|
||||
async function signInWithLINE() {
|
||||
const res = await authClient.signIn.social({ provider: "line" });
|
||||
}
|
||||
```
|
||||
|
||||
### Sign In with LINE using ID Token (optional)
|
||||
|
||||
If you obtain the LINE ID token on the client, you can sign in directly without redirection.
|
||||
|
||||
```ts title="auth-client.ts"
|
||||
await authClient.signIn.social({
|
||||
provider: "line",
|
||||
idToken: {
|
||||
token: "<LINE_ID_TOKEN>",
|
||||
accessToken: "<LINE_ACCESS_TOKEN>",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Notes
|
||||
|
||||
- Default scopes include `openid profile email`. Adjust as needed via provider options.
|
||||
- Verify redirect URI exactly matches the value configured in LINE Developers Console.
|
||||
- LINE ID token verification uses the official endpoint and checks audience and optional nonce per spec.
|
||||
|
||||
Designing a login button? Follow LINE's button [guidelines](https://developers.line.biz/en/docs/line-login/login-button/).
|
||||
|
||||
@@ -29,11 +29,15 @@ Enabling OAuth with Microsoft Azure Entra ID (formerly Active Directory) allows
|
||||
clientSecret: process.env.MICROSOFT_CLIENT_SECRET as string, // [!code highlight]
|
||||
// Optional
|
||||
tenantId: 'common', // [!code highlight]
|
||||
authority: "https://login.microsoftonline.com", // Authentication authority URL // [!code highlight]
|
||||
prompt: "select_account", // Forces account selection // [!code highlight]
|
||||
}, // [!code highlight]
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
**Authority URL**: Use the default `https://login.microsoftonline.com` for standard Entra ID scenarios or `https://<tenant-id>.ciamlogin.com` for CIAM (Customer Identity and Access Management) scenarios.
|
||||
|
||||
</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
47
docs/content/docs/authentication/naver.mdx
Normal file
47
docs/content/docs/authentication/naver.mdx
Normal file
@@ -0,0 +1,47 @@
|
||||
---
|
||||
title: Naver
|
||||
description: Naver provider setup and usage.
|
||||
---
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
### Get your Naver Credentials
|
||||
To use Naver sign in, you need a client ID and client secret. You can get them from the [Naver Developers](https://developers.naver.com/).
|
||||
|
||||
Make sure to set the redirect URL to `http://localhost:3000/api/auth/callback/naver` for local development. For production, you should set it to the URL of your application. If you change the base path of the auth routes, you should update the redirect URL accordingly.
|
||||
</Step>
|
||||
<Step>
|
||||
### Configure the provider
|
||||
To configure the provider, you need to import the provider and pass it to the `socialProviders` option of the auth instance.
|
||||
|
||||
```ts title="auth.ts"
|
||||
import { betterAuth } from "better-auth"
|
||||
|
||||
export const auth = betterAuth({
|
||||
socialProviders: {
|
||||
naver: { // [!code highlight]
|
||||
clientId: process.env.NAVER_CLIENT_ID as string, // [!code highlight]
|
||||
clientSecret: process.env.NAVER_CLIENT_SECRET as string, // [!code highlight]
|
||||
}, // [!code highlight]
|
||||
}
|
||||
})
|
||||
```
|
||||
</Step>
|
||||
<Step>
|
||||
### Sign In with Naver
|
||||
To sign in with Naver, you can use the `signIn.social` function provided by the client. The `signIn` function takes an object with the following properties:
|
||||
- `provider`: The provider to use. It should be set to `naver`.
|
||||
|
||||
```ts title="auth-client.ts"
|
||||
import { createAuthClient } from "better-auth/client"
|
||||
const authClient = createAuthClient()
|
||||
|
||||
const signIn = async () => {
|
||||
const data = await authClient.signIn.social({
|
||||
provider: "naver"
|
||||
})
|
||||
}
|
||||
```
|
||||
</Step>
|
||||
|
||||
</Steps>
|
||||
107
docs/content/docs/authentication/paypal.mdx
Normal file
107
docs/content/docs/authentication/paypal.mdx
Normal file
@@ -0,0 +1,107 @@
|
||||
---
|
||||
title: PayPal
|
||||
description: Paypal provider setup and usage.
|
||||
---
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
### Get your PayPal Credentials
|
||||
To integrate with PayPal, you need to obtain API credentials by creating an application in the [PayPal Developer Portal](https://developer.paypal.com/dashboard).
|
||||
|
||||
Follow these steps:
|
||||
1. Create an account on the PayPal Developer Portal
|
||||
2. Create a new application, [official docs]( https://developer.paypal.com/developer/applications/)
|
||||
3. Configure Log in with PayPal under "Other features"
|
||||
4. Set up your Return URL (redirect URL)
|
||||
5. Configure user information permissions
|
||||
6. Note your Client ID and Client Secret
|
||||
|
||||
<Callout type="info">
|
||||
- PayPal has two environments: Sandbox (for testing) and Live (for production)
|
||||
- For testing, create sandbox test accounts in the Developer Dashboard under "Sandbox" → "Accounts"
|
||||
- You cannot use your real PayPal account to test in sandbox mode - you must use the generated test accounts
|
||||
- The Return URL in your PayPal app settings must exactly match your redirect URI
|
||||
- The PayPal API does not work with localhost. You need to use a public domain for the redirect URL and HTTPS for local testing. You can use [NGROK](https://ngrok.com/) or another similar tool for this.
|
||||
</Callout>
|
||||
Make sure to configure "Log in with PayPal" in your app settings:
|
||||
1. Go to your app in the Developer Dashboard
|
||||
2. Under "Other features", check "Log in with PayPal"
|
||||
3. Click "Advanced Settings"
|
||||
4. Enter your Return URL
|
||||
5. Select the user information you want to access (email, name, etc.)
|
||||
6. Enter Privacy Policy and User Agreement URLs
|
||||
|
||||
<Callout type="info">
|
||||
- PayPal doesn't use traditional OAuth2 scopes in the authorization URL. Instead, you configure permissions directly in the Developer Dashboard
|
||||
- For live apps, PayPal must review and approve your application before it can go live, which typically takes a few weeks
|
||||
</Callout>
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Configure the provider
|
||||
To configure the provider, you need to import the provider and pass it to the `socialProviders` option of the auth instance.
|
||||
|
||||
```ts title="auth.ts"
|
||||
import { betterAuth } from "better-auth"
|
||||
|
||||
export const auth = betterAuth({
|
||||
socialProviders: {
|
||||
paypal: { // [!code highlight]
|
||||
clientId: process.env.PAYPAL_CLIENT_ID as string, // [!code highlight]
|
||||
clientSecret: process.env.PAYPAL_CLIENT_SECRET as string, // [!code highlight]
|
||||
environment: "sandbox", // or "live" for production //, // [!code highlight]
|
||||
}, // [!code highlight]
|
||||
},
|
||||
})
|
||||
```
|
||||
#### Options
|
||||
The PayPal provider accepts the following options:
|
||||
|
||||
- `environment`: `'sandbox' | 'live'` - PayPal environment to use (default: `'sandbox'`)
|
||||
- `requestShippingAddress`: `boolean` - Whether to request shipping address information (default: `false`)
|
||||
|
||||
```ts title="auth.ts"
|
||||
export const auth = betterAuth({
|
||||
socialProviders: {
|
||||
paypal: {
|
||||
clientId: process.env.PAYPAL_CLIENT_ID as string,
|
||||
clientSecret: process.env.PAYPAL_CLIENT_SECRET as string,
|
||||
environment: "live", // Use "live" for production
|
||||
requestShippingAddress: true, // Request address info
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
</Step>
|
||||
<Step>
|
||||
### Sign In with PayPal
|
||||
To sign in with PayPal, you can use the `signIn.social` function provided by the client. The `signIn` function takes an object with the following properties:
|
||||
- `provider`: The provider to use. It should be set to `paypal`.
|
||||
|
||||
```ts title="auth-client.ts"
|
||||
import { createAuthClient } from "better-auth/client"
|
||||
const authClient = createAuthClient()
|
||||
|
||||
const signIn = async () => {
|
||||
const data = await authClient.signIn.social({
|
||||
provider: "paypal"
|
||||
})
|
||||
}
|
||||
```
|
||||
### Additional Options:
|
||||
- `environment`: PayPal environment to use.
|
||||
- Default: `"sandbox"`
|
||||
- Options: `"sandbox"` | `"live"`
|
||||
- `requestShippingAddress`: Whether to request shipping address information.
|
||||
- Default: `false`
|
||||
- `scope`: Additional scopes to request (combined with default permissions).
|
||||
- Default: Configured in PayPal Developer Dashboard
|
||||
- Note: PayPal doesn't use traditional OAuth2 scopes - permissions are set in the Dashboard
|
||||
For more details refer to the [Scopes Reference](https://developer.paypal.com/docs/log-in-with-paypal/integrate/reference/#scope-attributes)
|
||||
- `mapProfileToUser`: Custom function to map PayPal profile data to user object.
|
||||
- `getUserInfo`: Custom function to retrieve user information.
|
||||
For more details refer to the [User Reference](https://developer.paypal.com/docs/api/identity/v1/#userinfo_get)
|
||||
- `verifyIdToken`: Custom ID token verification function.
|
||||
</Step>
|
||||
|
||||
</Steps>
|
||||
153
docs/content/docs/authentication/salesforce.mdx
Normal file
153
docs/content/docs/authentication/salesforce.mdx
Normal file
@@ -0,0 +1,153 @@
|
||||
---
|
||||
title: Salesforce
|
||||
description: Salesforce provider setup and usage.
|
||||
---
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
### Get your Salesforce Credentials
|
||||
1. Log into your Salesforce org (Production or Developer Edition)
|
||||
2. Navigate to **Setup > App Manager**
|
||||
3. Click **New Connected App**
|
||||
4. Fill in the basic information:
|
||||
- Connected App Name: Your app name
|
||||
- API Name: Auto-generated from app name
|
||||
- Contact Email: Your email address
|
||||
5. Enable OAuth Settings:
|
||||
- Check **Enable OAuth Settings**
|
||||
- Set **Callback URL** to your redirect URI (e.g., `http://localhost:3000/api/auth/callback/salesforce` for development)
|
||||
- Select Required OAuth Scopes:
|
||||
- Access your basic information (id)
|
||||
- Access your identity URL service (openid)
|
||||
- Access your email address (email)
|
||||
- Perform requests on your behalf at any time (refresh_token, offline_access)
|
||||
6. Enable **Require Proof Key for Code Exchange (PKCE)** (required)
|
||||
7. Save and note your **Consumer Key** (Client ID) and **Consumer Secret** (Client Secret)
|
||||
|
||||
<Callout type="info">
|
||||
- For development, you can use `http://localhost:3000` URLs, but production requires HTTPS
|
||||
- The callback URL must exactly match what's configured in Better Auth
|
||||
- PKCE (Proof Key for Code Exchange) is required by Salesforce and is automatically handled by the provider
|
||||
</Callout>
|
||||
|
||||
<Callout type="warning">
|
||||
For sandbox testing, you can create the Connected App in your sandbox org, or use the same Connected App but specify `environment: "sandbox"` in the provider configuration.
|
||||
</Callout>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Configure the provider
|
||||
To configure the provider, you need to import the provider and pass it to the `socialProviders` option of the auth instance.
|
||||
|
||||
```ts title="auth.ts"
|
||||
import { betterAuth } from "better-auth"
|
||||
|
||||
export const auth = betterAuth({
|
||||
socialProviders: {
|
||||
salesforce: { // [!code highlight]
|
||||
clientId: process.env.SALESFORCE_CLIENT_ID as string, // [!code highlight]
|
||||
clientSecret: process.env.SALESFORCE_CLIENT_SECRET as string, // [!code highlight]
|
||||
environment: "production", // or "sandbox" // [!code highlight]
|
||||
}, // [!code highlight]
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
#### Configuration Options
|
||||
|
||||
- `clientId`: Your Connected App's Consumer Key
|
||||
- `clientSecret`: Your Connected App's Consumer Secret
|
||||
- `environment`: `"production"` (default) or `"sandbox"`
|
||||
- `loginUrl`: Custom My Domain URL (without `https://`) - overrides environment setting
|
||||
- `redirectURI`: Override the auto-generated redirect URI if needed
|
||||
|
||||
#### Advanced Configuration
|
||||
|
||||
```ts title="auth.ts"
|
||||
export const auth = betterAuth({
|
||||
socialProviders: {
|
||||
salesforce: {
|
||||
clientId: process.env.SALESFORCE_CLIENT_ID as string,
|
||||
clientSecret: process.env.SALESFORCE_CLIENT_SECRET as string,
|
||||
environment: "sandbox", // [!code highlight]
|
||||
loginUrl: "mycompany.my.salesforce.com", // Custom My Domain // [!code highlight]
|
||||
redirectURI: "http://localhost:3000/api/auth/callback/salesforce", // Override if needed // [!code highlight]
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
<Callout type="info">
|
||||
- Use `environment: "sandbox"` for testing with Salesforce sandbox orgs
|
||||
- The `loginUrl` option is useful for organizations with My Domain enabled
|
||||
- The `redirectURI` option helps resolve redirect URI mismatch errors
|
||||
</Callout>
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Environment Variables
|
||||
Add the following environment variables to your `.env.local` file:
|
||||
|
||||
```bash title=".env.local"
|
||||
SALESFORCE_CLIENT_ID=your_consumer_key_here
|
||||
SALESFORCE_CLIENT_SECRET=your_consumer_secret_here
|
||||
BETTER_AUTH_URL=http://localhost:3000 # Important for redirect URI generation
|
||||
```
|
||||
|
||||
For production:
|
||||
```bash title=".env"
|
||||
SALESFORCE_CLIENT_ID=your_consumer_key_here
|
||||
SALESFORCE_CLIENT_SECRET=your_consumer_secret_here
|
||||
BETTER_AUTH_URL=https://yourdomain.com
|
||||
```
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Sign In with Salesforce
|
||||
To sign in with Salesforce, you can use the `signIn.social` function provided by the client. The `signIn` function takes an object with the following properties:
|
||||
- `provider`: The provider to use. It should be set to `salesforce`.
|
||||
|
||||
```ts title="auth-client.ts"
|
||||
import { createAuthClient } from "better-auth/client"
|
||||
const authClient = createAuthClient()
|
||||
|
||||
const signIn = async () => {
|
||||
const data = await authClient.signIn.social({
|
||||
provider: "salesforce"
|
||||
})
|
||||
}
|
||||
```
|
||||
</Step>
|
||||
<Step>
|
||||
### Troubleshooting
|
||||
|
||||
#### Redirect URI Mismatch Error
|
||||
If you encounter a `redirect_uri_mismatch` error:
|
||||
|
||||
1. **Check Callback URL**: Ensure the Callback URL in your Salesforce Connected App exactly matches your Better Auth callback URL
|
||||
2. **Protocol**: Make sure you're using the same protocol (`http://` vs `https://`)
|
||||
3. **Port**: Verify the port number matches (e.g., `:3000`)
|
||||
4. **Override if needed**: Use the `redirectURI` option to explicitly set the redirect URI
|
||||
|
||||
```ts
|
||||
salesforce: {
|
||||
clientId: process.env.SALESFORCE_CLIENT_ID as string,
|
||||
clientSecret: process.env.SALESFORCE_CLIENT_SECRET as string,
|
||||
redirectURI: "http://localhost:3000/api/auth/callback/salesforce", // [!code highlight]
|
||||
}
|
||||
```
|
||||
|
||||
#### Environment Issues
|
||||
- **Production**: Use `environment: "production"` (default) with `login.salesforce.com`
|
||||
- **Sandbox**: Use `environment: "sandbox"` with `test.salesforce.com`
|
||||
- **My Domain**: Use `loginUrl: "yourcompany.my.salesforce.com"` for custom domains
|
||||
|
||||
#### PKCE Requirements
|
||||
Salesforce requires PKCE (Proof Key for Code Exchange) which is automatically handled by this provider. Make sure PKCE is enabled in your Connected App settings.
|
||||
|
||||
<Callout type="info">
|
||||
The default scopes requested are `openid`, `email`, and `profile`. The provider will automatically include the `id` scope for accessing basic user information.
|
||||
</Callout>
|
||||
</Step>
|
||||
</Steps>
|
||||
@@ -13,7 +13,7 @@ description: TikTok provider setup and usage.
|
||||
2. Create a new application
|
||||
3. Set up a sandbox environment for testing
|
||||
4. Configure your redirect URL (must be HTTPS)
|
||||
5. Note your Client ID, Client Secret and Client Key
|
||||
5. Note your Client Secret and Client Key
|
||||
|
||||
<Callout type="info">
|
||||
- The TikTok API does not work with localhost. You need to use a public domain for the redirect URL and HTTPS for local testing. You can use [NGROK](https://ngrok.com/) or another similar tool for this.
|
||||
@@ -39,7 +39,6 @@ description: TikTok provider setup and usage.
|
||||
export const auth = betterAuth({
|
||||
socialProviders: {
|
||||
tiktok: { // [!code highlight]
|
||||
clientId: process.env.TIKTOK_CLIENT_ID as string, // [!code highlight]
|
||||
clientSecret: process.env.TIKTOK_CLIENT_SECRET as string, // [!code highlight]
|
||||
clientKey: process.env.TIKTOK_CLIENT_KEY as string, // [!code highlight]
|
||||
}, // [!code highlight]
|
||||
|
||||
@@ -3,7 +3,7 @@ title: CLI
|
||||
description: Built-in CLI for managing your project.
|
||||
---
|
||||
|
||||
Better Auth comes with a built-in CLI to help you manage the database schemas, initialize your project, and generate a secret key for your application.
|
||||
Better Auth comes with a built-in CLI to help you manage the database schemas, initialize your project, generate a secret key for your application, and gather diagnostic information about your setup.
|
||||
|
||||
## Generate
|
||||
|
||||
@@ -15,14 +15,14 @@ npx @better-auth/cli@latest generate
|
||||
|
||||
### Options
|
||||
|
||||
- `--output` - Where to save the generated schema. For Prisma, it will be saved in prisma/schema.prisma. For Drizzle, it goes to schema.ts in your project root. For Kysely, it’s an SQL file saved as schema.sql in your project root.
|
||||
- `--output` - Where to save the generated schema. For Prisma, it will be saved in prisma/schema.prisma. For Drizzle, it goes to schema.ts in your project root. For Kysely, it's an SQL file saved as schema.sql in your project root.
|
||||
- `--config` - The path to your Better Auth config file. By default, the CLI will search for a auth.ts file in **./**, **./utils**, **./lib**, or any of these directories under `src` directory.
|
||||
- `--yes` - Skip the confirmation prompt and generate the schema directly.
|
||||
|
||||
|
||||
## Migrate
|
||||
|
||||
The migrate command applies the Better Auth schema directly to your database. This is available if you’re using the built-in Kysely adapter. For other adapters, you'll need to apply the schema using your ORM's migration tool.
|
||||
The migrate command applies the Better Auth schema directly to your database. This is available if you're using the built-in Kysely adapter. For other adapters, you'll need to apply the schema using your ORM's migration tool.
|
||||
|
||||
```bash title="Terminal"
|
||||
npx @better-auth/cli@latest migrate
|
||||
@@ -49,6 +49,43 @@ npx @better-auth/cli@latest init
|
||||
- `--database` - The database you want to use. Currently, the only supported database is `sqlite`.
|
||||
- `--package-manager` - The package manager you want to use. Currently, the only supported package managers are `npm`, `pnpm`, `yarn`, `bun`. (Defaults to the manager you used to initialize the CLI.)
|
||||
|
||||
## Info
|
||||
|
||||
The `info` command provides diagnostic information about your Better Auth setup and environment. Useful for debugging and sharing when seeking support.
|
||||
|
||||
```bash title="Terminal"
|
||||
npx @better-auth/cli@latest info
|
||||
```
|
||||
|
||||
### Output
|
||||
|
||||
The command displays:
|
||||
- **System**: OS, CPU, memory, Node.js version
|
||||
- **Package Manager**: Detected manager and version
|
||||
- **Better Auth**: Version and configuration (sensitive data auto-redacted)
|
||||
- **Frameworks**: Detected frameworks (Next.js, React, Vue, etc.)
|
||||
- **Databases**: Database clients and ORMs (Prisma, Drizzle, etc.)
|
||||
|
||||
### Options
|
||||
|
||||
- `--config` - Path to your Better Auth config file
|
||||
- `--json` - Output as JSON for sharing or programmatic use
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
# Basic usage
|
||||
npx @better-auth/cli@latest info
|
||||
|
||||
# Custom config path
|
||||
npx @better-auth/cli@latest info --config ./config/auth.ts
|
||||
|
||||
# JSON output
|
||||
npx @better-auth/cli@latest info --json > auth-info.json
|
||||
```
|
||||
|
||||
Sensitive data like secrets, API keys, and database URLs are automatically replaced with `[REDACTED]` for safe sharing.
|
||||
|
||||
## Secret
|
||||
|
||||
The CLI also provides a way to generate a secret key for your Better Auth instance.
|
||||
@@ -61,6 +98,6 @@ npx @better-auth/cli@latest secret
|
||||
|
||||
**Error: Cannot find module X**
|
||||
|
||||
If you see this error, it means the CLI can’t resolve imported modules in your Better Auth config file. We're working on a fix for many of these issues, but in the meantime, you can try the following:
|
||||
If you see this error, it means the CLI can't resolve imported modules in your Better Auth config file. We're working on a fix for many of these issues, but in the meantime, you can try the following:
|
||||
|
||||
- Remove any import aliases in your config file and use relative paths instead. After running the CLI, you can revert to using aliases.
|
||||
- Remove any import aliases in your config file and use relative paths instead. After running the CLI, you can revert to using aliases.
|
||||
@@ -270,4 +270,26 @@ export const auth = betterAuth({
|
||||
```
|
||||
|
||||
2. When your server and client code are in separate projects or repositories, and you cannot import the `auth` instance as a type reference, type inference for custom session fields will not work on the client side.
|
||||
3. Session caching, including secondary storage or cookie cache, does not include custom fields. Each time the session is fetched, your custom session function will be called.
|
||||
3. Session caching, including secondary storage or cookie cache, does not include custom fields. Each time the session is fetched, your custom session function will be called.
|
||||
|
||||
**Mutating the list-device-sessions endpoint**
|
||||
The `/multi-session/list-device-sessions` endpoint from the [multi-session](/docs/plugins/multi-session) plugin is used to list the devices that the user is signed into.
|
||||
|
||||
You can mutate the response of this endpoint by passing the `shouldMutateListDeviceSessionsEndpoint` option to the `customSession` plugin.
|
||||
|
||||
By default, we do not mutate the response of this endpoint.
|
||||
|
||||
```ts title="auth.ts"
|
||||
import { customSession } from "better-auth/plugins";
|
||||
|
||||
export const auth = betterAuth({
|
||||
plugins: [
|
||||
customSession(async ({ user, session }, ctx) => {
|
||||
return {
|
||||
user,
|
||||
session
|
||||
}
|
||||
}, {}, { shouldMutateListDeviceSessionsEndpoint: true }), // [!code highlight]
|
||||
],
|
||||
});
|
||||
```
|
||||
@@ -362,7 +362,11 @@ const makeAuthenticatedRequest = async () => {
|
||||
const headers = {
|
||||
"Cookie": cookies, // [!code highlight]
|
||||
};
|
||||
const response = await fetch("http://localhost:8081/api/secure-endpoint", { headers });
|
||||
const response = await fetch("http://localhost:8081/api/secure-endpoint", {
|
||||
headers,
|
||||
// 'include' can interfere with the cookies we just set manually in the headers
|
||||
credentials: "omit" // [!code highlight]
|
||||
});
|
||||
const data = await response.json();
|
||||
return data;
|
||||
};
|
||||
|
||||
661
docs/content/docs/plugins/device-authorization.mdx
Normal file
661
docs/content/docs/plugins/device-authorization.mdx
Normal file
@@ -0,0 +1,661 @@
|
||||
---
|
||||
title: Device Authorization
|
||||
description: OAuth 2.0 Device Authorization Grant for limited-input devices
|
||||
---
|
||||
|
||||
`RFC 8628` `CLI` `Smart TV` `IoT`
|
||||
|
||||
The Device Authorization plugin implements the OAuth 2.0 Device Authorization Grant ([RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628)), enabling authentication for devices with limited input capabilities such as smart TVs, CLI applications, IoT devices, and gaming consoles.
|
||||
|
||||
## Try It Out
|
||||
|
||||
You can test the device authorization flow right now using the Better Auth CLI:
|
||||
|
||||
```bash
|
||||
npx @better-auth/cli login
|
||||
```
|
||||
|
||||
This will demonstrate the complete device authorization flow by:
|
||||
1. Requesting a device code from the Better Auth demo server
|
||||
2. Displaying a user code for you to enter
|
||||
3. Opening your browser to the verification page
|
||||
4. Polling for authorization completion
|
||||
|
||||
<Callout type="info">
|
||||
The CLI login command is a demo feature that connects to the Better Auth demo server to showcase the device authorization flow in action.
|
||||
</Callout>
|
||||
|
||||
## Installation
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
### Add the plugin to your auth config
|
||||
|
||||
Add the device authorization plugin to your server configuration.
|
||||
|
||||
```ts title="auth.ts"
|
||||
import { betterAuth } from "better-auth";
|
||||
import { deviceAuthorization } from "better-auth/plugins"; // [!code highlight]
|
||||
|
||||
export const auth = betterAuth({
|
||||
// ... other config
|
||||
plugins: [ // [!code highlight]
|
||||
deviceAuthorization({ // [!code highlight]
|
||||
// Optional configuration
|
||||
expiresIn: "30m", // Device code expiration time // [!code highlight]
|
||||
interval: "5s", // Minimum polling interval // [!code highlight]
|
||||
}), // [!code highlight]
|
||||
], // [!code highlight]
|
||||
});
|
||||
```
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Migrate the database
|
||||
|
||||
Run the migration or generate the schema to add the necessary tables to the database.
|
||||
|
||||
<Tabs items={["migrate", "generate"]}>
|
||||
<Tab value="migrate">
|
||||
```bash
|
||||
npx @better-auth/cli migrate
|
||||
```
|
||||
</Tab>
|
||||
<Tab value="generate">
|
||||
```bash
|
||||
npx @better-auth/cli generate
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
See the [Schema](#schema) section to add the fields manually.
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Add the client plugin
|
||||
|
||||
Add the device authorization plugin to your client.
|
||||
|
||||
```ts title="auth-client.ts"
|
||||
import { createAuthClient } from "better-auth/client";
|
||||
import { deviceAuthorizationClient } from "better-auth/client/plugins"; // [!code highlight]
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
plugins: [ // [!code highlight]
|
||||
deviceAuthorizationClient(), // [!code highlight]
|
||||
], // [!code highlight]
|
||||
});
|
||||
```
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## How It Works
|
||||
|
||||
The device flow follows these steps:
|
||||
|
||||
1. **Device requests codes**: The device requests a device code and user code from the authorization server
|
||||
2. **User authorizes**: The user visits a verification URL and enters the user code
|
||||
3. **Device polls for token**: The device polls the server until the user completes authorization
|
||||
4. **Access granted**: Once authorized, the device receives an access token
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Requesting Device Authorization
|
||||
|
||||
To initiate device authorization, call `device.code` with the client ID:
|
||||
|
||||
<APIMethod
|
||||
path="/device/code"
|
||||
method="POST"
|
||||
>
|
||||
```ts
|
||||
type deviceCode = {
|
||||
/**
|
||||
* The OAuth client identifier
|
||||
*/
|
||||
client_id: string;
|
||||
/**
|
||||
* Space-separated list of requested scopes (optional)
|
||||
*/
|
||||
scope?: string;
|
||||
}
|
||||
```
|
||||
</APIMethod>
|
||||
|
||||
Example usage:
|
||||
```ts
|
||||
const { data } = await authClient.device.code({
|
||||
client_id: "your-client-id",
|
||||
scope: "openid profile email",
|
||||
});
|
||||
|
||||
if (data) {
|
||||
console.log(`Please visit: ${data.verification_uri}`);
|
||||
console.log(`And enter code: ${data.user_code}`);
|
||||
}
|
||||
```
|
||||
|
||||
### Polling for Token
|
||||
|
||||
After displaying the user code, poll for the access token:
|
||||
|
||||
<APIMethod
|
||||
path="/device/token"
|
||||
method="POST"
|
||||
>
|
||||
```ts
|
||||
type deviceToken = {
|
||||
/**
|
||||
* Must be "urn:ietf:params:oauth:grant-type:device_code"
|
||||
*/
|
||||
grant_type: string;
|
||||
/**
|
||||
* The device code from the initial request
|
||||
*/
|
||||
device_code: string;
|
||||
/**
|
||||
* The OAuth client identifier
|
||||
*/
|
||||
client_id: string;
|
||||
}
|
||||
```
|
||||
</APIMethod>
|
||||
|
||||
Example polling implementation:
|
||||
```ts
|
||||
let pollingInterval = 5; // Start with 5 seconds
|
||||
const pollForToken = async () => {
|
||||
const { data, error } = await authClient.device.token({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
device_code,
|
||||
client_id: yourClientId,
|
||||
fetchOptions: {
|
||||
headers: {
|
||||
"user-agent": `My CLI`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (data?.access_token) {
|
||||
console.log("Authorization successful!");
|
||||
} else if (error) {
|
||||
switch (error.error) {
|
||||
case "authorization_pending":
|
||||
// Continue polling
|
||||
break;
|
||||
case "slow_down":
|
||||
pollingInterval += 5;
|
||||
break;
|
||||
case "access_denied":
|
||||
console.error("Access was denied by the user");
|
||||
return;
|
||||
case "expired_token":
|
||||
console.error("The device code has expired. Please try again.");
|
||||
return;
|
||||
default:
|
||||
console.error(`Error: ${error.error_description}`);
|
||||
return;
|
||||
}
|
||||
setTimeout(pollForToken, pollingInterval * 1000);
|
||||
}
|
||||
};
|
||||
|
||||
pollForToken();
|
||||
```
|
||||
|
||||
### User Authorization Flow
|
||||
|
||||
The user authorization flow requires two steps:
|
||||
1. **Code Verification**: Check if the entered user code is valid
|
||||
2. **Authorization**: User must be authenticated to approve/deny the device
|
||||
|
||||
<Callout type="warn">
|
||||
Users must be authenticated before they can approve or deny device authorization requests. If not authenticated, redirect them to the login page with a return URL.
|
||||
</Callout>
|
||||
|
||||
Create a page where users can enter their code:
|
||||
|
||||
```tsx title="app/device/page.tsx"
|
||||
export default function DeviceAuthorizationPage() {
|
||||
const [userCode, setUserCode] = useState("");
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
// Format the code: remove dashes and convert to uppercase
|
||||
const formattedCode = userCode.trim().replace(/-/g, "").toUpperCase();
|
||||
|
||||
// Check if the code is valid using GET /device endpoint
|
||||
const response = await authClient.device.deviceVerify({
|
||||
query: { user_code: formattedCode },
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
// Redirect to approval page
|
||||
window.location.href = `/device/approve?user_code=${formattedCode}`;
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Invalid or expired code");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
value={userCode}
|
||||
onChange={(e) => setUserCode(e.target.value)}
|
||||
placeholder="Enter device code (e.g., ABCD-1234)"
|
||||
maxLength={12}
|
||||
/>
|
||||
<button type="submit">Continue</button>
|
||||
{error && <p>{error}</p>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Approving or Denying Device
|
||||
|
||||
Users must be authenticated to approve or deny device authorization requests:
|
||||
|
||||
#### Approve Device
|
||||
|
||||
<APIMethod
|
||||
path="/device/approve"
|
||||
method="POST"
|
||||
requireSession
|
||||
>
|
||||
```ts
|
||||
type deviceApprove = {
|
||||
/**
|
||||
* The user code to approve
|
||||
*/
|
||||
userCode: string;
|
||||
}
|
||||
```
|
||||
</APIMethod>
|
||||
|
||||
#### Deny Device
|
||||
|
||||
<APIMethod
|
||||
path="/device/deny"
|
||||
method="POST"
|
||||
requireSession
|
||||
>
|
||||
```ts
|
||||
type deviceDeny = {
|
||||
/**
|
||||
* The user code to deny
|
||||
*/
|
||||
userCode: string;
|
||||
}
|
||||
```
|
||||
</APIMethod>
|
||||
|
||||
#### Example Approval Page
|
||||
|
||||
```tsx title="app/device/approve/page.tsx"
|
||||
export default function DeviceApprovalPage() {
|
||||
const { user } = useAuth(); // Must be authenticated
|
||||
const searchParams = useSearchParams();
|
||||
const userCode = searchParams.get("userCode");
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
const handleApprove = async () => {
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
await authClient.device.deviceApprove({
|
||||
userCode: userCode,
|
||||
});
|
||||
// Show success message
|
||||
alert("Device approved successfully!");
|
||||
window.location.href = "/";
|
||||
} catch (error) {
|
||||
alert("Failed to approve device");
|
||||
}
|
||||
setIsProcessing(false);
|
||||
};
|
||||
|
||||
const handleDeny = async () => {
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
await authClient.device.deviceDeny({
|
||||
userCode: userCode,
|
||||
});
|
||||
alert("Device denied");
|
||||
window.location.href = "/";
|
||||
} catch (error) {
|
||||
alert("Failed to deny device");
|
||||
}
|
||||
setIsProcessing(false);
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
// Redirect to login if not authenticated
|
||||
window.location.href = `/login?redirect=/device/approve?user_code=${userCode}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Device Authorization Request</h2>
|
||||
<p>A device is requesting access to your account.</p>
|
||||
<p>Code: {userCode}</p>
|
||||
|
||||
<button onClick={handleApprove} disabled={isProcessing}>
|
||||
Approve
|
||||
</button>
|
||||
<button onClick={handleDeny} disabled={isProcessing}>
|
||||
Deny
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Client Validation
|
||||
|
||||
You can validate client IDs to ensure only authorized applications can use the device flow:
|
||||
|
||||
```ts
|
||||
deviceAuthorization({
|
||||
validateClient: async (clientId) => {
|
||||
// Check if client is authorized
|
||||
const client = await db.oauth_clients.findOne({ id: clientId });
|
||||
return client && client.allowDeviceFlow;
|
||||
},
|
||||
|
||||
onDeviceAuthRequest: async (clientId, scope) => {
|
||||
// Log device authorization requests
|
||||
await logDeviceAuthRequest(clientId, scope);
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Custom Code Generation
|
||||
|
||||
Customize how device and user codes are generated:
|
||||
|
||||
```ts
|
||||
deviceAuthorization({
|
||||
generateDeviceCode: async () => {
|
||||
// Custom device code generation
|
||||
return crypto.randomBytes(32).toString("hex");
|
||||
},
|
||||
|
||||
generateUserCode: async () => {
|
||||
// Custom user code generation
|
||||
// Default uses: ABCDEFGHJKLMNPQRSTUVWXYZ23456789
|
||||
// (excludes 0, O, 1, I to avoid confusion)
|
||||
const charset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
let code = "";
|
||||
for (let i = 0; i < 8; i++) {
|
||||
code += charset[Math.floor(Math.random() * charset.length)];
|
||||
}
|
||||
return code;
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
The device flow defines specific error codes:
|
||||
|
||||
| Error Code | Description |
|
||||
|------------|-------------|
|
||||
| `authorization_pending` | User hasn't approved yet (continue polling) |
|
||||
| `slow_down` | Polling too frequently (increase interval) |
|
||||
| `expired_token` | Device code has expired |
|
||||
| `access_denied` | User denied the authorization |
|
||||
| `invalid_grant` | Invalid device code or client ID |
|
||||
|
||||
## Example: CLI Application
|
||||
|
||||
Here's a complete example for a CLI application based on the actual demo:
|
||||
|
||||
```ts title="cli-auth.ts"
|
||||
import { createAuthClient } from "better-auth/client";
|
||||
import { deviceAuthorizationClient } from "better-auth/client/plugins";
|
||||
import open from "open";
|
||||
|
||||
const authClient = createAuthClient({
|
||||
baseURL: "http://localhost:3000",
|
||||
plugins: [deviceAuthorizationClient()],
|
||||
});
|
||||
|
||||
async function authenticateCLI() {
|
||||
console.log("🔐 Better Auth Device Authorization Demo");
|
||||
console.log("⏳ Requesting device authorization...");
|
||||
|
||||
try {
|
||||
// Request device code
|
||||
const { data, error } = await authClient.device.code({
|
||||
client_id: "demo-cli",
|
||||
scope: "openid profile email",
|
||||
});
|
||||
|
||||
if (error || !data) {
|
||||
console.error("❌ Error:", error?.error_description);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const {
|
||||
device_code,
|
||||
user_code,
|
||||
verification_uri,
|
||||
verification_uri_complete,
|
||||
interval = 5,
|
||||
} = data;
|
||||
|
||||
console.log("\n📱 Device Authorization in Progress");
|
||||
console.log(`Please visit: ${verification_uri}`);
|
||||
console.log(`Enter code: ${user_code}\n`);
|
||||
|
||||
// Open browser with the complete URL
|
||||
const urlToOpen = verification_uri_complete || verification_uri;
|
||||
if (urlToOpen) {
|
||||
console.log("🌐 Opening browser...");
|
||||
await open(urlToOpen);
|
||||
}
|
||||
|
||||
console.log(`⏳ Waiting for authorization... (polling every ${interval}s)`);
|
||||
|
||||
// Poll for token
|
||||
await pollForToken(device_code, interval);
|
||||
} catch (err) {
|
||||
console.error("❌ Error:", err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function pollForToken(deviceCode: string, interval: number) {
|
||||
let pollingInterval = interval;
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
const poll = async () => {
|
||||
try {
|
||||
const { data, error } = await authClient.device.token({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
device_code: deviceCode,
|
||||
client_id: "demo-cli",
|
||||
});
|
||||
|
||||
if (data?.access_token) {
|
||||
console.log("\n✅ Authorization Successful!");
|
||||
console.log("Access token received!");
|
||||
|
||||
// Get user session
|
||||
const { data: session } = await authClient.getSession({
|
||||
fetchOptions: {
|
||||
headers: {
|
||||
Authorization: `Bearer ${data.access_token}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Hello, ${session?.user?.name || "User"}!`);
|
||||
resolve();
|
||||
process.exit(0);
|
||||
} else if (error) {
|
||||
switch (error.error) {
|
||||
case "authorization_pending":
|
||||
// Continue polling silently
|
||||
break;
|
||||
case "slow_down":
|
||||
pollingInterval += 5;
|
||||
console.log(`⚠️ Slowing down polling to ${pollingInterval}s`);
|
||||
break;
|
||||
case "access_denied":
|
||||
console.error("❌ Access was denied by the user");
|
||||
process.exit(1);
|
||||
break;
|
||||
case "expired_token":
|
||||
console.error("❌ The device code has expired. Please try again.");
|
||||
process.exit(1);
|
||||
break;
|
||||
default:
|
||||
console.error("❌ Error:", error.error_description);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("❌ Network error:", err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Schedule next poll
|
||||
setTimeout(poll, pollingInterval * 1000);
|
||||
};
|
||||
|
||||
// Start polling
|
||||
setTimeout(poll, pollingInterval * 1000);
|
||||
});
|
||||
}
|
||||
|
||||
// Run the authentication flow
|
||||
authenticateCLI().catch((err) => {
|
||||
console.error("❌ Fatal error:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Rate Limiting**: The plugin enforces polling intervals to prevent abuse
|
||||
2. **Code Expiration**: Device and user codes expire after the configured time (default: 30 minutes)
|
||||
3. **Client Validation**: Always validate client IDs in production to prevent unauthorized access
|
||||
4. **HTTPS Only**: Always use HTTPS in production for device authorization
|
||||
5. **User Code Format**: User codes use a limited character set (excluding similar-looking characters like 0/O, 1/I) to reduce typing errors
|
||||
6. **Authentication Required**: Users must be authenticated before they can approve or deny device requests
|
||||
|
||||
## Options
|
||||
|
||||
### Server
|
||||
|
||||
**expiresIn**: The expiration time for device codes. Default: `"30m"` (30 minutes).
|
||||
|
||||
**interval**: The minimum polling interval. Default: `"5s"` (5 seconds).
|
||||
|
||||
**userCodeLength**: The length of the user code. Default: `8`.
|
||||
|
||||
**deviceCodeLength**: The length of the device code. Default: `40`.
|
||||
|
||||
**generateDeviceCode**: Custom function to generate device codes. Returns a string or `Promise<string>`.
|
||||
|
||||
**generateUserCode**: Custom function to generate user codes. Returns a string or `Promise<string>`.
|
||||
|
||||
**validateClient**: Function to validate client IDs. Takes a clientId and returns boolean or `Promise<boolean>`.
|
||||
|
||||
**onDeviceAuthRequest**: Hook called when device authorization is requested. Takes clientId and optional scope.
|
||||
|
||||
### Client
|
||||
|
||||
No client-specific configuration options. The plugin adds the following methods:
|
||||
|
||||
- **device.code()**: Request device and user codes
|
||||
- **device.token()**: Poll for access token
|
||||
- **device.deviceVerify()**: Verify user code validity
|
||||
- **device.deviceApprove()**: Approve device (requires authentication)
|
||||
- **device.deviceDeny()**: Deny device (requires authentication)
|
||||
|
||||
## Schema
|
||||
|
||||
The plugin requires a new table to store device authorization data.
|
||||
|
||||
Table Name: `deviceCode`
|
||||
|
||||
<DatabaseTable
|
||||
fields={[
|
||||
{
|
||||
name: "id",
|
||||
type: "string",
|
||||
description: "Unique identifier for the device authorization request",
|
||||
isPrimaryKey: true
|
||||
},
|
||||
{
|
||||
name: "deviceCode",
|
||||
type: "string",
|
||||
description: "The device verification code",
|
||||
},
|
||||
{
|
||||
name: "userCode",
|
||||
type: "string",
|
||||
description: "The user-friendly code for verification",
|
||||
},
|
||||
{
|
||||
name: "userId",
|
||||
type: "string",
|
||||
description: "The ID of the user who approved/denied",
|
||||
isOptional: true,
|
||||
isForeignKey: true
|
||||
},
|
||||
{
|
||||
name: "clientId",
|
||||
type: "string",
|
||||
description: "The OAuth client identifier",
|
||||
isOptional: true
|
||||
},
|
||||
{
|
||||
name: "scope",
|
||||
type: "string",
|
||||
description: "Requested OAuth scopes",
|
||||
isOptional: true
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
type: "string",
|
||||
description: "Current status: pending, approved, or denied",
|
||||
},
|
||||
{
|
||||
name: "expiresAt",
|
||||
type: "Date",
|
||||
description: "When the device code expires",
|
||||
},
|
||||
{
|
||||
name: "lastPolledAt",
|
||||
type: "Date",
|
||||
description: "Last time the device polled for status",
|
||||
isOptional: true
|
||||
},
|
||||
{
|
||||
name: "pollingInterval",
|
||||
type: "number",
|
||||
description: "Minimum seconds between polls",
|
||||
isOptional: true
|
||||
},
|
||||
{
|
||||
name: "createdAt",
|
||||
type: "Date",
|
||||
description: "When the request was created",
|
||||
},
|
||||
{
|
||||
name: "updatedAt",
|
||||
type: "Date",
|
||||
description: "When the request was last updated",
|
||||
}
|
||||
]}
|
||||
/>
|
||||
@@ -66,7 +66,7 @@ To get the token, call the `/token` endpoint. This will return the following:
|
||||
}
|
||||
```
|
||||
|
||||
Make sure to include the token in the `Authorization` header of your requests and the `bearer` plugin is added in your auth configuration.
|
||||
Make sure to include the token in the `Authorization` header of your requests if the `bearer` plugin is added in your auth configuration.
|
||||
|
||||
```ts
|
||||
await fetch("/api/auth/token", {
|
||||
|
||||
354
docs/content/docs/plugins/last-login-method.mdx
Normal file
354
docs/content/docs/plugins/last-login-method.mdx
Normal file
@@ -0,0 +1,354 @@
|
||||
---
|
||||
title: Last Login Method
|
||||
description: Track and display the last authentication method used by users
|
||||
---
|
||||
|
||||
The last login method plugin tracks the most recent authentication method used by users (email, OAuth providers, etc.). This enables you to display helpful indicators on login pages, such as "Last signed in with Google" or prioritize certain login methods based on user preferences.
|
||||
|
||||
## Installation
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
### Add the plugin to your auth config
|
||||
|
||||
```ts title="auth.ts"
|
||||
import { betterAuth } from "better-auth"
|
||||
import { lastLoginMethod } from "better-auth/plugins" // [!code highlight]
|
||||
|
||||
export const auth = betterAuth({
|
||||
// ... other config options
|
||||
plugins: [
|
||||
lastLoginMethod() // [!code highlight]
|
||||
]
|
||||
})
|
||||
```
|
||||
</Step>
|
||||
<Step>
|
||||
### Add the client plugin to your auth client
|
||||
|
||||
```ts title="auth-client.ts"
|
||||
import { createAuthClient } from "better-auth/client"
|
||||
import { lastLoginMethodClient } from "better-auth/client/plugins" // [!code highlight]
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
plugins: [
|
||||
lastLoginMethodClient() // [!code highlight]
|
||||
]
|
||||
})
|
||||
```
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Usage
|
||||
|
||||
Once installed, the plugin automatically tracks the last authentication method used by users. You can then retrieve and display this information in your application.
|
||||
|
||||
### Getting the Last Used Method
|
||||
|
||||
The client plugin provides several methods to work with the last login method:
|
||||
|
||||
```ts title="app.tsx"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
|
||||
// Get the last used login method
|
||||
const lastMethod = authClient.getLastUsedLoginMethod()
|
||||
console.log(lastMethod) // "google", "email", "github", etc.
|
||||
|
||||
// Check if a specific method was last used
|
||||
const wasGoogle = authClient.isLastUsedLoginMethod("google")
|
||||
|
||||
// Clear the stored method
|
||||
authClient.clearLastUsedLoginMethod()
|
||||
```
|
||||
|
||||
### UI Integration Example
|
||||
|
||||
Here's how to use the plugin to enhance your login page:
|
||||
|
||||
```tsx title="sign-in.tsx"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
|
||||
export function SignInPage() {
|
||||
const lastMethod = authClient.getLastUsedLoginMethod()
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1>Sign In</h1>
|
||||
|
||||
{/* Email sign in */}
|
||||
<div className="relative">
|
||||
<Button
|
||||
onClick={() => authClient.signIn.email({...})}
|
||||
variant={lastMethod === "email" ? "default" : "outline"}
|
||||
className="w-full"
|
||||
>
|
||||
Sign in with Email
|
||||
{lastMethod === "email" && (
|
||||
<Badge className="ml-2">Last used</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* OAuth providers */}
|
||||
<div className="relative">
|
||||
<Button
|
||||
onClick={() => authClient.signIn.social({ provider: "google" })}
|
||||
variant={lastMethod === "google" ? "default" : "outline"}
|
||||
className="w-full"
|
||||
>
|
||||
Continue with Google
|
||||
{lastMethod === "google" && (
|
||||
<Badge className="ml-2">Last used</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Button
|
||||
onClick={() => authClient.signIn.social({ provider: "github" })}
|
||||
variant={lastMethod === "github" ? "default" : "outline"}
|
||||
className="w-full"
|
||||
>
|
||||
Continue with GitHub
|
||||
{lastMethod === "github" && (
|
||||
<Badge className="ml-2">Last used</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Database Persistence
|
||||
|
||||
By default, the last login method is stored only in cookies. For more persistent tracking and analytics, you can enable database storage.
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
### Enable database storage
|
||||
|
||||
Set `storeInDatabase` to `true` in your plugin configuration:
|
||||
|
||||
```ts title="auth.ts"
|
||||
import { betterAuth } from "better-auth"
|
||||
import { lastLoginMethod } from "better-auth/plugins"
|
||||
|
||||
export const auth = betterAuth({
|
||||
plugins: [
|
||||
lastLoginMethod({
|
||||
storeInDatabase: true // [!code highlight]
|
||||
})
|
||||
]
|
||||
})
|
||||
```
|
||||
</Step>
|
||||
<Step>
|
||||
### Run database migration
|
||||
|
||||
The plugin will automatically add a `lastLoginMethod` field to your user table. Run the migration to apply the changes:
|
||||
|
||||
<Tabs items={["migrate", "generate"]}>
|
||||
<Tab value="migrate">
|
||||
```bash
|
||||
npx @better-auth/cli migrate
|
||||
```
|
||||
</Tab>
|
||||
<Tab value="generate">
|
||||
```bash
|
||||
npx @better-auth/cli generate
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</Step>
|
||||
<Step>
|
||||
### Access database field
|
||||
|
||||
When database storage is enabled, the `lastLoginMethod` field becomes available in user objects:
|
||||
|
||||
```ts title="user-profile.tsx"
|
||||
import { auth } from "@/lib/auth"
|
||||
|
||||
// Server-side access
|
||||
const session = await auth.api.getSession({ headers })
|
||||
console.log(session?.user.lastLoginMethod) // "google", "email", etc.
|
||||
|
||||
// Client-side access via session
|
||||
const { data: session } = authClient.useSession()
|
||||
console.log(session?.user.lastLoginMethod)
|
||||
```
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
### Database Schema
|
||||
|
||||
When `storeInDatabase` is enabled, the plugin adds the following field to the `user` table:
|
||||
|
||||
Table: `user`
|
||||
|
||||
<DatabaseTable
|
||||
fields={[
|
||||
{ name: "lastLoginMethod", type: "string", description: "The last authentication method used by the user", isOptional: true },
|
||||
]}
|
||||
/>
|
||||
|
||||
### Custom Schema Configuration
|
||||
|
||||
You can customize the database field name:
|
||||
|
||||
```ts title="auth.ts"
|
||||
import { betterAuth } from "better-auth"
|
||||
import { lastLoginMethod } from "better-auth/plugins"
|
||||
|
||||
export const auth = betterAuth({
|
||||
plugins: [
|
||||
lastLoginMethod({
|
||||
storeInDatabase: true,
|
||||
schema: {
|
||||
user: {
|
||||
lastLoginMethod: "last_auth_method" // Custom field name
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
The last login method plugin accepts the following options:
|
||||
|
||||
### Server Options
|
||||
|
||||
```ts title="auth.ts"
|
||||
import { betterAuth } from "better-auth"
|
||||
import { lastLoginMethod } from "better-auth/plugins"
|
||||
|
||||
export const auth = betterAuth({
|
||||
plugins: [
|
||||
lastLoginMethod({
|
||||
// Cookie configuration
|
||||
cookieName: "better-auth.last_used_login_method", // Default: "better-auth.last_used_login_method"
|
||||
maxAge: 60 * 60 * 24 * 30, // Default: 30 days in seconds
|
||||
|
||||
// Database persistence
|
||||
storeInDatabase: false, // Default: false
|
||||
|
||||
// Custom method resolution
|
||||
customResolveMethod: (ctx) => {
|
||||
// Custom logic to determine the login method
|
||||
if (ctx.path === "/oauth/callback/custom-provider") {
|
||||
return "custom-provider"
|
||||
}
|
||||
// Return null to use default resolution
|
||||
return null
|
||||
},
|
||||
|
||||
// Schema customization (when storeInDatabase is true)
|
||||
schema: {
|
||||
user: {
|
||||
lastLoginMethod: "custom_field_name"
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
**cookieName**: `string`
|
||||
- The name of the cookie used to store the last login method
|
||||
- Default: `"better-auth.last_used_login_method"`
|
||||
|
||||
**maxAge**: `number`
|
||||
- Cookie expiration time in seconds
|
||||
- Default: `2592000` (30 days)
|
||||
|
||||
**storeInDatabase**: `boolean`
|
||||
- Whether to store the last login method in the database
|
||||
- Default: `false`
|
||||
- When enabled, adds a `lastLoginMethod` field to the user table
|
||||
|
||||
**customResolveMethod**: `(ctx: GenericEndpointContext) => string | null`
|
||||
- Custom function to determine the login method from the request context
|
||||
- Return `null` to use the default resolution logic
|
||||
- Useful for custom OAuth providers or authentication flows
|
||||
|
||||
**schema**: `object`
|
||||
- Customize database field names when `storeInDatabase` is enabled
|
||||
- Allows mapping the `lastLoginMethod` field to a custom column name
|
||||
|
||||
### Client Options
|
||||
|
||||
```ts title="auth-client.ts"
|
||||
import { createAuthClient } from "better-auth/client"
|
||||
import { lastLoginMethodClient } from "better-auth/client/plugins"
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
plugins: [
|
||||
lastLoginMethodClient({
|
||||
cookieName: "better-auth.last_used_login_method" // Default: "better-auth.last_used_login_method"
|
||||
})
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
**cookieName**: `string`
|
||||
- The name of the cookie to read the last login method from
|
||||
- Must match the server-side `cookieName` configuration
|
||||
- Default: `"better-auth.last_used_login_method"`
|
||||
|
||||
### Default Method Resolution
|
||||
|
||||
By default, the plugin tracks these authentication methods:
|
||||
|
||||
- **Email authentication**: `"email"`
|
||||
- **OAuth providers**: Provider ID (e.g., `"google"`, `"github"`, `"discord"`)
|
||||
- **OAuth2 callbacks**: Provider ID from URL path
|
||||
- **Sign up methods**: Tracked the same as sign in methods
|
||||
|
||||
The plugin automatically detects the method from these endpoints:
|
||||
- `/callback/:id` - OAuth callback with provider ID
|
||||
- `/oauth2/callback/:id` - OAuth2 callback with provider ID
|
||||
- `/sign-in/email` - Email sign in
|
||||
- `/sign-up/email` - Email sign up
|
||||
|
||||
## Advanced Examples
|
||||
|
||||
### Custom Provider Tracking
|
||||
|
||||
If you have custom OAuth providers or authentication methods, you can use the `customResolveMethod` option:
|
||||
|
||||
```ts title="auth.ts"
|
||||
import { betterAuth } from "better-auth"
|
||||
import { lastLoginMethod } from "better-auth/plugins"
|
||||
|
||||
export const auth = betterAuth({
|
||||
plugins: [
|
||||
lastLoginMethod({
|
||||
customResolveMethod: (ctx) => {
|
||||
// Track custom SAML provider
|
||||
if (ctx.path === "/saml/callback") {
|
||||
return "saml"
|
||||
}
|
||||
|
||||
// Track magic link authentication
|
||||
if (ctx.path === "/verify-magic-link") {
|
||||
return "magic-link"
|
||||
}
|
||||
|
||||
// Track phone authentication
|
||||
if (ctx.path === "/sign-in/phone") {
|
||||
return "phone"
|
||||
}
|
||||
|
||||
// Return null to use default logic
|
||||
return null
|
||||
}
|
||||
})
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
|
||||
@@ -443,7 +443,8 @@ Table Name: `oauthApplication`
|
||||
name: "userId",
|
||||
type: "string",
|
||||
description: "ID of the user who owns the client. (optional)",
|
||||
isOptional: true
|
||||
isOptional: true,
|
||||
references: { model: "user", field: "id" }
|
||||
},
|
||||
{
|
||||
name: "createdAt",
|
||||
@@ -498,7 +499,7 @@ Table Name: `oauthAccessToken`
|
||||
type: "string",
|
||||
description: "ID of the OAuth client",
|
||||
isForeignKey: true,
|
||||
references: { model: "oauthClient", field: "clientId" }
|
||||
references: { model: "oauthApplication", field: "clientId" }
|
||||
},
|
||||
{
|
||||
name: "userId",
|
||||
@@ -550,7 +551,7 @@ Table Name: `oauthConsent`
|
||||
type: "string",
|
||||
description: "ID of the OAuth client",
|
||||
isForeignKey: true,
|
||||
references: { model: "oauthClient", field: "clientId" }
|
||||
references: { model: "oauthApplication", field: "clientId" }
|
||||
},
|
||||
{
|
||||
name: "scopes",
|
||||
@@ -591,4 +592,6 @@ Table Name: `oauthConsent`
|
||||
|
||||
**getAdditionalUserInfoClaim**: `(user: User, scopes: string[], client: Client) => Record<string, any>` - Function to get additional user info claims.
|
||||
|
||||
**useJWTPlugin**: `boolean` - When `true`, ID tokens are signed using the JWT plugin's asymmetric keys. When `false` (default), ID tokens are signed with HMAC-SHA256 using the application secret.
|
||||
**useJWTPlugin**: `boolean` - When `true`, ID tokens are signed using the JWT plugin's asymmetric keys. When `false` (default), ID tokens are signed with HMAC-SHA256 using the application secret.
|
||||
|
||||
**schema**: `AuthPluginSchema` - Customize the OIDC provider schema.
|
||||
@@ -61,4 +61,6 @@ console.log(openAPISchema)
|
||||
|
||||
`path` - The path where the Open API reference is served. Default is `/api/auth/reference`. You can change it to any path you like, but keep in mind that it will be appended to the base path of your auth server.
|
||||
|
||||
`disableDefaultReference` - If set to `true`, the default Open API reference UI by Scalar will be disabled. Default is `false`.
|
||||
`disableDefaultReference` - If set to `true`, the default Open API reference UI by Scalar will be disabled. Default is `false`.
|
||||
|
||||
`theme` - Allows you to change the theme of the OpenAPI reference page. Default is `default`.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -395,6 +395,10 @@ To create a [Stripe billing portal session](https://docs.stripe.com/api/customer
|
||||
>
|
||||
```ts
|
||||
type createBillingPortal = {
|
||||
/**
|
||||
* The IETF language tag of the locale customer portal is displayed in. If blank or auto, browser's locale is used.
|
||||
*/
|
||||
locale?: string
|
||||
/**
|
||||
* Reference id of the subscription to upgrade.
|
||||
*/
|
||||
@@ -406,6 +410,9 @@ type createBillingPortal = {
|
||||
}
|
||||
```
|
||||
</APIMethod>
|
||||
<Callout type="info" >
|
||||
For supported locales, see the [IETF language tag documentation](https://docs.stripe.com/js/appendix/supported_locales).
|
||||
</Callout>
|
||||
|
||||
This endpoint creates a Stripe billing portal session and returns a URL in the response as `data.url`. You can redirect users to this URL to allow them to manage their subscription, payment methods, and billing history.
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ Better Auth collects anonymous usage data to help us improve the project. This i
|
||||
|
||||
## Why is telemetry collected?
|
||||
|
||||
Since v1.3.5, Better Auth collects anonymous telemetry data about general usage.
|
||||
Since v1.3.5, Better Auth collects anonymous telemetry data about general usage if enabled.
|
||||
|
||||
Telemetry data helps us understand how Better Auth is being used across different environments so we can improve performance, prioritize features, and fix issues more effectively. It guides our decisions on performance optimizations, feature development, and bug fixes. All data is collected completely anonymously and with privacy in mind, and users can opt out at any time. We strive to keep what we collect as transparent as possible.
|
||||
|
||||
|
||||
14
docs/lib/export-search-indexes.ts
Normal file
14
docs/lib/export-search-indexes.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { source } from "@/lib/source";
|
||||
import type { OramaDocument } from "fumadocs-core/search/orama-cloud";
|
||||
|
||||
export async function exportSearchIndexes() {
|
||||
return source.getPages().map((page) => {
|
||||
return {
|
||||
id: page.url,
|
||||
structured: page.data.structuredData,
|
||||
url: page.url,
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
} satisfies OramaDocument;
|
||||
});
|
||||
}
|
||||
@@ -4,86 +4,88 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"build": "next build && pnpm run scripts:sync-orama",
|
||||
"dev": "next dev --turbopack",
|
||||
"start": "next start",
|
||||
"postinstall": "fumadocs-mdx",
|
||||
"scripts:endpoint-to-doc": "bun ./scripts/endpoint-to-doc/index.ts"
|
||||
"scripts:endpoint-to-doc": "bun ./scripts/endpoint-to-doc/index.ts",
|
||||
"scripts:sync-orama": "node ./scripts/sync-orama.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@oramacloud/client": "^2.1.4",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.2",
|
||||
"@radix-ui/react-avatar": "^1.1.3",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-menubar": "^1.1.16",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.2",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.2",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@scalar/nextjs-api-reference": "^0.5.15",
|
||||
"@scalar/nextjs-api-reference": "^0.8.17",
|
||||
"@vercel/analytics": "^1.5.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"embla-carousel-react": "^8.5.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"foxact": "^0.2.49",
|
||||
"fumadocs-core": "15.7.1",
|
||||
"fumadocs-core": "15.7.8",
|
||||
"fumadocs-docgen": "2.1.0",
|
||||
"fumadocs-mdx": "11.8.0",
|
||||
"fumadocs-mdx": "11.8.3",
|
||||
"fumadocs-typescript": "^4.0.6",
|
||||
"fumadocs-ui": "15.7.1",
|
||||
"fumadocs-ui": "15.7.8",
|
||||
"gray-matter": "^4.0.3",
|
||||
"input-otp": "^1.4.1",
|
||||
"input-otp": "^1.4.2",
|
||||
"jotai": "^2.13.1",
|
||||
"js-beautify": "^1.15.4",
|
||||
"jsrsasign": "^11.1.0",
|
||||
"lucide-react": "^0.477.0",
|
||||
"lucide-react": "^0.542.0",
|
||||
"motion": "^12.23.12",
|
||||
"next": "15.5.0",
|
||||
"next": "15.5.2",
|
||||
"next-themes": "^0.4.6",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"react": "^19.0.0",
|
||||
"react-day-picker": "8.10.1",
|
||||
"react-dom": "^19.0.0",
|
||||
"react": "^19.1.1",
|
||||
"react-day-picker": "9.9.0",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-hook-form": "^7.62.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"recharts": "^2.14.1",
|
||||
"react-resizable-panels": "^3.0.5",
|
||||
"recharts": "^3.1.2",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^3.24.2"
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.0.9",
|
||||
"@tailwindcss/postcss": "^4.1.12",
|
||||
"@types/jsrsasign": "^10.5.15",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^4.0.12",
|
||||
"typescript": "^5.8.2"
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.12",
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ async function generateMDX() {
|
||||
const functionName = Object.keys(exports)[0]! as string;
|
||||
|
||||
const [path, options]: [string, Options] =
|
||||
//@ts-ignore
|
||||
//@ts-expect-error
|
||||
await exports[Object.keys(exports)[0]!];
|
||||
if (!path || !options) return console.error(`No path or options.`);
|
||||
|
||||
@@ -194,17 +194,17 @@ function parseZodShape(zod: z.ZodAny, path: string[]) {
|
||||
{ description: "some descriptiom" },
|
||||
).shape;
|
||||
|
||||
//@ts-ignore
|
||||
//@ts-expect-error
|
||||
if (zod._def.typeName === "ZodOptional") {
|
||||
isRootOptional = true;
|
||||
const eg = z.optional(z.object({}));
|
||||
const x = zod as never as typeof eg;
|
||||
//@ts-ignore
|
||||
//@ts-expect-error
|
||||
shape = x._def.innerType.shape;
|
||||
} else {
|
||||
const eg = z.object({});
|
||||
const x = zod as never as typeof eg;
|
||||
//@ts-ignore
|
||||
//@ts-expect-error
|
||||
shape = x.shape;
|
||||
}
|
||||
|
||||
@@ -485,7 +485,7 @@ function pathToDotNotation(input: string): string {
|
||||
.join(".");
|
||||
}
|
||||
|
||||
async function playSound(name: string = "Ping") {
|
||||
function playSound(name: string = "Ping") {
|
||||
const path = `/System/Library/Sounds/${name}.aiff`;
|
||||
await Bun.$`afplay ${path}`;
|
||||
void Bun.$`afplay ${path}`;
|
||||
}
|
||||
|
||||
30
docs/scripts/sync-orama.ts
Normal file
30
docs/scripts/sync-orama.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { sync, type OramaDocument } from "fumadocs-core/search/orama-cloud";
|
||||
import * as fs from "node:fs/promises";
|
||||
import { CloudManager } from "@oramacloud/client";
|
||||
import * as process from "node:process";
|
||||
import "dotenv/config";
|
||||
|
||||
const filePath = ".next/server/app/static.json.body";
|
||||
|
||||
async function main() {
|
||||
const apiKey = process.env.ORAMA_PRIVATE_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
console.log("no api key for Orama found, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await fs.readFile(filePath);
|
||||
const records = JSON.parse(content.toString()) as OramaDocument[];
|
||||
const manager = new CloudManager({ api_key: apiKey });
|
||||
|
||||
await sync(manager, {
|
||||
index: process.env.ORAMA_INDEX_ID!,
|
||||
documents: records,
|
||||
autoDeploy: true,
|
||||
});
|
||||
|
||||
console.log(`search updated: ${records.length} records`);
|
||||
}
|
||||
|
||||
void main();
|
||||
15
docs/turbo.json
Normal file
15
docs/turbo.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://turborepo.org/schema.json",
|
||||
"extends": ["//"],
|
||||
"tasks": {
|
||||
"build": {
|
||||
"env": [
|
||||
"GITHUB_TOKEN",
|
||||
"ORAMA_PRIVATE_API_KEY",
|
||||
"ORAMA_INDEX_ID",
|
||||
"NEXT_PUBLIC_ORAMA_PUBLIC_API_KEY",
|
||||
"NEXT_PUBLIC_ORAMA_ENDPOINT"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
13
e2e/integration/package.json
Normal file
13
e2e/integration/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "integration",
|
||||
"scripts": {
|
||||
"e2e:integration": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-auth": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.55.0",
|
||||
"terminate": "^2.8.0"
|
||||
}
|
||||
}
|
||||
20
e2e/integration/playwright.config.ts
Normal file
20
e2e/integration/playwright.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testMatch: "**/e2e/**/*.spec.ts",
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: "html",
|
||||
use: {
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
55
e2e/integration/vanilla-node/e2e/app.ts
Normal file
55
e2e/integration/vanilla-node/e2e/app.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { createServer } from "node:http";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { toNodeHandler } from "better-auth/node";
|
||||
import Database from "better-sqlite3";
|
||||
import { getMigrations } from "better-auth/db";
|
||||
|
||||
export async function createAuthServer(
|
||||
baseURL: string = "http://localhost:3000",
|
||||
) {
|
||||
const database = new Database(":memory:");
|
||||
|
||||
const auth = betterAuth({
|
||||
database,
|
||||
baseURL,
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { runMigrations } = await getMigrations(auth.options);
|
||||
|
||||
await runMigrations();
|
||||
// Create an example user
|
||||
await auth.api.signUpEmail({
|
||||
body: {
|
||||
name: "Test User",
|
||||
email: "test@test.com",
|
||||
password: "password123",
|
||||
},
|
||||
});
|
||||
|
||||
const authHandler = toNodeHandler(auth);
|
||||
|
||||
return createServer(async (req, res) => {
|
||||
res.setHeader("Access-Control-Allow-Origin", req.headers.origin || "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||
res.setHeader("Access-Control-Allow-Credentials", "true");
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
res.statusCode = 200;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const isAuthRoute = req.url?.startsWith("/api/auth");
|
||||
|
||||
if (isAuthRoute) {
|
||||
return authHandler(req, res);
|
||||
}
|
||||
|
||||
res.statusCode = 404;
|
||||
res.end(JSON.stringify({ error: "Not found" }));
|
||||
});
|
||||
}
|
||||
40
e2e/integration/vanilla-node/e2e/domain.spec.ts
Normal file
40
e2e/integration/vanilla-node/e2e/domain.spec.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { chromium, expect, test } from "@playwright/test";
|
||||
import { runClient, setup } from "./utils";
|
||||
|
||||
const { ref, start, clean } = setup();
|
||||
test.describe("cross domain", async () => {
|
||||
test.beforeEach(async () => start());
|
||||
test.afterEach(async () => clean());
|
||||
|
||||
test("should work across domains", async () => {
|
||||
const browser = await chromium.launch({
|
||||
args: [`--host-resolver-rules=MAP * localhost`],
|
||||
});
|
||||
|
||||
const page = await browser.newPage();
|
||||
|
||||
await page.goto(
|
||||
`http://test.com:${ref.clientPort}/?port=${ref.serverPort}`,
|
||||
);
|
||||
await page.locator("text=Ready").waitFor();
|
||||
|
||||
await expect(
|
||||
runClient(page, ({ client }) => typeof client !== "undefined"),
|
||||
).resolves.toBe(true);
|
||||
await expect(
|
||||
runClient(page, async ({ client }) => client.getSession()),
|
||||
).resolves.toEqual({ data: null, error: null });
|
||||
await runClient(page, ({ client }) =>
|
||||
client.signIn.email({
|
||||
email: "test@test.com",
|
||||
password: "password123",
|
||||
}),
|
||||
);
|
||||
|
||||
// Check that the session is not set because of we didn't set the cookie domain correctly
|
||||
const cookies = await page.context().cookies();
|
||||
expect(
|
||||
cookies.find((c) => c.name === "better-auth.session_token"),
|
||||
).not.toBeDefined();
|
||||
});
|
||||
});
|
||||
36
e2e/integration/vanilla-node/e2e/test.spec.ts
Normal file
36
e2e/integration/vanilla-node/e2e/test.spec.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { runClient, setup } from "./utils";
|
||||
|
||||
const { ref, start, clean } = setup();
|
||||
test.describe("vanilla-node", async () => {
|
||||
test.beforeEach(async () => start());
|
||||
test.afterEach(async () => clean());
|
||||
|
||||
test("signIn with existing email and password should work", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(
|
||||
`http://localhost:${ref.clientPort}/?port=${ref.serverPort}`,
|
||||
);
|
||||
await page.locator("text=Ready").waitFor();
|
||||
|
||||
await expect(
|
||||
runClient(page, ({ client }) => typeof client !== "undefined"),
|
||||
).resolves.toBe(true);
|
||||
await expect(
|
||||
runClient(page, async ({ client }) => client.getSession()),
|
||||
).resolves.toEqual({ data: null, error: null });
|
||||
await runClient(page, ({ client }) =>
|
||||
client.signIn.email({
|
||||
email: "test@test.com",
|
||||
password: "password123",
|
||||
}),
|
||||
);
|
||||
|
||||
// Check that the session is now set
|
||||
const cookies = await page.context().cookies();
|
||||
expect(
|
||||
cookies.find((c) => c.name === "better-auth.session_token"),
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
82
e2e/integration/vanilla-node/e2e/utils.ts
Normal file
82
e2e/integration/vanilla-node/e2e/utils.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createAuthServer } from "./app";
|
||||
|
||||
const terminate = createRequire(import.meta.url)(
|
||||
// use terminate instead of cp.kill,
|
||||
// because cp.kill will not kill the child process of the child process
|
||||
// to avoid the zombie process
|
||||
"terminate/promise",
|
||||
) as (pid: number) => Promise<void>;
|
||||
|
||||
const root = fileURLToPath(new URL("../", import.meta.url));
|
||||
|
||||
export async function runClient<R>(
|
||||
page: Page,
|
||||
fn: ({ client }: { client: Window["client"] }) => R,
|
||||
): Promise<R> {
|
||||
const client = await page.evaluateHandle<Window["client"]>("window.client");
|
||||
return page.evaluate(fn, { client });
|
||||
}
|
||||
|
||||
export function setup() {
|
||||
let server: Awaited<ReturnType<typeof createAuthServer>>;
|
||||
let clientChild: ChildProcessWithoutNullStreams;
|
||||
const ref: {
|
||||
clientPort: number;
|
||||
serverPort: number;
|
||||
} = {
|
||||
clientPort: -1,
|
||||
serverPort: -1,
|
||||
};
|
||||
return {
|
||||
ref,
|
||||
start: async () => {
|
||||
server = await createAuthServer();
|
||||
clientChild = spawn("pnpm", ["run", "start:client"], {
|
||||
cwd: root,
|
||||
stdio: "pipe",
|
||||
});
|
||||
clientChild.stderr.on("data", (data) => {
|
||||
const message = data.toString();
|
||||
console.log(message);
|
||||
});
|
||||
clientChild.stdout.on("data", (data) => {
|
||||
const message = data.toString();
|
||||
console.log(message);
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
new Promise<void>((resolve) => {
|
||||
server.listen(0, "0.0.0.0", () => {
|
||||
const address = server.address();
|
||||
if (address && typeof address === "object") {
|
||||
ref.serverPort = address.port;
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}),
|
||||
new Promise<void>((resolve) => {
|
||||
clientChild.stdout.on("data", (data) => {
|
||||
const message = data.toString();
|
||||
// find: http://localhost:5173/
|
||||
if (message.includes("http://localhost:")) {
|
||||
const port: string = message
|
||||
.split("http://localhost:")[1]
|
||||
.split("/")[0]
|
||||
.trim();
|
||||
ref.clientPort = Number(port.replace(/\x1b\[[0-9;]*m/g, ""));
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}),
|
||||
]);
|
||||
},
|
||||
clean: async () => {
|
||||
await terminate(clientChild.pid!);
|
||||
server.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
11
e2e/integration/vanilla-node/index.html
Normal file
11
e2e/integration/vanilla-node/index.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Better Auth - Vanilla Node E2E Test</title>
|
||||
</head>
|
||||
<body>
|
||||
<script type="module" src="./src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
15
e2e/integration/vanilla-node/package.json
Normal file
15
e2e/integration/vanilla-node/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "vanilla-node-e2e",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start:client": "vite"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^7.1.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-auth": "workspace:*",
|
||||
"better-sqlite3": "^12.2.0"
|
||||
}
|
||||
}
|
||||
18
e2e/integration/vanilla-node/src/main.ts
Normal file
18
e2e/integration/vanilla-node/src/main.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { createAuthClient } from "better-auth/client";
|
||||
|
||||
const search = new URLSearchParams(window.location.search);
|
||||
const port = search.get("port");
|
||||
|
||||
const client = createAuthClient({
|
||||
baseURL: `http://localhost:${port ?? 3000}`,
|
||||
});
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
client: typeof client;
|
||||
}
|
||||
}
|
||||
|
||||
window.client = client;
|
||||
|
||||
document.body.innerHTML = "Ready";
|
||||
1
e2e/integration/vanilla-node/src/vite-env.d.ts
vendored
Normal file
1
e2e/integration/vanilla-node/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -1,24 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"target": "ESNext",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
"declaration": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
e2e/integration/vanilla-node/vite.config.ts
Normal file
7
e2e/integration/vanilla-node/vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
allowedHosts: ["test.com", "localhost"],
|
||||
},
|
||||
});
|
||||
13
e2e/smoke/package.json
Normal file
13
e2e/smoke/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "smoke",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"better-auth": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.3.0"
|
||||
},
|
||||
"scripts": {
|
||||
"e2e:smoke": "node --test ./test/*.spec.ts"
|
||||
}
|
||||
}
|
||||
49
e2e/smoke/test/bun.spec.ts
Normal file
49
e2e/smoke/test/bun.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, it } from "node:test";
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { join } from "node:path";
|
||||
import assert from "node:assert";
|
||||
|
||||
const fixturesDir = fileURLToPath(new URL("./fixtures", import.meta.url));
|
||||
|
||||
describe("(bun) simple server", () => {
|
||||
it("run server", async (t) => {
|
||||
const cp = spawn("bun", [join(fixturesDir, "bun-simple.ts")], {
|
||||
stdio: "pipe",
|
||||
});
|
||||
t.after(() => {
|
||||
cp.kill("SIGINT");
|
||||
});
|
||||
cp.stdout.on("data", (data) => {
|
||||
console.log(data.toString());
|
||||
});
|
||||
cp.stderr.on("data", (data) => {
|
||||
console.error(data.toString());
|
||||
});
|
||||
const port = await new Promise<number>((resolve) => {
|
||||
cp.stdout.once("data", (data) => {
|
||||
// Bun outputs colored string, we need to remove it
|
||||
const port = +data.toString().replace(/\u001b\[[0-9;]*m/g, "");
|
||||
assert.ok(port > 0);
|
||||
assert.ok(!isNaN(port));
|
||||
assert.ok(isFinite(port));
|
||||
resolve(port);
|
||||
});
|
||||
});
|
||||
const response = await fetch(
|
||||
`http://localhost:${port}/api/auth/sign-up/email`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
email: "test-2@test.com",
|
||||
password: "password",
|
||||
name: "test-2",
|
||||
}),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.ok(response.ok);
|
||||
});
|
||||
});
|
||||
35
e2e/smoke/test/cloudflare.spec.ts
Normal file
35
e2e/smoke/test/cloudflare.spec.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, it } from "node:test";
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { join } from "node:path";
|
||||
|
||||
const fixturesDir = fileURLToPath(new URL("./fixtures", import.meta.url));
|
||||
|
||||
describe("(cloudflare) simple server", () => {
|
||||
it("check repo", async (t) => {
|
||||
const cp = spawn("npm", ["run", "check"], {
|
||||
cwd: join(fixturesDir, "cloudflare"),
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
t.after(() => {
|
||||
cp.kill("SIGINT");
|
||||
});
|
||||
|
||||
cp.stdout.on("data", (data) => {
|
||||
console.log(data.toString());
|
||||
});
|
||||
|
||||
cp.stderr.on("data", (data) => {
|
||||
console.error(data.toString());
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
cp.stdout.on("data", (data) => {
|
||||
if (data.toString().includes("exiting now.")) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
48
e2e/smoke/test/deno.spec.ts
Normal file
48
e2e/smoke/test/deno.spec.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, it } from "node:test";
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { join } from "node:path";
|
||||
import assert from "node:assert";
|
||||
|
||||
const fixturesDir = fileURLToPath(new URL("./fixtures", import.meta.url));
|
||||
|
||||
describe("(deno) simple server", () => {
|
||||
it("run server", async (t) => {
|
||||
const cp = spawn("deno", ["-A", join(fixturesDir, "deno-simple.ts")], {
|
||||
stdio: "pipe",
|
||||
});
|
||||
t.after(() => {
|
||||
cp.kill("SIGINT");
|
||||
});
|
||||
cp.stdout.on("data", (data) => {
|
||||
console.log(data.toString());
|
||||
});
|
||||
cp.stderr.on("data", (data) => {
|
||||
console.error(data.toString());
|
||||
});
|
||||
const port = await new Promise<number>((resolve) => {
|
||||
cp.stdout.once("data", (data) => {
|
||||
const port = +data.toString().split(":")[2].split("/")[0];
|
||||
assert.ok(port > 0);
|
||||
assert.ok(!isNaN(port));
|
||||
assert.ok(isFinite(port));
|
||||
resolve(port);
|
||||
});
|
||||
});
|
||||
const response = await fetch(
|
||||
`http://localhost:${port}/api/auth/sign-up/email`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
email: "test-2@test.com",
|
||||
password: "password",
|
||||
name: "test-2",
|
||||
}),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.ok(response.ok);
|
||||
});
|
||||
});
|
||||
27
e2e/smoke/test/fixtures/bun-simple.ts
vendored
Normal file
27
e2e/smoke/test/fixtures/bun-simple.ts
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import Database from "bun:sqlite";
|
||||
import { getMigrations } from "better-auth/db";
|
||||
|
||||
const database = new Database(":memory:");
|
||||
|
||||
export const auth = betterAuth({
|
||||
baseURL: "http://localhost:4000",
|
||||
database,
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
logger: {
|
||||
level: "debug",
|
||||
},
|
||||
});
|
||||
|
||||
const { runMigrations } = await getMigrations(auth.options);
|
||||
|
||||
await runMigrations();
|
||||
|
||||
const server = Bun.serve({
|
||||
fetch: auth.handler,
|
||||
port: 0,
|
||||
});
|
||||
|
||||
console.log(server.port);
|
||||
@@ -1,105 +1,114 @@
|
||||
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/node,macos
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=node,macos
|
||||
|
||||
### macOS ###
|
||||
# General
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Icon must end with two \r
|
||||
Icon
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
# Files that might appear in the root of a volume
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
|
||||
# Directories potentially created on remote AFP share
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
### Node ###
|
||||
# Logs
|
||||
|
||||
logs
|
||||
_.log
|
||||
npm-debug.log_
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Caches
|
||||
|
||||
.cache
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
|
||||
pids
|
||||
_.pid
|
||||
_.seed
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
# Moved from ./templates for ignoring all locks in templates
|
||||
templates/**/*-lock.*
|
||||
templates/**/*.lock
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
@@ -107,69 +116,73 @@ web_modules/
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
|
||||
.temp
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
### Node Patch ###
|
||||
# Serverless Webpack directories
|
||||
.webpack/
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
# Optional stylelint cache
|
||||
|
||||
# SvelteKit build / generate output
|
||||
.svelte-kit
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/node,macos
|
||||
|
||||
# Wrangler output
|
||||
.wrangler/
|
||||
build/
|
||||
|
||||
# Turbo output
|
||||
.turbo/
|
||||
|
||||
.dev.vars*
|
||||
!.dev.vars.example
|
||||
.env*
|
||||
!.env.example
|
||||
22
e2e/smoke/test/fixtures/cloudflare/package.json
vendored
Normal file
22
e2e/smoke/test/fixtures/cloudflare/package.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "cloudflare",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"better-auth": "workspace:*",
|
||||
"drizzle-orm": "^0.44.5",
|
||||
"hono": "^4.9.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vitest-pool-workers": "^0.8.69",
|
||||
"@cloudflare/workers-types": "^4.20250903.0",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"wrangler": "4.33.2"
|
||||
},
|
||||
"scripts": {
|
||||
"check": "tsc && wrangler deploy --dry-run",
|
||||
"dev": "wrangler dev",
|
||||
"migrate:local": "wrangler d1 migrations apply db --local",
|
||||
"cf-typegen": "wrangler types --env-interface CloudflareBindings",
|
||||
"e2e:smoke": "vitest"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,26 @@
|
||||
import { Hono } from "hono";
|
||||
import { Auth, createAuth } from "./auth";
|
||||
|
||||
import { betterAuth } from "better-auth";
|
||||
import { createDrizzle } from "./db";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
|
||||
interface CloudflareBindings {
|
||||
DB: D1Database;
|
||||
}
|
||||
|
||||
const createAuth = (env: CloudflareBindings) =>
|
||||
betterAuth({
|
||||
baseURL: "http://localhost:4000",
|
||||
database: drizzleAdapter(createDrizzle(env.DB), { provider: "sqlite" }),
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
logger: {
|
||||
level: "debug",
|
||||
},
|
||||
});
|
||||
|
||||
type Auth = ReturnType<typeof createAuth>;
|
||||
|
||||
const app = new Hono<{
|
||||
Bindings: CloudflareBindings;
|
||||
19
e2e/smoke/test/fixtures/cloudflare/tsconfig.json
vendored
Normal file
19
e2e/smoke/test/fixtures/cloudflare/tsconfig.json
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"lib": ["esnext"],
|
||||
"module": "esnext",
|
||||
"outDir": "./dist",
|
||||
"moduleResolution": "bundler",
|
||||
"types": [
|
||||
"@cloudflare/workers-types/2023-07-01",
|
||||
"@cloudflare/workers-types/experimental",
|
||||
"@cloudflare/vitest-pool-workers"
|
||||
],
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export default defineWorkersProject(async () => {
|
||||
poolOptions: {
|
||||
workers: {
|
||||
singleWorker: true,
|
||||
wrangler: { configPath: "./wrangler.jsonc" },
|
||||
wrangler: { configPath: "./wrangler.json" },
|
||||
miniflare: {
|
||||
d1Databases: ["DB"],
|
||||
bindings: { TEST_MIGRATIONS: migrations },
|
||||
8658
e2e/smoke/test/fixtures/cloudflare/worker-configuration.d.ts
vendored
Normal file
8658
e2e/smoke/test/fixtures/cloudflare/worker-configuration.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user