From 22a14141b316965b2443d9140f3bf56da1e481cb Mon Sep 17 00:00:00 2001 From: ravindu0823 Date: Tue, 9 Jun 2026 14:38:03 +0530 Subject: [PATCH 1/2] fix: redirect to login when session expires instead of generic error Expired sessions surfaced as generic per-component toasts because the frontend tRPC client had no global handler for UNAUTHORIZED responses. Add a pure, testable auth-error helper and wire a global QueryCache / MutationCache onError handler into the tRPC client so any UNAUTHORIZED response on a protected page redirects to login, regardless of per-component .catch blocks. Fixes #4310 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dokploy/__test__/utils/auth-error.test.ts | 67 +++++++++++++++++++ apps/dokploy/utils/api.ts | 13 +++- apps/dokploy/utils/auth-error.ts | 45 +++++++++++++ 3 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 apps/dokploy/__test__/utils/auth-error.test.ts create mode 100644 apps/dokploy/utils/auth-error.ts diff --git a/apps/dokploy/__test__/utils/auth-error.test.ts b/apps/dokploy/__test__/utils/auth-error.test.ts new file mode 100644 index 000000000..85ce819d3 --- /dev/null +++ b/apps/dokploy/__test__/utils/auth-error.test.ts @@ -0,0 +1,67 @@ +import { TRPCClientError } from "@trpc/client"; +import { describe, expect, test } from "vitest"; +import { + isUnauthorizedError, + shouldRedirectOnAuthError, +} from "@/utils/auth-error"; + +const makeUnauthorizedError = () => { + const error = new TRPCClientError("Unauthorized"); + (error as any).data = { code: "UNAUTHORIZED" }; + return error; +}; + +const makeForbiddenError = () => { + const error = new TRPCClientError("Forbidden"); + (error as any).data = { code: "FORBIDDEN" }; + return error; +}; + +describe("isUnauthorizedError", () => { + test("returns true for a TRPCClientError with UNAUTHORIZED code", () => { + expect(isUnauthorizedError(makeUnauthorizedError())).toBe(true); + }); + + test("returns false for a TRPCClientError with a different code", () => { + expect(isUnauthorizedError(makeForbiddenError())).toBe(false); + }); + + test("returns false for a plain Error", () => { + expect(isUnauthorizedError(new Error("UNAUTHORIZED"))).toBe(false); + }); + + test("returns false for null", () => { + expect(isUnauthorizedError(null)).toBe(false); + }); + + test("returns false for undefined", () => { + expect(isUnauthorizedError(undefined)).toBe(false); + }); +}); + +describe("shouldRedirectOnAuthError", () => { + test("returns false for public paths even with an unauthorized error", () => { + const error = makeUnauthorizedError(); + expect(shouldRedirectOnAuthError("/", error)).toBe(false); + expect(shouldRedirectOnAuthError("/register", error)).toBe(false); + expect(shouldRedirectOnAuthError("/invitation/abc", error)).toBe(false); + }); + + test("returns true for a protected path with an unauthorized error", () => { + expect( + shouldRedirectOnAuthError( + "/dashboard/project/abc", + makeUnauthorizedError(), + ), + ).toBe(true); + }); + + test("returns false for a protected path with a non-auth error", () => { + expect( + shouldRedirectOnAuthError("/dashboard/project/abc", makeForbiddenError()), + ).toBe(false); + expect( + shouldRedirectOnAuthError("/dashboard/project/abc", new Error("nope")), + ).toBe(false); + }); +}); diff --git a/apps/dokploy/utils/api.ts b/apps/dokploy/utils/api.ts index d7f165f6a..3ccd677ca 100644 --- a/apps/dokploy/utils/api.ts +++ b/apps/dokploy/utils/api.ts @@ -1,3 +1,4 @@ +import { MutationCache, QueryCache } from "@tanstack/react-query"; import { createWSClient, httpBatchLink, @@ -9,6 +10,7 @@ import { createTRPCNext } from "@trpc/next"; import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server"; import superjson from "superjson"; import type { AppRouter } from "@/server/api/root"; +import { handleAuthError } from "@/utils/auth-error"; const getBaseUrl = () => { if (typeof window !== "undefined") return ""; @@ -71,9 +73,18 @@ const links = }), ]; +const queryClientConfig = { + queryCache: new QueryCache({ + onError: (error: unknown) => handleAuthError(error), + }), + mutationCache: new MutationCache({ + onError: (error: unknown) => handleAuthError(error), + }), +}; + export const api = createTRPCNext({ config() { - return { links }; + return { links, queryClientConfig }; }, ssr: false, transformer: superjson, diff --git a/apps/dokploy/utils/auth-error.ts b/apps/dokploy/utils/auth-error.ts new file mode 100644 index 000000000..1b8c98fc2 --- /dev/null +++ b/apps/dokploy/utils/auth-error.ts @@ -0,0 +1,45 @@ +import { TRPCClientError } from "@trpc/client"; + +const PUBLIC_PATH_PREFIXES = ["/register", "/invitation"]; + +/** + * Returns true when the given error is a tRPC client error whose underlying + * response carried an UNAUTHORIZED code (i.e. the session is invalid/expired). + */ +export const isUnauthorizedError = (error: unknown): boolean => { + if (!(error instanceof TRPCClientError)) return false; + return (error.data as { code?: string } | null | undefined)?.code === + "UNAUTHORIZED"; +}; + +/** + * Decides whether an auth error should trigger a redirect to the login screen. + * Public/unauthenticated pages (login, register, invitation) never redirect, + * and only genuine UNAUTHORIZED errors qualify. + */ +export const shouldRedirectOnAuthError = ( + pathname: string, + error: unknown, +): boolean => { + if (!isUnauthorizedError(error)) return false; + if (pathname === "/") return false; + if (PUBLIC_PATH_PREFIXES.some((prefix) => pathname.startsWith(prefix))) + return false; + return true; +}; + +let isRedirecting = false; + +/** + * Side-effecting handler wired into the global tRPC query/mutation caches. + * Redirects the browser to the login screen exactly once when an expired + * session is detected on a protected page. + */ +export const handleAuthError = (error: unknown): void => { + if (typeof window === "undefined") return; + if (isRedirecting) return; + if (!shouldRedirectOnAuthError(window.location.pathname, error)) return; + + isRedirecting = true; + window.location.href = "/"; +}; From 7f236a3617f65aab958fb6015076a7d6cb984c54 Mon Sep 17 00:00:00 2001 From: ravindu0823 Date: Tue, 9 Jun 2026 15:02:41 +0530 Subject: [PATCH 2/2] fix: only redirect on genuine session expiry, not permission denials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review (blocker): the API reuses the UNAUTHORIZED code for two different situations — a missing/expired session AND an authenticated user who lacks a role or resource permission (146 such throws across the routers). Redirecting on every UNAUTHORIZED would yank a logged-in user off their page whenever they hit any permission-denied path. Tag only the genuine no-session throws (protectedProcedure, and the no-session branch of the cli/admin/enterprise procedures) with a SESSION_EXPIRED sentinel message, and gate the client redirect on that sentinel instead of the bare UNAUTHORIZED code. Permission/role denials now fall through to their normal per-component toast with no navigation. Also extend the public-path allowlist (/accept-invitation, /reset-password, /send-reset-password) so an auth error on those pages never redirects. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dokploy/__test__/utils/auth-error.test.ts | 66 ++++++++++++------- apps/dokploy/server/api/auth-constants.ts | 12 ++++ apps/dokploy/server/api/trpc.ts | 42 +++++++----- apps/dokploy/utils/auth-error.ts | 30 ++++++--- 4 files changed, 103 insertions(+), 47 deletions(-) create mode 100644 apps/dokploy/server/api/auth-constants.ts diff --git a/apps/dokploy/__test__/utils/auth-error.test.ts b/apps/dokploy/__test__/utils/auth-error.test.ts index 85ce819d3..b1da8dcdd 100644 --- a/apps/dokploy/__test__/utils/auth-error.test.ts +++ b/apps/dokploy/__test__/utils/auth-error.test.ts @@ -1,12 +1,20 @@ -import { TRPCClientError } from "@trpc/client"; -import { describe, expect, test } from "vitest"; +import { SESSION_EXPIRED_MESSAGE } from "@/server/api/auth-constants"; import { - isUnauthorizedError, + isSessionExpiredError, shouldRedirectOnAuthError, } from "@/utils/auth-error"; +import { TRPCClientError } from "@trpc/client"; +import { describe, expect, test } from "vitest"; -const makeUnauthorizedError = () => { - const error = new TRPCClientError("Unauthorized"); +const makeSessionExpiredError = () => { + const error = new TRPCClientError(SESSION_EXPIRED_MESSAGE); + (error as any).data = { code: "UNAUTHORIZED" }; + return error; +}; + +// UNAUTHORIZED, but from a permission/role check on a still-valid session. +const makePermissionDeniedError = () => { + const error = new TRPCClientError("You don't have access to this project"); (error as any).data = { code: "UNAUTHORIZED" }; return error; }; @@ -17,45 +25,59 @@ const makeForbiddenError = () => { return error; }; -describe("isUnauthorizedError", () => { - test("returns true for a TRPCClientError with UNAUTHORIZED code", () => { - expect(isUnauthorizedError(makeUnauthorizedError())).toBe(true); +describe("isSessionExpiredError", () => { + test("returns true for an UNAUTHORIZED error tagged as session-expired", () => { + expect(isSessionExpiredError(makeSessionExpiredError())).toBe(true); }); - test("returns false for a TRPCClientError with a different code", () => { - expect(isUnauthorizedError(makeForbiddenError())).toBe(false); + // Regression for #4310: the API reuses UNAUTHORIZED for permission denials + // on authenticated users; those must NOT be treated as session expiry. + test("returns false for a permission-denied UNAUTHORIZED error", () => { + expect(isSessionExpiredError(makePermissionDeniedError())).toBe(false); }); - test("returns false for a plain Error", () => { - expect(isUnauthorizedError(new Error("UNAUTHORIZED"))).toBe(false); + test("returns false for a different code", () => { + expect(isSessionExpiredError(makeForbiddenError())).toBe(false); }); - test("returns false for null", () => { - expect(isUnauthorizedError(null)).toBe(false); - }); - - test("returns false for undefined", () => { - expect(isUnauthorizedError(undefined)).toBe(false); + test("returns false for a plain Error / null / undefined", () => { + expect(isSessionExpiredError(new Error(SESSION_EXPIRED_MESSAGE))).toBe(false); + expect(isSessionExpiredError(null)).toBe(false); + expect(isSessionExpiredError(undefined)).toBe(false); }); }); describe("shouldRedirectOnAuthError", () => { - test("returns false for public paths even with an unauthorized error", () => { - const error = makeUnauthorizedError(); + test("returns false on public paths even with a session-expired error", () => { + const error = makeSessionExpiredError(); expect(shouldRedirectOnAuthError("/", error)).toBe(false); expect(shouldRedirectOnAuthError("/register", error)).toBe(false); expect(shouldRedirectOnAuthError("/invitation/abc", error)).toBe(false); + expect(shouldRedirectOnAuthError("/accept-invitation/abc", error)).toBe( + false, + ); + expect(shouldRedirectOnAuthError("/reset-password", error)).toBe(false); + expect(shouldRedirectOnAuthError("/send-reset-password", error)).toBe(false); }); - test("returns true for a protected path with an unauthorized error", () => { + test("returns true for a protected path with a session-expired error", () => { expect( shouldRedirectOnAuthError( "/dashboard/project/abc", - makeUnauthorizedError(), + makeSessionExpiredError(), ), ).toBe(true); }); + test("returns false for a protected path with a permission-denied error", () => { + expect( + shouldRedirectOnAuthError( + "/dashboard/project/abc", + makePermissionDeniedError(), + ), + ).toBe(false); + }); + test("returns false for a protected path with a non-auth error", () => { expect( shouldRedirectOnAuthError("/dashboard/project/abc", makeForbiddenError()), diff --git a/apps/dokploy/server/api/auth-constants.ts b/apps/dokploy/server/api/auth-constants.ts new file mode 100644 index 000000000..2371de533 --- /dev/null +++ b/apps/dokploy/server/api/auth-constants.ts @@ -0,0 +1,12 @@ +/** + * Sentinel message attached to UNAUTHORIZED errors that specifically mean + * "there is no valid session" (i.e. the session expired or is missing), as + * opposed to the many UNAUTHORIZED errors that represent an authenticated user + * lacking a role/resource permission. + * + * The client (utils/auth-error.ts) keys the "redirect to login" behaviour off + * this exact message so that permission denials do NOT bounce logged-in users + * off their page. Keep this file dependency-free so it can be imported from + * both server and client code. + */ +export const SESSION_EXPIRED_MESSAGE = "SESSION_EXPIRED"; diff --git a/apps/dokploy/server/api/trpc.ts b/apps/dokploy/server/api/trpc.ts index d0b43e76f..5ccaafed3 100644 --- a/apps/dokploy/server/api/trpc.ts +++ b/apps/dokploy/server/api/trpc.ts @@ -19,6 +19,7 @@ import type { CreateNextContextOptions } from "@trpc/server/adapters/next"; import type { Session, User } from "better-auth"; import superjson from "superjson"; import { ZodError } from "zod"; +import { SESSION_EXPIRED_MESSAGE } from "./auth-constants"; type Resource = keyof typeof statements; type ActionOf = (typeof statements)[R][number]; @@ -160,7 +161,10 @@ export const publicProcedure = t.procedure; */ export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session || !ctx.user) { - throw new TRPCError({ code: "UNAUTHORIZED" }); + throw new TRPCError({ + code: "UNAUTHORIZED", + message: SESSION_EXPIRED_MESSAGE, + }); } return next({ ctx: { @@ -173,11 +177,13 @@ export const protectedProcedure = t.procedure.use(({ ctx, next }) => { }); export const cliProcedure = t.procedure.use(({ ctx, next }) => { - if ( - !ctx.session || - !ctx.user || - (ctx.user.role !== "owner" && ctx.user.role !== "admin") - ) { + if (!ctx.session || !ctx.user) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: SESSION_EXPIRED_MESSAGE, + }); + } + if (ctx.user.role !== "owner" && ctx.user.role !== "admin") { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ @@ -191,11 +197,13 @@ export const cliProcedure = t.procedure.use(({ ctx, next }) => { }); export const adminProcedure = t.procedure.use(({ ctx, next }) => { - if ( - !ctx.session || - !ctx.user || - (ctx.user.role !== "owner" && ctx.user.role !== "admin") - ) { + if (!ctx.session || !ctx.user) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: SESSION_EXPIRED_MESSAGE, + }); + } + if (ctx.user.role !== "owner" && ctx.user.role !== "admin") { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ @@ -214,11 +222,13 @@ export const adminProcedure = t.procedure.use(({ ctx, next }) => { * is used in the UI gate and when activating/validating keys. */ export const enterpriseProcedure = t.procedure.use(async ({ ctx, next }) => { - if ( - !ctx.session || - !ctx.user || - (ctx.user.role !== "owner" && ctx.user.role !== "admin") - ) { + if (!ctx.session || !ctx.user) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: SESSION_EXPIRED_MESSAGE, + }); + } + if (ctx.user.role !== "owner" && ctx.user.role !== "admin") { throw new TRPCError({ code: "UNAUTHORIZED" }); } diff --git a/apps/dokploy/utils/auth-error.ts b/apps/dokploy/utils/auth-error.ts index 1b8c98fc2..94e5c53d6 100644 --- a/apps/dokploy/utils/auth-error.ts +++ b/apps/dokploy/utils/auth-error.ts @@ -1,27 +1,39 @@ +import { SESSION_EXPIRED_MESSAGE } from "@/server/api/auth-constants"; import { TRPCClientError } from "@trpc/client"; -const PUBLIC_PATH_PREFIXES = ["/register", "/invitation"]; +const PUBLIC_PATH_PREFIXES = [ + "/register", + "/invitation", + "/accept-invitation", + "/reset-password", + "/send-reset-password", +]; /** - * Returns true when the given error is a tRPC client error whose underlying - * response carried an UNAUTHORIZED code (i.e. the session is invalid/expired). + * Returns true only for the specific UNAUTHORIZED error that means the session + * is missing/expired (tagged server-side with SESSION_EXPIRED_MESSAGE). + * + * This is deliberately NOT every UNAUTHORIZED error: the API reuses the + * UNAUTHORIZED code for authenticated users who lack a role or resource + * permission. Redirecting those to login would yank logged-in users off their + * page on any permission denial, so we match the sentinel message instead. */ -export const isUnauthorizedError = (error: unknown): boolean => { +export const isSessionExpiredError = (error: unknown): boolean => { if (!(error instanceof TRPCClientError)) return false; - return (error.data as { code?: string } | null | undefined)?.code === - "UNAUTHORIZED"; + const code = (error.data as { code?: string } | null | undefined)?.code; + return code === "UNAUTHORIZED" && error.message === SESSION_EXPIRED_MESSAGE; }; /** * Decides whether an auth error should trigger a redirect to the login screen. - * Public/unauthenticated pages (login, register, invitation) never redirect, - * and only genuine UNAUTHORIZED errors qualify. + * Public/unauthenticated pages never redirect, and only a genuine expired + * session (not a permission denial) qualifies. */ export const shouldRedirectOnAuthError = ( pathname: string, error: unknown, ): boolean => { - if (!isUnauthorizedError(error)) return false; + if (!isSessionExpiredError(error)) return false; if (pathname === "/") return false; if (PUBLIC_PATH_PREFIXES.some((prefix) => pathname.startsWith(prefix))) return false;