feat: admin plugin (#117)
460
demo/nextjs/app/admin/page.tsx
Normal file
@@ -0,0 +1,460 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { toast, Toaster } from "sonner";
|
||||
import { client } from "@/lib/auth-client";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Loader2,
|
||||
Plus,
|
||||
Trash,
|
||||
RefreshCw,
|
||||
UserCircle,
|
||||
Calendar as CalendarIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { format } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
type User = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: "admin" | "user";
|
||||
};
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [newUser, setNewUser] = useState({
|
||||
email: "",
|
||||
password: "",
|
||||
name: "",
|
||||
role: "user" as const,
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState<string | undefined>();
|
||||
const [isBanDialogOpen, setIsBanDialogOpen] = useState(false);
|
||||
const [banForm, setBanForm] = useState({
|
||||
userId: "",
|
||||
reason: "",
|
||||
expirationDate: undefined as Date | undefined,
|
||||
});
|
||||
|
||||
const { data: users, isLoading: isUsersLoading } = useQuery({
|
||||
queryKey: ["users"],
|
||||
queryFn: () =>
|
||||
client.admin
|
||||
.listUsers({
|
||||
query: {
|
||||
limit: 10,
|
||||
sortBy: "createdAt",
|
||||
sortDirection: "desc",
|
||||
},
|
||||
})
|
||||
.then((res) => res.data?.users ?? []),
|
||||
});
|
||||
|
||||
const handleCreateUser = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading("create");
|
||||
try {
|
||||
await client.admin.createUser({
|
||||
email: newUser.email,
|
||||
password: newUser.password,
|
||||
name: newUser.name,
|
||||
role: newUser.role,
|
||||
});
|
||||
toast.success("User created successfully");
|
||||
setNewUser({ email: "", password: "", name: "", role: "user" });
|
||||
setIsDialogOpen(false);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["users"],
|
||||
});
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || "Failed to create user");
|
||||
} finally {
|
||||
setIsLoading(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (id: string) => {
|
||||
setIsLoading(`delete-${id}`);
|
||||
try {
|
||||
await client.admin.removeUser({ userId: id });
|
||||
toast.success("User deleted successfully");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["users"],
|
||||
});
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || "Failed to delete user");
|
||||
} finally {
|
||||
setIsLoading(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevokeSessions = async (id: string) => {
|
||||
setIsLoading(`revoke-${id}`);
|
||||
try {
|
||||
await client.admin.revokeUserSessions({ userId: id });
|
||||
toast.success("Sessions revoked for user");
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || "Failed to revoke sessions");
|
||||
} finally {
|
||||
setIsLoading(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImpersonateUser = async (id: string) => {
|
||||
setIsLoading(`impersonate-${id}`);
|
||||
try {
|
||||
await client.admin.impersonateUser({ userId: id });
|
||||
toast.success("Impersonated user");
|
||||
router.push("/dashboard");
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || "Failed to impersonate user");
|
||||
} finally {
|
||||
setIsLoading(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBanUser = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(`ban-${banForm.userId}`);
|
||||
try {
|
||||
if (!banForm.expirationDate) {
|
||||
throw new Error("Expiration date is required");
|
||||
}
|
||||
await client.admin.banUser({
|
||||
userId: banForm.userId,
|
||||
banReason: banForm.reason,
|
||||
banExpiresIn: banForm.expirationDate.getTime() - new Date().getTime(),
|
||||
});
|
||||
toast.success("User banned successfully");
|
||||
setIsBanDialogOpen(false);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["users"],
|
||||
});
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || "Failed to ban user");
|
||||
} finally {
|
||||
setIsLoading(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-4 space-y-8">
|
||||
<Toaster richColors />
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-2xl">Admin Dashboard</CardTitle>
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" /> Create User
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New User</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleCreateUser} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={newUser.email}
|
||||
onChange={(e) =>
|
||||
setNewUser({ ...newUser, email: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={newUser.password}
|
||||
onChange={(e) =>
|
||||
setNewUser({ ...newUser, password: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={newUser.name}
|
||||
onChange={(e) =>
|
||||
setNewUser({ ...newUser, name: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="role">Role</Label>
|
||||
<Select
|
||||
value={newUser.role}
|
||||
onValueChange={(value: "admin" | "user") =>
|
||||
setNewUser({ ...newUser, role: value as "user" })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isLoading === "create"}
|
||||
>
|
||||
{isLoading === "create" ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
"Create User"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={isBanDialogOpen} onOpenChange={setIsBanDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ban User</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleBanUser} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="reason">Reason</Label>
|
||||
<Input
|
||||
id="reason"
|
||||
value={banForm.reason}
|
||||
onChange={(e) =>
|
||||
setBanForm({ ...banForm, reason: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-1.5">
|
||||
<Label htmlFor="expirationDate">Expiration Date</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id="expirationDate"
|
||||
variant={"outline"}
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal",
|
||||
!banForm.expirationDate && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{banForm.expirationDate ? (
|
||||
format(banForm.expirationDate, "PPP")
|
||||
) : (
|
||||
<span>Pick a date</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={banForm.expirationDate}
|
||||
onSelect={(date) =>
|
||||
setBanForm({ ...banForm, expirationDate: date })
|
||||
}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isLoading === `ban-${banForm.userId}`}
|
||||
>
|
||||
{isLoading === `ban-${banForm.userId}` ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Banning...
|
||||
</>
|
||||
) : (
|
||||
"Ban User"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isUsersLoading ? (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Banned</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users?.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>{user.name}</TableCell>
|
||||
<TableCell>{user.role || "user"}</TableCell>
|
||||
<TableCell>
|
||||
{user.banned ? (
|
||||
<Badge variant="destructive">Yes</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">No</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteUser(user.id)}
|
||||
disabled={isLoading?.startsWith("delete")}
|
||||
>
|
||||
{isLoading === `delete-${user.id}` ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleRevokeSessions(user.id)}
|
||||
disabled={isLoading?.startsWith("revoke")}
|
||||
>
|
||||
{isLoading === `revoke-${user.id}` ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => handleImpersonateUser(user.id)}
|
||||
disabled={isLoading?.startsWith("impersonate")}
|
||||
>
|
||||
{isLoading === `impersonate-${user.id}` ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<UserCircle className="h-4 w-4 mr-2" />
|
||||
Impersonate
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
setBanForm({
|
||||
userId: user.id,
|
||||
reason: "",
|
||||
expirationDate: undefined,
|
||||
});
|
||||
if (user.banned) {
|
||||
setIsLoading(`ban-${user.id}`);
|
||||
await client.admin.unbanUser(
|
||||
{
|
||||
userId: user.id,
|
||||
},
|
||||
{
|
||||
onError(context) {
|
||||
toast.error(
|
||||
context.error.message ||
|
||||
"Failed to unban user",
|
||||
);
|
||||
setIsLoading(undefined);
|
||||
},
|
||||
onSuccess() {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["users"],
|
||||
});
|
||||
toast.success("User unbanned successfully");
|
||||
},
|
||||
},
|
||||
);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["users"],
|
||||
});
|
||||
} else {
|
||||
setIsBanDialogOpen(true);
|
||||
}
|
||||
}}
|
||||
disabled={isLoading?.startsWith("ban")}
|
||||
>
|
||||
{isLoading === `ban-${user.id}` ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : user.banned ? (
|
||||
"Unban"
|
||||
) : (
|
||||
"Ban"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -216,7 +216,7 @@ export default function UserCard(props: {
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm">Two Factor</p>
|
||||
<div className="flex gap-2">
|
||||
{session?.user.twoFactorEnabled && (
|
||||
{!!session?.user.twoFactorEnabled && (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="gap-2">
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
organizationClient,
|
||||
passkeyClient,
|
||||
twoFactorClient,
|
||||
adminClient,
|
||||
} from "better-auth/client/plugins";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -13,6 +14,7 @@ export const client = createAuthClient({
|
||||
twoFactorPage: "/two-factor",
|
||||
}),
|
||||
passkeyClient(),
|
||||
adminClient(),
|
||||
],
|
||||
fetchOptions: {
|
||||
onError(e) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
passkey,
|
||||
phoneNumber,
|
||||
twoFactor,
|
||||
admin,
|
||||
} from "better-auth/plugins";
|
||||
import { reactInvitationEmail } from "./email/invitation";
|
||||
import { LibsqlDialect } from "@libsql/kysely-libsql";
|
||||
@@ -83,6 +84,7 @@ export const auth = betterAuth({
|
||||
}),
|
||||
passkey(),
|
||||
bearer(),
|
||||
admin(),
|
||||
],
|
||||
socialProviders: {
|
||||
github: {
|
||||
|
||||
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 369 B After Width: | Height: | Size: 186 B |
|
Before Width: | Height: | Size: 664 B After Width: | Height: | Size: 243 B |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
BIN
demo/nextjs/public/favicon/light/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
demo/nextjs/public/favicon/light/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
demo/nextjs/public/favicon/light/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
demo/nextjs/public/favicon/light/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 186 B |
BIN
demo/nextjs/public/favicon/light/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 243 B |
BIN
demo/nextjs/public/favicon/light/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
19
demo/nextjs/public/favicon/light/site.webmanifest
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "",
|
||||
"short_name": "",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
@@ -188,8 +188,8 @@ export const Icons = {
|
||||
),
|
||||
remix: () => (
|
||||
<svg
|
||||
width="1em"
|
||||
height="1em"
|
||||
width="1.2em"
|
||||
height="1.2em"
|
||||
viewBox="0 0 412 474"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -198,15 +198,15 @@ export const Icons = {
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M393.946 364.768C398.201 419.418 398.201 445.036 398.201 473H271.756C271.756 466.909 271.865 461.337 271.975 455.687C272.317 438.123 272.674 419.807 269.828 382.819C266.067 328.667 242.748 316.634 199.871 316.634H161.883H1V218.109H205.889C260.049 218.109 287.13 201.633 287.13 158.011C287.13 119.654 260.049 96.4098 205.889 96.4098H1V0H228.456C351.069 0 412 57.9117 412 150.42C412 219.613 369.123 264.739 311.201 272.26C360.096 282.037 388.681 309.865 393.946 364.768Z"
|
||||
fill="white"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M1 473V399.553H134.697C157.029 399.553 161.878 416.116 161.878 425.994V473H1Z"
|
||||
fill="white"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M1 399.053H0.5V399.553V473V473.5H1H161.878H162.378V473V425.994C162.378 420.988 161.152 414.26 157.063 408.77C152.955 403.255 146.004 399.053 134.697 399.053H1Z"
|
||||
stroke="white"
|
||||
stroke="currentColor"
|
||||
stroke-opacity="0.8"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
@@ -640,6 +640,33 @@ export const contents: Content[] = [
|
||||
href: "/docs/plugins/1st-party-plugins",
|
||||
icon: LucideAArrowDown,
|
||||
},
|
||||
{
|
||||
title: "Admin",
|
||||
href: "/docs/plugins/admin",
|
||||
icon: () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1.2em"
|
||||
height="1.2em"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
className="fill-foreground"
|
||||
d="M12 23C6.443 21.765 2 16.522 2 11V5l10-4l10 4v6c0 5.524-4.443 10.765-10 12M4 6v5a10.58 10.58 0 0 0 8 10a10.58 10.58 0 0 0 8-10V6l-8-3Z"
|
||||
></path>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="8.5"
|
||||
r="2.5"
|
||||
className="fill-foreground"
|
||||
></circle>
|
||||
<path
|
||||
className="fill-foreground"
|
||||
d="M7 15a5.78 5.78 0 0 0 5 3a5.78 5.78 0 0 0 5-3c-.025-1.896-3.342-3-5-3c-1.667 0-4.975 1.104-5 3"
|
||||
></path>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Organization",
|
||||
icon: Users2,
|
||||
|
||||
237
docs/content/docs/plugins/admin.mdx
Normal file
@@ -0,0 +1,237 @@
|
||||
---
|
||||
title: Admin
|
||||
description: Admin plugin for Better Auth
|
||||
---
|
||||
|
||||
The Admin plugin provides a set of administrative functions for user management in your application. It allows administrators to perform various operations such as creating users, managing user roles, banning/unbanning users, impersonating users, and more.
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
### Add the plugin to your auth config
|
||||
|
||||
To use the Admin plugin, add it to your auth config.
|
||||
|
||||
```ts title="auth.ts"
|
||||
import { betterAuth } from "better-auth"
|
||||
import { admin } from "better-auth/plugins" // [!code highlight]
|
||||
|
||||
export const auth = betterAuth({
|
||||
// ... other config options
|
||||
plugins: [
|
||||
admin() // [!code highlight]
|
||||
]
|
||||
})
|
||||
```
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Migrate your database
|
||||
|
||||
Run the migration command to create the necessary tables in your database.
|
||||
|
||||
```bash title="terminal"
|
||||
npx better-auth migrate
|
||||
```
|
||||
|
||||
Or, you can generate the required tables with:
|
||||
|
||||
```bash title="terminal"
|
||||
npx better-auth generate
|
||||
```
|
||||
|
||||
Refer to the [Schema section](#schema) to see which fields this plugin requires. You can also manually add these fields to your database.
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Add the client plugin
|
||||
|
||||
Next, include the anonymous client plugin in your authentication client instance.
|
||||
|
||||
```ts title="auth-client.ts"
|
||||
import { createAuthClient } from "better-auth/client"
|
||||
import { adminClient } from "better-auth/client/plugins"
|
||||
|
||||
const authClient = createAuthClient({
|
||||
plugins: [
|
||||
adminClient()
|
||||
]
|
||||
})
|
||||
```
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Usage
|
||||
|
||||
Before performing any admin operations, the user must be authenticated with an admin account. An admin is any user assigned the `admin` role. For the first admin user, you'll need to manually assign the `admin` role to their account in your database.
|
||||
|
||||
### Create User
|
||||
|
||||
Allows an admin to create a new user.
|
||||
|
||||
```ts title="admin.ts"
|
||||
const newUser = await authClient.admin.createUser({
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
password: "password123",
|
||||
role: "user"
|
||||
});
|
||||
```
|
||||
|
||||
### List Users
|
||||
|
||||
Allows an admin to list all users in the database.
|
||||
|
||||
```ts title="admin.ts"
|
||||
const users = await authClient.admin.listUsers({
|
||||
query: {
|
||||
limit: 10,
|
||||
}
|
||||
});
|
||||
```
|
||||
By default, 100 users are returned. You can adjust the limit and offset using the following query parameters:
|
||||
|
||||
- `limit`: The number of users to return.
|
||||
- `offset`: The number of users to skip.
|
||||
- `sortBy`: The field to sort the users by.
|
||||
- `sortDirection`: The direction to sort the users by. Defaults to `asc`.
|
||||
|
||||
```ts title="admin.ts"
|
||||
const users = await authClient.admin.listUsers({
|
||||
query: {
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
sortBy: "createdAt",
|
||||
sortDirection: "desc"
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Set User Role
|
||||
|
||||
Changes the role of a user.
|
||||
|
||||
```ts title="admin.ts"
|
||||
const updatedUser = await authClient.admin.setRole({
|
||||
userId: "user_id_here",
|
||||
role: "admin"
|
||||
});
|
||||
```
|
||||
|
||||
### Ban User
|
||||
|
||||
Bans a user, preventing them from signing in and revokes all of their existing sessions.
|
||||
|
||||
```ts title="admin.ts"
|
||||
const bannedUser = await authClient.admin.banUser({
|
||||
userId: "user_id_here"
|
||||
});
|
||||
```
|
||||
|
||||
### Unban User
|
||||
|
||||
Removes the ban from a user, allowing them to sign in again.
|
||||
|
||||
```ts title="admin.ts"
|
||||
const unbannedUser = await authClient.admin.unbanUser({
|
||||
userId: "user_id_here"
|
||||
});
|
||||
```
|
||||
|
||||
### List User Sessions
|
||||
|
||||
Lists all sessions for a user.
|
||||
|
||||
```ts title="admin.ts"
|
||||
const sessions = await authClient.admin.listSessions({
|
||||
userId: "user_id_here"
|
||||
});
|
||||
```
|
||||
|
||||
### Revoke User Session
|
||||
|
||||
Revokes a specific session for a user.
|
||||
|
||||
```ts title="admin.ts"
|
||||
const revokedSessions = await authClient.admin.revokeSession({
|
||||
sessionId: "session_id_here"
|
||||
});
|
||||
```
|
||||
|
||||
### Revoke All Sessions for a User
|
||||
|
||||
Revokes all sessions for a user.
|
||||
|
||||
```ts title="admin.ts"
|
||||
const revokedSessions = await authClient.admin.revokeUserSessions({
|
||||
userId: "user_id_here"
|
||||
});
|
||||
```
|
||||
|
||||
### Impersonate User
|
||||
|
||||
Allows an admin to create a session as if they were the specified user.
|
||||
|
||||
```ts title="admin.ts"
|
||||
const impersonatedSession = await authClient.admin.impersonateUser({
|
||||
userId: "user_id_here"
|
||||
});
|
||||
```
|
||||
|
||||
### Remove User
|
||||
|
||||
Hard deletes a user from the database.
|
||||
|
||||
```ts title="admin.ts"
|
||||
const deletedUser = await authClient.admin.removeUser({
|
||||
userId: "user_id_here"
|
||||
});
|
||||
```
|
||||
|
||||
## Schema
|
||||
|
||||
This plugin adds the following fields to the `user` table:
|
||||
|
||||
<DatabaseTable
|
||||
fields={[
|
||||
{
|
||||
name: "role",
|
||||
type: "string",
|
||||
description: "The user's role. Defaults to `user`. Admins will have the `admin` role.",
|
||||
isOptional: true,
|
||||
},
|
||||
{
|
||||
name: "banned",
|
||||
type: "boolean",
|
||||
description: "Indicates whether the user is banned.",
|
||||
isOptional: true,
|
||||
},
|
||||
{
|
||||
name: "banReason",
|
||||
type: "string",
|
||||
description: "The reason for the user's ban.",
|
||||
isOptional: true,
|
||||
},
|
||||
{
|
||||
name: "banExpires",
|
||||
type: "number",
|
||||
description: "The Unix timestamp when the user's ban will expire.",
|
||||
isOptional: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
And adds one field in the `session` table:
|
||||
|
||||
<DatabaseTable
|
||||
fields={[
|
||||
{
|
||||
name: "impersonatedBy",
|
||||
type: "string",
|
||||
description: "The ID of the admin that is impersonating this session.",
|
||||
isOptional: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq, or, SQL } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, or, SQL } from "drizzle-orm";
|
||||
import type { Adapter, Where } from "../../types";
|
||||
import type { FieldType } from "../../db";
|
||||
import { getAuthTables } from "../../db/get-tables";
|
||||
@@ -133,20 +133,22 @@ export const drizzleAdapter = (
|
||||
else return null;
|
||||
},
|
||||
async findMany(data) {
|
||||
const { model, where } = data;
|
||||
const { model, where, limit, offset, sortBy } = data;
|
||||
|
||||
const schemaModel = getSchema(model, {
|
||||
schema,
|
||||
usePlural: options.usePlural,
|
||||
});
|
||||
const wheres = where ? whereConvertor(where, schemaModel) : [];
|
||||
if (!wheres.length) {
|
||||
return await db.select().from(schemaModel);
|
||||
}
|
||||
const fn = sortBy?.direction === "desc" ? desc : asc;
|
||||
const res = await db
|
||||
.select()
|
||||
.from(schemaModel)
|
||||
.where(...wheres);
|
||||
.limit(limit || 100)
|
||||
.offset(offset || 0)
|
||||
.orderBy(fn(schemaModel[sortBy?.field || "id"]))
|
||||
.where(...(wheres.length ? wheres : []));
|
||||
|
||||
return res;
|
||||
},
|
||||
async update(data) {
|
||||
|
||||
@@ -163,7 +163,7 @@ export const kyselyAdapter = (
|
||||
return (res || null) as any;
|
||||
},
|
||||
async findMany(data) {
|
||||
const { model, where } = data;
|
||||
const { model, where, limit, offset, sortBy } = data;
|
||||
let query = db.selectFrom(model);
|
||||
const { and, or } = convertWhere(where);
|
||||
if (and) {
|
||||
@@ -172,6 +172,13 @@ export const kyselyAdapter = (
|
||||
if (or) {
|
||||
query = query.where((eb) => eb.or(or));
|
||||
}
|
||||
query = query.limit(limit || 100);
|
||||
if (offset) {
|
||||
query = query.offset(offset);
|
||||
}
|
||||
if (sortBy) {
|
||||
query = query.orderBy(sortBy.field, sortBy.direction);
|
||||
}
|
||||
const res = await query.selectAll().execute();
|
||||
if (config?.transform) {
|
||||
const schema = config.transform.schema[model];
|
||||
|
||||
@@ -56,21 +56,6 @@ function selectConvertor(selects: string[]) {
|
||||
return selectConstruct;
|
||||
}
|
||||
|
||||
// interface MongoClient {
|
||||
// collection: (model: string) => {
|
||||
// insertOne: (data: any) => Promise<any>;
|
||||
// find: (
|
||||
// where: any,
|
||||
// select: any,
|
||||
// ) => {
|
||||
// toArray: () => any;
|
||||
// };
|
||||
// findMany: (where: any) => Promise<any>;
|
||||
// findOneAndUpdate: (where: any, update: any, config: any) => Promise<any>;
|
||||
// findOneAndDelete: (where: any) => Promise<any>;
|
||||
// };
|
||||
// }
|
||||
|
||||
export const mongodbAdapter = (mongo: Db) => {
|
||||
const db = mongo;
|
||||
return {
|
||||
@@ -106,13 +91,16 @@ export const mongodbAdapter = (mongo: Db) => {
|
||||
return removeMongoId(result);
|
||||
},
|
||||
async findMany(data) {
|
||||
const { model, where } = data;
|
||||
const { model, where, limit, offset, sortBy } = data;
|
||||
const wheres = whereConvertor(where);
|
||||
const toReturn = await db
|
||||
.collection(model)
|
||||
.find()
|
||||
// @ts-expect-error
|
||||
.filter(wheres)
|
||||
.skip(offset || 0)
|
||||
.limit(limit || 100)
|
||||
.sort(sortBy?.field || "id", sortBy?.direction === "desc" ? -1 : 1)
|
||||
.toArray();
|
||||
return toReturn.map(removeMongoId);
|
||||
},
|
||||
|
||||
@@ -95,10 +95,19 @@ export const prismaAdapter = (
|
||||
});
|
||||
},
|
||||
async findMany(data) {
|
||||
const { model, where } = data;
|
||||
const { model, where, limit, offset, sortBy } = data;
|
||||
const whereClause = whereConvertor(where);
|
||||
|
||||
return await db[model].findMany({ where: whereClause });
|
||||
return await db[model].findMany({
|
||||
where: whereClause,
|
||||
take: limit || 100,
|
||||
skip: offset || 0,
|
||||
orderBy: sortBy?.field
|
||||
? {
|
||||
[sortBy.field]: sortBy.direction === "desc" ? "desc" : "asc",
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
},
|
||||
async update(data) {
|
||||
const { model, where, update } = data;
|
||||
|
||||
@@ -119,7 +119,6 @@ export async function runAdapterTest(opts: AdapterTestOptions) {
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const res = await adapter.findMany({
|
||||
model: "user",
|
||||
where: [
|
||||
@@ -132,6 +131,53 @@ export async function runAdapterTest(opts: AdapterTestOptions) {
|
||||
expect(res.length).toBe(1);
|
||||
});
|
||||
|
||||
test("should find many with sortBy", async () => {
|
||||
await adapter.create({
|
||||
model: "user",
|
||||
data: {
|
||||
name: "a",
|
||||
email: "a@email.com",
|
||||
emailVerified: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
const res = await adapter.findMany<User>({
|
||||
model: "user",
|
||||
sortBy: {
|
||||
field: "name",
|
||||
direction: "asc",
|
||||
},
|
||||
});
|
||||
expect(res[0].name).toBe("a");
|
||||
|
||||
const res2 = await adapter.findMany<User>({
|
||||
model: "user",
|
||||
sortBy: {
|
||||
field: "name",
|
||||
direction: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res2[res2.length - 1].name).toBe("a");
|
||||
});
|
||||
|
||||
test("should find many with limit", async () => {
|
||||
const res = await adapter.findMany({
|
||||
model: "user",
|
||||
limit: 1,
|
||||
});
|
||||
expect(res.length).toBe(1);
|
||||
});
|
||||
|
||||
test("should find many with offset", async () => {
|
||||
const res = await adapter.findMany({
|
||||
model: "user",
|
||||
offset: 2,
|
||||
});
|
||||
expect(res.length).toBe(1);
|
||||
});
|
||||
|
||||
test("delete model", async () => {
|
||||
await adapter.delete({
|
||||
model: "user",
|
||||
|
||||
@@ -151,15 +151,9 @@ export const listSessions = <Option extends BetterAuthOptions>() =>
|
||||
requireHeaders: true,
|
||||
},
|
||||
async (ctx) => {
|
||||
const sessions = await ctx.context.adapter.findMany<Session>({
|
||||
model: ctx.context.tables.session.tableName,
|
||||
where: [
|
||||
{
|
||||
field: "userId",
|
||||
value: ctx.context.session.user.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
const sessions = await ctx.context.internalAdapter.listSessions(
|
||||
ctx.context.session.user.id,
|
||||
);
|
||||
const activeSessions = sessions.filter((session) => {
|
||||
return session.expiresAt > new Date();
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@ import { generateState } from "../../utils/state";
|
||||
import { createAuthEndpoint } from "../call";
|
||||
import { getSessionFromCtx } from "./session";
|
||||
import { setSessionCookie } from "../../cookies";
|
||||
import type { toZod } from "../../types/to-zod";
|
||||
|
||||
export const signInOAuth = createAuthEndpoint(
|
||||
"/sign-in/social",
|
||||
@@ -178,7 +177,9 @@ export const signInEmail = createAuthEndpoint(
|
||||
);
|
||||
if (!session) {
|
||||
ctx.context.logger.error("Failed to create session");
|
||||
throw new APIError("INTERNAL_SERVER_ERROR");
|
||||
throw new APIError("UNAUTHORIZED", {
|
||||
message: "Failed to create session",
|
||||
});
|
||||
}
|
||||
await setSessionCookie(ctx, session.id, ctx.body.dontRememberMe);
|
||||
return ctx.json({
|
||||
|
||||
@@ -28,7 +28,7 @@ describe("updateUser", async () => {
|
||||
headers,
|
||||
},
|
||||
});
|
||||
expect(updated.data?.name).toBe("newName");
|
||||
expect(updated.data?.user.name).toBe("newName");
|
||||
});
|
||||
|
||||
it("should update the user's password", async () => {
|
||||
|
||||
@@ -19,7 +19,9 @@ export const updateUser = createAuthEndpoint(
|
||||
const { name, image } = ctx.body;
|
||||
const session = ctx.context.session;
|
||||
if (!image && !name) {
|
||||
return ctx.json(session.user);
|
||||
return ctx.json({
|
||||
user: session.user,
|
||||
});
|
||||
}
|
||||
const user = await ctx.context.internalAdapter.updateUserByEmail(
|
||||
session.user.email,
|
||||
@@ -28,7 +30,9 @@ export const updateUser = createAuthEndpoint(
|
||||
image,
|
||||
},
|
||||
);
|
||||
return ctx.json(user);
|
||||
return ctx.json({
|
||||
user,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -60,23 +60,20 @@ type InferCtx<C extends Context<any, any>> = C["body"] extends Record<
|
||||
|
||||
type MergeRoutes<T> = UnionToIntersection<T>;
|
||||
|
||||
type InferReturn<R, O extends ClientOptions> = R extends
|
||||
| {
|
||||
user: any;
|
||||
}
|
||||
| {
|
||||
session: any;
|
||||
}
|
||||
| {
|
||||
user: any;
|
||||
session: any;
|
||||
}
|
||||
type InferReturn<R, O extends ClientOptions> = R extends Record<string, any>
|
||||
? StripEmptyObjects<
|
||||
{
|
||||
user: InferUserFromClient<O>;
|
||||
session: InferSessionFromClient<O>;
|
||||
user: R extends { user: any } ? InferUserFromClient<O> : never;
|
||||
users: R extends { users: any[] } ? InferUserFromClient<O>[] : never;
|
||||
session: R extends { session: any } ? InferSessionFromClient<O> : never;
|
||||
sessions: R extends { sessions: any[] }
|
||||
? InferSessionFromClient<O>[]
|
||||
: never;
|
||||
} & {
|
||||
[key in Exclude<keyof R, "user" | "session">]: R[key];
|
||||
[key in Exclude<
|
||||
keyof R,
|
||||
"user" | "users" | "session" | "sessions"
|
||||
>]: R[key];
|
||||
}
|
||||
>
|
||||
: R;
|
||||
|
||||
@@ -7,3 +7,4 @@ export * from "../../plugins/magic-link/client";
|
||||
export * from "../../plugins/phone-number/client";
|
||||
export * from "../../plugins/anonymous/client";
|
||||
export * from "../../plugins/additional-fields/client";
|
||||
export * from "../../plugins/admin/client";
|
||||
|
||||
@@ -88,6 +88,6 @@ describe("db", async () => {
|
||||
headers,
|
||||
},
|
||||
);
|
||||
expect(res2.data?.name).toBe("New Name");
|
||||
expect(res2.data?.user.name).toBe("New Name");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,23 +32,70 @@ export const createInternalAdapter = (
|
||||
return null;
|
||||
}
|
||||
},
|
||||
createUser: async (
|
||||
user: Omit<User, "id" | "emailVerified" | "createdAt" | "updatedAt"> &
|
||||
Record<string, any> &
|
||||
Partial<User>,
|
||||
createUser: async <T>(
|
||||
user: Omit<User, "id" | "createdAt" | "updatedAt" | "emailVerified"> &
|
||||
Partial<User> &
|
||||
Record<string, any>,
|
||||
) => {
|
||||
const createdUser = await createWithHooks(
|
||||
{
|
||||
id: generateId(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
emailVerified: false,
|
||||
...user,
|
||||
},
|
||||
"user",
|
||||
);
|
||||
return createdUser;
|
||||
return createdUser as T & User;
|
||||
},
|
||||
listSessions: async (userId: string) => {
|
||||
const sessions = await adapter.findMany<Session>({
|
||||
model: tables.session.tableName,
|
||||
where: [
|
||||
{
|
||||
field: tables.session.fields.userId.fieldName || "userId",
|
||||
value: userId,
|
||||
},
|
||||
],
|
||||
});
|
||||
return sessions;
|
||||
},
|
||||
listUsers: async (
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
sortBy?: {
|
||||
field: string;
|
||||
direction: "asc" | "desc";
|
||||
},
|
||||
) => {
|
||||
const users = await adapter.findMany<User>({
|
||||
model: tables.user.tableName,
|
||||
limit,
|
||||
offset,
|
||||
sortBy,
|
||||
});
|
||||
return users;
|
||||
},
|
||||
deleteUser: async (userId: string) => {
|
||||
await adapter.delete({
|
||||
model: tables.account.tableName,
|
||||
where: [
|
||||
{
|
||||
field: tables.account.fields.userId.fieldName || "userId",
|
||||
value: userId,
|
||||
},
|
||||
],
|
||||
});
|
||||
await adapter.delete({
|
||||
model: tables.session.tableName,
|
||||
where: [
|
||||
{
|
||||
field: tables.session.fields.userId.fieldName || "userId",
|
||||
value: userId,
|
||||
},
|
||||
],
|
||||
});
|
||||
await adapter.delete<User>({
|
||||
model: tables.user.tableName,
|
||||
where: [
|
||||
@@ -63,11 +110,13 @@ export const createInternalAdapter = (
|
||||
userId: string,
|
||||
request?: Request | Headers,
|
||||
dontRememberMe?: boolean,
|
||||
inputData?: Partial<Session> & Record<string, any>,
|
||||
) => {
|
||||
const headers = request instanceof Request ? request.headers : request;
|
||||
const data: Session = {
|
||||
id: generateId(),
|
||||
userId,
|
||||
...inputData,
|
||||
/**
|
||||
* If the user doesn't want to be remembered
|
||||
* set the session to expire in 1 day.
|
||||
@@ -217,17 +266,19 @@ export const createInternalAdapter = (
|
||||
});
|
||||
return user;
|
||||
},
|
||||
linkAccount: async (account: Omit<Account, "id"> & Record<string, any>) => {
|
||||
linkAccount: async (account: Omit<Account, "id"> & Partial<Account>) => {
|
||||
const _account = await createWithHooks(
|
||||
{
|
||||
...account,
|
||||
id: generateId(),
|
||||
},
|
||||
"account",
|
||||
);
|
||||
return _account;
|
||||
},
|
||||
updateUser: async (userId: string, data: Partial<User>) => {
|
||||
updateUser: async (
|
||||
userId: string,
|
||||
data: Partial<User> & Record<string, any>,
|
||||
) => {
|
||||
const user = await updateWithHooks<User>(
|
||||
data,
|
||||
[
|
||||
|
||||
@@ -29,8 +29,10 @@ export function getWithHooks(
|
||||
if (result === false) {
|
||||
return null;
|
||||
}
|
||||
const isObject = typeof result === "object";
|
||||
actualData = isObject ? (result as any).data : result;
|
||||
const isObject = typeof result === "object" && "data" in result;
|
||||
if (isObject) {
|
||||
actualData = result.data as T;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,9 +14,7 @@ describe("init", async () => {
|
||||
});
|
||||
|
||||
it("should execute plugins init", async () => {
|
||||
let changedCtx = {
|
||||
baseURL: "http://test.test",
|
||||
};
|
||||
const newBaseURL = "http://test.test";
|
||||
const res = await init({
|
||||
database,
|
||||
plugins: [
|
||||
@@ -24,13 +22,15 @@ describe("init", async () => {
|
||||
id: "test",
|
||||
init: () => {
|
||||
return {
|
||||
context: changedCtx,
|
||||
context: {
|
||||
baseURL: newBaseURL,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(res).toMatchObject(changedCtx);
|
||||
expect(res.baseURL).toBe(newBaseURL);
|
||||
});
|
||||
|
||||
it("should work with custom path", async () => {
|
||||
|
||||
@@ -22,14 +22,11 @@ import {
|
||||
import { createLogger, logger } from "./utils/logger";
|
||||
import { oAuthProviderList, oAuthProviders } from "./social-providers";
|
||||
|
||||
export const init = async (opts: BetterAuthOptions) => {
|
||||
/**
|
||||
* Run plugins init to get the actual options
|
||||
*/
|
||||
let { options, context, dbHooks } = runPluginInit(opts);
|
||||
export const init = async (options: BetterAuthOptions) => {
|
||||
const adapter = await getAdapter(options);
|
||||
const plugins = options.plugins || [];
|
||||
const internalPlugins = getInternalPlugins(options);
|
||||
const adapter = await getAdapter(options);
|
||||
|
||||
const { kysely: db } = await createKyselyAdapter(options);
|
||||
const baseURL = getBaseURL(options.baseURL, options.basePath) || "";
|
||||
|
||||
@@ -71,7 +68,7 @@ export const init = async (opts: BetterAuthOptions) => {
|
||||
})
|
||||
.filter((x) => x !== null);
|
||||
|
||||
return {
|
||||
const ctx: AuthContext = {
|
||||
appName: options.appName || "Better Auth",
|
||||
socialProviders,
|
||||
options,
|
||||
@@ -110,11 +107,12 @@ export const init = async (opts: BetterAuthOptions) => {
|
||||
adapter: adapter,
|
||||
internalAdapter: createInternalAdapter(adapter, {
|
||||
options,
|
||||
hooks: dbHooks.filter((u) => u !== undefined),
|
||||
hooks: options.databaseHooks ? [options.databaseHooks] : [],
|
||||
}),
|
||||
createAuthCookie: createCookieGetter(options),
|
||||
...context,
|
||||
};
|
||||
let { context } = runPluginInit(ctx);
|
||||
return context;
|
||||
};
|
||||
|
||||
export type AuthContext = {
|
||||
@@ -151,13 +149,14 @@ export type AuthContext = {
|
||||
tables: ReturnType<typeof getAuthTables>;
|
||||
};
|
||||
|
||||
function runPluginInit(options: BetterAuthOptions) {
|
||||
function runPluginInit(ctx: AuthContext) {
|
||||
let options = ctx.options;
|
||||
const plugins = options.plugins || [];
|
||||
let context: Partial<AuthContext> = {};
|
||||
let context: AuthContext = ctx;
|
||||
const dbHooks: BetterAuthOptions["databaseHooks"][] = [options.databaseHooks];
|
||||
for (const plugin of plugins) {
|
||||
if (plugin.init) {
|
||||
const result = plugin.init(options);
|
||||
const result = plugin.init(ctx);
|
||||
if (typeof result === "object") {
|
||||
if (result.options) {
|
||||
if (result.options.databaseHooks) {
|
||||
@@ -166,16 +165,20 @@ function runPluginInit(options: BetterAuthOptions) {
|
||||
options = defu(options, result.options);
|
||||
}
|
||||
if (result.context) {
|
||||
context = defu(context, result.context);
|
||||
context = {
|
||||
...context,
|
||||
...(result.context as Partial<AuthContext>),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
context.internalAdapter = createInternalAdapter(ctx.adapter, {
|
||||
options,
|
||||
context,
|
||||
dbHooks,
|
||||
};
|
||||
hooks: dbHooks.filter((u) => u !== undefined),
|
||||
});
|
||||
context.options = options;
|
||||
return { context };
|
||||
}
|
||||
|
||||
function getInternalPlugins(options: BetterAuthOptions) {
|
||||
|
||||
250
packages/better-auth/src/plugins/admin/admin.test.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getTestInstance } from "../../test-utils/test-instance";
|
||||
import { admin, type UserWithRole } from ".";
|
||||
import { adminClient } from "./client";
|
||||
|
||||
describe("Admin plugin", async () => {
|
||||
const { client, signInWithTestUser } = await getTestInstance(
|
||||
{
|
||||
plugins: [admin()],
|
||||
logger: {
|
||||
verboseLogging: true,
|
||||
},
|
||||
databaseHooks: {
|
||||
user: {
|
||||
create: {
|
||||
before: async (user) => {
|
||||
if (user.name === "Admin") {
|
||||
return {
|
||||
data: {
|
||||
...user,
|
||||
role: "admin",
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
testUser: {
|
||||
name: "Admin",
|
||||
},
|
||||
clientOptions: {
|
||||
plugins: [adminClient()],
|
||||
},
|
||||
},
|
||||
);
|
||||
const { headers: adminHeaders } = await signInWithTestUser();
|
||||
let newUser: UserWithRole | undefined;
|
||||
|
||||
it("should allow admin to create users", async () => {
|
||||
const res = await client.admin.createUser(
|
||||
{
|
||||
name: "Test User",
|
||||
email: "test2@test.com",
|
||||
password: "test",
|
||||
role: "user",
|
||||
},
|
||||
{
|
||||
headers: adminHeaders,
|
||||
},
|
||||
);
|
||||
newUser = res.data?.user;
|
||||
expect(newUser?.role).toBe("user");
|
||||
});
|
||||
|
||||
it("should allow admin to list users", async () => {
|
||||
const res = await client.admin.listUsers({
|
||||
query: {
|
||||
limit: 2,
|
||||
},
|
||||
fetchOptions: {
|
||||
headers: adminHeaders,
|
||||
},
|
||||
});
|
||||
expect(res.data?.users.length).toBe(2);
|
||||
});
|
||||
|
||||
it("should allow to sort users by name", async () => {
|
||||
const res = await client.admin.listUsers({
|
||||
query: {
|
||||
sortBy: "name",
|
||||
sortDirection: "desc",
|
||||
},
|
||||
fetchOptions: {
|
||||
headers: adminHeaders,
|
||||
},
|
||||
});
|
||||
expect(res.data?.users[0].name).toBe("Test User");
|
||||
|
||||
const res2 = await client.admin.listUsers({
|
||||
query: {
|
||||
sortBy: "name",
|
||||
sortDirection: "asc",
|
||||
},
|
||||
fetchOptions: {
|
||||
headers: adminHeaders,
|
||||
},
|
||||
});
|
||||
expect(res2.data?.users[0].name).toBe("Admin");
|
||||
});
|
||||
|
||||
it("should allow offset and limit", async () => {
|
||||
const res = await client.admin.listUsers({
|
||||
query: {
|
||||
limit: 1,
|
||||
offset: 1,
|
||||
},
|
||||
fetchOptions: {
|
||||
headers: adminHeaders,
|
||||
},
|
||||
});
|
||||
expect(res.data?.users.length).toBe(1);
|
||||
expect(res.data?.users[0].name).toBe("Test User");
|
||||
});
|
||||
|
||||
it("should allow to set user role", async () => {
|
||||
const res = await client.admin.setRole(
|
||||
{
|
||||
userId: newUser?.id || "",
|
||||
role: "admin",
|
||||
},
|
||||
{
|
||||
headers: adminHeaders,
|
||||
},
|
||||
);
|
||||
expect(res.data?.user?.role).toBe("admin");
|
||||
});
|
||||
|
||||
it("should allow to ban user", async () => {
|
||||
const res = await client.admin.banUser(
|
||||
{
|
||||
userId: newUser?.id || "",
|
||||
},
|
||||
{
|
||||
headers: adminHeaders,
|
||||
},
|
||||
);
|
||||
expect(res.data?.user?.banned).toBe(true);
|
||||
});
|
||||
|
||||
it("should allow to ban user with reason and expiration", async () => {
|
||||
const res = await client.admin.banUser(
|
||||
{
|
||||
userId: newUser?.id || "",
|
||||
banReason: "Test reason",
|
||||
banExpiresIn: 60 * 60 * 24,
|
||||
},
|
||||
{
|
||||
headers: adminHeaders,
|
||||
},
|
||||
);
|
||||
expect(res.data?.user?.banned).toBe(true);
|
||||
expect(res.data?.user?.banReason).toBe("Test reason");
|
||||
expect(res.data?.user?.banExpires).toBeDefined();
|
||||
});
|
||||
|
||||
it("should not allow banned user to sign in", async () => {
|
||||
const res = await client.signIn.email({
|
||||
email: newUser?.email || "",
|
||||
password: "test",
|
||||
});
|
||||
expect(res.error?.status).toBe(401);
|
||||
});
|
||||
|
||||
it("should allow banned user to sign in if ban expired", async () => {
|
||||
vi.useFakeTimers();
|
||||
await vi.advanceTimersByTimeAsync(60 * 60 * 24 * 1000);
|
||||
const res = await client.signIn.email({
|
||||
email: newUser?.email || "",
|
||||
password: "test",
|
||||
});
|
||||
expect(res.data?.session).toBeDefined();
|
||||
});
|
||||
|
||||
it("should allow to unban user", async () => {
|
||||
const res = await client.admin.unbanUser(
|
||||
{
|
||||
userId: newUser?.id || "",
|
||||
},
|
||||
{
|
||||
headers: adminHeaders,
|
||||
},
|
||||
);
|
||||
expect(res.data?.user?.banned).toBe(false);
|
||||
});
|
||||
|
||||
it("should allow admin to list user sessions", async () => {
|
||||
const res = await client.admin.listUserSessions(
|
||||
{
|
||||
userId: newUser?.id || "",
|
||||
},
|
||||
{
|
||||
headers: adminHeaders,
|
||||
},
|
||||
);
|
||||
expect(res.data?.sessions.length).toBe(1);
|
||||
});
|
||||
|
||||
it("should allow admins to impersonate user", async () => {
|
||||
const res = await client.admin.impersonateUser(
|
||||
{
|
||||
userId: newUser?.id || "",
|
||||
},
|
||||
{
|
||||
headers: adminHeaders,
|
||||
},
|
||||
);
|
||||
expect(res.data?.session).toBeDefined();
|
||||
expect(res.data?.user?.id).toBe(newUser?.id);
|
||||
});
|
||||
|
||||
it("should allow admin to revoke user session", async () => {
|
||||
const sessions = await client.admin.listUserSessions(
|
||||
{
|
||||
userId: newUser?.id || "",
|
||||
},
|
||||
{
|
||||
headers: adminHeaders,
|
||||
},
|
||||
);
|
||||
expect(sessions.data?.sessions.length).toBe(2);
|
||||
const res = await client.admin.revokeUserSession(
|
||||
{ sessionId: sessions.data?.sessions[0].id || "" },
|
||||
{ headers: adminHeaders },
|
||||
);
|
||||
expect(res.data?.success).toBe(true);
|
||||
const sessions2 = await client.admin.listUserSessions(
|
||||
{ userId: newUser?.id || "" },
|
||||
{ headers: adminHeaders },
|
||||
);
|
||||
expect(sessions2.data?.sessions.length).toBe(1);
|
||||
});
|
||||
|
||||
it("should allow admin to revoke user sessions", async () => {
|
||||
const res = await client.admin.revokeUserSessions(
|
||||
{ userId: newUser?.id || "" },
|
||||
{ headers: adminHeaders },
|
||||
);
|
||||
expect(res.data?.success).toBe(true);
|
||||
const sessions2 = await client.admin.listUserSessions(
|
||||
{ userId: newUser?.id || "" },
|
||||
{ headers: adminHeaders },
|
||||
);
|
||||
expect(sessions2.data?.sessions.length).toBe(0);
|
||||
});
|
||||
|
||||
it("should allow admin to delete user", async () => {
|
||||
const res = await client.admin.removeUser(
|
||||
{
|
||||
userId: newUser?.id || "",
|
||||
},
|
||||
{
|
||||
headers: adminHeaders,
|
||||
},
|
||||
);
|
||||
expect(res.data?.success).toBe(true);
|
||||
});
|
||||
});
|
||||
9
packages/better-auth/src/plugins/admin/client.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { admin } from ".";
|
||||
import type { BetterAuthClientPlugin } from "../../types";
|
||||
|
||||
export const adminClient = () => {
|
||||
return {
|
||||
id: "better-auth-client",
|
||||
$InferServerPlugin: {} as ReturnType<typeof admin>,
|
||||
} satisfies BetterAuthClientPlugin;
|
||||
};
|
||||
406
packages/better-auth/src/plugins/admin/index.ts
Normal file
@@ -0,0 +1,406 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
APIError,
|
||||
createAuthEndpoint,
|
||||
createAuthMiddleware,
|
||||
getSessionFromCtx,
|
||||
} from "../../api";
|
||||
import type { BetterAuthPlugin, Session, User } from "../../types";
|
||||
import { setSessionCookie } from "../../cookies";
|
||||
|
||||
export interface UserWithRole extends User {
|
||||
role?: string;
|
||||
banned?: boolean;
|
||||
banReason?: string;
|
||||
banExpires?: number;
|
||||
}
|
||||
|
||||
interface SessionWithImpersonatedBy extends Session {
|
||||
impersonatedBy?: string;
|
||||
}
|
||||
|
||||
export const adminMiddleware = createAuthMiddleware(async (ctx) => {
|
||||
const session = await getSessionFromCtx(ctx);
|
||||
if (!session?.session) {
|
||||
throw new APIError("UNAUTHORIZED");
|
||||
}
|
||||
const user = session.user as UserWithRole;
|
||||
if (user.role !== "admin") {
|
||||
throw new APIError("FORBIDDEN", {
|
||||
message: "Only admins can access this endpoint",
|
||||
});
|
||||
}
|
||||
return {
|
||||
session: {
|
||||
user: user,
|
||||
session: session.session,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const admin = () => {
|
||||
return {
|
||||
id: "admin",
|
||||
init(ctx) {
|
||||
return {
|
||||
options: {
|
||||
databaseHooks: {
|
||||
session: {
|
||||
create: {
|
||||
async before(session) {
|
||||
const user = (await ctx.internalAdapter.findUserById(
|
||||
session.userId,
|
||||
)) as UserWithRole;
|
||||
if (user.banned) {
|
||||
if (user.banExpires && user.banExpires < Date.now()) {
|
||||
await ctx.internalAdapter.updateUser(session.userId, {
|
||||
banned: false,
|
||||
banReason: null,
|
||||
banExpires: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
hooks: {
|
||||
after: [
|
||||
{
|
||||
matcher(context) {
|
||||
return context.path === "/user/list-sessions";
|
||||
},
|
||||
handler: createAuthMiddleware(async (ctx) => {
|
||||
const returned = ctx.context.returned;
|
||||
if (returned) {
|
||||
const json =
|
||||
(await returned.json()) as SessionWithImpersonatedBy[];
|
||||
const newJson = json.filter((session) => {
|
||||
return !session.impersonatedBy;
|
||||
});
|
||||
const response = new Response(JSON.stringify(newJson), {
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: returned.headers,
|
||||
});
|
||||
return {
|
||||
response: response,
|
||||
};
|
||||
}
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
endpoints: {
|
||||
setRole: createAuthEndpoint(
|
||||
"/admin/set-role",
|
||||
{
|
||||
method: "POST",
|
||||
body: z.object({
|
||||
userId: z.string(),
|
||||
role: z.string(),
|
||||
}),
|
||||
use: [adminMiddleware],
|
||||
},
|
||||
async (ctx) => {
|
||||
const updatedUser = await ctx.context.internalAdapter.updateUser(
|
||||
ctx.body.userId,
|
||||
{
|
||||
role: ctx.body.role,
|
||||
},
|
||||
);
|
||||
return {
|
||||
user: updatedUser as UserWithRole,
|
||||
};
|
||||
},
|
||||
),
|
||||
createUser: createAuthEndpoint(
|
||||
"/admin/create-user",
|
||||
{
|
||||
method: "POST",
|
||||
body: z.object({
|
||||
email: z.string(),
|
||||
password: z.string(),
|
||||
name: z.string(),
|
||||
role: z.enum(["user", "admin"]),
|
||||
/**
|
||||
* extra fields for user
|
||||
*/
|
||||
data: z.optional(z.record(z.any())),
|
||||
}),
|
||||
use: [adminMiddleware],
|
||||
},
|
||||
async (ctx) => {
|
||||
const existUser = await ctx.context.internalAdapter.findUserByEmail(
|
||||
ctx.body.email,
|
||||
);
|
||||
if (existUser) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "User already exists",
|
||||
});
|
||||
}
|
||||
const user =
|
||||
await ctx.context.internalAdapter.createUser<UserWithRole>({
|
||||
email: ctx.body.email,
|
||||
name: ctx.body.name,
|
||||
role: ctx.body.role,
|
||||
...ctx.body.data,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new APIError("INTERNAL_SERVER_ERROR", {
|
||||
message: "Failed to create user",
|
||||
});
|
||||
}
|
||||
const hashedPassword = await ctx.context.password.hash(
|
||||
ctx.body.password,
|
||||
);
|
||||
await ctx.context.internalAdapter.linkAccount({
|
||||
accountId: user.id,
|
||||
providerId: "credential",
|
||||
password: hashedPassword,
|
||||
userId: user.id,
|
||||
});
|
||||
return {
|
||||
user: user as UserWithRole,
|
||||
};
|
||||
},
|
||||
),
|
||||
listUsers: createAuthEndpoint(
|
||||
"/admin/list-users",
|
||||
{
|
||||
method: "GET",
|
||||
use: [adminMiddleware],
|
||||
query: z.object({
|
||||
limit: z.string().or(z.number()).optional(),
|
||||
offset: z.string().or(z.number()).optional(),
|
||||
sortBy: z.string().optional(),
|
||||
sortDirection: z.enum(["asc", "desc"]).optional(),
|
||||
}),
|
||||
},
|
||||
async (ctx) => {
|
||||
const users = await ctx.context.internalAdapter.listUsers(
|
||||
Number(ctx.query?.limit) || undefined,
|
||||
Number(ctx.query?.offset) || undefined,
|
||||
ctx.query?.sortBy
|
||||
? {
|
||||
field: ctx.query.sortBy,
|
||||
direction: ctx.query.sortDirection || "asc",
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
return {
|
||||
users: users as UserWithRole[],
|
||||
};
|
||||
},
|
||||
),
|
||||
listUserSessions: createAuthEndpoint(
|
||||
"/admin/list-user-sessions",
|
||||
{
|
||||
method: "POST",
|
||||
use: [adminMiddleware],
|
||||
body: z.object({
|
||||
userId: z.string(),
|
||||
}),
|
||||
},
|
||||
async (ctx) => {
|
||||
const sessions = await ctx.context.internalAdapter.listSessions(
|
||||
ctx.body.userId,
|
||||
);
|
||||
return {
|
||||
sessions: sessions,
|
||||
};
|
||||
},
|
||||
),
|
||||
unbanUser: createAuthEndpoint(
|
||||
"/admin/unban-user",
|
||||
{
|
||||
method: "POST",
|
||||
body: z.object({
|
||||
userId: z.string(),
|
||||
}),
|
||||
use: [adminMiddleware],
|
||||
},
|
||||
async (ctx) => {
|
||||
const user = await ctx.context.internalAdapter.updateUser(
|
||||
ctx.body.userId,
|
||||
{
|
||||
banned: false,
|
||||
},
|
||||
);
|
||||
return {
|
||||
user: user,
|
||||
};
|
||||
},
|
||||
),
|
||||
banUser: createAuthEndpoint(
|
||||
"/admin/ban-user",
|
||||
{
|
||||
method: "POST",
|
||||
body: z.object({
|
||||
userId: z.string(),
|
||||
/**
|
||||
* Reason for the ban
|
||||
*/
|
||||
banReason: z.string().optional(),
|
||||
/**
|
||||
* Number of seconds until the ban expires
|
||||
*/
|
||||
banExpiresIn: z.number().optional(),
|
||||
}),
|
||||
use: [adminMiddleware],
|
||||
},
|
||||
async (ctx) => {
|
||||
if (ctx.body.userId === ctx.context.session.user.id) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "You cannot ban yourself",
|
||||
});
|
||||
}
|
||||
const user = await ctx.context.internalAdapter.updateUser(
|
||||
ctx.body.userId,
|
||||
{
|
||||
banned: true,
|
||||
banReason: ctx.body.banReason,
|
||||
banExpires: ctx.body.banExpiresIn
|
||||
? Date.now() + ctx.body.banExpiresIn * 1000
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
//revoke all sessions
|
||||
await ctx.context.internalAdapter.deleteSessions(ctx.body.userId);
|
||||
return {
|
||||
user: user,
|
||||
};
|
||||
},
|
||||
),
|
||||
impersonateUser: createAuthEndpoint(
|
||||
"/admin/impersonate-user",
|
||||
{
|
||||
method: "POST",
|
||||
body: z.object({
|
||||
userId: z.string(),
|
||||
}),
|
||||
use: [adminMiddleware],
|
||||
},
|
||||
async (ctx) => {
|
||||
const targetUser = await ctx.context.internalAdapter.findUserById(
|
||||
ctx.body.userId,
|
||||
);
|
||||
|
||||
if (!targetUser) {
|
||||
throw new APIError("NOT_FOUND", {
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
const session = await ctx.context.internalAdapter.createSession(
|
||||
targetUser.id,
|
||||
undefined,
|
||||
true,
|
||||
{
|
||||
impersonatedBy: ctx.context.session.user.id,
|
||||
},
|
||||
);
|
||||
if (!session) {
|
||||
throw new APIError("INTERNAL_SERVER_ERROR", {
|
||||
message: "Failed to create session",
|
||||
});
|
||||
}
|
||||
await setSessionCookie(ctx, session.id, true);
|
||||
return {
|
||||
session: session,
|
||||
user: targetUser,
|
||||
};
|
||||
},
|
||||
),
|
||||
revokeUserSession: createAuthEndpoint(
|
||||
"/admin/revoke-user-session",
|
||||
{
|
||||
method: "POST",
|
||||
body: z.object({
|
||||
sessionId: z.string(),
|
||||
}),
|
||||
use: [adminMiddleware],
|
||||
},
|
||||
async (ctx) => {
|
||||
await ctx.context.internalAdapter.deleteSession(ctx.body.sessionId);
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
),
|
||||
revokeUserSessions: createAuthEndpoint(
|
||||
"/admin/revoke-user-sessions",
|
||||
{
|
||||
method: "POST",
|
||||
body: z.object({
|
||||
userId: z.string(),
|
||||
}),
|
||||
use: [adminMiddleware],
|
||||
},
|
||||
async (ctx) => {
|
||||
await ctx.context.internalAdapter.deleteSessions(ctx.body.userId);
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
),
|
||||
removeUser: createAuthEndpoint(
|
||||
"/admin/remove-user",
|
||||
{
|
||||
method: "POST",
|
||||
body: z.object({
|
||||
userId: z.string(),
|
||||
}),
|
||||
use: [adminMiddleware],
|
||||
},
|
||||
async (ctx) => {
|
||||
await ctx.context.internalAdapter.deleteUser(ctx.body.userId);
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
),
|
||||
},
|
||||
schema: {
|
||||
user: {
|
||||
fields: {
|
||||
role: {
|
||||
type: "string",
|
||||
required: false,
|
||||
},
|
||||
banned: {
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
required: false,
|
||||
},
|
||||
banReason: {
|
||||
type: "string",
|
||||
required: false,
|
||||
},
|
||||
banExpires: {
|
||||
type: "number",
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
session: {
|
||||
fields: {
|
||||
impersonatedBy: {
|
||||
type: "string",
|
||||
required: false,
|
||||
references: {
|
||||
model: "user",
|
||||
field: "id",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies BetterAuthPlugin;
|
||||
};
|
||||
@@ -9,3 +9,4 @@ export * from "../utils/hide-metadata";
|
||||
export * from "./magic-link";
|
||||
export * from "./phone-number";
|
||||
export * from "./anonymous";
|
||||
export * from "./admin";
|
||||
|
||||
@@ -3,18 +3,23 @@ import { alphabet, generateRandomString } from "../crypto/random";
|
||||
import { afterAll } from "vitest";
|
||||
import { betterAuth } from "../auth";
|
||||
import { createAuthClient } from "../client/vanilla";
|
||||
import type { BetterAuthOptions } from "../types";
|
||||
import type { BetterAuthOptions, ClientOptions, User } from "../types";
|
||||
import { getMigrations } from "../cli/utils/get-migration";
|
||||
import { parseSetCookieHeader } from "../cookies";
|
||||
import type { SuccessContext } from "@better-fetch/fetch";
|
||||
import { getAdapter } from "../db/utils";
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
export async function getTestInstance<O extends Partial<BetterAuthOptions>>(
|
||||
export async function getTestInstance<
|
||||
O extends Partial<BetterAuthOptions>,
|
||||
C extends ClientOptions,
|
||||
>(
|
||||
options?: O,
|
||||
config?: {
|
||||
clientOptions?: C;
|
||||
port?: number;
|
||||
disableTestUser?: boolean;
|
||||
testUser?: Partial<User>;
|
||||
},
|
||||
) {
|
||||
/**
|
||||
@@ -50,13 +55,14 @@ export async function getTestInstance<O extends Partial<BetterAuthOptions>>(
|
||||
email: "test@test.com",
|
||||
password: "test123456",
|
||||
name: "test",
|
||||
...config?.testUser,
|
||||
};
|
||||
async function createTestUser() {
|
||||
if (config?.disableTestUser) {
|
||||
return;
|
||||
}
|
||||
//@ts-expect-error
|
||||
const res = await auth.api.signUpEmail({
|
||||
await auth.api.signUpEmail({
|
||||
body: testUser,
|
||||
});
|
||||
}
|
||||
@@ -81,10 +87,13 @@ export async function getTestInstance<O extends Partial<BetterAuthOptions>>(
|
||||
const current = headers.get("cookie");
|
||||
headers.set("cookie", `${current || ""}; ${name}=${value}`);
|
||||
};
|
||||
//@ts-expect-error
|
||||
const res = await client.signIn.email({
|
||||
email: testUser.email,
|
||||
password: testUser.password,
|
||||
|
||||
fetchOptions: {
|
||||
//@ts-expect-error
|
||||
onSuccess(context) {
|
||||
const header = context.response.headers.get("set-cookie");
|
||||
const cookies = parseSetCookieHeader(header || "");
|
||||
@@ -101,10 +110,12 @@ export async function getTestInstance<O extends Partial<BetterAuthOptions>>(
|
||||
}
|
||||
async function signInWithUser(email: string, password: string) {
|
||||
let headers = new Headers();
|
||||
//@ts-expect-error
|
||||
const res = await client.signIn.email({
|
||||
email,
|
||||
password,
|
||||
fetchOptions: {
|
||||
//@ts-expect-error
|
||||
onSuccess(context) {
|
||||
const header = context.response.headers.get("set-cookie");
|
||||
const cookies = parseSetCookieHeader(header || "");
|
||||
@@ -139,6 +150,7 @@ export async function getTestInstance<O extends Partial<BetterAuthOptions>>(
|
||||
}
|
||||
|
||||
const client = createAuthClient({
|
||||
...(config?.clientOptions as C extends undefined ? {} : C),
|
||||
baseURL:
|
||||
options?.baseURL ||
|
||||
"http://localhost:" + (config?.port || 3000) + "/api/auth",
|
||||
|
||||
@@ -28,6 +28,12 @@ export interface Adapter {
|
||||
findMany: <T>(data: {
|
||||
model: string;
|
||||
where?: Where[];
|
||||
limit?: number;
|
||||
sortBy?: {
|
||||
field: string;
|
||||
direction: "asc" | "desc";
|
||||
};
|
||||
offset?: number;
|
||||
}) => Promise<T[]>;
|
||||
update: <T>(data: {
|
||||
model: string;
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { AuthEndpoint } from "../api/call";
|
||||
import type { FieldAttribute } from "../db/field";
|
||||
import type { HookEndpointContext } from "./context";
|
||||
import type { DeepPartial, LiteralString } from "./helper";
|
||||
import type { AuthContext, BetterAuthOptions } from ".";
|
||||
import type { Adapter, AuthContext, BetterAuthOptions } from ".";
|
||||
|
||||
export type PluginSchema = {
|
||||
[table: string]: {
|
||||
@@ -21,9 +21,9 @@ export type BetterAuthPlugin = {
|
||||
* The init function is called when the plugin is initialized.
|
||||
* You can return a new context or modify the existing context.
|
||||
*/
|
||||
init?: (options: BetterAuthOptions) => {
|
||||
init?: (ctx: AuthContext) => {
|
||||
context?: DeepPartial<Omit<AuthContext, "options">>;
|
||||
options?: BetterAuthOptions;
|
||||
options?: Partial<BetterAuthOptions>;
|
||||
} | void;
|
||||
endpoints?: {
|
||||
[key: string]: AuthEndpoint;
|
||||
|
||||