refactor(sso): update trusted origins handling and introduce license validation

- Replaced user data fetching with a dedicated query for trusted origins in SSO settings.
- Updated mutation functions to utilize the new trusted origins query.
- Introduced a new service function to validate enterprise licenses based on organization ownership.
- Enhanced SSO router to ensure trusted origins are managed by the organization owner.
- Added callback URL for email sign-in in the home page.
This commit is contained in:
Mauricio Siu
2026-02-18 01:34:07 -06:00
parent 756d276f47
commit 6c3230648a
9 changed files with 132 additions and 43 deletions

View File

@@ -84,9 +84,10 @@ export const SSOSettings = () => {
const [newOriginInput, setNewOriginInput] = useState("");
const { data: providers, isLoading } = api.sso.listProviders.useQuery();
const { data: userData } = api.user.get.useQuery(undefined, {
enabled: manageOriginsOpen,
});
const { data: trustedOrigins = [] } = api.sso.getTrustedOrigins.useQuery(
undefined,
{ enabled: manageOriginsOpen },
);
const { mutateAsync: deleteProvider, isLoading: isDeleting } =
api.sso.deleteProvider.useMutation();
const { mutateAsync: addTrustedOrigin, isLoading: isAddingOrigin } =
@@ -96,8 +97,6 @@ export const SSOSettings = () => {
const { mutateAsync: updateTrustedOrigin, isLoading: isUpdatingOrigin } =
api.sso.updateTrustedOrigin.useMutation();
const trustedOrigins = userData?.user?.trustedOrigins ?? [];
const handleAddOrigin = async () => {
const value = newOriginInput.trim();
if (!value) return;
@@ -105,7 +104,7 @@ export const SSOSettings = () => {
await addTrustedOrigin({ origin: value });
toast.success("Trusted origin added");
setNewOriginInput("");
await utils.user.get.invalidate();
await utils.sso.getTrustedOrigins.invalidate();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to add trusted origin",
@@ -118,7 +117,7 @@ export const SSOSettings = () => {
await removeTrustedOrigin({ origin });
toast.success("Trusted origin removed");
if (editingOrigin === origin) setEditingOrigin(null);
await utils.user.get.invalidate();
await utils.sso.getTrustedOrigins.invalidate();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to remove trusted origin",
@@ -144,7 +143,7 @@ export const SSOSettings = () => {
toast.success("Trusted origin updated");
setEditingOrigin(null);
setEditingValue("");
await utils.user.get.invalidate();
await utils.sso.getTrustedOrigins.invalidate();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to update trusted origin",

View File

@@ -81,6 +81,7 @@ export default function Home({ IS_CLOUD }: Props) {
const { data, error } = await authClient.signIn.email({
email: values.email,
password: values.password,
callbackURL: "/dashboard/projects",
});
if (error) {
@@ -105,7 +106,6 @@ export default function Home({ IS_CLOUD }: Props) {
setIsLoginLoading(false);
}
};
const onTwoFactorSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (twoFactorCode.length !== 6) {

View File

@@ -1,5 +1,5 @@
import { user } from "@dokploy/server/db/schema";
import { validateLicenseKey } from "@dokploy/server/index";
import { hasValidLicense, validateLicenseKey } from "@dokploy/server/index";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { z } from "zod";
@@ -184,18 +184,7 @@ export const licenseKeyRouter = createTRPCRouter({
};
}),
haveValidLicenseKey: adminProcedure.query(async ({ ctx }) => {
const currentUserId = ctx.user.id;
const currentUser = await db.query.user.findFirst({
where: eq(user.id, currentUserId),
columns: {
enableEnterpriseFeatures: true,
isValidEnterpriseLicense: true,
},
});
return !!(
currentUser?.enableEnterpriseFeatures &&
currentUser?.isValidEnterpriseLicense
);
return await hasValidLicense(ctx.session.activeOrganizationId);
}),
updateEnterpriseSettings: adminProcedure
.input(

View File

@@ -2,7 +2,10 @@ import { normalizeTrustedOrigin } from "@dokploy/server";
import { IS_CLOUD } from "@dokploy/server/constants";
import { member, ssoProvider, user } from "@dokploy/server/db/schema";
import { ssoProviderBodySchema } from "@dokploy/server/db/schema/sso";
import { requestToHeaders } from "@dokploy/server/index";
import {
getOrganizationOwnerId,
requestToHeaders,
} from "@dokploy/server/index";
import { auth } from "@dokploy/server/lib/auth";
import { TRPCError } from "@trpc/server";
import { and, asc, eq } from "drizzle-orm";
@@ -59,6 +62,17 @@ export const ssoRouter = createTRPCRouter({
});
return providers;
}),
getTrustedOrigins: enterpriseProcedure.query(async ({ ctx }) => {
const ownerId = await getOrganizationOwnerId(
ctx.session.activeOrganizationId,
);
if (!ownerId) return [];
const ownerUser = await db.query.user.findFirst({
where: eq(user.id, ownerId),
columns: { trustedOrigins: true },
});
return ownerUser?.trustedOrigins ?? [];
}),
one: enterpriseProcedure
.input(z.object({ providerId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
@@ -135,11 +149,20 @@ export const ssoRouter = createTRPCRouter({
normalizeTrustedOrigin(existing.issuer) !==
normalizeTrustedOrigin(input.issuer);
if (issuerChanged) {
const currentUser = await db.query.user.findFirst({
where: eq(user.id, ctx.session.userId),
const ownerId = await getOrganizationOwnerId(
ctx.session.activeOrganizationId,
);
if (!ownerId) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Organization owner not found",
});
}
const ownerUser = await db.query.user.findFirst({
where: eq(user.id, ownerId),
columns: { trustedOrigins: true },
});
const trustedOrigins = currentUser?.trustedOrigins ?? [];
const trustedOrigins = ownerUser?.trustedOrigins ?? [];
const newOrigin = normalizeTrustedOrigin(input.issuer);
const isInTrustedOrigins = trustedOrigins.some(
(o) => o.toLowerCase() === newOrigin.toLowerCase(),
@@ -148,7 +171,7 @@ export const ssoRouter = createTRPCRouter({
throw new TRPCError({
code: "BAD_REQUEST",
message:
"The new Issuer URL is not in your trusted origins list. Please add it in Manage origins before saving.",
"The new Issuer URL is not in the organization's trusted origins list. Please add it in Manage origins before saving.",
});
}
}
@@ -262,12 +285,21 @@ export const ssoRouter = createTRPCRouter({
addTrustedOrigin: enterpriseProcedure
.input(z.object({ origin: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const ownerId = await getOrganizationOwnerId(
ctx.session.activeOrganizationId,
);
if (!ownerId) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Organization owner not found",
});
}
const normalized = normalizeTrustedOrigin(input.origin);
const currentUser = await db.query.user.findFirst({
where: eq(user.id, ctx.session.userId),
const ownerUser = await db.query.user.findFirst({
where: eq(user.id, ownerId),
columns: { trustedOrigins: true },
});
const existing = currentUser?.trustedOrigins || [];
const existing = ownerUser?.trustedOrigins || [];
if (existing.some((o) => o.toLowerCase() === normalized.toLowerCase())) {
return { success: true };
}
@@ -275,25 +307,34 @@ export const ssoRouter = createTRPCRouter({
await db
.update(user)
.set({ trustedOrigins: next })
.where(eq(user.id, ctx.session.userId));
.where(eq(user.id, ownerId));
return { success: true };
}),
removeTrustedOrigin: enterpriseProcedure
.input(z.object({ origin: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const ownerId = await getOrganizationOwnerId(
ctx.session.activeOrganizationId,
);
if (!ownerId) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Organization owner not found",
});
}
const normalized = normalizeTrustedOrigin(input.origin);
const currentUser = await db.query.user.findFirst({
where: eq(user.id, ctx.session.userId),
const ownerUser = await db.query.user.findFirst({
where: eq(user.id, ownerId),
columns: { trustedOrigins: true },
});
const existing = currentUser?.trustedOrigins || [];
const existing = ownerUser?.trustedOrigins || [];
const next = existing.filter(
(o) => o.toLowerCase() !== normalized.toLowerCase(),
);
await db
.update(user)
.set({ trustedOrigins: next })
.where(eq(user.id, ctx.session.userId));
.where(eq(user.id, ownerId));
return { success: true };
}),
updateTrustedOrigin: enterpriseProcedure
@@ -304,20 +345,29 @@ export const ssoRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
const ownerId = await getOrganizationOwnerId(
ctx.session.activeOrganizationId,
);
if (!ownerId) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Organization owner not found",
});
}
const oldNorm = normalizeTrustedOrigin(input.oldOrigin);
const newNorm = normalizeTrustedOrigin(input.newOrigin);
const currentUser = await db.query.user.findFirst({
where: eq(user.id, ctx.session.userId),
const ownerUser = await db.query.user.findFirst({
where: eq(user.id, ownerId),
columns: { trustedOrigins: true },
});
const existing = currentUser?.trustedOrigins || [];
const existing = ownerUser?.trustedOrigins || [];
const next = existing.map((o) =>
o.toLowerCase() === oldNorm.toLowerCase() ? newNorm : o,
);
await db
.update(user)
.set({ trustedOrigins: next })
.where(eq(user.id, ctx.session.userId));
.where(eq(user.id, ownerId));
return { success: true };
}),
});

View File

@@ -118,6 +118,14 @@ export const userRouter = createTRPCRouter({
return memberResult;
}),
getTrustedOrigins: protectedProcedure.query(async ({ ctx }) => {
const memberResult = await db.query.member.findFirst({
where: and(
eq(member.organizationId, ctx.session?.activeOrganizationId || ""),
),
});
return memberResult?.user?.trustedOrigins ?? [];
}),
haveRootAccess: protectedProcedure.query(async ({ ctx }) => {
if (!IS_CLOUD) {
return false;

View File

@@ -7,6 +7,7 @@
* need to use are documented accordingly near the end.
*/
import { hasValidLicense } from "@dokploy/server/index";
import { validateRequest } from "@dokploy/server/lib/auth";
import type { OpenApiMeta } from "@dokploy/trpc-openapi";
import { initTRPC, TRPCError } from "@trpc/server";
@@ -239,10 +240,11 @@ export const enterpriseProcedure = t.procedure.use(async ({ ctx, next }) => {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
if (
!ctx.user?.enableEnterpriseFeatures ||
!ctx.user.isValidEnterpriseLicense
) {
const hasValidLicenseResult = await hasValidLicense(
ctx.session.activeOrganizationId,
);
if (!hasValidLicenseResult) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Valid enterprise license required",

View File

@@ -31,6 +31,7 @@ export * from "./services/port";
export * from "./services/postgres";
export * from "./services/preview-deployment";
export * from "./services/project";
export * from "./services/proprietary/license-key";
export * from "./services/proprietary/sso";
export * from "./services/redirect";
export * from "./services/redis";

View File

@@ -0,0 +1,29 @@
import { db } from "@dokploy/server/db";
import { organization, user } from "@dokploy/server/db/schema";
import { eq } from "drizzle-orm";
export const hasValidLicense = async (organizationId: string) => {
// we need to find the owner of the organization
const organizationResult = await db.query.organization.findFirst({
where: eq(organization.id, organizationId),
columns: { ownerId: true },
});
if (!organizationResult) {
return false;
}
const ownerId = organizationResult?.ownerId;
const currentUser = await db.query.user.findFirst({
where: eq(user.id, ownerId),
columns: {
enableEnterpriseFeatures: true,
isValidEnterpriseLicense: true,
},
});
return !!(
currentUser?.enableEnterpriseFeatures &&
currentUser?.isValidEnterpriseLicense
);
};

View File

@@ -1,4 +1,6 @@
import { db } from "@dokploy/server/db";
import { organization } from "@dokploy/server/db/schema";
import { eq } from "drizzle-orm";
export const getSSOProviders = async () => {
const providers = await db.query.ssoProvider.findMany({
@@ -33,3 +35,12 @@ export const normalizeTrustedOrigin = (value: string): string => {
// e.g. "https://example.com/" -> "https://example.com"
return value.trim().replace(/\/+$/, "");
};
export const getOrganizationOwnerId = async (organizationId: string) => {
const org = await db.query.organization.findFirst({
where: eq(organization.id, organizationId),
columns: { ownerId: true },
});
if (!org) return null;
return org.ownerId;
};