feat: admin plugin (#117)

This commit is contained in:
Bereket Engida
2024-10-11 21:16:21 +03:00
committed by GitHub
parent 1b8d9d70d2
commit 33223fd575
43 changed files with 1635 additions and 99 deletions

View 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>
);
}

View File

@@ -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">

View File

@@ -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) {

View File

@@ -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: {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 369 B

After

Width:  |  Height:  |  Size: 186 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 664 B

After

Width:  |  Height:  |  Size: 243 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View 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"
}

View File

@@ -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>

View File

@@ -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,

View 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,
},
]}
/>

View File

@@ -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) {

View File

@@ -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];

View File

@@ -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);
},

View File

@@ -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;

View File

@@ -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",

View File

@@ -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();
});

View File

@@ -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({

View File

@@ -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 () => {

View File

@@ -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,
});
},
);

View File

@@ -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;

View File

@@ -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";

View File

@@ -88,6 +88,6 @@ describe("db", async () => {
headers,
},
);
expect(res2.data?.name).toBe("New Name");
expect(res2.data?.user.name).toBe("New Name");
});
});

View File

@@ -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,
[

View File

@@ -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;
}
}
}

View File

@@ -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 () => {

View File

@@ -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) {

View 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);
});
});

View 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;
};

View 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;
};

View File

@@ -9,3 +9,4 @@ export * from "../utils/hide-metadata";
export * from "./magic-link";
export * from "./phone-number";
export * from "./anonymous";
export * from "./admin";

View File

@@ -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",

View File

@@ -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;

View File

@@ -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;