From 629f17294ab871562c864ab7a20e920eb8f3251b Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 24 Oct 2025 14:31:50 -0700 Subject: [PATCH] 2fa policy check working --- messages/en-US.json | 21 ++ server/lib/checkOrgAccessPolicy.ts | 10 +- server/middlewares/verifyOrgAccess.ts | 5 +- server/private/lib/checkOrgAccessPolicy.ts | 48 ++--- server/routers/external.ts | 4 +- server/routers/org/checkOrgUserAccess.ts | 136 ++++++++++++ server/routers/org/index.ts | 1 + server/routers/org/updateOrg.ts | 51 ++++- server/routers/resource/getExchangeToken.ts | 21 +- src/app/[orgId]/layout.tsx | 54 +++-- src/app/[orgId]/policy/page.tsx | 3 + src/app/[orgId]/settings/general/page.tsx | 166 +++++++++++---- .../resources/[niceId]/general/page.tsx | 6 + src/app/auth/resource/[resourceGuid]/page.tsx | 32 ++- src/components/OrgPolicyRequired.tsx | 59 ++++++ src/components/OrgPolicyResult.tsx | 195 ++++++++++++++++++ 16 files changed, 724 insertions(+), 88 deletions(-) create mode 100644 server/routers/org/checkOrgUserAccess.ts create mode 100644 src/app/[orgId]/policy/page.tsx create mode 100644 src/components/OrgPolicyRequired.tsx create mode 100644 src/components/OrgPolicyResult.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 4cffaf98..f7b98eac 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1340,6 +1340,15 @@ "securityKeyUnknownError": "There was a problem using your security key. Please try again.", "twoFactorRequired": "Two-factor authentication is required to register a security key.", "twoFactor": "Two-Factor Authentication", + "twoFactorAuthentication": "Two-Factor Authentication", + "twoFactorDescription": "Add an extra layer of security to your account with two-factor authentication", + "enableTwoFactor": "Enable Two-Factor Authentication", + "organizationSecurityPolicy": "Organization Security Policy", + "organizationSecurityPolicyDescription": "This organization has security requirements that must be met before you can access it", + "securityRequirements": "Security Requirements", + "allRequirementsMet": "All requirements have been met", + "completeRequirementsToContinue": "Complete the requirements below to continue accessing this organization", + "youCanNowAccessOrganization": "You can now access this organization", "adminEnabled2FaOnYourAccount": "Your administrator has enabled two-factor authentication for {email}. Please complete the setup process to continue.", "securityKeyAdd": "Add Security Key", "securityKeyRegisterTitle": "Register New Security Key", @@ -1726,6 +1735,18 @@ "resourceExposePortsEditFile": "Edit file: docker-compose.yml", "emailVerificationRequired": "Email verification is required. Please log in again via {dashboardUrl}/auth/login complete this step. Then, come back here.", "twoFactorSetupRequired": "Two-factor authentication setup is required. Please log in again via {dashboardUrl}/auth/login complete this step. Then, come back here.", + "additionalSecurityRequired": "Additional Security Required", + "organizationRequiresAdditionalSteps": "This organization requires additional security steps before you can access resources.", + "completeTheseSteps": "Complete these steps", + "enableTwoFactorAuthentication": "Enable two-factor authentication", + "completeSecuritySteps": "Complete Security Steps", + "securitySettings": "Security Settings", + "securitySettingsDescription": "Configure security policies for your organization", + "requireTwoFactorForAllUsers": "Require Two-Factor Authentication for All Users", + "requireTwoFactorDescription": "When enabled, all internal users in this organization must have two-factor authentication enabled to access the organization.", + "requireTwoFactorDisabledDescription": "This feature requires a valid license (Enterprise) or active subscription (SaaS)", + "requireTwoFactorCannotEnableDescription": "You must enable two-factor authentication for your account before enforcing it for all users", + "subscriptionBadge": "Subscription Required", "authPageErrorUpdateMessage": "An error occurred while updating the auth page settings", "authPageUpdated": "Auth page updated successfully", "healthCheckNotAvailable": "Local", diff --git a/server/lib/checkOrgAccessPolicy.ts b/server/lib/checkOrgAccessPolicy.ts index 3f99f7f4..42af5174 100644 --- a/server/lib/checkOrgAccessPolicy.ts +++ b/server/lib/checkOrgAccessPolicy.ts @@ -1,12 +1,20 @@ import { Org, User } from "@server/db"; -type CheckOrgAccessPolicyProps = { +export type CheckOrgAccessPolicyProps = { orgId?: string; org?: Org; userId?: string; user?: User; }; +export type CheckOrgAccessPolicyResult = { + allowed: boolean; + error?: string; + policies?: { + requiredTwoFactor?: boolean; + }; +}; + export async function checkOrgAccessPolicy( props: CheckOrgAccessPolicyProps ): Promise<{ diff --git a/server/middlewares/verifyOrgAccess.ts b/server/middlewares/verifyOrgAccess.ts index b26af280..22c11f16 100644 --- a/server/middlewares/verifyOrgAccess.ts +++ b/server/middlewares/verifyOrgAccess.ts @@ -5,6 +5,7 @@ import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import logger from "@server/logger"; export async function verifyOrgAccess( req: Request, @@ -51,7 +52,9 @@ export async function verifyOrgAccess( userId }); - if (!policyCheck.success || policyCheck.error) { + logger.debug("Org check policy result", { policyCheck }); + + if (!policyCheck.allowed || policyCheck.error) { return next( createHttpError( HttpCode.FORBIDDEN, diff --git a/server/private/lib/checkOrgAccessPolicy.ts b/server/private/lib/checkOrgAccessPolicy.ts index 31c1936f..addf6f81 100644 --- a/server/private/lib/checkOrgAccessPolicy.ts +++ b/server/private/lib/checkOrgAccessPolicy.ts @@ -17,28 +17,26 @@ import { getOrgTierData } from "#private/lib/billing"; import { TierId } from "@server/lib/billing/tiers"; import license from "#private/license/license"; import { eq } from "drizzle-orm"; - -type CheckOrgAccessPolicyProps = { - orgId?: string; - org?: Org; - userId?: string; - user?: User; -}; +import { + CheckOrgAccessPolicyProps, + CheckOrgAccessPolicyResult +} from "@server/lib/checkOrgAccessPolicy"; +import { UserType } from "@server/types/UserTypes"; export async function checkOrgAccessPolicy( props: CheckOrgAccessPolicyProps -): Promise<{ - success: boolean; - error?: string; -}> { +): Promise { const userId = props.userId || props.user?.userId; const orgId = props.orgId || props.org?.orgId; if (!orgId) { - return { success: false, error: "Organization ID is required" }; + return { + allowed: false, + error: "Organization ID is required" + }; } if (!userId) { - return { success: false, error: "User ID is required" }; + return { allowed: false, error: "User ID is required" }; } if (build === "saas") { @@ -46,7 +44,7 @@ export async function checkOrgAccessPolicy( const subscribed = tier === TierId.STANDARD; // if not subscribed, don't check the policies if (!subscribed) { - return { success: true }; + return { allowed: true }; } } @@ -54,7 +52,7 @@ export async function checkOrgAccessPolicy( const isUnlocked = await license.isUnlocked(); // if not licensed, don't check the policies if (!isUnlocked) { - return { success: true }; + return { allowed: true }; } } @@ -67,7 +65,7 @@ export async function checkOrgAccessPolicy( .where(eq(orgs.orgId, orgId)); props.org = orgQuery; if (!props.org) { - return { success: false, error: "Organization not found" }; + return { allowed: false, error: "Organization not found" }; } } @@ -78,18 +76,22 @@ export async function checkOrgAccessPolicy( .where(eq(users.userId, userId)); props.user = userQuery; if (!props.user) { - return { success: false, error: "User not found" }; + return { allowed: false, error: "User not found" }; } } // now check the policies + const policies: CheckOrgAccessPolicyResult["policies"] = {}; - if (!props.org.requireTwoFactor && !props.user.twoFactorEnabled) { - return { - success: false, - error: "Two-factor authentication is required" - }; + // only applies to internal users + if (props.user.type === UserType.Internal && props.org.requireTwoFactor) { + policies.requiredTwoFactor = props.user.twoFactorEnabled || false; } - return { success: true }; + const allowed = Object.values(policies).every((v) => v === true); + + return { + allowed, + policies + }; } diff --git a/server/routers/external.ts b/server/routers/external.ts index 8bd72f62..67daae77 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -606,6 +606,7 @@ authenticated.post( ); authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser); +authenticated.get("/org/:orgId/user/:userId/check", org.checkOrgUserAccess); authenticated.post( "/user/:userId/2fa", @@ -675,8 +676,6 @@ authenticated.post( idp.updateOidcIdp ); - - authenticated.delete("/idp/:idpId", verifyUserIsServerAdmin, idp.deleteIdp); authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp); @@ -705,7 +704,6 @@ authenticated.get( idp.listIdpOrgPolicies ); - authenticated.get("/idp", idp.listIdps); // anyone can see this; it's just a list of idp names and ids authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp); diff --git a/server/routers/org/checkOrgUserAccess.ts b/server/routers/org/checkOrgUserAccess.ts new file mode 100644 index 00000000..1a0c024c --- /dev/null +++ b/server/routers/org/checkOrgUserAccess.ts @@ -0,0 +1,136 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, idp, idpOidcConfig } from "@server/db"; +import { roles, userOrgs, users } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; +import { OpenAPITags, registry } from "@server/openApi"; +import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { CheckOrgAccessPolicyResult } from "@server/lib/checkOrgAccessPolicy"; + +async function queryUser(orgId: string, userId: string) { + const [user] = await db + .select({ + orgId: userOrgs.orgId, + userId: users.userId, + email: users.email, + username: users.username, + name: users.name, + type: users.type, + roleId: userOrgs.roleId, + roleName: roles.name, + isOwner: userOrgs.isOwner, + isAdmin: roles.isAdmin, + twoFactorEnabled: users.twoFactorEnabled, + autoProvisioned: userOrgs.autoProvisioned, + idpId: users.idpId, + idpName: idp.name, + idpType: idp.type, + idpVariant: idpOidcConfig.variant, + idpAutoProvision: idp.autoProvision + }) + .from(userOrgs) + .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) + .leftJoin(users, eq(userOrgs.userId, users.userId)) + .leftJoin(idp, eq(users.idpId, idp.idpId)) + .leftJoin(idpOidcConfig, eq(idp.idpId, idpOidcConfig.idpId)) + .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) + .limit(1); + return user; +} + +export type CheckOrgUserAccessResponse = CheckOrgAccessPolicyResult; + +const paramsSchema = z.object({ + userId: z.string(), + orgId: z.string() +}); + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/user/{userId}/check", + description: "Check a user's access in an organization.", + tags: [OpenAPITags.Org, OpenAPITags.User], + request: { + params: paramsSchema + }, + responses: {} +}); + +export async function checkOrgUserAccess( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + logger.debug("here0 ") + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, userId } = parsedParams.data; + + if (userId !== req.user?.userId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "You do not have permission to check this user's access" + ) + ); + } + + let user; + user = await queryUser(orgId, userId); + + if (!user) { + const [fullUser] = await db + .select() + .from(users) + .where(eq(users.email, userId)) + .limit(1); + + if (fullUser) { + user = await queryUser(orgId, fullUser.userId); + } + } + + if (!user) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `User with ID ${userId} not found in org` + ) + ); + } + + const policyCheck = await checkOrgAccessPolicy({ + orgId, + userId + }); + + // if we get here, the user has an org join, we just don't know if they pass the policies + return response(res, { + data: policyCheck, + success: true, + error: false, + message: "User access checked successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/org/index.ts b/server/routers/org/index.ts index 7887fcac..8ce01e92 100644 --- a/server/routers/org/index.ts +++ b/server/routers/org/index.ts @@ -8,3 +8,4 @@ export * from "./getOrgOverview"; export * from "./listOrgs"; export * from "./pickOrgDefaults"; export * from "./applyBlueprint"; +export * from "./checkOrgUserAccess"; diff --git a/server/routers/org/updateOrg.ts b/server/routers/org/updateOrg.ts index 64075cab..3a0d60df 100644 --- a/server/routers/org/updateOrg.ts +++ b/server/routers/org/updateOrg.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { orgs } from "@server/db"; +import { orgs, users } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -9,6 +9,11 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; +import { build } from "@server/build"; +import { UserType } from "@server/types/UserTypes"; +import license from "#dynamic/license/license"; +import { getOrgTierData } from "#dynamic/lib/billing"; +import { TierId } from "@server/lib/billing/tiers"; const updateOrgParamsSchema = z .object({ @@ -18,7 +23,8 @@ const updateOrgParamsSchema = z const updateOrgBodySchema = z .object({ - name: z.string().min(1).max(255).optional() + name: z.string().min(1).max(255).optional(), + requireTwoFactor: z.boolean().optional() }) .strict() .refine((data) => Object.keys(data).length > 0, { @@ -71,10 +77,30 @@ export async function updateOrg( const { orgId } = parsedParams.data; + const isLicensed = await isLicensedOrSubscribed(orgId); + if (!isLicensed) { + parsedBody.data.requireTwoFactor = undefined; + } + + if ( + req.user && + req.user.type === UserType.Internal && + parsedBody.data.requireTwoFactor === true && + !req.user.twoFactorEnabled + ) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "You must enable two-factor authentication for your account before enforcing it for all users" + ) + ); + } + const updatedOrg = await db .update(orgs) .set({ - name: parsedBody.data.name + name: parsedBody.data.name, + requireTwoFactor: parsedBody.data.requireTwoFactor }) .where(eq(orgs.orgId, orgId)) .returning(); @@ -102,3 +128,22 @@ export async function updateOrg( ); } } + +async function isLicensedOrSubscribed(orgId: string): Promise { + if (build === "enterprise") { + const isUnlocked = await license.isUnlocked(); + if (!isUnlocked) { + return false; + } + } + + if (build === "saas") { + const { tier } = await getOrgTierData(orgId); + const subscribed = tier === TierId.STANDARD; + if (!subscribed) { + return false; + } + } + + return true; +} diff --git a/server/routers/resource/getExchangeToken.ts b/server/routers/resource/getExchangeToken.ts index 605e5ca6..53aab82f 100644 --- a/server/routers/resource/getExchangeToken.ts +++ b/server/routers/resource/getExchangeToken.ts @@ -10,11 +10,10 @@ import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { generateSessionToken } from "@server/auth/sessions/app"; import config from "@server/lib/config"; -import { - encodeHexLowerCase -} from "@oslojs/encoding"; +import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; import { response } from "@server/lib/response"; +import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; const getExchangeTokenParams = z .object({ @@ -74,6 +73,22 @@ export async function getExchangeToken( ); } + // check org policy here + const hasAccess = await checkOrgAccessPolicy({ + orgId: resource[0].orgId, + userId: req.user!.userId + }); + + if (!hasAccess.allowed || hasAccess.error) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Failed organization access policy check: " + + (hasAccess.error || "Unknown error") + ) + ); + } + const sessionId = encodeHexLowerCase( sha256(new TextEncoder().encode(ssoSession)) ); diff --git a/src/app/[orgId]/layout.tsx b/src/app/[orgId]/layout.tsx index 0f67fc83..c307efcb 100644 --- a/src/app/[orgId]/layout.tsx +++ b/src/app/[orgId]/layout.tsx @@ -1,7 +1,11 @@ -import { internal } from "@app/lib/api"; +import { formatAxiosError, internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { verifySession } from "@app/lib/auth/verifySession"; -import { GetOrgResponse } from "@server/routers/org"; +import { + CheckOrgUserAccessResponse, + GetOrgResponse, + ListUserOrgsResponse +} from "@server/routers/org"; import { GetOrgUserResponse } from "@server/routers/user"; import { AxiosResponse } from "axios"; import { redirect } from "next/navigation"; @@ -11,6 +15,9 @@ import SubscriptionStatusProvider from "@app/providers/SubscriptionStatusProvide import { GetOrgSubscriptionResponse } from "@server/routers/billing/types"; import { pullEnv } from "@app/lib/pullEnv"; import { build } from "@server/build"; +import OrgPolicyResult from "@app/components/OrgPolicyResult"; +import UserProvider from "@app/providers/UserProvider"; +import { Layout } from "@app/components/Layout"; export default async function OrgLayout(props: { children: React.ReactNode; @@ -32,25 +39,46 @@ export default async function OrgLayout(props: { redirect(`/`); } + let accessRes: CheckOrgUserAccessResponse | null = null; try { - const getOrgUser = cache(() => - internal.get>( - `/org/${orgId}/user/${user.userId}`, + const checkOrgAccess = cache(() => + internal.get>( + `/org/${orgId}/user/${user.userId}/check`, cookie ) ); - const orgUser = await getOrgUser(); - } catch { + const res = await checkOrgAccess(); + accessRes = res.data.data; + } catch (e) { redirect(`/`); } - try { - const getOrg = cache(() => - internal.get>(`/org/${orgId}`, cookie) + if (!accessRes?.allowed) { + // For non-admin users, show the member resources portal + let orgs: ListUserOrgsResponse["orgs"] = []; + try { + const getOrgs = cache(async () => + internal.get>( + `/user/${user.userId}/orgs`, + await authCookieHeader() + ) + ); + const res = await getOrgs(); + if (res && res.data.data.orgs) { + orgs = res.data.data.orgs; + } + } catch (e) {} + return ( + + + + + ); - await getOrg(); - } catch { - redirect(`/`); } let subscriptionStatus = null; diff --git a/src/app/[orgId]/policy/page.tsx b/src/app/[orgId]/policy/page.tsx new file mode 100644 index 00000000..ea8d32ba --- /dev/null +++ b/src/app/[orgId]/policy/page.tsx @@ -0,0 +1,3 @@ +export async function OrgPolicyPage() { + return
Org Policy Page
; +} diff --git a/src/app/[orgId]/settings/general/page.tsx b/src/app/[orgId]/settings/general/page.tsx index b301fd32..b0f77959 100644 --- a/src/app/[orgId]/settings/general/page.tsx +++ b/src/app/[orgId]/settings/general/page.tsx @@ -42,11 +42,15 @@ import { import { useUserContext } from "@app/hooks/useUserContext"; import { useTranslations } from "next-intl"; import { build } from "@server/build"; - +import { SwitchInput } from "@app/components/SwitchInput"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; +import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; +import { Badge } from "@app/components/ui/badge"; // Schema for general organization settings const GeneralFormSchema = z.object({ name: z.string(), - subnet: z.string().optional() + subnet: z.string().optional(), + requireTwoFactor: z.boolean().optional() }); type GeneralFormValues = z.infer; @@ -60,6 +64,8 @@ export default function GeneralPage() { const { user } = useUserContext(); const t = useTranslations(); const { env } = useEnvContext(); + const { licenseStatus, isUnlocked } = useLicenseStatusContext(); + const subscriptionStatus = useSubscriptionStatusContext(); const [loadingDelete, setLoadingDelete] = useState(false); const [loadingSave, setLoadingSave] = useState(false); @@ -69,7 +75,8 @@ export default function GeneralPage() { resolver: zodResolver(GeneralFormSchema), defaultValues: { name: org?.org.name, - subnet: org?.org.subnet || "" // Add default value for subnet + subnet: org?.org.subnet || "", // Add default value for subnet + requireTwoFactor: org?.org.requireTwoFactor || false }, mode: "onChange" }); @@ -129,11 +136,15 @@ export default function GeneralPage() { setLoadingSave(true); try { - // Update organization - await api.post(`/org/${org?.org.orgId}`, { + const reqData = { name: data.name - // subnet: data.subnet // Include subnet in the API request - }); + } as any; + if (build !== "oss") { + reqData.requireTwoFactor = data.requireTwoFactor || false; + } + + // Update organization + await api.post(`/org/${org?.org.orgId}`, reqData); // Also save auth page settings if they have unsaved changes if ( @@ -168,9 +179,7 @@ export default function GeneralPage() { }} dialog={
-

- {t("orgQuestionRemove")} -

+

{t("orgQuestionRemove")}

{t("orgMessageRemove")}

} @@ -241,45 +250,122 @@ export default function GeneralPage() { - {(build === "saas") && ( - - )} + {/* Security Settings Section */} + + +
+ + {t("securitySettings")} + + {build === "enterprise" && !isUnlocked() ? ( + + {build === "enterprise" + ? t("licenseBadge") + : t("subscriptionBadge")} + + ) : null} +
+ + {t("securitySettingsDescription")} + +
+ + +
+ + { + const isEnterpriseNotLicensed = + build === "enterprise" && + !isUnlocked(); + const isSaasNotSubscribed = + build === "saas" && + !subscriptionStatus?.isSubscribed(); + const isDisabled = + isEnterpriseNotLicensed || + isSaasNotSubscribed; + const shouldDisableToggle = isDisabled; - {/* Save Button */} -
+ return ( + +
+ + { + if ( + !shouldDisableToggle + ) { + form.setValue( + "requireTwoFactor", + val + ); + } + }} + /> + +
+ + + {isDisabled + ? t( + "requireTwoFactorDisabledDescription" + ) + : t( + "requireTwoFactorDescription" + )} + +
+ ); + }} + /> + + + + + + + {build === "saas" && } + +
+ {build !== "saas" && ( + + )}
- - {build !== "saas" && ( - - - - {t("orgDangerZone")} - - - {t("orgDangerZoneDescription")} - - - - - - - )} ); } diff --git a/src/app/[orgId]/settings/resources/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[niceId]/general/page.tsx index 21d601ed..28f7754b 100644 --- a/src/app/[orgId]/settings/resources/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[niceId]/general/page.tsx @@ -56,6 +56,9 @@ import { build } from "@server/build"; import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils"; import { DomainRow } from "../../../../../../components/DomainsTable"; import { toASCII, toUnicode } from "punycode"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; +import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; +import { useUserContext } from "@app/hooks/useUserContext"; export default function GeneralForm() { const [formKey, setFormKey] = useState(0); @@ -65,6 +68,9 @@ export default function GeneralForm() { const router = useRouter(); const t = useTranslations(); const [editDomainOpen, setEditDomainOpen] = useState(false); + const {licenseStatus } = useLicenseStatusContext(); + const subscriptionStatus = useSubscriptionStatusContext(); + const {user} = useUserContext(); const { env } = useEnvContext(); diff --git a/src/app/auth/resource/[resourceGuid]/page.tsx b/src/app/auth/resource/[resourceGuid]/page.tsx index c6231c69..26d8bbea 100644 --- a/src/app/auth/resource/[resourceGuid]/page.tsx +++ b/src/app/auth/resource/[resourceGuid]/page.tsx @@ -22,6 +22,8 @@ import { headers } from "next/headers"; import { GetLoginPageResponse } from "@server/routers/loginPage/types"; import { GetOrgTierResponse } from "@server/routers/billing/types"; import { TierId } from "@server/lib/billing/tiers"; +import { CheckOrgUserAccessResponse } from "@server/routers/org"; +import OrgPolicyRequired from "@app/components/OrgPolicyRequired"; export const dynamic = "force-dynamic"; @@ -136,6 +138,34 @@ export default async function ResourceAuthPage(props: { ); } + const cookie = await authCookieHeader(); + + // Check org policy compliance before proceeding + let orgPolicyCheck: CheckOrgUserAccessResponse | null = null; + if (user && authInfo.orgId) { + try { + const policyRes = await internal.get< + AxiosResponse + >(`/org/${authInfo.orgId}/user/${user.userId}/check`, cookie); + + orgPolicyCheck = policyRes.data.data; + } catch (e) { + console.error(formatAxiosError(e)); + } + } + + // If user is not compliant with org policies, show policy requirements + if (orgPolicyCheck && !orgPolicyCheck.allowed && orgPolicyCheck.policies) { + return ( +
+ +
+ ); + } + if (!hasAuth) { // no authentication so always go straight to the resource redirect(redirectUrl); @@ -151,7 +181,7 @@ export default async function ResourceAuthPage(props: { >( `/resource/${authInfo.resourceId}/get-exchange-token`, {}, - await authCookieHeader() + cookie ); if (res.data.data.requestToken) { diff --git a/src/components/OrgPolicyRequired.tsx b/src/components/OrgPolicyRequired.tsx new file mode 100644 index 00000000..efc3d532 --- /dev/null +++ b/src/components/OrgPolicyRequired.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle +} from "@app/components/ui/card"; +import { Shield, ArrowRight } from "lucide-react"; +import Link from "next/link"; +import { useTranslations } from "next-intl"; + +type OrgPolicyRequiredProps = { + orgId: string; + policies: { + requiredTwoFactor?: boolean; + }; +}; + +export default function OrgPolicyRequired({ + orgId, + policies +}: OrgPolicyRequiredProps) { + const t = useTranslations(); + + const policySteps = []; + + if (policies?.requiredTwoFactor === false) { + policySteps.push(t("enableTwoFactorAuthentication")); + } + + return ( + + +
+ +
+ + {t("additionalSecurityRequired")} + + + {t("organizationRequiresAdditionalSteps")} + +
+ +
+ + + +
+
+
+ ); +} diff --git a/src/components/OrgPolicyResult.tsx b/src/components/OrgPolicyResult.tsx new file mode 100644 index 00000000..18c20e39 --- /dev/null +++ b/src/components/OrgPolicyResult.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { useState } from "react"; +import { CheckOrgUserAccessResponse } from "@server/routers/org"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle +} from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { CheckCircle2, XCircle, Shield } from "lucide-react"; +import Enable2FaDialog from "./Enable2FaDialog"; +import { useTranslations } from "next-intl"; +import { useUserContext } from "@app/hooks/useUserContext"; +import { useRouter } from "next/navigation"; + +type OrgPolicyResultProps = { + orgId: string; + userId: string; + accessRes: CheckOrgUserAccessResponse; +}; + +type PolicyItem = { + id: string; + name: string; + description: string; + compliant: boolean; + action?: () => void; + actionText?: string; +}; + +export default function OrgPolicyResult({ + orgId, + userId, + accessRes +}: OrgPolicyResultProps) { + const [show2FaDialog, setShow2FaDialog] = useState(false); + const t = useTranslations(); + const { user } = useUserContext(); + const router = useRouter(); + + // Determine if user is compliant with 2FA policy + const isTwoFactorCompliant = user?.twoFactorEnabled || false; + const policyKeys = Object.keys(accessRes.policies || {}); + + const policies: PolicyItem[] = []; + + // Only add 2FA policy if the organization has it enforced + if (policyKeys.includes("requiredTwoFactor")) { + policies.push({ + id: "two-factor", + name: t("twoFactorAuthentication"), + description: t("twoFactorDescription"), + compliant: isTwoFactorCompliant, + action: !isTwoFactorCompliant + ? () => setShow2FaDialog(true) + : undefined, + actionText: !isTwoFactorCompliant ? t("enableTwoFactor") : undefined + }); + + // policies.push({ + // id: "reauth-required", + // name: "Re-authentication", + // description: + // "It's been 30 days since you last verified your identity. Please log out and log back in to continue.", + // compliant: false, + // action: () => {}, + // actionText: "Log Out" + // }); + // + // policies.push({ + // id: "password-rotation", + // name: "Password Rotation", + // description: + // "It's been 30 days since you last changed your password. Please update your password to continue.", + // compliant: false, + // action: () => {}, + // actionText: "Change Password" + // }); + } + + const nonCompliantPolicies = policies.filter((policy) => !policy.compliant); + const allCompliant = + policies.length === 0 || nonCompliantPolicies.length === 0; + + // Calculate progress + const completedPolicies = policies.filter( + (policy) => policy.compliant + ).length; + const totalPolicies = policies.length; + const progressPercentage = + totalPolicies > 0 ? (completedPolicies / totalPolicies) * 100 : 100; + + // If no policies are enforced, show a simple success message + if (policies.length === 0) { + return ( +
+ +

+ {t("accessGranted")} +

+

+ {t("noSecurityRequirements")} +

+
+ ); + } + + return ( + <> + + + + {t("securityRequirements")} + + + {allCompliant + ? t("allRequirementsMet") + : t("completeRequirementsToContinue")} + + + + {/* Progress Bar */} +
+
+ + {completedPolicies} of {totalPolicies} steps + completed + + {Math.round(progressPercentage)}% +
+ +
+ + + {policies.map((policy) => ( +
+
+ {policy.compliant ? ( + + ) : ( + + )} +
+
+

+ {policy.name} +

+

+ {policy.description} +

+ {policy.action && policy.actionText && ( +
+ +
+ )} +
+
+ ))} +
+
+ + {allCompliant && ( +
+

+ {t("allRequirementsMet")} +

+

+ {t("youCanNowAccessOrganization")} +

+
+ )} + + { + setShow2FaDialog(val); + router.refresh(); + }} + /> + + ); +}