mirror of
https://github.com/fosrl/pangolin.git
synced 2026-01-28 22:00:51 +00:00
2fa policy check working
This commit is contained in:
@@ -1340,6 +1340,15 @@
|
|||||||
"securityKeyUnknownError": "There was a problem using your security key. Please try again.",
|
"securityKeyUnknownError": "There was a problem using your security key. Please try again.",
|
||||||
"twoFactorRequired": "Two-factor authentication is required to register a security key.",
|
"twoFactorRequired": "Two-factor authentication is required to register a security key.",
|
||||||
"twoFactor": "Two-Factor Authentication",
|
"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.",
|
"adminEnabled2FaOnYourAccount": "Your administrator has enabled two-factor authentication for {email}. Please complete the setup process to continue.",
|
||||||
"securityKeyAdd": "Add Security Key",
|
"securityKeyAdd": "Add Security Key",
|
||||||
"securityKeyRegisterTitle": "Register New Security Key",
|
"securityKeyRegisterTitle": "Register New Security Key",
|
||||||
@@ -1726,6 +1735,18 @@
|
|||||||
"resourceExposePortsEditFile": "Edit file: docker-compose.yml",
|
"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.",
|
"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.",
|
"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",
|
"authPageErrorUpdateMessage": "An error occurred while updating the auth page settings",
|
||||||
"authPageUpdated": "Auth page updated successfully",
|
"authPageUpdated": "Auth page updated successfully",
|
||||||
"healthCheckNotAvailable": "Local",
|
"healthCheckNotAvailable": "Local",
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
import { Org, User } from "@server/db";
|
import { Org, User } from "@server/db";
|
||||||
|
|
||||||
type CheckOrgAccessPolicyProps = {
|
export type CheckOrgAccessPolicyProps = {
|
||||||
orgId?: string;
|
orgId?: string;
|
||||||
org?: Org;
|
org?: Org;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
user?: User;
|
user?: User;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CheckOrgAccessPolicyResult = {
|
||||||
|
allowed: boolean;
|
||||||
|
error?: string;
|
||||||
|
policies?: {
|
||||||
|
requiredTwoFactor?: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export async function checkOrgAccessPolicy(
|
export async function checkOrgAccessPolicy(
|
||||||
props: CheckOrgAccessPolicyProps
|
props: CheckOrgAccessPolicyProps
|
||||||
): Promise<{
|
): Promise<{
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { and, eq } from "drizzle-orm";
|
|||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
|
||||||
export async function verifyOrgAccess(
|
export async function verifyOrgAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -51,7 +52,9 @@ export async function verifyOrgAccess(
|
|||||||
userId
|
userId
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!policyCheck.success || policyCheck.error) {
|
logger.debug("Org check policy result", { policyCheck });
|
||||||
|
|
||||||
|
if (!policyCheck.allowed || policyCheck.error) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
|
|||||||
@@ -17,28 +17,26 @@ import { getOrgTierData } from "#private/lib/billing";
|
|||||||
import { TierId } from "@server/lib/billing/tiers";
|
import { TierId } from "@server/lib/billing/tiers";
|
||||||
import license from "#private/license/license";
|
import license from "#private/license/license";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
import {
|
||||||
type CheckOrgAccessPolicyProps = {
|
CheckOrgAccessPolicyProps,
|
||||||
orgId?: string;
|
CheckOrgAccessPolicyResult
|
||||||
org?: Org;
|
} from "@server/lib/checkOrgAccessPolicy";
|
||||||
userId?: string;
|
import { UserType } from "@server/types/UserTypes";
|
||||||
user?: User;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function checkOrgAccessPolicy(
|
export async function checkOrgAccessPolicy(
|
||||||
props: CheckOrgAccessPolicyProps
|
props: CheckOrgAccessPolicyProps
|
||||||
): Promise<{
|
): Promise<CheckOrgAccessPolicyResult> {
|
||||||
success: boolean;
|
|
||||||
error?: string;
|
|
||||||
}> {
|
|
||||||
const userId = props.userId || props.user?.userId;
|
const userId = props.userId || props.user?.userId;
|
||||||
const orgId = props.orgId || props.org?.orgId;
|
const orgId = props.orgId || props.org?.orgId;
|
||||||
|
|
||||||
if (!orgId) {
|
if (!orgId) {
|
||||||
return { success: false, error: "Organization ID is required" };
|
return {
|
||||||
|
allowed: false,
|
||||||
|
error: "Organization ID is required"
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return { success: false, error: "User ID is required" };
|
return { allowed: false, error: "User ID is required" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (build === "saas") {
|
if (build === "saas") {
|
||||||
@@ -46,7 +44,7 @@ export async function checkOrgAccessPolicy(
|
|||||||
const subscribed = tier === TierId.STANDARD;
|
const subscribed = tier === TierId.STANDARD;
|
||||||
// if not subscribed, don't check the policies
|
// if not subscribed, don't check the policies
|
||||||
if (!subscribed) {
|
if (!subscribed) {
|
||||||
return { success: true };
|
return { allowed: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +52,7 @@ export async function checkOrgAccessPolicy(
|
|||||||
const isUnlocked = await license.isUnlocked();
|
const isUnlocked = await license.isUnlocked();
|
||||||
// if not licensed, don't check the policies
|
// if not licensed, don't check the policies
|
||||||
if (!isUnlocked) {
|
if (!isUnlocked) {
|
||||||
return { success: true };
|
return { allowed: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +65,7 @@ export async function checkOrgAccessPolicy(
|
|||||||
.where(eq(orgs.orgId, orgId));
|
.where(eq(orgs.orgId, orgId));
|
||||||
props.org = orgQuery;
|
props.org = orgQuery;
|
||||||
if (!props.org) {
|
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));
|
.where(eq(users.userId, userId));
|
||||||
props.user = userQuery;
|
props.user = userQuery;
|
||||||
if (!props.user) {
|
if (!props.user) {
|
||||||
return { success: false, error: "User not found" };
|
return { allowed: false, error: "User not found" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// now check the policies
|
// now check the policies
|
||||||
|
const policies: CheckOrgAccessPolicyResult["policies"] = {};
|
||||||
|
|
||||||
if (!props.org.requireTwoFactor && !props.user.twoFactorEnabled) {
|
// only applies to internal users
|
||||||
return {
|
if (props.user.type === UserType.Internal && props.org.requireTwoFactor) {
|
||||||
success: false,
|
policies.requiredTwoFactor = props.user.twoFactorEnabled || false;
|
||||||
error: "Two-factor authentication is required"
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
const allowed = Object.values(policies).every((v) => v === true);
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed,
|
||||||
|
policies
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -606,6 +606,7 @@ authenticated.post(
|
|||||||
);
|
);
|
||||||
|
|
||||||
authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser);
|
authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser);
|
||||||
|
authenticated.get("/org/:orgId/user/:userId/check", org.checkOrgUserAccess);
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/user/:userId/2fa",
|
"/user/:userId/2fa",
|
||||||
@@ -675,8 +676,6 @@ authenticated.post(
|
|||||||
idp.updateOidcIdp
|
idp.updateOidcIdp
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
authenticated.delete("/idp/:idpId", verifyUserIsServerAdmin, idp.deleteIdp);
|
authenticated.delete("/idp/:idpId", verifyUserIsServerAdmin, idp.deleteIdp);
|
||||||
|
|
||||||
authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp);
|
authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp);
|
||||||
@@ -705,7 +704,6 @@ authenticated.get(
|
|||||||
idp.listIdpOrgPolicies
|
idp.listIdpOrgPolicies
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
authenticated.get("/idp", idp.listIdps); // anyone can see this; it's just a list of idp names and ids
|
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);
|
authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp);
|
||||||
|
|
||||||
|
|||||||
136
server/routers/org/checkOrgUserAccess.ts
Normal file
136
server/routers/org/checkOrgUserAccess.ts
Normal file
@@ -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<any> {
|
||||||
|
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<CheckOrgUserAccessResponse>(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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,3 +8,4 @@ export * from "./getOrgOverview";
|
|||||||
export * from "./listOrgs";
|
export * from "./listOrgs";
|
||||||
export * from "./pickOrgDefaults";
|
export * from "./pickOrgDefaults";
|
||||||
export * from "./applyBlueprint";
|
export * from "./applyBlueprint";
|
||||||
|
export * from "./checkOrgUserAccess";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { orgs } from "@server/db";
|
import { orgs, users } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
@@ -9,6 +9,11 @@ import createHttpError from "http-errors";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
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
|
const updateOrgParamsSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -18,7 +23,8 @@ const updateOrgParamsSchema = z
|
|||||||
|
|
||||||
const updateOrgBodySchema = z
|
const updateOrgBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().min(1).max(255).optional()
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
requireTwoFactor: z.boolean().optional()
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.refine((data) => Object.keys(data).length > 0, {
|
.refine((data) => Object.keys(data).length > 0, {
|
||||||
@@ -71,10 +77,30 @@ export async function updateOrg(
|
|||||||
|
|
||||||
const { orgId } = parsedParams.data;
|
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
|
const updatedOrg = await db
|
||||||
.update(orgs)
|
.update(orgs)
|
||||||
.set({
|
.set({
|
||||||
name: parsedBody.data.name
|
name: parsedBody.data.name,
|
||||||
|
requireTwoFactor: parsedBody.data.requireTwoFactor
|
||||||
})
|
})
|
||||||
.where(eq(orgs.orgId, orgId))
|
.where(eq(orgs.orgId, orgId))
|
||||||
.returning();
|
.returning();
|
||||||
@@ -102,3 +128,22 @@ export async function updateOrg(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function isLicensedOrSubscribed(orgId: string): Promise<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,11 +10,10 @@ import { fromError } from "zod-validation-error";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { generateSessionToken } from "@server/auth/sessions/app";
|
import { generateSessionToken } from "@server/auth/sessions/app";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import {
|
import { encodeHexLowerCase } from "@oslojs/encoding";
|
||||||
encodeHexLowerCase
|
|
||||||
} from "@oslojs/encoding";
|
|
||||||
import { sha256 } from "@oslojs/crypto/sha2";
|
import { sha256 } from "@oslojs/crypto/sha2";
|
||||||
import { response } from "@server/lib/response";
|
import { response } from "@server/lib/response";
|
||||||
|
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||||
|
|
||||||
const getExchangeTokenParams = z
|
const getExchangeTokenParams = z
|
||||||
.object({
|
.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(
|
const sessionId = encodeHexLowerCase(
|
||||||
sha256(new TextEncoder().encode(ssoSession))
|
sha256(new TextEncoder().encode(ssoSession))
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
import { verifySession } from "@app/lib/auth/verifySession";
|
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 { GetOrgUserResponse } from "@server/routers/user";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
@@ -11,6 +15,9 @@ import SubscriptionStatusProvider from "@app/providers/SubscriptionStatusProvide
|
|||||||
import { GetOrgSubscriptionResponse } from "@server/routers/billing/types";
|
import { GetOrgSubscriptionResponse } from "@server/routers/billing/types";
|
||||||
import { pullEnv } from "@app/lib/pullEnv";
|
import { pullEnv } from "@app/lib/pullEnv";
|
||||||
import { build } from "@server/build";
|
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: {
|
export default async function OrgLayout(props: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -32,25 +39,46 @@ export default async function OrgLayout(props: {
|
|||||||
redirect(`/`);
|
redirect(`/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let accessRes: CheckOrgUserAccessResponse | null = null;
|
||||||
try {
|
try {
|
||||||
const getOrgUser = cache(() =>
|
const checkOrgAccess = cache(() =>
|
||||||
internal.get<AxiosResponse<GetOrgUserResponse>>(
|
internal.get<AxiosResponse<CheckOrgUserAccessResponse>>(
|
||||||
`/org/${orgId}/user/${user.userId}`,
|
`/org/${orgId}/user/${user.userId}/check`,
|
||||||
cookie
|
cookie
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const orgUser = await getOrgUser();
|
const res = await checkOrgAccess();
|
||||||
} catch {
|
accessRes = res.data.data;
|
||||||
|
} catch (e) {
|
||||||
redirect(`/`);
|
redirect(`/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (!accessRes?.allowed) {
|
||||||
const getOrg = cache(() =>
|
// For non-admin users, show the member resources portal
|
||||||
internal.get<AxiosResponse<GetOrgResponse>>(`/org/${orgId}`, cookie)
|
let orgs: ListUserOrgsResponse["orgs"] = [];
|
||||||
|
try {
|
||||||
|
const getOrgs = cache(async () =>
|
||||||
|
internal.get<AxiosResponse<ListUserOrgsResponse>>(
|
||||||
|
`/user/${user.userId}/orgs`,
|
||||||
|
await authCookieHeader()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const res = await getOrgs();
|
||||||
|
if (res && res.data.data.orgs) {
|
||||||
|
orgs = res.data.data.orgs;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
return (
|
||||||
|
<UserProvider user={user}>
|
||||||
|
<Layout orgId={orgId} navItems={[]} orgs={orgs}>
|
||||||
|
<OrgPolicyResult
|
||||||
|
orgId={orgId}
|
||||||
|
userId={user.userId}
|
||||||
|
accessRes={accessRes}
|
||||||
|
/>
|
||||||
|
</Layout>
|
||||||
|
</UserProvider>
|
||||||
);
|
);
|
||||||
await getOrg();
|
|
||||||
} catch {
|
|
||||||
redirect(`/`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let subscriptionStatus = null;
|
let subscriptionStatus = null;
|
||||||
|
|||||||
3
src/app/[orgId]/policy/page.tsx
Normal file
3
src/app/[orgId]/policy/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export async function OrgPolicyPage() {
|
||||||
|
return <div>Org Policy Page</div>;
|
||||||
|
}
|
||||||
@@ -42,11 +42,15 @@ import {
|
|||||||
import { useUserContext } from "@app/hooks/useUserContext";
|
import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { build } from "@server/build";
|
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
|
// Schema for general organization settings
|
||||||
const GeneralFormSchema = z.object({
|
const GeneralFormSchema = z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
subnet: z.string().optional()
|
subnet: z.string().optional(),
|
||||||
|
requireTwoFactor: z.boolean().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
||||||
@@ -60,6 +64,8 @@ export default function GeneralPage() {
|
|||||||
const { user } = useUserContext();
|
const { user } = useUserContext();
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
|
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
|
||||||
|
const subscriptionStatus = useSubscriptionStatusContext();
|
||||||
|
|
||||||
const [loadingDelete, setLoadingDelete] = useState(false);
|
const [loadingDelete, setLoadingDelete] = useState(false);
|
||||||
const [loadingSave, setLoadingSave] = useState(false);
|
const [loadingSave, setLoadingSave] = useState(false);
|
||||||
@@ -69,7 +75,8 @@ export default function GeneralPage() {
|
|||||||
resolver: zodResolver(GeneralFormSchema),
|
resolver: zodResolver(GeneralFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: org?.org.name,
|
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"
|
mode: "onChange"
|
||||||
});
|
});
|
||||||
@@ -129,11 +136,15 @@ export default function GeneralPage() {
|
|||||||
setLoadingSave(true);
|
setLoadingSave(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Update organization
|
const reqData = {
|
||||||
await api.post(`/org/${org?.org.orgId}`, {
|
|
||||||
name: data.name
|
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
|
// Also save auth page settings if they have unsaved changes
|
||||||
if (
|
if (
|
||||||
@@ -168,9 +179,7 @@ export default function GeneralPage() {
|
|||||||
}}
|
}}
|
||||||
dialog={
|
dialog={
|
||||||
<div>
|
<div>
|
||||||
<p>
|
<p>{t("orgQuestionRemove")}</p>
|
||||||
{t("orgQuestionRemove")}
|
|
||||||
</p>
|
|
||||||
<p>{t("orgMessageRemove")}</p>
|
<p>{t("orgMessageRemove")}</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -241,45 +250,122 @@ export default function GeneralPage() {
|
|||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
{(build === "saas") && (
|
{/* Security Settings Section */}
|
||||||
<AuthPageSettings ref={authPageSettingsRef} />
|
<SettingsSection>
|
||||||
)}
|
<SettingsSectionHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("securitySettings")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
{build === "enterprise" && !isUnlocked() ? (
|
||||||
|
<Badge variant="outlinePrimary">
|
||||||
|
{build === "enterprise"
|
||||||
|
? t("licenseBadge")
|
||||||
|
: t("subscriptionBadge")}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t("securitySettingsDescription")}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="space-y-4"
|
||||||
|
id="security-settings-form"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="requireTwoFactor"
|
||||||
|
render={({ field }) => {
|
||||||
|
const isEnterpriseNotLicensed =
|
||||||
|
build === "enterprise" &&
|
||||||
|
!isUnlocked();
|
||||||
|
const isSaasNotSubscribed =
|
||||||
|
build === "saas" &&
|
||||||
|
!subscriptionStatus?.isSubscribed();
|
||||||
|
const isDisabled =
|
||||||
|
isEnterpriseNotLicensed ||
|
||||||
|
isSaasNotSubscribed;
|
||||||
|
const shouldDisableToggle = isDisabled;
|
||||||
|
|
||||||
{/* Save Button */}
|
return (
|
||||||
<div className="flex justify-end">
|
<FormItem className="col-span-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FormControl>
|
||||||
|
<SwitchInput
|
||||||
|
id="require-two-factor"
|
||||||
|
defaultChecked={
|
||||||
|
field.value ||
|
||||||
|
false
|
||||||
|
}
|
||||||
|
label={t(
|
||||||
|
"requireTwoFactorForAllUsers"
|
||||||
|
)}
|
||||||
|
disabled={
|
||||||
|
shouldDisableToggle
|
||||||
|
}
|
||||||
|
onCheckedChange={(
|
||||||
|
val
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
!shouldDisableToggle
|
||||||
|
) {
|
||||||
|
form.setValue(
|
||||||
|
"requireTwoFactor",
|
||||||
|
val
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
<FormMessage />
|
||||||
|
<FormDescription>
|
||||||
|
{isDisabled
|
||||||
|
? t(
|
||||||
|
"requireTwoFactorDisabledDescription"
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
"requireTwoFactorDescription"
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</SettingsSectionForm>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
{build === "saas" && <AuthPageSettings ref={authPageSettingsRef} />}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
{build !== "saas" && (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setIsDeleteModalOpen(true)}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
loading={loadingDelete}
|
||||||
|
disabled={loadingDelete}
|
||||||
|
>
|
||||||
|
{t("orgDelete")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
form="org-settings-form"
|
form="org-settings-form"
|
||||||
loading={loadingSave}
|
loading={loadingSave}
|
||||||
disabled={loadingSave}
|
disabled={loadingSave}
|
||||||
>
|
>
|
||||||
{t("saveGeneralSettings")}
|
{t("saveSettings")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{build !== "saas" && (
|
|
||||||
<SettingsSection>
|
|
||||||
<SettingsSectionHeader>
|
|
||||||
<SettingsSectionTitle>
|
|
||||||
{t("orgDangerZone")}
|
|
||||||
</SettingsSectionTitle>
|
|
||||||
<SettingsSectionDescription>
|
|
||||||
{t("orgDangerZoneDescription")}
|
|
||||||
</SettingsSectionDescription>
|
|
||||||
</SettingsSectionHeader>
|
|
||||||
<SettingsSectionFooter>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => setIsDeleteModalOpen(true)}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
loading={loadingDelete}
|
|
||||||
disabled={loadingDelete}
|
|
||||||
>
|
|
||||||
{t("orgDelete")}
|
|
||||||
</Button>
|
|
||||||
</SettingsSectionFooter>
|
|
||||||
</SettingsSection>
|
|
||||||
)}
|
|
||||||
</SettingsContainer>
|
</SettingsContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ import { build } from "@server/build";
|
|||||||
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
|
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
|
||||||
import { DomainRow } from "../../../../../../components/DomainsTable";
|
import { DomainRow } from "../../../../../../components/DomainsTable";
|
||||||
import { toASCII, toUnicode } from "punycode";
|
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() {
|
export default function GeneralForm() {
|
||||||
const [formKey, setFormKey] = useState(0);
|
const [formKey, setFormKey] = useState(0);
|
||||||
@@ -65,6 +68,9 @@ export default function GeneralForm() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const [editDomainOpen, setEditDomainOpen] = useState(false);
|
const [editDomainOpen, setEditDomainOpen] = useState(false);
|
||||||
|
const {licenseStatus } = useLicenseStatusContext();
|
||||||
|
const subscriptionStatus = useSubscriptionStatusContext();
|
||||||
|
const {user} = useUserContext();
|
||||||
|
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import { headers } from "next/headers";
|
|||||||
import { GetLoginPageResponse } from "@server/routers/loginPage/types";
|
import { GetLoginPageResponse } from "@server/routers/loginPage/types";
|
||||||
import { GetOrgTierResponse } from "@server/routers/billing/types";
|
import { GetOrgTierResponse } from "@server/routers/billing/types";
|
||||||
import { TierId } from "@server/lib/billing/tiers";
|
import { TierId } from "@server/lib/billing/tiers";
|
||||||
|
import { CheckOrgUserAccessResponse } from "@server/routers/org";
|
||||||
|
import OrgPolicyRequired from "@app/components/OrgPolicyRequired";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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<CheckOrgUserAccessResponse>
|
||||||
|
>(`/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 (
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<OrgPolicyRequired
|
||||||
|
orgId={authInfo.orgId}
|
||||||
|
policies={orgPolicyCheck.policies}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!hasAuth) {
|
if (!hasAuth) {
|
||||||
// no authentication so always go straight to the resource
|
// no authentication so always go straight to the resource
|
||||||
redirect(redirectUrl);
|
redirect(redirectUrl);
|
||||||
@@ -151,7 +181,7 @@ export default async function ResourceAuthPage(props: {
|
|||||||
>(
|
>(
|
||||||
`/resource/${authInfo.resourceId}/get-exchange-token`,
|
`/resource/${authInfo.resourceId}/get-exchange-token`,
|
||||||
{},
|
{},
|
||||||
await authCookieHeader()
|
cookie
|
||||||
);
|
);
|
||||||
|
|
||||||
if (res.data.data.requestToken) {
|
if (res.data.data.requestToken) {
|
||||||
|
|||||||
59
src/components/OrgPolicyRequired.tsx
Normal file
59
src/components/OrgPolicyRequired.tsx
Normal file
@@ -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 (
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-orange-100">
|
||||||
|
<Shield className="h-6 w-6 text-orange-600" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-xl font-semibold">
|
||||||
|
{t("additionalSecurityRequired")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t("organizationRequiresAdditionalSteps")}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="pt-4">
|
||||||
|
<Link href={`/${orgId}`}>
|
||||||
|
<Button className="w-full">
|
||||||
|
{t("completeSecuritySteps")}
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
195
src/components/OrgPolicyResult.tsx
Normal file
195
src/components/OrgPolicyResult.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<CheckCircle2 className="mx-auto h-12 w-12 text-green-500 mb-4" />
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
|
{t("accessGranted")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{t("noSecurityRequirements")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
{t("securityRequirements")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{allCompliant
|
||||||
|
? t("allRequirementsMet")
|
||||||
|
: t("completeRequirementsToContinue")}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div className="px-6 pb-4">
|
||||||
|
<div className="flex items-center justify-between text-sm text-muted-foreground mb-2">
|
||||||
|
<span>
|
||||||
|
{completedPolicies} of {totalPolicies} steps
|
||||||
|
completed
|
||||||
|
</span>
|
||||||
|
<span>{Math.round(progressPercentage)}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={progressPercentage} className="h-2" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{policies.map((policy) => (
|
||||||
|
<div
|
||||||
|
key={policy.id}
|
||||||
|
className="flex items-start gap-3 p-4 border rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0 mt-0.5">
|
||||||
|
{policy.compliant ? (
|
||||||
|
<CheckCircle2 className="h-5 w-5 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="h-5 w-5 text-red-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-sm font-medium">
|
||||||
|
{policy.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
{policy.description}
|
||||||
|
</p>
|
||||||
|
{policy.action && policy.actionText && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={policy.action}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
{policy.actionText}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{allCompliant && (
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm text-green-600 font-medium">
|
||||||
|
{t("allRequirementsMet")}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
{t("youCanNowAccessOrganization")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Enable2FaDialog
|
||||||
|
open={show2FaDialog}
|
||||||
|
setOpen={(val) => {
|
||||||
|
setShow2FaDialog(val);
|
||||||
|
router.refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user