feat: enforce single active session per account
This commit is contained in:
parent
90458511c1
commit
366e0e3e5c
@ -10,6 +10,13 @@ import { buildAuthConfig } from "./auth.config";
|
||||
import { db } from "@/lib/db";
|
||||
import { loginToken, user as userTable } from "@/lib/db/schema";
|
||||
import { eq, and, gt } from "drizzle-orm";
|
||||
import {
|
||||
establishSingleActiveSession,
|
||||
getValidatedSessionUser,
|
||||
isManagedSessionUserType,
|
||||
requiresSingleActiveSession,
|
||||
revokeSingleActiveSession,
|
||||
} from "@/lib/auth/session-control";
|
||||
|
||||
export type UserType = "guest" | "regular" | "admin" | "agent_admin";
|
||||
|
||||
@ -19,6 +26,7 @@ declare module "next-auth" {
|
||||
id: string;
|
||||
type: UserType;
|
||||
name?: string | null;
|
||||
sessionKey?: string;
|
||||
} & DefaultSession["user"];
|
||||
}
|
||||
|
||||
@ -36,6 +44,7 @@ declare module "next-auth/jwt" {
|
||||
id: string;
|
||||
type: UserType;
|
||||
name?: string | null;
|
||||
sessionKey?: string;
|
||||
}
|
||||
}
|
||||
|
||||
@ -191,12 +200,7 @@ async function resolveRuntimeAuthUrl(settingsAuthUrl?: string | null) {
|
||||
return `${protocol}://${resolvedHost}`;
|
||||
}
|
||||
|
||||
export const {
|
||||
handlers: { GET, POST },
|
||||
auth,
|
||||
signIn,
|
||||
signOut,
|
||||
} = NextAuth(async () => {
|
||||
const nextAuth = NextAuth(async () => {
|
||||
const runtimeAuthSettings = await getAuthSettings().catch(() => null as any);
|
||||
const resolvedAuthUrl =
|
||||
(await resolveRuntimeAuthUrl(runtimeAuthSettings?.authUrl)) || undefined;
|
||||
@ -233,10 +237,19 @@ export const {
|
||||
return Boolean(dbUser?.isActive);
|
||||
},
|
||||
async jwt({ token, user, account, profile }) {
|
||||
let resolvedUserId =
|
||||
typeof token.id === "string" && token.id.length > 0 ? token.id : undefined;
|
||||
let resolvedUserType =
|
||||
typeof token.type === "string" && isManagedSessionUserType(token.type)
|
||||
? token.type
|
||||
: undefined;
|
||||
|
||||
if (user?.id && user?.type) {
|
||||
token.id = user.id as string;
|
||||
token.type = user.type;
|
||||
token.name = user.name;
|
||||
resolvedUserId = token.id;
|
||||
resolvedUserType = token.type;
|
||||
}
|
||||
|
||||
if (account?.provider === "google") {
|
||||
@ -257,10 +270,21 @@ export const {
|
||||
token.type = (dbUser.role as UserType) || "regular";
|
||||
token.name = dbUser.name;
|
||||
token.email = dbUser.email;
|
||||
resolvedUserId = dbUser.id;
|
||||
resolvedUserType = (dbUser.role as UserType) || "regular";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (account && resolvedUserId && requiresSingleActiveSession(resolvedUserType)) {
|
||||
const { sessionKey } = await establishSingleActiveSession({
|
||||
userId: resolvedUserId,
|
||||
});
|
||||
token.sessionKey = sessionKey;
|
||||
} else if (!requiresSingleActiveSession(resolvedUserType)) {
|
||||
delete token.sessionKey;
|
||||
}
|
||||
|
||||
return token;
|
||||
},
|
||||
session({ session, token }) {
|
||||
@ -268,6 +292,8 @@ export const {
|
||||
session.user.id = token.id;
|
||||
session.user.type = token.type || "regular";
|
||||
session.user.name = token.name as string;
|
||||
session.user.sessionKey =
|
||||
typeof token.sessionKey === "string" ? token.sessionKey : undefined;
|
||||
}
|
||||
|
||||
return session;
|
||||
@ -275,3 +301,68 @@ export const {
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const {
|
||||
handlers: { GET, POST },
|
||||
auth: rawAuth,
|
||||
signIn,
|
||||
signOut: rawSignOut,
|
||||
} = nextAuth;
|
||||
|
||||
export { GET, POST, signIn };
|
||||
|
||||
export async function auth() {
|
||||
const session = await rawAuth();
|
||||
if (!session?.user?.id) {
|
||||
return session;
|
||||
}
|
||||
|
||||
if (!requiresSingleActiveSession(session.user.type)) {
|
||||
return session;
|
||||
}
|
||||
|
||||
const sessionKey = session.user.sessionKey;
|
||||
if (!sessionKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const validatedUser = await getValidatedSessionUser({
|
||||
userId: session.user.id,
|
||||
sessionKey,
|
||||
});
|
||||
|
||||
if (!validatedUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...session,
|
||||
user: {
|
||||
...session.user,
|
||||
email: validatedUser.email,
|
||||
name: validatedUser.name ?? session.user.name,
|
||||
type: validatedUser.role,
|
||||
sessionKey,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[Auth] Failed to validate active session:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function signOut(options?: Parameters<typeof rawSignOut>[0]) {
|
||||
const session = await auth().catch(() => null);
|
||||
|
||||
if (session?.user?.id && requiresSingleActiveSession(session.user.type)) {
|
||||
await revokeSingleActiveSession({
|
||||
userId: session.user.id,
|
||||
sessionKey: session.user.sessionKey,
|
||||
}).catch((error) => {
|
||||
console.error("[Auth] Failed to revoke active session during sign out:", error);
|
||||
});
|
||||
}
|
||||
|
||||
return rawSignOut(options);
|
||||
}
|
||||
|
||||
@ -39,6 +39,7 @@ export default function Page() {
|
||||
const { setTheme, resolvedTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [isHandlingToken, setIsHandlingToken] = useState(false);
|
||||
const [isCheckingExistingSession, setIsCheckingExistingSession] = useState(true);
|
||||
|
||||
// 系统通知状态
|
||||
const [notification, setNotification] = useState<{ enabled: boolean; title: string; content: string } | null>(null);
|
||||
@ -56,6 +57,33 @@ export default function Page() {
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
fetch("/api/auth/validate-session", {
|
||||
cache: "no-store",
|
||||
credentials: "include",
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
if (data?.valid === true && data?.userType !== "guest") {
|
||||
window.location.replace("/");
|
||||
return;
|
||||
}
|
||||
setIsCheckingExistingSession(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setIsCheckingExistingSession(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 使用 token 完成钉钉登录
|
||||
const completeDingTalkLogin = useCallback(async (token: string) => {
|
||||
console.log("[DingTalk] Completing login with token:", token.substring(0, 10) + "...");
|
||||
@ -435,6 +463,14 @@ export default function Page() {
|
||||
(isDingTalkEnabled && (Boolean(dingTalkAppKey) || dingTalkOrganizations.length > 0)) ||
|
||||
(isGoogleEnabled && isGoogleConfigured);
|
||||
|
||||
if (isCheckingExistingSession) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center text-muted-foreground text-sm">
|
||||
正在检查登录状态...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh w-screen items-start justify-center bg-background pt-12 md:items-center md:pt-0 relative overflow-hidden">
|
||||
{/* Antigravity 动画背景 */}
|
||||
|
||||
@ -22,6 +22,7 @@ export default function Page() {
|
||||
const [emailVerificationExpiresMinutes, setEmailVerificationExpiresMinutes] = useState(10);
|
||||
const [isSendingCode, setIsSendingCode] = useState(false);
|
||||
const [codeCooldown, setCodeCooldown] = useState(0);
|
||||
const [isCheckingExistingSession, setIsCheckingExistingSession] = useState(true);
|
||||
const redirectTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@ -43,10 +44,30 @@ export default function Page() {
|
||||
fetch(`/api/site-settings?t=${Date.now()}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.siteName) setSiteName(data.siteName);
|
||||
if (data.siteLogo) setSiteLogo(data.siteLogo);
|
||||
if (data.siteName) {
|
||||
setSiteName(data.siteName);
|
||||
}
|
||||
if (data.siteLogo) {
|
||||
setSiteLogo(data.siteLogo);
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
fetch("/api/auth/validate-session", {
|
||||
cache: "no-store",
|
||||
credentials: "include",
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data?.valid === true && data?.userType !== "guest") {
|
||||
window.location.replace("/");
|
||||
return;
|
||||
}
|
||||
setIsCheckingExistingSession(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setIsCheckingExistingSession(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@ -119,6 +140,14 @@ export default function Page() {
|
||||
formAction(formData);
|
||||
};
|
||||
|
||||
if (isCheckingExistingSession) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center text-muted-foreground text-sm">
|
||||
正在检查登录状态...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSendCode = async () => {
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
if (!normalizedEmail) {
|
||||
|
||||
@ -1,11 +1,25 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { revokeSingleActiveSession } from "@/lib/auth/session-control";
|
||||
|
||||
export async function POST() {
|
||||
const isSecure = process.env.AUTH_URL?.startsWith("https://") ?? false;
|
||||
const domain = process.env.AUTH_URL ? new URL(process.env.AUTH_URL).hostname : undefined;
|
||||
|
||||
const domain = process.env.AUTH_URL
|
||||
? new URL(process.env.AUTH_URL).hostname
|
||||
: undefined;
|
||||
const session = await auth().catch(() => null);
|
||||
|
||||
// 创建清除 cookie 的响应
|
||||
const response = NextResponse.json({ success: true });
|
||||
|
||||
if (session?.user?.id && session.user.type !== "guest") {
|
||||
await revokeSingleActiveSession({
|
||||
userId: session.user.id,
|
||||
sessionKey: session.user.sessionKey,
|
||||
}).catch((error) => {
|
||||
console.error("[Logout API] Failed to revoke active session:", error);
|
||||
});
|
||||
}
|
||||
|
||||
// 清除所有认证相关的 cookie
|
||||
const cookiesToClear = [
|
||||
@ -32,16 +46,17 @@ export async function POST() {
|
||||
response.cookies.set(cookieName, "", {
|
||||
expires: new Date(0),
|
||||
path: "/",
|
||||
domain: domain,
|
||||
domain,
|
||||
secure: isSecure,
|
||||
httpOnly: cookieName.includes("session-token") || cookieName.includes("csrf-token"),
|
||||
httpOnly:
|
||||
cookieName.includes("session-token") ||
|
||||
cookieName.includes("csrf-token"),
|
||||
sameSite: "lax",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
console.log("[Logout API] Cleared all auth cookies");
|
||||
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
18
app/api/auth/validate-session/route.ts
Normal file
18
app/api/auth/validate-session/route.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth().catch(() => null);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
valid: Boolean(session?.user?.id),
|
||||
userType: session?.user?.type ?? null,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control": "no-store, max-age=0",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
149
lib/auth/session-control.ts
Normal file
149
lib/auth/session-control.ts
Normal file
@ -0,0 +1,149 @@
|
||||
import "server-only";
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { headers } from "next/headers";
|
||||
import { db } from "@/lib/db";
|
||||
import { user } from "@/lib/db/schema";
|
||||
|
||||
type ManagedUserType = "guest" | "regular" | "admin" | "agent_admin";
|
||||
|
||||
type ManagedSessionUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
role: ManagedUserType;
|
||||
isActive: boolean;
|
||||
activeSessionKey: string | null;
|
||||
};
|
||||
|
||||
function normalizeClientIp(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstHop = value.split(",")[0]?.trim();
|
||||
return firstHop || null;
|
||||
}
|
||||
|
||||
function normalizeUserAgent(value: string | null | undefined) {
|
||||
const normalized = String(value || "").trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized.slice(0, 512);
|
||||
}
|
||||
|
||||
async function readRequestSessionMetadata() {
|
||||
const requestHeaders = await headers();
|
||||
const forwardedFor = requestHeaders.get("x-forwarded-for");
|
||||
const realIp = requestHeaders.get("x-real-ip");
|
||||
|
||||
return {
|
||||
ip: normalizeClientIp(forwardedFor) || normalizeClientIp(realIp),
|
||||
userAgent: normalizeUserAgent(requestHeaders.get("user-agent")),
|
||||
};
|
||||
}
|
||||
|
||||
export function isManagedSessionUserType(
|
||||
type: string | null | undefined
|
||||
): type is ManagedUserType {
|
||||
return (
|
||||
type === "guest" ||
|
||||
type === "regular" ||
|
||||
type === "admin" ||
|
||||
type === "agent_admin"
|
||||
);
|
||||
}
|
||||
|
||||
export function requiresSingleActiveSession(type: string | null | undefined) {
|
||||
return type === "regular" || type === "admin" || type === "agent_admin";
|
||||
}
|
||||
|
||||
export async function establishSingleActiveSession(params: { userId: string }) {
|
||||
const { userId } = params;
|
||||
const sessionKey = crypto.randomUUID();
|
||||
const metadata = await readRequestSessionMetadata();
|
||||
const now = new Date();
|
||||
|
||||
await db
|
||||
.update(user)
|
||||
.set({
|
||||
activeSessionKey: sessionKey,
|
||||
activeSessionCreatedAt: now,
|
||||
activeSessionIp: metadata.ip,
|
||||
activeSessionUserAgent: metadata.userAgent,
|
||||
activeSessionLastSeenAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(user.id, userId));
|
||||
|
||||
return { sessionKey };
|
||||
}
|
||||
|
||||
export async function getValidatedSessionUser(params: {
|
||||
userId: string;
|
||||
sessionKey: string;
|
||||
}) {
|
||||
const { userId, sessionKey } = params;
|
||||
const rows = await db
|
||||
.select({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
isActive: user.isActive,
|
||||
activeSessionKey: user.activeSessionKey,
|
||||
})
|
||||
.from(user)
|
||||
.where(eq(user.id, userId))
|
||||
.limit(1);
|
||||
|
||||
const currentUser = rows[0] as ManagedSessionUser | undefined;
|
||||
if (!currentUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!currentUser.isActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!requiresSingleActiveSession(currentUser.role)) {
|
||||
return currentUser;
|
||||
}
|
||||
|
||||
if (
|
||||
!currentUser.activeSessionKey ||
|
||||
currentUser.activeSessionKey !== sessionKey
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return currentUser;
|
||||
}
|
||||
|
||||
export async function revokeSingleActiveSession(params: {
|
||||
userId: string;
|
||||
sessionKey?: string | null;
|
||||
}) {
|
||||
const { userId, sessionKey } = params;
|
||||
const now = new Date();
|
||||
const whereClause = sessionKey
|
||||
? and(eq(user.id, userId), eq(user.activeSessionKey, sessionKey))
|
||||
: eq(user.id, userId);
|
||||
|
||||
const revokedUsers = await db
|
||||
.update(user)
|
||||
.set({
|
||||
activeSessionKey: null,
|
||||
activeSessionCreatedAt: null,
|
||||
activeSessionIp: null,
|
||||
activeSessionUserAgent: null,
|
||||
activeSessionLastSeenAt: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(whereClause)
|
||||
.returning({ id: user.id });
|
||||
|
||||
return revokedUsers.length > 0;
|
||||
}
|
||||
6
lib/db/migrations/0062_add_single_active_session.sql
Normal file
6
lib/db/migrations/0062_add_single_active_session.sql
Normal file
@ -0,0 +1,6 @@
|
||||
ALTER TABLE "User"
|
||||
ADD COLUMN IF NOT EXISTS "activeSessionKey" varchar(64),
|
||||
ADD COLUMN IF NOT EXISTS "activeSessionCreatedAt" timestamp,
|
||||
ADD COLUMN IF NOT EXISTS "activeSessionIp" varchar(64),
|
||||
ADD COLUMN IF NOT EXISTS "activeSessionUserAgent" text,
|
||||
ADD COLUMN IF NOT EXISTS "activeSessionLastSeenAt" timestamp;
|
||||
@ -337,6 +337,13 @@
|
||||
"when": 1774483200000,
|
||||
"tag": "0061_add_product_planning_enabled_site_setting",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 48,
|
||||
"version": "7",
|
||||
"when": 1775145600000,
|
||||
"tag": "0062_add_single_active_session",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -26,6 +26,11 @@ export const user = pgTable("User", {
|
||||
.default("regular"),
|
||||
isHighVip: boolean("isHighVip").notNull().default(false),
|
||||
isActive: boolean("isActive").notNull().default(true),
|
||||
activeSessionKey: varchar("activeSessionKey", { length: 64 }),
|
||||
activeSessionCreatedAt: timestamp("activeSessionCreatedAt"),
|
||||
activeSessionIp: varchar("activeSessionIp", { length: 64 }),
|
||||
activeSessionUserAgent: text("activeSessionUserAgent"),
|
||||
activeSessionLastSeenAt: timestamp("activeSessionLastSeenAt"),
|
||||
monthlyImageLimit: integer("monthlyImageLimit"), // 每月出图限额,NULL或0表示无限制
|
||||
pointsBalance: integer("pointsBalance").notNull().default(0), // 当前剩余积分
|
||||
pointsGiftedTotal: integer("pointsGiftedTotal").notNull().default(0), // 管理员累计赠送积分
|
||||
@ -622,6 +627,7 @@ export const canvasProject = pgTable("CanvasProject", {
|
||||
fifthCategory?: string;
|
||||
priceBand?: string;
|
||||
};
|
||||
appearanceRecognitionEnabled?: boolean;
|
||||
materials?: {
|
||||
primary?: {
|
||||
result?: {
|
||||
@ -641,6 +647,7 @@ export const canvasProject = pgTable("CanvasProject", {
|
||||
imageUrl?: string;
|
||||
assetId?: string | null;
|
||||
mimeType?: string | null;
|
||||
appearanceSummary?: string | null;
|
||||
group?: "fixed" | "other";
|
||||
sortOrder?: number;
|
||||
}>;
|
||||
@ -665,6 +672,7 @@ export const canvasProject = pgTable("CanvasProject", {
|
||||
imageUrl?: string;
|
||||
assetId?: string | null;
|
||||
mimeType?: string | null;
|
||||
appearanceSummary?: string | null;
|
||||
group?: "fixed" | "other";
|
||||
sortOrder?: number;
|
||||
}>;
|
||||
|
||||
12
proxy.ts
12
proxy.ts
@ -253,10 +253,6 @@ export async function proxy(request: NextRequest) {
|
||||
|
||||
const token = await resolveToken();
|
||||
|
||||
const externalBase =
|
||||
process.env.AUTH_URL ??
|
||||
`${request.nextUrl.protocol}//${request.headers.get("host") ?? request.nextUrl.host}`;
|
||||
|
||||
const isPublicPage =
|
||||
pathname === "/" ||
|
||||
pathname === "/login" ||
|
||||
@ -275,17 +271,15 @@ export async function proxy(request: NextRequest) {
|
||||
}
|
||||
|
||||
// 对于页面路由(GET 请求),重定向到登录页面
|
||||
const externalBase =
|
||||
process.env.AUTH_URL ??
|
||||
`${request.nextUrl.protocol}//${request.headers.get("host") ?? request.nextUrl.host}`;
|
||||
const loginUrl = new URL("/login", externalBase);
|
||||
// 保存当前路径作为回调 URL
|
||||
loginUrl.searchParams.set("callbackUrl", pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
// 已登录用户访问 /login 或 /register 时重定向到首页
|
||||
if (token && ["/login", "/register"].includes(pathname)) {
|
||||
return NextResponse.redirect(new URL("/", externalBase));
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
|
||||
@ -183,6 +183,41 @@ test.describe
|
||||
await page.goto("/login");
|
||||
await expect(page).toHaveURL("/");
|
||||
});
|
||||
|
||||
test("Invalidate the previous browser session after a second login", async ({
|
||||
browser,
|
||||
page,
|
||||
}) => {
|
||||
const isolatedUser = generateRandomTestUser();
|
||||
const secondaryContext = await browser.newContext();
|
||||
const secondaryPage = await secondaryContext.newPage();
|
||||
const primaryAuthPage = new AuthPage(page);
|
||||
const secondaryAuthPage = new AuthPage(secondaryPage);
|
||||
|
||||
try {
|
||||
await primaryAuthPage.register(isolatedUser.email, isolatedUser.password);
|
||||
await primaryAuthPage.expectToastToContain("Account created successfully!");
|
||||
await page.waitForURL("/");
|
||||
|
||||
const beforeInvalidation = await page.context().request.get(
|
||||
"/api/user/account-center"
|
||||
);
|
||||
expect(beforeInvalidation.status()).toBe(200);
|
||||
|
||||
await secondaryAuthPage.login(isolatedUser.email, isolatedUser.password);
|
||||
await secondaryPage.waitForURL("/");
|
||||
|
||||
const afterInvalidation = await page.context().request.get(
|
||||
"/api/user/account-center"
|
||||
);
|
||||
expect(afterInvalidation.status()).toBe(401);
|
||||
|
||||
await page.goto("/login");
|
||||
await expect(page).toHaveURL("/login");
|
||||
} finally {
|
||||
await secondaryContext.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Entitlements", () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user