feat: multi session plugin (#204)

This commit is contained in:
Bereket Engida
2024-10-18 17:27:37 +03:00
committed by GitHub
parent 39a890d583
commit 05fd1e072d
16 changed files with 778 additions and 39 deletions

View File

@@ -3,21 +3,28 @@ import { headers } from "next/headers";
import { redirect } from "next/navigation";
import UserCard from "./user-card";
import { OrganizationCard } from "./organization-card";
import AccountSwitcher from "@/components/account-swtich";
export default async function DashboardPage() {
const [session, activeSessions] = await Promise.all([
const [session, activeSessions, deviceSessions] = await Promise.all([
auth.api.getSession({
headers: await headers(),
}),
auth.api.listSessions({
headers: await headers(),
}),
auth.api.listDeviceSessions({
headers: await headers(),
}),
]).catch((e) => {
throw redirect("/sign-in");
});
return (
<div className="w-full">
<div className="flex gap-4 flex-col">
<AccountSwitcher
sessions={JSON.parse(JSON.stringify(deviceSessions))}
/>
<UserCard
session={JSON.parse(JSON.stringify(session))}
activeSessions={JSON.parse(JSON.stringify(activeSessions))}

View File

@@ -3,6 +3,7 @@
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
@@ -24,7 +25,7 @@
--border: 20 5.9% 90%;
--input: 20 5.9% 90%;
--ring: 20 14.3% 4.1%;
--radius: 0.3rem;
--radius: 0rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;

View File

@@ -0,0 +1,141 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandGroup,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import { ChevronDown, LogOutIcon, PlusCircle } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { Session } from "@/lib/auth-types";
import { client, useSession } from "@/lib/auth-client";
import { useRouter } from "next/navigation";
export default function AccountSwitcher({
sessions,
}: {
sessions: Session[];
}) {
const { data: users } = useQuery({
queryKey: ["users"],
queryFn: async () => {
return;
},
});
const { data: currentUser } = useSession();
const [open, setOpen] = useState(false);
const handleUserSelect = (user: Session) => {
// setCurrentUser(user);
setOpen(false);
};
const handleAddAccount = () => {
// Implement add account logic here
console.log("Add account clicked");
setOpen(false);
};
const router = useRouter();
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
aria-label="Select a user"
className="w-[250px] justify-between"
>
<Avatar className="mr-2 h-6 w-6">
<AvatarImage
src={currentUser?.user.image}
alt={currentUser?.user.name}
/>
<AvatarFallback>{currentUser?.user.name.charAt(0)}</AvatarFallback>
</Avatar>
{currentUser?.user.name}
<ChevronDown className="ml-auto h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[250px] p-0">
<Command>
<CommandList>
<CommandGroup heading="Current Account">
<CommandItem
onSelect={() => {}}
className="text-sm w-full justify-between"
key={currentUser?.user.id}
>
<div className="flex items-center">
<Avatar className="mr-2 h-5 w-5">
<AvatarImage
src={currentUser?.user.image}
alt={currentUser?.user.name}
/>
<AvatarFallback>
{currentUser?.user.name.charAt(0)}
</AvatarFallback>
</Avatar>
{currentUser?.user.name}
</div>
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Switch Account">
{sessions
.filter((s) => s.user.id !== currentUser?.user.id)
.map((u, i) => (
<CommandItem
key={i}
onSelect={async () => {
await client.multiSession.setActive({
sessionId: u.session.id,
});
setOpen(false);
}}
className="text-sm"
>
<Avatar className="mr-2 h-5 w-5">
<AvatarImage src={u.user.image} alt={u.user.name} />
<AvatarFallback>{u.user.name.charAt(0)}</AvatarFallback>
</Avatar>
<div className="flex items-center justify-between w-full">
<div>
<p>{u.user.name}</p>
<p className="text-xs">({u.user.email})</p>
</div>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
<CommandSeparator />
<CommandList>
<CommandGroup>
<CommandItem
onSelect={() => {
router.push("/sign-in");
setOpen(false);
}}
className="cursor-pointer text-sm"
>
<PlusCircle className="mr-2 h-5 w-5" />
Add Account
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -16,10 +16,12 @@ const PasswordInput = React.forwardRef<HTMLInputElement, InputProps>(
return (
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
{...props}
type="text"
autoComplete="new-password"
name="password_fake"
className={cn("hide-password-toggle pr-10", className)}
ref={ref}
{...props}
/>
<Button
type="button"
@@ -29,7 +31,7 @@ const PasswordInput = React.forwardRef<HTMLInputElement, InputProps>(
onClick={() => setShowPassword((prev) => !prev)}
disabled={disabled}
>
{showPassword && !disabled ? (
{!showPassword && !disabled ? (
<EyeIcon className="h-4 w-4" aria-hidden="true" />
) : (
<EyeOffIcon className="h-4 w-4" aria-hidden="true" />

View File

@@ -4,6 +4,7 @@ import {
passkeyClient,
twoFactorClient,
adminClient,
multiSessionClient,
} from "better-auth/client/plugins";
import { toast } from "sonner";
@@ -16,6 +17,7 @@ export const client = createAuthClient({
}),
passkeyClient(),
adminClient(),
multiSessionClient(),
],
fetchOptions: {
onError(e) {

View File

@@ -5,11 +5,13 @@ import {
passkey,
twoFactor,
admin,
multiSession,
} from "better-auth/plugins";
import { reactInvitationEmail } from "./email/invitation";
import { LibsqlDialect } from "@libsql/kysely-libsql";
import { reactResetPasswordEmail } from "./email/rest-password";
import { resend } from "./email/resend";
import Database from "better-sqlite3";
const from = process.env.BETTER_AUTH_EMAIL || "delivered@resend.dev";
const to = process.env.TEST_EMAIL || "";
@@ -19,6 +21,8 @@ const libsql = new LibsqlDialect({
authToken: process.env.TURSO_AUTH_TOKEN || "",
});
const database = new Database("auth.db");
export const auth = betterAuth({
database: {
dialect: libsql,
@@ -51,6 +55,28 @@ export const auth = betterAuth({
});
},
},
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID || "",
clientSecret: process.env.GITHUB_CLIENT_SECRET || "",
},
google: {
clientId: process.env.GOOGLE_CLIENT_ID || "",
clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
},
discord: {
clientId: process.env.DISCORD_CLIENT_ID || "",
clientSecret: process.env.DISCORD_CLIENT_SECRET || "",
},
microsoft: {
clientId: process.env.MICROSOFT_CLIENT_ID || "",
clientSecret: process.env.MICROSOFT_CLIENT_SECRET || "",
},
twitch: {
clientId: process.env.TWITCH_CLIENT_ID || "",
clientSecret: process.env.TWITCH_CLIENT_SECRET || "",
},
},
plugins: [
organization({
async sendInvitationEmail(data) {
@@ -91,27 +117,6 @@ export const auth = betterAuth({
passkey(),
bearer(),
admin(),
multiSession(),
],
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID || "",
clientSecret: process.env.GITHUB_CLIENT_SECRET || "",
},
google: {
clientId: process.env.GOOGLE_CLIENT_ID || "",
clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
},
discord: {
clientId: process.env.DISCORD_CLIENT_ID || "",
clientSecret: process.env.DISCORD_CLIENT_SECRET || "",
},
microsoft: {
clientId: process.env.MICROSOFT_CLIENT_ID || "",
clientSecret: process.env.MICROSOFT_CLIENT_SECRET || "",
},
twitch: {
clientId: process.env.TWITCH_CLIENT_ID || "",
clientSecret: process.env.TWITCH_CLIENT_SECRET || "",
},
},
});

View File

@@ -5,9 +5,7 @@ import { auth } from "@/lib/auth";
export default authMiddleware({
customRedirect: async (session, request) => {
const baseURL = request.nextUrl.origin;
if (request.nextUrl.pathname === "/sign-in" && session) {
return NextResponse.redirect(new URL("/dashboard", baseURL));
}
if (request.nextUrl.pathname === "/dashboard" && !session) {
return NextResponse.redirect(new URL("/sign-in", baseURL));
}

View File

@@ -727,6 +727,23 @@ export const contents: Content[] = [
icon: () => <Key className="w-4 h-4" />,
href: "/docs/plugins/bearer",
},
{
title: "Multi Session",
icon: () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1.2em"
height="1.2em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M16.885 13.616q-.82 0-1.41-.591t-.59-1.41t.59-1.41t1.41-.59q.819 0 1.41.59q.59.591.59 1.41q0 .82-.59 1.41q-.591.59-1.41.59m-4.5 5v-.9q0-.465.232-.843q.232-.379.66-.545q.845-.356 1.748-.534q.904-.177 1.86-.177q.916 0 1.821.177q.905.178 1.786.534q.428.166.66.545q.232.378.232.844v.9zm-2.77-7.23q-1.237 0-2.118-.882t-.881-2.118t.88-2.12t2.12-.88t2.118.88t.882 2.12t-.882 2.118t-2.118.882m-7 7.23V16.97q0-.648.357-1.192q.358-.544.973-.804q1.327-.673 2.756-1.015t2.914-.342q.605 0 1.211.063t1.212.167l-.427.446l-.427.447q-.393-.077-.785-.1t-.784-.023q-1.354 0-2.675.292t-2.518.942q-.327.183-.567.456t-.24.663v.646h6v1zm7-8.23q.825 0 1.412-.588t.588-1.412t-.588-1.413t-1.412-.587t-1.413.587t-.587 1.413t.587 1.412t1.413.588"
></path>
</svg>
),
href: "/docs/plugins/multi-session",
},
{
title: "JWT",
icon: () => (

View File

@@ -0,0 +1,106 @@
---
title: Multi Session
description: Learn how to use multi-session plugin in Better Auth.
---
The multi-session plugin allows users to maintain multiple active sessions across different accounts in the same browser. This plugin is useful for applications that require users to switch between multiple accounts without logging out.
## Installation
<Steps>
<Step>
### Add the plugin to your **auth** config
```ts title="auth.ts"
import { betterAuth } from "better-auth"
import { multiSession } from "better-auth/plugins"
export const auth = betterAuth({
plugins: [ // [!code highlight]
multiSession(), // [!code highlight]
] // [!code highlight]
})
```
</Step>
<Step>
### Add the client Plugin
Add the client plugin and Specify where the user should be redirected if they need to verify 2nd factor
```ts title="client.ts"
import { createAuthClient } from "better-auth/client"
import { multiSessionClient } from "better-auth/client/plugins"
const client = createAuthClient({
plugins: [
multiSessionClient()
]
})
```
</Step>
</Steps>
## Usage
Whenver a user logs in, the plugin will add additional cookie to the browser. This cookie will be used to maintain multiple sessions across different accounts.
### List all device sessions
To list all active sessions for the current user, you can call the `listDeviceSessions` method.
```ts
await authClient.multiSession.listDeviceSessions()
```
on the server you can call `listDeviceSessions` method.
```ts
await auth.api.listDeviceSessions()
```
### Set active session
To set the active session, you can call the `setActive` method.
```ts
await authClient.multiSession.setActive({
sessionId: "session-id"
})
```
### Revoke a session
To revoke a session, you can call the `revoke` method.
```ts
await authClient.multiSession.revoke({
sessionId: "session-id"
})
```
### Revoke all sessions
To revoke all sessions, you can call the existing `signOut` method.
```ts
await authClient.signOut()
```
## Options
### Max Sessions
You can specify the maximum number of sessions a user can have by passing the `maximumSessions` option to the plugin. By default, the plugin allows 5 sessions per device.
```ts title="auth.ts"
import { betterAuth } from "better-auth"
export const auth = betterAuth({
plugins: [
multiSession({
maximumSessions: 3
})
]
})
```

View File

@@ -1,4 +1,4 @@
import { APIError, type Endpoint, createRouter } from "better-call";
import { APIError, type Endpoint, createRouter, statusCode } from "better-call";
import type { AuthContext } from "../init";
import type { BetterAuthOptions } from "../types";
import type { UnionToIntersection } from "../types/helper";
@@ -140,15 +140,46 @@ export function getEndpoints<
}
}
}
let endpointRes: any;
try {
//@ts-ignore
const endpointRes = await value({
endpointRes = await value({
...context,
context: {
...c,
...context.context,
},
});
} catch (e) {
if (e instanceof APIError) {
let response = new Response(JSON.stringify(e.body), {
status: statusCode[e.status],
headers: e.headers,
});
for (const plugin of options.plugins || []) {
if (plugin.hooks?.after) {
for (const hook of plugin.hooks.after) {
const match = hook.matcher(context);
if (match) {
const obj = Object.assign(context, {
context: {
...ctx,
returned: response,
},
});
const hookRes = await hook.handler(obj);
if (hookRes && "response" in hookRes) {
response = hookRes.response as any;
}
}
}
}
}
return response;
}
throw e;
}
let response = endpointRes;
for (const plugin of options.plugins || []) {
if (plugin.hooks?.after) {

View File

@@ -9,3 +9,4 @@ export * from "../../plugins/anonymous/client";
export * from "../../plugins/additional-fields/client";
export * from "../../plugins/admin/client";
export * from "../../plugins/generic-oauth/client";
export * from "../../plugins/multi-session/client";

View File

@@ -207,5 +207,15 @@ export function parseSetCookieHeader(
return cookieMap;
}
export function parseCookies(cookieHeader: string) {
const cookies = cookieHeader.split("; ");
const cookieMap = new Map<string, string>();
cookies.forEach((cookie) => {
const [name, value] = cookie.split("=");
cookieMap.set(name, value);
});
return cookieMap;
}
export type EligibleCookies = (string & {}) | (keyof BetterAuthCookies & {});

View File

@@ -12,3 +12,4 @@ export * from "./anonymous";
export * from "./admin";
export * from "./generic-oauth";
export * from "./jwt";
export * from "./multi-session";

View File

@@ -0,0 +1,20 @@
import type { multiSession } from ".";
import type { BetterAuthClientPlugin } from "../../types";
export const multiSessionClient = () => {
return {
id: "multi-session",
$InferServerPlugin: {} as ReturnType<typeof multiSession>,
pathMethods: {
"/multi-session/sign-out-device-sessions": "POST",
},
atomListeners: [
{
matcher(path) {
return path === "/multi-session/set-active";
},
signal: "_sessionSignal",
},
],
} satisfies BetterAuthClientPlugin;
};

View File

@@ -0,0 +1,257 @@
import { z } from "zod";
import {
APIError,
createAuthEndpoint,
createAuthMiddleware,
sessionMiddleware,
} from "../../api";
import { parseCookies, parseSetCookieHeader } from "../../cookies";
import type { BetterAuthPlugin, Session, User } from "../../types";
interface MultiSessionConfig {
/**
* The maximum number of sessions a user can have
* at a time
* @default 5
*/
maximumSessions?: number;
}
export const multiSession = (options?: MultiSessionConfig) => {
const opts = {
maximumSessions: 5,
...options,
};
const isMultiSessionCookie = (key: string) => key.includes("_multi-");
return {
id: "multi-session",
endpoints: {
listDeviceSessions: createAuthEndpoint(
"/multi-session/list-device-sessions",
{
method: "GET",
requireHeaders: true,
},
async (ctx) => {
const cookieHeader = ctx.headers?.get("cookie");
if (!cookieHeader) return ctx.json([]);
const cookies = Object.fromEntries(parseCookies(cookieHeader));
const sessions: {
session: Session;
user: User;
}[] = [];
const sessionPromises = Object.entries(cookies)
.filter(([key]) => isMultiSessionCookie(key))
.map(async ([key]) => {
const sessionId = await ctx.getSignedCookie(
key,
ctx.context.secret,
);
if (!sessionId) return null;
const session =
await ctx.context.internalAdapter.findSession(sessionId);
if (!session || session.session.expiresAt <= new Date()) {
ctx.setCookie(key, "", {
...ctx.context.authCookies.sessionToken.options,
maxAge: 0,
});
return null;
}
return session;
});
const validSessions = (await Promise.all(sessionPromises)).filter(
Boolean,
) as {
session: Session;
user: User;
}[];
sessions.push(
...validSessions.filter(
(session, index, self) =>
index === self.findIndex((s) => s.user.id === session.user.id),
),
);
return ctx.json(sessions);
},
),
setActiveSession: createAuthEndpoint(
"/multi-session/set-active",
{
method: "POST",
body: z.object({
sessionId: z.string(),
}),
requireHeaders: true,
use: [sessionMiddleware],
},
async (ctx) => {
const sessionId = ctx.body.sessionId;
const multiSessionCookieName = `${ctx.context.authCookies.sessionToken.name}_multi-${sessionId}`;
const sessionCookie = await ctx.getSignedCookie(
multiSessionCookieName,
ctx.context.secret,
);
if (!sessionCookie) {
throw new APIError("UNAUTHORIZED", {
message: "Invalid session id",
});
}
const session =
await ctx.context.internalAdapter.findSession(sessionId);
if (!session || session.session.expiresAt < new Date()) {
ctx.setCookie(multiSessionCookieName, "", {
...ctx.context.authCookies.sessionToken.options,
maxAge: 0,
});
throw new APIError("UNAUTHORIZED", {
message: "Invalid session id",
});
}
await ctx.setSignedCookie(
ctx.context.authCookies.sessionToken.name,
sessionId,
ctx.context.secret,
ctx.context.authCookies.sessionToken.options,
);
return ctx.json(session);
},
),
DeviceSession: createAuthEndpoint(
"/multi-session/revoke",
{
method: "POST",
body: z.object({
sessionId: z.string(),
}),
requireHeaders: true,
use: [sessionMiddleware],
},
async (ctx) => {
const sessionId = ctx.body.sessionId;
const multiSessionCookieName = `${ctx.context.authCookies.sessionToken.name}_multi-${sessionId}`;
const sessionCookie = await ctx.getSignedCookie(
multiSessionCookieName,
ctx.context.secret,
);
if (!sessionCookie) {
throw new APIError("UNAUTHORIZED", {
message: "Invalid session id",
});
}
const session =
await ctx.context.internalAdapter.findSession(sessionId);
if (!session || session.session.expiresAt < new Date()) {
ctx.setCookie(multiSessionCookieName, "", {
...ctx.context.authCookies.sessionToken.options,
maxAge: 0,
});
return ctx.json({
success: true,
});
}
await ctx.context.internalAdapter.deleteSession(sessionId);
ctx.setCookie(multiSessionCookieName, "", {
...ctx.context.authCookies.sessionToken.options,
maxAge: 0,
});
return ctx.json({
success: true,
});
},
),
},
hooks: {
after: [
{
matcher: () => true,
handler: createAuthMiddleware(async (ctx) => {
if (
!ctx.context.returned ||
!(ctx.context.returned instanceof Response)
)
return;
const cookieString = ctx.context.returned.headers.get("set-cookie");
if (!cookieString) return;
const setCookies = parseSetCookieHeader(cookieString);
const sessionCookieConfig = ctx.context.authCookies.sessionToken;
const sessionToken = setCookies.get(
sessionCookieConfig.name,
)?.value;
if (!sessionToken) return;
const cookies = parseCookies(ctx.headers?.get("cookie") || "");
const rawSession = sessionToken.split(".")[0];
const cookieName = `${sessionCookieConfig.name}_multi-${rawSession}`;
if (setCookies.get(cookieName) || cookies.get(cookieName)) return;
const currentMultiSessions =
Object.keys(cookies).filter(isMultiSessionCookie).length;
const toBeAdded = Object.keys(setCookies).filter((key) =>
key.includes("session_token"),
).length;
if (currentMultiSessions + toBeAdded > opts.maximumSessions) {
throw new APIError("UNAUTHORIZED", {
message: "Maximum number of device sessions reached.",
});
}
await ctx.setSignedCookie(
cookieName,
rawSession,
ctx.context.secret,
sessionCookieConfig.options,
);
const response = ctx.context.returned;
response.headers.append(
"Set-Cookie",
ctx.responseHeader.get("set-cookie")!,
);
return { response };
}),
},
{
matcher: (context) => context.path === "/sign-out",
handler: createAuthMiddleware(async (ctx) => {
const cookieHeader = ctx.headers?.get("cookie");
if (!cookieHeader) return;
const cookies = Object.fromEntries(parseCookies(cookieHeader));
await Promise.all(
Object.entries(cookies).map(async ([key, value]) => {
if (isMultiSessionCookie(key)) {
ctx.setCookie(key, "", { maxAge: 0 });
await ctx.context.internalAdapter.deleteSession(
key.split("_multi-")[1],
);
}
}),
);
const response = ctx.context.returned;
response?.headers.append(
"Set-Cookie",
ctx.responseHeader.get("set-cookie")!,
);
return { response };
}),
},
],
},
} satisfies BetterAuthPlugin;
};

View File

@@ -0,0 +1,140 @@
import { describe, expect, it } from "vitest";
import { getTestInstance } from "../../test-utils/test-instance";
import { multiSession } from ".";
import { multiSessionClient } from "./client";
import { parseSetCookieHeader } from "../../cookies";
describe("multi-session", async () => {
const { auth, client, signInWithTestUser, testUser } = await getTestInstance(
{
plugins: [
multiSession({
maximumSessions: 2,
}),
],
},
{
clientOptions: {
plugins: [multiSessionClient()],
},
},
);
let headers = new Headers();
const testUser2 = {
email: "second-email@test.com",
password: "password",
name: "Name",
};
it("should set multi session when there is set-cookie header", async () => {
await client.signIn.email(
{
email: testUser.email,
password: testUser.password,
},
{
onResponse(context) {
const setCookieString = context.response.headers.get("set-cookie");
const setCookies = parseSetCookieHeader(setCookieString || "");
const sessionToken = setCookies
.get("better-auth.session_token")
?.value.split(".")[0];
const multiSession = setCookies.get(
`better-auth.session_token_multi-${sessionToken}`,
)?.value;
expect(sessionToken).not.toBe(null);
expect(multiSession).not.toBe(null);
expect(multiSession).toContain(sessionToken);
expect(setCookieString).toContain("better-auth.session_token_multi-");
headers.set("cookie", `${setCookieString?.split(",").join(";")}`);
},
},
);
await client.signUp.email(testUser2, {
onSuccess(context) {
const setCookieString = context.response.headers.get("set-cookie");
headers.set(
"cookie",
`${headers.get("cookie")}; ${setCookieString?.split(",").join(";")}`,
);
},
});
});
it("should get active session", async () => {
const session = await client.session({
fetchOptions: {
headers,
},
});
expect(session.data?.user.email).toBe(testUser2.email);
});
let sessionId = "";
it("should list all device sessions", async () => {
const res = await client.multiSession.listDeviceSessions({
fetchOptions: {
headers,
},
});
if (res.data) {
sessionId = res.data[0].session.id;
}
expect(res.data).toHaveLength(2);
});
it("should set active session", async () => {
const res = await client.multiSession.setActive({
sessionId,
fetchOptions: {
headers,
},
});
expect(res.data?.user.email).toBe(testUser.email);
});
it("should throw error when setting above maximum sessions", async () => {
const res = await client.signUp.email(
{
email: "new-email-2@email.com",
password: "password",
name: "Name",
},
{
headers,
},
);
console.log(res);
});
it("should sign-out a session", async () => {
await client.multiSession.revoke({
fetchOptions: {
headers,
},
sessionId,
});
const res = await client.multiSession.listDeviceSessions({
fetchOptions: {
headers,
},
});
expect(res.data).toHaveLength(1);
});
it("should sign-out all sessions", async () => {
await client.signOut({
fetchOptions: {
headers,
},
});
const res = await client.multiSession.listDeviceSessions({
fetchOptions: {
headers,
},
});
expect(res.data).toHaveLength(0);
});
});