Skip to content

Commit 5a25176

Browse files
authored
fix(client): break /admin unauthorized loop for non-admin users (#345)
* fix(client): break /admin unauthorized loop for non-admin users When a Users-collection session was active, Payload's admin panel ran the Users authjs strategy on /admin requests and rendered the Unauthorized view; the "Log out" link looped back to the same page because Payload's native logout never cleared the NextAuth cookie. Gate the Users + AnonymousUsers authjs strategies by request path: return no user when x-current-path starts with /admin. Middleware now propagates the pathname on /admin and /v1 too. Admin panel shows the login form instead of the unauthorized view, removing the loop. Drops the obsolete Admins beforeOperation workaround. * style: apply prettier to middleware matcher * fix(quality): resolve duplicated authjs strategy in Users + AnonymousUsers Extracts the NextAuth-backed Payload strategy and the /logout endpoint into a shared module so both collections call the same implementation. Brings new-code duplicated lines density back under the 3% quality gate threshold without changing runtime behavior.
1 parent b13b1c4 commit 5a25176

5 files changed

Lines changed: 70 additions & 113 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { AuthStrategy, AuthStrategyResult, CollectionSlug, Endpoint } from "payload";
2+
3+
import { auth, signOut } from "@/lib/auth";
4+
5+
type AuthjsCollection = Extract<CollectionSlug, "users" | "anonymous-users">;
6+
7+
const isAdminRequest = (headers: Headers): boolean => {
8+
const currentPath = headers.get("x-current-path") ?? "";
9+
return currentPath === "/admin" || currentPath.startsWith("/admin/");
10+
};
11+
12+
// Builds the NextAuth-backed Payload strategy used by Users and AnonymousUsers.
13+
// Skipping admin-context requests prevents non-admin sessions from being surfaced
14+
// inside Payload's admin panel (which would trigger the Unauthorized loop).
15+
export const createAuthjsStrategy = <C extends AuthjsCollection>(collection: C): AuthStrategy => ({
16+
name: "authjs",
17+
authenticate: async ({ headers, payload }) => {
18+
if (isAdminRequest(headers)) {
19+
return { user: null };
20+
}
21+
22+
const session = await auth();
23+
24+
if (!session?.user?.id) {
25+
return { user: null };
26+
}
27+
28+
const user = await payload.findByID({
29+
collection,
30+
id: session.user.id,
31+
disableErrors: true,
32+
});
33+
34+
return { user: user ? { ...user, collection } : null } as AuthStrategyResult;
35+
},
36+
});
37+
38+
export const logoutEndpoint: Endpoint = {
39+
path: "/logout",
40+
method: "post",
41+
handler: async () => {
42+
await signOut({ redirect: false });
43+
return Response.json({ message: "You have been logged out successfully." });
44+
},
45+
};

client/src/cms/collections/Admins.ts

Lines changed: 1 addition & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,11 @@
1-
import { CollectionConfig, getPayload } from "payload";
2-
3-
import config from "@payload-config";
4-
5-
import { logout } from "@payloadcms/next/auth";
1+
import { CollectionConfig } from "payload";
62

73
export const Admins: CollectionConfig = {
84
slug: "admins",
95
admin: {
106
useAsTitle: "email",
117
},
128
auth: true,
13-
hooks: {
14-
// When the user is logged in with a non-admin user (users collection), they are asked to log
15-
// out by Payload by clicking on a “Log out” button. Unfortunately, this button does not work
16-
// and simply brings the user back to the same page.
17-
// To work around this, the hook below automatically logs out the user when performing any
18-
// admins-related operation. The user will still be presented a page that tells them to log out
19-
// first, but if they reload or click the button, they are at least shown the login form.
20-
beforeOperation: [
21-
async ({ req }) => {
22-
const payload = await getPayload({ config });
23-
const { user } = await payload.auth({ headers: req.headers });
24-
25-
if (user?.collection === "users") {
26-
await logout({ config });
27-
}
28-
},
29-
],
30-
},
319
fields: [
3210
// Email added by default
3311
// Add more fields as needed

client/src/cms/collections/AnonymousUsers.ts

Lines changed: 3 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,16 @@
11
import type { CollectionConfig } from "payload";
22

3-
import { auth, signOut } from "@/lib/auth";
4-
53
import { adminAccess } from "@/cms/access/admin";
64
import { appAccess } from "@/cms/access/app";
5+
import { createAuthjsStrategy, logoutEndpoint } from "@/cms/auth/authjs-strategy";
76
import { beforeDeleteAnonymousUser } from "@/cms/hooks/user";
87

98
export const AnonymousUsers: CollectionConfig = {
109
slug: "anonymous-users",
1110
auth: {
1211
disableLocalStrategy: true,
1312
tokenExpiration: 60 * 60 * 24 * 30, // 30 days
14-
strategies: [
15-
{
16-
name: "authjs",
17-
authenticate: async ({ payload }) => {
18-
const session = await auth();
19-
20-
if (!session || !session?.user?.id) {
21-
return { user: null };
22-
}
23-
24-
const user = await payload.findByID({
25-
collection: "anonymous-users",
26-
id: session.user.id,
27-
disableErrors: true,
28-
});
29-
30-
return { user: user ? { ...user, collection: "anonymous-users" } : null };
31-
},
32-
},
33-
],
13+
strategies: [createAuthjsStrategy("anonymous-users")],
3414
},
3515
access: {
3616
create: appAccess,
@@ -39,20 +19,7 @@ export const AnonymousUsers: CollectionConfig = {
3919
delete: adminAccess,
4020
},
4121
fields: [],
42-
endpoints: [
43-
{
44-
path: "/logout",
45-
method: "post",
46-
handler: async () => {
47-
await signOut({
48-
redirect: false,
49-
});
50-
return Response.json({
51-
message: "You have been logged out successfully.",
52-
});
53-
},
54-
},
55-
],
22+
endpoints: [logoutEndpoint],
5623
hooks: {
5724
beforeDelete: [beforeDeleteAnonymousUser],
5825
},

client/src/cms/collections/Users.ts

Lines changed: 3 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,10 @@ import type { CollectionConfig } from "payload";
22

33
import { env } from "@/env.mjs";
44

5-
import { auth, signOut } from "@/lib/auth";
6-
75
import { adminAccess } from "@/cms/access/admin";
86
import { anyoneAccess } from "@/cms/access/anyone";
97
import { userAccess } from "@/cms/access/user";
8+
import { createAuthjsStrategy, logoutEndpoint } from "@/cms/auth/authjs-strategy";
109
import { buildVerifyEmailHTML, VERIFY_EMAIL_SUBJECT } from "@/cms/emails/verify-email";
1110
import { resendVerificationHandler } from "@/cms/endpoints/resend-verification";
1211
import { beforeDeleteUser } from "@/cms/hooks/user";
@@ -60,26 +59,7 @@ export const Users: CollectionConfig = {
6059
`;
6160
},
6261
},
63-
strategies: [
64-
{
65-
name: "authjs",
66-
authenticate: async ({ payload }) => {
67-
const session = await auth();
68-
69-
if (!session || !session?.user?.id) {
70-
return { user: null };
71-
}
72-
73-
const user = await payload.findByID({
74-
collection: "users",
75-
id: session.user.id,
76-
disableErrors: true,
77-
});
78-
79-
return { user: user ? { ...user, collection: "users" } : null };
80-
},
81-
},
82-
],
62+
strategies: [createAuthjsStrategy("users")],
8363
},
8464
access: {
8565
create: anyoneAccess,
@@ -105,18 +85,7 @@ export const Users: CollectionConfig = {
10585
},
10686
],
10787
endpoints: [
108-
{
109-
path: "/logout",
110-
method: "post",
111-
handler: async () => {
112-
await signOut({
113-
redirect: false,
114-
});
115-
return Response.json({
116-
message: "You have been logged out successfully.",
117-
});
118-
},
119-
},
88+
logoutEndpoint,
12089
{
12190
path: "/resend-verification",
12291
method: "post",

client/src/middleware.ts

Lines changed: 18 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,37 +8,44 @@ import { env } from "@/env.mjs";
88

99
import { routing } from "@/i18n/routing";
1010

11-
// Initialize the i18n middleware
1211
const intlMiddleware = createMiddleware(routing);
1312

14-
// Main middleware handler
13+
const PAYLOAD_PATH_PREFIXES = ["/admin", "/v1"];
14+
15+
const isPayloadPath = (pathname: string) =>
16+
PAYLOAD_PATH_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
17+
1518
export default async function middleware(req: NextRequest) {
16-
// Step 1: Ignore requests for static files like images, icons, etc.
1719
const PUBLIC_FILE = /\.(.*)$/;
1820
if (PUBLIC_FILE.test(req.nextUrl.pathname)) {
1921
return NextResponse.next();
2022
}
2123

22-
// Step 2: Apply HTTP Basic Auth if enabled in the environment
2324
if (!isAuthenticated(req)) {
2425
return new NextResponse("Authentication required", {
2526
status: 401,
2627
headers: { "WWW-Authenticate": "Basic" },
2728
});
2829
}
2930

30-
// Step 3: Apply locale-based routing using next-intl
31-
const response = intlMiddleware(req);
31+
const pathname = req.nextUrl.pathname;
3232

33-
// Step 4: Pass along the modified headers
34-
response.headers.set("x-current-path", req.nextUrl.pathname);
33+
// Forward the pathname so downstream auth strategies can detect admin-context
34+
// requests (Payload admin + REST). Without this, the Users authjs strategy would
35+
// authenticate non-admin users on /admin and trigger Payload's Unauthorized loop.
36+
const requestHeaders = new Headers(req.headers);
37+
requestHeaders.set("x-current-path", pathname);
3538

39+
if (isPayloadPath(pathname)) {
40+
return NextResponse.next({ request: { headers: requestHeaders } });
41+
}
42+
43+
const response = intlMiddleware(req);
44+
response.headers.set("x-current-path", pathname);
3645
return response;
3746
}
3847

39-
// HTTP Basic Auth logic
4048
function isAuthenticated(req: NextRequest) {
41-
// Skip auth if disabled via environment config
4249
if (!env.BASIC_AUTH_ENABLED) return true;
4350

4451
const authHeader = req.headers.get("authorization") || req.headers.get("Authorization");
@@ -49,15 +56,6 @@ function isAuthenticated(req: NextRequest) {
4956
return user === env.BASIC_AUTH_USER && pass === env.BASIC_AUTH_PASSWORD;
5057
}
5158

52-
// Middleware matcher: apply to all routes except static assets and Next.js internals
5359
export const config = {
54-
matcher: [
55-
// This pattern skips:
56-
// - /local-api
57-
// - /api
58-
// - /admin
59-
// - /_next
60-
// - all static files like .png, .ico, etc.
61-
"/((?!local-api|api|admin|v1|_next|.*\\..*).*)",
62-
],
60+
matcher: ["/((?!local-api|api|_next|.*\\..*).*)"],
6361
};

0 commit comments

Comments
 (0)