mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-09-14 11:06:15 +05:00
Merge 7f236a3617 into 853ca33659
This commit is contained in:
commit
bfb5ff89d4
89
apps/dokploy/__test__/utils/auth-error.test.ts
Normal file
89
apps/dokploy/__test__/utils/auth-error.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
12
apps/dokploy/server/api/auth-constants.ts
Normal file
12
apps/dokploy/server/api/auth-constants.ts
Normal file
@ -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";
|
||||
@ -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<R extends Resource> = (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" });
|
||||
}
|
||||
|
||||
|
||||
@ -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<AppRouter>({
|
||||
config() {
|
||||
return { links };
|
||||
return { links, queryClientConfig };
|
||||
},
|
||||
ssr: false,
|
||||
transformer: superjson,
|
||||
|
||||
57
apps/dokploy/utils/auth-error.ts
Normal file
57
apps/dokploy/utils/auth-error.ts
Normal file
@ -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 = "/";
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user