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..b1da8dcdd --- /dev/null +++ b/apps/dokploy/__test__/utils/auth-error.test.ts @@ -0,0 +1,89 @@ +import { SESSION_EXPIRED_MESSAGE } from "@/server/api/auth-constants"; +import { + isSessionExpiredError, + shouldRedirectOnAuthError, +} from "@/utils/auth-error"; +import { TRPCClientError } from "@trpc/client"; +import { describe, expect, test } from "vitest"; + +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; +}; + +const makeForbiddenError = () => { + const error = new TRPCClientError("Forbidden"); + (error as any).data = { code: "FORBIDDEN" }; + return error; +}; + +describe("isSessionExpiredError", () => { + test("returns true for an UNAUTHORIZED error tagged as session-expired", () => { + expect(isSessionExpiredError(makeSessionExpiredError())).toBe(true); + }); + + // 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 different code", () => { + expect(isSessionExpiredError(makeForbiddenError())).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 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 a session-expired error", () => { + expect( + shouldRedirectOnAuthError( + "/dashboard/project/abc", + 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()), + ).toBe(false); + expect( + shouldRedirectOnAuthError("/dashboard/project/abc", new Error("nope")), + ).toBe(false); + }); +}); 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/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..94e5c53d6 --- /dev/null +++ b/apps/dokploy/utils/auth-error.ts @@ -0,0 +1,57 @@ +import { SESSION_EXPIRED_MESSAGE } from "@/server/api/auth-constants"; +import { TRPCClientError } from "@trpc/client"; + +const PUBLIC_PATH_PREFIXES = [ + "/register", + "/invitation", + "/accept-invitation", + "/reset-password", + "/send-reset-password", +]; + +/** + * 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 isSessionExpiredError = (error: unknown): boolean => { + if (!(error instanceof TRPCClientError)) return false; + 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 never redirect, and only a genuine expired + * session (not a permission denial) qualifies. + */ +export const shouldRedirectOnAuthError = ( + pathname: string, + error: unknown, +): boolean => { + if (!isSessionExpiredError(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 = "/"; +};