Merge pull request #1757 from fosrl/user-compliance

Enforce org user compliance
This commit is contained in:
Owen Schwartz
2025-10-27 10:44:13 -07:00
committed by GitHub
34 changed files with 2261 additions and 131 deletions

View File

@@ -90,7 +90,8 @@ export const setAdminCredentials: CommandModule<{}, SetAdminCredentialsArgs> = {
passwordHash,
dateCreated: moment().toISOString(),
serverAdmin: true,
emailVerified: true
emailVerified: true,
lastPasswordChange: new Date().getTime()
});
console.log("Server admin created");

View File

@@ -911,6 +911,18 @@
"passwordResetCodeDescription": "Check your email for the reset code.",
"passwordNew": "New Password",
"passwordNewConfirm": "Confirm New Password",
"changePassword": "Change Password",
"changePasswordDescription": "Update your account password",
"oldPassword": "Current Password",
"newPassword": "New Password",
"confirmNewPassword": "Confirm New Password",
"changePasswordError": "Failed to change password",
"changePasswordErrorDescription": "An error occurred while changing your password",
"changePasswordSuccess": "Password Changed Successfully",
"changePasswordSuccessDescription": "Your password has been updated successfully",
"passwordExpiryRequired": "Password Expiry Required",
"passwordExpiryDescription": "This organization requires you to change your password every {maxDays} days.",
"changePasswordNow": "Change Password Now",
"pincodeAuth": "Authenticator Code",
"pincodeSubmit2": "Submit Code",
"passwordResetSubmit": "Request Reset",
@@ -1340,6 +1352,19 @@
"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": "This organization requires 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",
"reauthenticationRequired": "Session Length",
"reauthenticationDescription": "This organization requires you to log in every {maxDays} days.",
"reauthenticationDescriptionHours": "This organization requires you to log in every {maxHours} hours.",
"reauthenticateNow": "Log In Again",
"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",
@@ -1719,6 +1744,7 @@
"orgAuthNoIdpConfigured": "This organization doesn't have any identity providers configured. You can log in with your Pangolin identity instead.",
"orgAuthSignInWithPangolin": "Sign in with Pangolin",
"subscriptionRequiredToUse": "A subscription is required to use this feature.",
"licenseRequiredToUse": "An Enterprise license is required to use this feature.",
"idpDisabled": "Identity providers are disabled.",
"orgAuthPageDisabled": "Organization auth page is disabled.",
"domainRestartedDescription": "Domain verification restarted successfully",
@@ -1726,6 +1752,47 @@
"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",
"maxSessionLength": "Maximum Session Length",
"maxSessionLengthDescription": "Set the maximum duration for user sessions. After this time, users will need to re-authenticate.",
"maxSessionLengthDisabledDescription": "This feature requires a valid license (Enterprise) or active subscription (SaaS)",
"selectSessionLength": "Select session length",
"unenforced": "Unenforced",
"1Hour": "1 hour",
"3Hours": "3 hours",
"6Hours": "6 hours",
"12Hours": "12 hours",
"1DaySession": "1 day",
"3Days": "3 days",
"7Days": "7 days",
"14Days": "14 days",
"30DaysSession": "30 days",
"90DaysSession": "90 days",
"180DaysSession": "180 days",
"passwordExpiryDays": "Password Expiry",
"editPasswordExpiryDescription": "Set the number of days before users are required to change their password.",
"selectPasswordExpiry": "Select password expiry",
"30Days": "30 days",
"1Day": "1 day",
"60Days": "60 days",
"90Days": "90 days",
"180Days": "180 days",
"1Year": "1 year",
"subscriptionBadge": "Subscription Required",
"securityPolicyChangeWarning": "Security Policy Change Warning",
"securityPolicyChangeDescription": "You are about to change security policy settings. After saving, you may need to reauthenticate to comply with these policy updates. All users who are not compliant will also need to reauthenticate.",
"securityPolicyChangeConfirmMessage": "I confirm",
"securityPolicyChangeWarningText": "This will affect all users in the organization",
"authPageErrorUpdateMessage": "An error occurred while updating the auth page settings",
"authPageUpdated": "Auth page updated successfully",
"healthCheckNotAvailable": "Local",

View File

@@ -39,7 +39,8 @@ export async function createSession(
const session: Session = {
sessionId: sessionId,
userId,
expiresAt: new Date(Date.now() + SESSION_COOKIE_EXPIRES).getTime()
expiresAt: new Date(Date.now() + SESSION_COOKIE_EXPIRES).getTime(),
issuedAt: new Date().getTime()
};
await db.insert(sessions).values(session);
return session;

View File

@@ -50,7 +50,8 @@ export async function createResourceSession(opts: {
doNotExtend: opts.doNotExtend || false,
accessTokenId: opts.accessTokenId || null,
isRequestToken: opts.isRequestToken || false,
userSessionId: opts.userSessionId || null
userSessionId: opts.userSessionId || null,
issuedAt: new Date().getTime()
};
await db.insert(resourceSessions).values(session);

View File

@@ -42,6 +42,9 @@ export const orgs = pgTable("orgs", {
name: varchar("name").notNull(),
subnet: varchar("subnet"),
createdAt: text("createdAt"),
requireTwoFactor: boolean("requireTwoFactor"),
maxSessionLengthHours: integer("maxSessionLengthHours"),
passwordExpiryDays: integer("passwordExpiryDays"),
settingsLogRetentionDaysRequest: integer("settingsLogRetentionDaysRequest") // where 0 = dont keep logs and -1 = keep forever
.notNull()
.default(7),
@@ -226,7 +229,8 @@ export const users = pgTable("user", {
dateCreated: varchar("dateCreated").notNull(),
termsAcceptedTimestamp: varchar("termsAcceptedTimestamp"),
termsVersion: varchar("termsVersion"),
serverAdmin: boolean("serverAdmin").notNull().default(false)
serverAdmin: boolean("serverAdmin").notNull().default(false),
lastPasswordChange: bigint("lastPasswordChange", { mode: "number" })
});
export const newts = pgTable("newt", {
@@ -252,7 +256,8 @@ export const sessions = pgTable("session", {
userId: varchar("userId")
.notNull()
.references(() => users.userId, { onDelete: "cascade" }),
expiresAt: bigint("expiresAt", { mode: "number" }).notNull()
expiresAt: bigint("expiresAt", { mode: "number" }).notNull(),
issuedAt: bigint("issuedAt", { mode: "number" })
});
export const newtSessions = pgTable("newtSession", {
@@ -469,7 +474,8 @@ export const resourceSessions = pgTable("resourceSessions", {
{
onDelete: "cascade"
}
)
),
issuedAt: bigint("issuedAt", { mode: "number" })
});
export const resourceWhitelist = pgTable("resourceWhitelist", {

View File

@@ -1,4 +1,4 @@
import { db, loginPage, LoginPage, loginPageOrg } from "@server/db";
import { db, loginPage, LoginPage, loginPageOrg, Org, orgs } from "@server/db";
import {
Resource,
ResourcePassword,
@@ -23,6 +23,7 @@ export type ResourceWithAuth = {
pincode: ResourcePincode | null;
password: ResourcePassword | null;
headerAuth: ResourceHeaderAuth | null;
org: Org;
};
export type UserSessionWithUser = {
@@ -51,6 +52,10 @@ export async function getResourceByDomain(
resourceHeaderAuth,
eq(resourceHeaderAuth.resourceId, resources.resourceId)
)
.innerJoin(
orgs,
eq(orgs.orgId, resources.orgId)
)
.where(eq(resources.fullDomain, domain))
.limit(1);
@@ -62,7 +67,8 @@ export async function getResourceByDomain(
resource: result.resources,
pincode: result.resourcePincode,
password: result.resourcePassword,
headerAuth: result.resourceHeaderAuth
headerAuth: result.resourceHeaderAuth,
org: result.orgs
};
}

View File

@@ -35,6 +35,9 @@ export const orgs = sqliteTable("orgs", {
name: text("name").notNull(),
subnet: text("subnet"),
createdAt: text("createdAt"),
requireTwoFactor: integer("requireTwoFactor", { mode: "boolean" }),
maxSessionLengthHours: integer("maxSessionLengthHours"), // hours
passwordExpiryDays: integer("passwordExpiryDays"), // days
settingsLogRetentionDaysRequest: integer("settingsLogRetentionDaysRequest") // where 0 = dont keep logs and -1 = keep forever
.notNull()
.default(7),
@@ -255,7 +258,8 @@ export const users = sqliteTable("user", {
termsVersion: text("termsVersion"),
serverAdmin: integer("serverAdmin", { mode: "boolean" })
.notNull()
.default(false)
.default(false),
lastPasswordChange: integer("lastPasswordChange")
});
export const securityKeys = sqliteTable("webauthnCredentials", {
@@ -360,7 +364,8 @@ export const sessions = sqliteTable("session", {
userId: text("userId")
.notNull()
.references(() => users.userId, { onDelete: "cascade" }),
expiresAt: integer("expiresAt").notNull()
expiresAt: integer("expiresAt").notNull(),
issuedAt: integer("issuedAt")
});
export const newtSessions = sqliteTable("newtSession", {
@@ -610,7 +615,8 @@ export const resourceSessions = sqliteTable("resourceSessions", {
{
onDelete: "cascade"
}
)
),
issuedAt: integer("issuedAt")
});
export const resourceWhitelist = sqliteTable("resourceWhitelist", {

View File

@@ -0,0 +1,41 @@
import { Org, ResourceSession, Session, User } from "@server/db";
export type CheckOrgAccessPolicyProps = {
orgId?: string;
org?: Org;
userId?: string;
user?: User;
sessionId?: string;
session?: Session;
};
export type CheckOrgAccessPolicyResult = {
allowed: boolean;
error?: string;
policies?: {
requiredTwoFactor?: boolean;
maxSessionLength?: {
compliant: boolean;
maxSessionLengthHours: number;
sessionAgeHours: number;
};
passwordAge?: {
compliant: boolean;
maxPasswordAgeDays: number;
passwordAgeDays: number;
};
};
};
export async function enforceResourceSessionLength(
resourceSession: ResourceSession,
org: Org
): Promise<{ valid: boolean; error?: string }> {
return { valid: true };
}
export async function checkOrgAccessPolicy(
props: CheckOrgAccessPolicyProps
): Promise<CheckOrgAccessPolicyResult> {
return { allowed: true };
}

View File

@@ -40,6 +40,10 @@ export class License {
public setServerSecret(secret: string) {
this.serverSecret = secret;
}
public async isUnlocked() {
return false;
}
}
await setHostMeta();

View File

@@ -1,9 +1,11 @@
import { Request, Response, NextFunction } from "express";
import { db } from "@server/db";
import { db, orgs } from "@server/db";
import { userOrgs } from "@server/db";
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,
@@ -43,12 +45,30 @@ export async function verifyOrgAccess(
"User does not have access to this organization"
)
);
} else {
// User has access, attach the user's role to the request for potential future use
req.userOrgRoleId = req.userOrg.roleId;
req.userOrgId = orgId;
return next();
}
const policyCheck = await checkOrgAccessPolicy({
orgId,
userId,
session: req.session
});
logger.debug("Org check policy result", { policyCheck });
if (!policyCheck.allowed || policyCheck.error) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Failed organization access policy check: " +
(policyCheck.error || "Unknown error")
)
);
}
// User has access, attach the user's role to the request for potential future use
req.userOrgRoleId = req.userOrg.roleId;
req.userOrgId = orgId;
return next();
} catch (e) {
return next(
createHttpError(

View File

@@ -0,0 +1,201 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { build } from "@server/build";
import {
db,
Org,
orgs,
ResourceSession,
sessions,
users
} from "@server/db";
import { getOrgTierData } from "#private/lib/billing";
import { TierId } from "@server/lib/billing/tiers";
import license from "#private/license/license";
import { eq } from "drizzle-orm";
import {
CheckOrgAccessPolicyProps,
CheckOrgAccessPolicyResult
} from "@server/lib/checkOrgAccessPolicy";
import { UserType } from "@server/types/UserTypes";
export async function enforceResourceSessionLength(
resourceSession: ResourceSession,
org: Org
): Promise<{ valid: boolean; error?: string }> {
if (org.maxSessionLengthHours) {
const sessionIssuedAt = resourceSession.issuedAt; // may be null
const maxSessionLengthHours = org.maxSessionLengthHours;
if (sessionIssuedAt) {
const maxSessionLengthMs = maxSessionLengthHours * 60 * 60 * 1000;
const sessionAgeMs = Date.now() - sessionIssuedAt;
if (sessionAgeMs > maxSessionLengthMs) {
return {
valid: false,
error: `Resource session has expired due to organization policy (max session length: ${maxSessionLengthHours} hours)`
};
}
} else {
return {
valid: false,
error: `Resource session is invalid due to organization policy (max session length: ${maxSessionLengthHours} hours)`
};
}
}
return { valid: true };
}
export async function checkOrgAccessPolicy(
props: CheckOrgAccessPolicyProps
): Promise<CheckOrgAccessPolicyResult> {
const userId = props.userId || props.user?.userId;
const orgId = props.orgId || props.org?.orgId;
const sessionId = props.sessionId || props.session?.sessionId;
if (!orgId) {
return {
allowed: false,
error: "Organization ID is required"
};
}
if (!userId) {
return { allowed: false, error: "User ID is required" };
}
if (!sessionId) {
return { allowed: false, error: "Session ID is required" };
}
if (build === "enterprise") {
const isUnlocked = await license.isUnlocked();
// if not licensed, don't check the policies
if (!isUnlocked) {
return { allowed: true };
}
}
// get the needed data
if (!props.org) {
const [orgQuery] = await db
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId));
props.org = orgQuery;
if (!props.org) {
return { allowed: false, error: "Organization not found" };
}
}
if (!props.user) {
const [userQuery] = await db
.select()
.from(users)
.where(eq(users.userId, userId));
props.user = userQuery;
if (!props.user) {
return { allowed: false, error: "User not found" };
}
}
if (!props.session) {
const [sessionQuery] = await db
.select()
.from(sessions)
.where(eq(sessions.sessionId, sessionId));
props.session = sessionQuery;
if (!props.session) {
return { allowed: false, error: "Session not found" };
}
}
if (props.session.userId !== props.user.userId) {
return {
allowed: false,
error: "Session does not belong to the user"
};
}
// now check the policies
const policies: CheckOrgAccessPolicyResult["policies"] = {};
// only applies to internal users; oidc users 2fa is managed by the IDP
if (props.user.type === UserType.Internal && props.org.requireTwoFactor) {
policies.requiredTwoFactor = props.user.twoFactorEnabled || false;
}
// applies to all users
if (props.org.maxSessionLengthHours) {
const sessionIssuedAt = props.session.issuedAt; // may be null
const maxSessionLengthHours = props.org.maxSessionLengthHours;
if (sessionIssuedAt) {
const maxSessionLengthMs = maxSessionLengthHours * 60 * 60 * 1000;
const sessionAgeMs = Date.now() - sessionIssuedAt;
policies.maxSessionLength = {
compliant: sessionAgeMs <= maxSessionLengthMs,
maxSessionLengthHours,
sessionAgeHours: sessionAgeMs / (60 * 60 * 1000)
};
} else {
policies.maxSessionLength = {
compliant: false,
maxSessionLengthHours,
sessionAgeHours: maxSessionLengthHours
};
}
}
// only applies to internal users; oidc users don't have passwords
if (props.user.type === UserType.Internal && props.org.passwordExpiryDays) {
if (props.user.lastPasswordChange) {
const passwordExpiryDays = props.org.passwordExpiryDays;
const passwordAgeMs = Date.now() - props.user.lastPasswordChange;
const passwordAgeDays = passwordAgeMs / (24 * 60 * 60 * 1000);
policies.passwordAge = {
compliant: passwordAgeDays <= passwordExpiryDays,
maxPasswordAgeDays: passwordExpiryDays,
passwordAgeDays: passwordAgeDays
};
} else {
policies.passwordAge = {
compliant: false,
maxPasswordAgeDays: props.org.passwordExpiryDays,
passwordAgeDays: props.org.passwordExpiryDays // Treat as expired
};
}
}
let allowed = true;
if (policies.requiredTwoFactor === false) {
allowed = false;
}
if (
policies.maxSessionLength &&
policies.maxSessionLength.compliant === false
) {
allowed = false;
}
if (policies.passwordAge && policies.passwordAge.compliant === false) {
allowed = false;
}
return {
allowed,
policies
};
}

View File

@@ -25,7 +25,7 @@ import { LoadLoginPageResponse } from "@server/routers/loginPage/types";
const querySchema = z.object({
resourceId: z.coerce.number().int().positive().optional(),
idpId: z.coerce.number().int().positive().optional(),
orgId: z.coerce.number().int().positive().optional(),
orgId: z.string().min(1).optional(),
fullDomain: z.string().min(1)
});
@@ -87,9 +87,8 @@ export async function loadLoginPage(
);
}
const { resourceId, idpId, fullDomain } = parsedQuery.data;
const { resourceId, idpId, fullDomain, orgId } = parsedQuery.data;
let orgId;
if (resourceId) {
const [resource] = await db
.select()
@@ -118,7 +117,7 @@ export async function loadLoginPage(
orgId = idpOrgLink.orgId;
} else if (parsedQuery.data.orgId) {
orgId = parsedQuery.data.orgId.toString();
orgId = parsedQuery.data.orgId;
}
const loginPage = await query(orgId, fullDomain);

View File

@@ -5,7 +5,6 @@ import { fromError } from "zod-validation-error";
import { z } from "zod";
import { db } from "@server/db";
import { User, users } from "@server/db";
import { eq } from "drizzle-orm";
import { response } from "@server/lib/response";
import {
hashPassword,
@@ -15,8 +14,13 @@ import { verifyTotpCode } from "@server/auth/totp";
import logger from "@server/logger";
import { unauthorized } from "@server/auth/unauthorizedResponse";
import { invalidateAllSessions } from "@server/auth/sessions/app";
import { sessions, resourceSessions } from "@server/db";
import { and, eq, ne, inArray } from "drizzle-orm";
import { passwordSchema } from "@server/auth/passwordSchema";
import { UserType } from "@server/types/UserTypes";
import { sendEmail } from "@server/emails";
import ConfirmPasswordReset from "@server/emails/templates/NotifyResetPassword";
import config from "@server/lib/config";
export const changePasswordBody = z
.object({
@@ -32,6 +36,46 @@ export type ChangePasswordResponse = {
codeRequested?: boolean;
};
async function invalidateAllSessionsExceptCurrent(
userId: string,
currentSessionId: string
): Promise<void> {
try {
await db.transaction(async (trx) => {
// Get all user sessions except the current one
const userSessions = await trx
.select()
.from(sessions)
.where(
and(
eq(sessions.userId, userId),
ne(sessions.sessionId, currentSessionId)
)
);
// Delete resource sessions for the sessions we're invalidating
if (userSessions.length > 0) {
await trx.delete(resourceSessions).where(
inArray(
resourceSessions.userSessionId,
userSessions.map((s) => s.sessionId)
)
);
}
// Delete the user sessions (except current)
await trx.delete(sessions).where(
and(
eq(sessions.userId, userId),
ne(sessions.sessionId, currentSessionId)
)
);
});
} catch (e) {
logger.error("Failed to invalidate user sessions except current", e);
}
}
export async function changePassword(
req: Request,
res: Response,
@@ -109,13 +153,24 @@ export async function changePassword(
await db
.update(users)
.set({
passwordHash: hash
passwordHash: hash,
lastPasswordChange: new Date().getTime()
})
.where(eq(users.userId, user.userId));
await invalidateAllSessions(user.userId);
// Invalidate all sessions except the current one
await invalidateAllSessionsExceptCurrent(user.userId, req.session.sessionId);
// TODO: send email to user confirming password change
try {
const email = user.email!;
await sendEmail(ConfirmPasswordReset({ email }), {
from: config.getNoReplyEmail(),
to: email,
subject: "Password Reset Confirmation"
});
} catch (e) {
logger.error("Failed to send password reset confirmation email", e);
}
return response(res, {
data: null,

View File

@@ -19,10 +19,7 @@ import { passwordSchema } from "@server/auth/passwordSchema";
export const resetPasswordBody = z
.object({
email: z
.string()
.toLowerCase()
.email(),
email: z.string().toLowerCase().email(),
token: z.string(), // reset secret code
newPassword: passwordSchema,
code: z.string().optional() // 2fa code
@@ -152,7 +149,7 @@ export async function resetPassword(
await db.transaction(async (trx) => {
await trx
.update(users)
.set({ passwordHash })
.set({ passwordHash, lastPasswordChange: new Date().getTime() })
.where(eq(users.userId, resetRequest[0].userId));
await trx

View File

@@ -98,7 +98,8 @@ export async function setServerAdmin(
passwordHash,
dateCreated: moment().toISOString(),
serverAdmin: true,
emailVerified: true
emailVerified: true,
lastPasswordChange: new Date().getTime()
});
});

View File

@@ -23,10 +23,7 @@ import { passwordSchema } from "@server/auth/passwordSchema";
import { UserType } from "@server/types/UserTypes";
import { createUserAccountOrg } from "@server/lib/createUserAccountOrg";
import { build } from "@server/build";
import resend, {
AudienceIds,
moveEmailToAudience
} from "#dynamic/lib/resend";
import resend, { AudienceIds, moveEmailToAudience } from "#dynamic/lib/resend";
export const signupBodySchema = z.object({
email: z.string().toLowerCase().email(),
@@ -183,7 +180,8 @@ export async function signup(
passwordHash,
dateCreated: moment().toISOString(),
termsAcceptedTimestamp: termsAcceptedTimestamp || null,
termsVersion: "1"
termsVersion: "1",
lastPasswordChange: new Date().getTime()
});
// give the user their default permissions:

View File

@@ -11,11 +11,13 @@ import {
} from "@server/db/queries/verifySessionQueries";
import {
LoginPage,
Org,
Resource,
ResourceHeaderAuth,
ResourcePassword,
ResourcePincode,
ResourceRule
ResourceRule,
resourceSessions
} from "@server/db";
import config from "@server/lib/config";
import { isIpInCidr } from "@server/lib/ip";
@@ -30,6 +32,10 @@ import { getCountryCodeForIp } from "@server/lib/geoip";
import { getOrgTierData } from "#dynamic/lib/billing";
import { TierId } from "@server/lib/billing/tiers";
import { verifyPassword } from "@server/auth/password";
import {
checkOrgAccessPolicy,
enforceResourceSessionLength
} from "#dynamic/lib/checkOrgAccessPolicy";
import { logRequestAudit } from "./logRequestAudit";
import cache from "@server/lib/cache";
@@ -135,6 +141,7 @@ export async function verifyResourceSession(
pincode: ResourcePincode | null;
password: ResourcePassword | null;
headerAuth: ResourceHeaderAuth | null;
org: Org;
}
| undefined = cache.get(resourceCacheKey);
@@ -333,7 +340,7 @@ export async function verifyResourceSession(
location: ipCC,
apiKey: {
name: tokenItem.title,
apiKeyId: tokenItem.accessTokenId,
apiKeyId: tokenItem.accessTokenId
}
},
parsedBody.data
@@ -384,7 +391,7 @@ export async function verifyResourceSession(
location: ipCC,
apiKey: {
name: tokenItem.title,
apiKeyId: tokenItem.accessTokenId,
apiKeyId: tokenItem.accessTokenId
}
},
parsedBody.data
@@ -408,7 +415,7 @@ export async function verifyResourceSession(
reason: 103, // valid header auth
resourceId: resource.resourceId,
orgId: resource.orgId,
location: ipCC,
location: ipCC
},
parsedBody.data
);
@@ -549,6 +556,20 @@ export async function verifyResourceSession(
}
if (resourceSession) {
// only run this check if not SSO sesion; SSO session length is checked later
const accessPolicy = await enforceResourceSessionLength(
resourceSession,
resourceData.org
);
if (!accessPolicy.valid) {
logger.debug(
"Resource session invalid due to org policy:",
accessPolicy.error
);
return notAllowed(res, redirectPath, resource.orgId);
}
if (pincode && resourceSession.pincodeId) {
logger.debug(
"Resource allowed because pincode session is valid"
@@ -623,7 +644,7 @@ export async function verifyResourceSession(
location: ipCC,
apiKey: {
name: resourceSession.accessTokenTitle,
apiKeyId: resourceSession.accessTokenId,
apiKeyId: resourceSession.accessTokenId
}
},
parsedBody.data
@@ -643,7 +664,8 @@ export async function verifyResourceSession(
if (allowedUserData === undefined) {
allowedUserData = await isUserAllowedToAccessResource(
resourceSession.userSessionId,
resource
resource,
resourceData.org
);
cache.set(userAccessCacheKey, allowedUserData, 5);
@@ -812,7 +834,8 @@ function allowed(res: Response, userData?: BasicUserData) {
async function isUserAllowedToAccessResource(
userSessionId: string,
resource: Resource
resource: Resource,
org: Org
): Promise<BasicUserData | null> {
const result = await getUserSessionWithUser(userSessionId);
@@ -839,6 +862,18 @@ async function isUserAllowedToAccessResource(
return null;
}
const accessPolicy = await checkOrgAccessPolicy({
org,
user,
session
});
if (!accessPolicy.allowed || accessPolicy.error) {
logger.debug(`User not allowed by org access policy because`, {
accessPolicy
});
return null;
}
const roleResourceAccess = await getRoleResourceAccess(
resource.resourceId,
userOrgRole.roleId

View File

@@ -667,6 +667,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",
@@ -737,8 +738,6 @@ authenticated.post(
idp.updateOidcIdp
);
authenticated.delete("/idp/:idpId", verifyUserIsServerAdmin, idp.deleteIdp);
authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp);
@@ -767,7 +766,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);
@@ -1058,11 +1056,11 @@ authRouter.post(
auth.requestEmailVerificationCode
);
// authRouter.post(
// "/change-password",
// verifySessionUserMiddleware,
// auth.changePassword
// );
authRouter.post(
"/change-password",
verifySessionUserMiddleware,
auth.changePassword
);
authRouter.post(
"/reset-password/request",

View 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 {
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,
session: req.session
});
// 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")
);
}
}

View File

@@ -8,3 +8,4 @@ export * from "./getOrgOverview";
export * from "./listOrgs";
export * from "./pickOrgDefaults";
export * from "./applyBlueprint";
export * from "./checkOrgUserAccess";

View File

@@ -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";
@@ -10,7 +10,9 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { build } from "@server/build";
import { getOrgTierData } from "@server/lib/billing";
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
@@ -22,6 +24,9 @@ const updateOrgParamsSchema = z
const updateOrgBodySchema = z
.object({
name: z.string().min(1).max(255).optional(),
requireTwoFactor: z.boolean().optional(),
maxSessionLengthHours: z.number().nullable().optional(),
passwordExpiryDays: z.number().nullable().optional(),
settingsLogRetentionDaysRequest: z
.number()
.min(build === "saas" ? 0 : -1)
@@ -86,9 +91,15 @@ export async function updateOrg(
const { orgId } = parsedParams.data;
const { tier } = await getOrgTierData(orgId); // returns null in oss
const isLicensed = await isLicensedOrSubscribed(orgId);
if (!isLicensed) {
parsedBody.data.requireTwoFactor = undefined;
parsedBody.data.maxSessionLengthHours = undefined;
parsedBody.data.passwordExpiryDays = undefined;
}
if (
tier != TierId.STANDARD &&
!isLicensed &&
parsedBody.data.settingsLogRetentionDaysRequest &&
parsedBody.data.settingsLogRetentionDaysRequest > 30
) {
@@ -103,7 +114,16 @@ export async function updateOrg(
const updatedOrg = await db
.update(orgs)
.set({
...parsedBody.data
name: parsedBody.data.name,
requireTwoFactor: parsedBody.data.requireTwoFactor,
maxSessionLengthHours: parsedBody.data.maxSessionLengthHours,
passwordExpiryDays: parsedBody.data.passwordExpiryDays,
settingsLogRetentionDaysRequest:
parsedBody.data.settingsLogRetentionDaysRequest,
settingsLogRetentionDaysAccess:
parsedBody.data.settingsLogRetentionDaysAccess,
settingsLogRetentionDaysAction:
parsedBody.data.settingsLogRetentionDaysAction
})
.where(eq(orgs.orgId, orgId))
.returning();
@@ -131,3 +151,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;
}

View File

@@ -13,7 +13,8 @@ import config from "@server/lib/config";
import { encodeHexLowerCase } from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2";
import { response } from "@server/lib/response";
import { logAccessAudit } from "@server/private/lib/logAccessAudit";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { logAccessAudit } from "#private/lib/logAccessAudit";
const getExchangeTokenParams = z
.object({
@@ -73,6 +74,23 @@ export async function getExchangeToken(
);
}
// check org policy here
const hasAccess = await checkOrgAccessPolicy({
orgId: resource.orgId,
userId: req.user!.userId,
session: req.session
});
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))
);

View File

@@ -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<AxiosResponse<GetOrgUserResponse>>(
`/org/${orgId}/user/${user.userId}`,
const checkOrgAccess = cache(() =>
internal.get<AxiosResponse<CheckOrgUserAccessResponse>>(
`/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<AxiosResponse<GetOrgResponse>>(`/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<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;

View File

@@ -19,6 +19,13 @@ import {
FormMessage
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import { z } from "zod";
import { useForm } from "react-hook-form";
@@ -42,21 +49,53 @@ import {
import { useUserContext } from "@app/hooks/useUserContext";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
import { SwitchInput } from "@app/components/SwitchInput";
import { SecurityFeaturesAlert } from "@app/components/SecurityFeaturesAlert";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { ChevronDown, SubscriptIcon } from "lucide-react";
import { ChevronDown } from "lucide-react";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
import { Alert, AlertDescription } from "@app/components/ui/alert";
// Session length options in hours
const SESSION_LENGTH_OPTIONS = [
{ value: null, labelKey: "unenforced" },
{ value: 1, labelKey: "1Hour" },
{ value: 3, labelKey: "3Hours" },
{ value: 6, labelKey: "6Hours" },
{ value: 12, labelKey: "12Hours" },
{ value: 24, labelKey: "1DaySession" },
{ value: 72, labelKey: "3Days" },
{ value: 168, labelKey: "7Days" },
{ value: 336, labelKey: "14Days" },
{ value: 720, labelKey: "30DaysSession" },
{ value: 2160, labelKey: "90DaysSession" },
{ value: 4320, labelKey: "180DaysSession" }
];
// Password expiry options in days - will be translated in component
const PASSWORD_EXPIRY_OPTIONS = [
{ value: null, labelKey: "neverExpire" },
{ value: 1, labelKey: "1Day" },
{ value: 30, labelKey: "30Days" },
{ value: 60, labelKey: "60Days" },
{ value: 90, labelKey: "90Days" },
{ value: 180, labelKey: "180Days" },
{ value: 365, labelKey: "1Year" }
];
// Schema for general organization settings
const GeneralFormSchema = z.object({
name: z.string(),
subnet: z.string().optional(),
requireTwoFactor: z.boolean().optional(),
maxSessionLengthHours: z.number().nullable().optional(),
passwordExpiryDays: z.number().nullable().optional(),
settingsLogRetentionDaysRequest: z.number(),
settingsLogRetentionDaysAccess: z.number(),
settingsLogRetentionDaysAction: z.number()
@@ -83,11 +122,21 @@ export default function GeneralPage() {
const { user } = useUserContext();
const t = useTranslations();
const { env } = useEnvContext();
const { isUnlocked } = useLicenseStatusContext();
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
const subscription = useSubscriptionStatusContext();
// Check if security features are disabled due to licensing/subscription
const isSecurityFeatureDisabled = () => {
const isEnterpriseNotLicensed = build === "enterprise" && !isUnlocked();
const isSaasNotSubscribed =
build === "saas" && !subscription?.isSubscribed();
return isEnterpriseNotLicensed || isSaasNotSubscribed;
};
const [loadingDelete, setLoadingDelete] = useState(false);
const [loadingSave, setLoadingSave] = useState(false);
const [isSecurityPolicyConfirmOpen, setIsSecurityPolicyConfirmOpen] =
useState(false);
const authPageSettingsRef = useRef<AuthPageSettingsRef>(null);
const form = useForm({
@@ -95,6 +144,9 @@ export default function GeneralPage() {
defaultValues: {
name: org?.org.name,
subnet: org?.org.subnet || "", // Add default value for subnet
requireTwoFactor: org?.org.requireTwoFactor || false,
maxSessionLengthHours: org?.org.maxSessionLengthHours || null,
passwordExpiryDays: org?.org.passwordExpiryDays || null,
settingsLogRetentionDaysRequest:
org.org.settingsLogRetentionDaysRequest ?? 15,
settingsLogRetentionDaysAccess:
@@ -105,6 +157,26 @@ export default function GeneralPage() {
mode: "onChange"
});
// Track initial security policy values
const initialSecurityValues = {
requireTwoFactor: org?.org.requireTwoFactor || false,
maxSessionLengthHours: org?.org.maxSessionLengthHours || null,
passwordExpiryDays: org?.org.passwordExpiryDays || null
};
// Check if security policies have changed
const hasSecurityPolicyChanged = () => {
const currentValues = form.getValues();
return (
currentValues.requireTwoFactor !==
initialSecurityValues.requireTwoFactor ||
currentValues.maxSessionLengthHours !==
initialSecurityValues.maxSessionLengthHours ||
currentValues.passwordExpiryDays !==
initialSecurityValues.passwordExpiryDays
);
};
async function deleteOrg() {
setLoadingDelete(true);
try {
@@ -157,20 +229,36 @@ export default function GeneralPage() {
}
async function onSubmit(data: GeneralFormValues) {
// Check if security policies have changed
if (hasSecurityPolicyChanged()) {
setIsSecurityPolicyConfirmOpen(true);
return;
}
await performSave(data);
}
async function performSave(data: GeneralFormValues) {
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
settingsLogRetentionDaysRequest:
data.settingsLogRetentionDaysRequest,
settingsLogRetentionDaysAccess:
data.settingsLogRetentionDaysAccess,
settingsLogRetentionDaysAction:
data.settingsLogRetentionDaysAction
});
} as any;
if (build !== "oss") {
reqData.requireTwoFactor = data.requireTwoFactor || false;
reqData.maxSessionLengthHours = data.maxSessionLengthHours;
reqData.passwordExpiryDays = data.passwordExpiryDays;
}
// Update organization
await api.post(`/org/${org?.org.orgId}`, reqData);
// Also save auth page settings if they have unsaved changes
if (
@@ -219,6 +307,20 @@ export default function GeneralPage() {
string={org?.org.name || ""}
title={t("orgDelete")}
/>
<ConfirmDeleteDialog
open={isSecurityPolicyConfirmOpen}
setOpen={setIsSecurityPolicyConfirmOpen}
dialog={
<div>
<p>{t("securityPolicyChangeDescription")}</p>
</div>
}
buttonText={t("saveSettings")}
onConfirm={() => performSave(form.getValues())}
string={t("securityPolicyChangeConfirmMessage")}
title={t("securityPolicyChangeWarning")}
warningText={t("securityPolicyChangeWarningText")}
/>
<Form {...form}>
<form
@@ -315,29 +417,35 @@ export default function GeneralPage() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-full">
{LOG_RETENTION_OPTIONS.filter((option) => {
if (build == "saas" && !subscription?.subscribed && option.value > 30) {
return false;
{LOG_RETENTION_OPTIONS.filter(
(option) => {
if (
build ==
"saas" &&
!subscription?.subscribed &&
option.value >
30
) {
return false;
}
return true;
}
return true;
}).map(
(option) => (
<DropdownMenuItem
key={
).map((option) => (
<DropdownMenuItem
key={
option.value
}
onClick={() =>
field.onChange(
option.value
}
onClick={() =>
field.onChange(
option.value
)
}
>
{t(
option.label
)}
</DropdownMenuItem>
)
)}
)
}
>
{t(
option.label
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
@@ -521,41 +629,265 @@ export default function GeneralPage() {
{build === "saas" && <AuthPageSettings ref={authPageSettingsRef} />}
{/* Save Button */}
<div className="flex justify-end">
{/* Security Settings Section */}
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("securitySettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("securitySettingsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SecurityFeaturesAlert />
<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 isDisabled =
isSecurityFeatureDisabled();
return (
<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={
isDisabled
}
onCheckedChange={(
val
) => {
if (
!isDisabled
) {
form.setValue(
"requireTwoFactor",
val
);
}
}}
/>
</FormControl>
</div>
<FormMessage />
<FormDescription>
{t(
"requireTwoFactorDescription"
)}
</FormDescription>
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="maxSessionLengthHours"
render={({ field }) => {
const isDisabled =
isSecurityFeatureDisabled();
return (
<FormItem className="col-span-2">
<FormLabel>
{t("maxSessionLength")}
</FormLabel>
<FormControl>
<Select
value={
field.value?.toString() ||
"null"
}
onValueChange={(
value
) => {
if (!isDisabled) {
const numValue =
value ===
"null"
? null
: parseInt(
value,
10
);
form.setValue(
"maxSessionLengthHours",
numValue
);
}
}}
disabled={isDisabled}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectSessionLength"
)}
/>
</SelectTrigger>
<SelectContent>
{SESSION_LENGTH_OPTIONS.map(
(option) => (
<SelectItem
key={
option.value ===
null
? "null"
: option.value.toString()
}
value={
option.value ===
null
? "null"
: option.value.toString()
}
>
{t(
option.labelKey
)}
</SelectItem>
)
)}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"maxSessionLengthDescription"
)}
</FormDescription>
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="passwordExpiryDays"
render={({ field }) => {
const isDisabled =
isSecurityFeatureDisabled();
return (
<FormItem className="col-span-2">
<FormLabel>
{t("passwordExpiryDays")}
</FormLabel>
<FormControl>
<Select
value={
field.value?.toString() ||
"null"
}
onValueChange={(
value
) => {
if (!isDisabled) {
const numValue =
value ===
"null"
? null
: parseInt(
value,
10
);
form.setValue(
"passwordExpiryDays",
numValue
);
}
}}
disabled={isDisabled}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectPasswordExpiry"
)}
/>
</SelectTrigger>
<SelectContent>
{PASSWORD_EXPIRY_OPTIONS.map(
(option) => (
<SelectItem
key={
option.value ===
null
? "null"
: option.value.toString()
}
value={
option.value ===
null
? "null"
: option.value.toString()
}
>
{t(
option.labelKey
)}
</SelectItem>
)
)}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<FormMessage />
{t(
"editPasswordExpiryDescription"
)}
</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
type="submit"
form="org-settings-form"
loading={loadingSave}
disabled={loadingSave}
>
{t("saveGeneralSettings")}
{t("saveSettings")}
</Button>
</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>
);
}

View File

@@ -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();

View File

@@ -13,6 +13,7 @@ import { AxiosResponse } from "axios";
import { ListIdpsResponse } from "@server/routers/idp";
import { getTranslations } from "next-intl/server";
import { build } from "@server/build";
import { LoadLoginPageResponse } from "@server/routers/loginPage/types";
export const dynamic = "force-dynamic";
@@ -33,6 +34,33 @@ export default async function Page(props: {
redirect("/");
}
// Check for orgId and redirect to org-specific login page if found
const orgId = searchParams.orgId as string | undefined;
let loginPageDomain: string | undefined;
if (orgId) {
try {
const res = await priv.get<AxiosResponse<LoadLoginPageResponse>>(
`/login-page?orgId=${orgId}`
);
if (res && res.status === 200 && res.data.data.fullDomain) {
loginPageDomain = res.data.data.fullDomain;
}
} catch (e) {
console.debug("No custom login page found for org", orgId);
}
}
if (loginPageDomain) {
const redirectUrl = searchParams.redirect as string | undefined;
let url = `https://${loginPageDomain}/auth/org`;
if (redirectUrl) {
url += `?redirect=${redirectUrl}`;
}
redirect(url);
}
let redirectUrl: string | undefined = undefined;
if (searchParams.redirect) {
redirectUrl = cleanRedirect(searchParams.redirect as string);

View File

@@ -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<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) {
// 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) {

View File

@@ -0,0 +1,87 @@
"use client";
import { useState, useRef } from "react";
import { Button } from "@/components/ui/button";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import ChangePasswordForm from "@app/components/ChangePasswordForm";
import { useTranslations } from "next-intl";
type ChangePasswordDialogProps = {
open: boolean;
setOpen: (val: boolean) => void;
};
export default function ChangePasswordDialog({ open, setOpen }: ChangePasswordDialogProps) {
const t = useTranslations();
const [currentStep, setCurrentStep] = useState(1);
const [loading, setLoading] = useState(false);
const formRef = useRef<{ handleSubmit: () => void }>(null);
function reset() {
setCurrentStep(1);
setLoading(false);
}
const handleSubmit = () => {
if (formRef.current) {
formRef.current.handleSubmit();
}
};
return (
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
reset();
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
{t('changePassword')}
</CredenzaTitle>
<CredenzaDescription>
{t('changePasswordDescription')}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<ChangePasswordForm
ref={formRef}
isDialog={true}
submitButtonText={t('submit')}
cancelButtonText="Close"
showCancelButton={false}
onComplete={() => setOpen(false)}
onStepChange={setCurrentStep}
onLoadingChange={setLoading}
/>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
{(currentStep === 1 || currentStep === 2) && (
<Button
type="button"
loading={loading}
disabled={loading}
onClick={handleSubmit}
>
{t('submit')}
</Button>
)}
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}

View File

@@ -0,0 +1,647 @@
"use client";
import { useState, forwardRef, useImperativeHandle, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { CheckCircle2, Check, X } from "lucide-react";
import { Progress } from "@/components/ui/progress";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { AxiosResponse } from "axios";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { useTranslations } from "next-intl";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot
} from "./ui/input-otp";
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
import { ChangePasswordResponse } from "@server/routers/auth";
import { cn } from "@app/lib/cn";
// Password strength calculation
const calculatePasswordStrength = (password: string) => {
const requirements = {
length: password.length >= 8,
uppercase: /[A-Z]/.test(password),
lowercase: /[a-z]/.test(password),
number: /[0-9]/.test(password),
special: /[~!`@#$%^&*()_\-+={}[\]|\\:;"'<>,.\/?]/.test(password)
};
const score = Object.values(requirements).filter(Boolean).length;
let strength: "weak" | "medium" | "strong" = "weak";
let color = "bg-red-500";
let percentage = 0;
if (score >= 5) {
strength = "strong";
color = "bg-green-500";
percentage = 100;
} else if (score >= 3) {
strength = "medium";
color = "bg-yellow-500";
percentage = 60;
} else if (score >= 1) {
strength = "weak";
color = "bg-red-500";
percentage = 30;
}
return { requirements, strength, color, percentage, score };
};
type ChangePasswordFormProps = {
onComplete?: () => void;
onCancel?: () => void;
isDialog?: boolean;
submitButtonText?: string;
cancelButtonText?: string;
showCancelButton?: boolean;
onStepChange?: (step: number) => void;
onLoadingChange?: (loading: boolean) => void;
};
const ChangePasswordForm = forwardRef<
{ handleSubmit: () => void },
ChangePasswordFormProps
>(
(
{
onComplete,
onCancel,
isDialog = false,
submitButtonText,
cancelButtonText,
showCancelButton = false,
onStepChange,
onLoadingChange
},
ref
) => {
const [step, setStep] = useState(1);
const [loading, setLoading] = useState(false);
const [newPasswordValue, setNewPasswordValue] = useState("");
const [confirmPasswordValue, setConfirmPasswordValue] = useState("");
const api = createApiClient(useEnvContext());
const t = useTranslations();
const passwordStrength = calculatePasswordStrength(newPasswordValue);
const doPasswordsMatch =
newPasswordValue.length > 0 &&
confirmPasswordValue.length > 0 &&
newPasswordValue === confirmPasswordValue;
// Notify parent of step and loading changes
useEffect(() => {
onStepChange?.(step);
}, [step, onStepChange]);
useEffect(() => {
onLoadingChange?.(loading);
}, [loading, onLoadingChange]);
const passwordSchema = z.object({
oldPassword: z.string().min(1, { message: t("passwordRequired") }),
newPassword: z.string().min(8, { message: t("passwordRequirementsChars") }),
confirmPassword: z.string().min(1, { message: t("passwordRequired") })
}).refine((data) => data.newPassword === data.confirmPassword, {
message: t("passwordsDoNotMatch"),
path: ["confirmPassword"],
});
const mfaSchema = z.object({
code: z.string().length(6, { message: t("pincodeInvalid") })
});
const passwordForm = useForm({
resolver: zodResolver(passwordSchema),
defaultValues: {
oldPassword: "",
newPassword: "",
confirmPassword: ""
}
});
const mfaForm = useForm({
resolver: zodResolver(mfaSchema),
defaultValues: {
code: ""
}
});
const changePassword = async (values: z.infer<typeof passwordSchema>) => {
setLoading(true);
const endpoint = `/auth/change-password`;
const payload = {
oldPassword: values.oldPassword,
newPassword: values.newPassword
};
const res = await api
.post<AxiosResponse<ChangePasswordResponse>>(endpoint, payload)
.catch((e) => {
toast({
title: t("changePasswordError"),
description: formatAxiosError(
e,
t("changePasswordErrorDescription")
),
variant: "destructive"
});
});
if (res && res.data) {
if (res.data.data?.codeRequested) {
setStep(2);
} else {
setStep(3);
}
}
setLoading(false);
};
const confirmMfa = async (values: z.infer<typeof mfaSchema>) => {
setLoading(true);
const endpoint = `/auth/change-password`;
const passwordValues = passwordForm.getValues();
const payload = {
oldPassword: passwordValues.oldPassword,
newPassword: passwordValues.newPassword,
code: values.code
};
const res = await api
.post<AxiosResponse<ChangePasswordResponse>>(endpoint, payload)
.catch((e) => {
toast({
title: t("changePasswordError"),
description: formatAxiosError(
e,
t("changePasswordErrorDescription")
),
variant: "destructive"
});
});
if (res && res.data) {
setStep(3);
}
setLoading(false);
};
const handleSubmit = () => {
if (step === 1) {
passwordForm.handleSubmit(changePassword)();
} else if (step === 2) {
mfaForm.handleSubmit(confirmMfa)();
}
};
const handleComplete = () => {
if (onComplete) {
onComplete();
}
};
useImperativeHandle(ref, () => ({
handleSubmit
}));
return (
<div className="space-y-4">
{step === 1 && (
<Form {...passwordForm}>
<form
onSubmit={passwordForm.handleSubmit(changePassword)}
className="space-y-4"
id="form"
>
<div className="space-y-4">
<FormField
control={passwordForm.control}
name="oldPassword"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("oldPassword")}
</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={passwordForm.control}
name="newPassword"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel>
{t("newPassword")}
</FormLabel>
{passwordStrength.strength ===
"strong" && (
<Check className="h-4 w-4 text-green-500" />
)}
</div>
<FormControl>
<div className="relative">
<Input
type="password"
{...field}
onChange={(e) => {
field.onChange(e);
setNewPasswordValue(
e.target.value
);
}}
className={cn(
passwordStrength.strength ===
"strong" &&
"border-green-500 focus-visible:ring-green-500",
passwordStrength.strength ===
"medium" &&
"border-yellow-500 focus-visible:ring-yellow-500",
passwordStrength.strength ===
"weak" &&
newPasswordValue.length >
0 &&
"border-red-500 focus-visible:ring-red-500"
)}
autoComplete="new-password"
/>
</div>
</FormControl>
{newPasswordValue.length > 0 && (
<div className="space-y-3 mt-2">
{/* Password Strength Meter */}
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-foreground">
{t("passwordStrength")}
</span>
<span
className={cn(
"text-sm font-semibold",
passwordStrength.strength ===
"strong" &&
"text-green-600 dark:text-green-400",
passwordStrength.strength ===
"medium" &&
"text-yellow-600 dark:text-yellow-400",
passwordStrength.strength ===
"weak" &&
"text-red-600 dark:text-red-400"
)}
>
{t(
`passwordStrength${passwordStrength.strength.charAt(0).toUpperCase() + passwordStrength.strength.slice(1)}`
)}
</span>
</div>
<Progress
value={
passwordStrength.percentage
}
className="h-2"
/>
</div>
{/* Requirements Checklist */}
<div className="bg-muted rounded-lg p-3 space-y-2">
<div className="text-sm font-medium text-foreground mb-2">
{t("passwordRequirements")}
</div>
<div className="grid grid-cols-1 gap-1.5">
<div className="flex items-center gap-2">
{passwordStrength
.requirements
.length ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span
className={cn(
"text-sm",
passwordStrength
.requirements
.length
? "text-green-600 dark:text-green-400"
: "text-muted-foreground"
)}
>
{t(
"passwordRequirementLengthText"
)}
</span>
</div>
<div className="flex items-center gap-2">
{passwordStrength
.requirements
.uppercase ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span
className={cn(
"text-sm",
passwordStrength
.requirements
.uppercase
? "text-green-600 dark:text-green-400"
: "text-muted-foreground"
)}
>
{t(
"passwordRequirementUppercaseText"
)}
</span>
</div>
<div className="flex items-center gap-2">
{passwordStrength
.requirements
.lowercase ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span
className={cn(
"text-sm",
passwordStrength
.requirements
.lowercase
? "text-green-600 dark:text-green-400"
: "text-muted-foreground"
)}
>
{t(
"passwordRequirementLowercaseText"
)}
</span>
</div>
<div className="flex items-center gap-2">
{passwordStrength
.requirements
.number ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span
className={cn(
"text-sm",
passwordStrength
.requirements
.number
? "text-green-600 dark:text-green-400"
: "text-muted-foreground"
)}
>
{t(
"passwordRequirementNumberText"
)}
</span>
</div>
<div className="flex items-center gap-2">
{passwordStrength
.requirements
.special ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span
className={cn(
"text-sm",
passwordStrength
.requirements
.special
? "text-green-600 dark:text-green-400"
: "text-muted-foreground"
)}
>
{t(
"passwordRequirementSpecialText"
)}
</span>
</div>
</div>
</div>
</div>
)}
{/* Only show FormMessage when not showing our custom requirements */}
{newPasswordValue.length === 0 && (
<FormMessage />
)}
</FormItem>
)}
/>
<FormField
control={passwordForm.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel>
{t("confirmNewPassword")}
</FormLabel>
{doPasswordsMatch && (
<Check className="h-4 w-4 text-green-500" />
)}
</div>
<FormControl>
<div className="relative">
<Input
type="password"
{...field}
onChange={(e) => {
field.onChange(e);
setConfirmPasswordValue(
e.target.value
);
}}
className={cn(
doPasswordsMatch &&
"border-green-500 focus-visible:ring-green-500",
confirmPasswordValue.length >
0 &&
!doPasswordsMatch &&
"border-red-500 focus-visible:ring-red-500"
)}
autoComplete="new-password"
/>
</div>
</FormControl>
{confirmPasswordValue.length > 0 &&
!doPasswordsMatch && (
<p className="text-sm text-red-600 mt-1">
{t("passwordsDoNotMatch")}
</p>
)}
{/* Only show FormMessage when field is empty */}
{confirmPasswordValue.length === 0 && (
<FormMessage />
)}
</FormItem>
)}
/>
</div>
</form>
</Form>
)}
{step === 2 && (
<div className="space-y-4">
<div className="text-center">
<h3 className="text-lg font-medium">{t("otpAuth")}</h3>
<p className="text-sm text-muted-foreground">
{t("otpAuthDescription")}
</p>
</div>
<Form {...mfaForm}>
<form
onSubmit={mfaForm.handleSubmit(confirmMfa)}
className="space-y-4"
id="form"
>
<FormField
control={mfaForm.control}
name="code"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex justify-center">
<InputOTP
maxLength={6}
{...field}
pattern={
REGEXP_ONLY_DIGITS_AND_CHARS
}
onChange={(
value: string
) => {
field.onChange(value);
if (
value.length === 6
) {
mfaForm.handleSubmit(
confirmMfa
)();
}
}}
>
<InputOTPGroup>
<InputOTPSlot
index={0}
/>
<InputOTPSlot
index={1}
/>
<InputOTPSlot
index={2}
/>
<InputOTPSlot
index={3}
/>
<InputOTPSlot
index={4}
/>
<InputOTPSlot
index={5}
/>
</InputOTPGroup>
</InputOTP>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</div>
)}
{step === 3 && (
<div className="space-y-4 text-center">
<CheckCircle2
className="mx-auto text-green-500"
size={48}
/>
<p className="font-semibold text-lg">
{t("changePasswordSuccess")}
</p>
<p>{t("changePasswordSuccessDescription")}</p>
</div>
)}
{/* Action buttons - only show when not in dialog */}
{!isDialog && (
<div className="flex gap-2 justify-end">
{showCancelButton && onCancel && (
<Button
variant="outline"
onClick={onCancel}
disabled={loading}
>
{cancelButtonText || "Cancel"}
</Button>
)}
{(step === 1 || step === 2) && (
<Button
type="button"
loading={loading}
disabled={loading}
onClick={handleSubmit}
className="w-full"
>
{submitButtonText || t("submit")}
</Button>
)}
{step === 3 && (
<Button
onClick={handleComplete}
className="w-full"
>
{t("continueToApplication")}
</Button>
)}
</div>
)}
</div>
);
}
);
export default ChangePasswordForm;

View File

@@ -54,6 +54,7 @@ type InviteUserFormProps = {
dialog: React.ReactNode;
buttonText: string;
onConfirm: () => Promise<void>;
warningText?: string;
};
export default function InviteUserForm({
@@ -63,7 +64,8 @@ export default function InviteUserForm({
title,
onConfirm,
buttonText,
dialog
dialog,
warningText
}: InviteUserFormProps) {
const [loading, setLoading] = useState(false);
@@ -86,13 +88,20 @@ export default function InviteUserForm({
function reset() {
form.reset();
setLoading(false);
}
async function onSubmit(values: z.infer<typeof formSchema>) {
setLoading(true);
await onConfirm();
reset();
try {
await onConfirm();
setOpen(false);
reset();
} catch (error) {
// Handle error if needed
console.error("Confirmation failed:", error);
} finally {
setLoading(false);
}
}
return (
@@ -111,8 +120,8 @@ export default function InviteUserForm({
<CredenzaBody>
<div className="mb-4 break-all overflow-hidden">
{dialog}
<div className="mt-2 mb-6 font-bold text-red-700">
{t("cannotbeUndone")}
<div className="mt-2 mb-6 font-bold text-destructive">
{warningText || t("cannotbeUndone")}
</div>
<div>

View 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>
);
}

View File

@@ -0,0 +1,229 @@
"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 ChangePasswordDialog from "./ChangePasswordDialog";
import { useTranslations } from "next-intl";
import { useUserContext } from "@app/hooks/useUserContext";
import { useRouter } from "next/navigation";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient } from "@app/lib/api";
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 [showChangePasswordDialog, setShowChangePasswordDialog] = useState(false);
const t = useTranslations();
const { user } = useUserContext();
const router = useRouter();
let requireedSteps = 0;
let completedSteps = 0;
const { env } = useEnvContext();
const api = createApiClient({ env });
const policies: PolicyItem[] = [];
if (
accessRes.policies?.requiredTwoFactor === false ||
accessRes.policies?.requiredTwoFactor === true
) {
const isTwoFactorCompliant =
accessRes.policies?.requiredTwoFactor === true;
policies.push({
id: "two-factor",
name: t("twoFactorAuthentication"),
description: t("twoFactorDescription"),
compliant: isTwoFactorCompliant,
action: !isTwoFactorCompliant
? () => setShow2FaDialog(true)
: undefined,
actionText: !isTwoFactorCompliant ? t("enableTwoFactor") : undefined
});
requireedSteps += 1;
if (isTwoFactorCompliant) {
completedSteps += 1;
}
}
// Add max session length policy if the organization has it enforced
if (accessRes.policies?.maxSessionLength) {
const maxSessionPolicy = accessRes.policies?.maxSessionLength;
const maxHours = maxSessionPolicy.maxSessionLengthHours;
// Use hours if less than 24, otherwise convert to days
const useHours = maxHours < 24;
const maxTime = useHours ? maxHours : Math.round(maxHours / 24);
const descriptionKey = useHours
? "reauthenticationDescriptionHours"
: "reauthenticationDescription";
const description = useHours
? t(descriptionKey, { maxHours })
: t(descriptionKey, { maxDays: maxTime });
policies.push({
id: "max-session-length",
name: t("reauthenticationRequired"),
description,
compliant: maxSessionPolicy.compliant,
action: !maxSessionPolicy.compliant
? async () => {
try {
await api.post("/auth/logout", undefined);
router.push(`/auth/login?orgId=${orgId}`);
} catch (error) {
console.error("Error during logout:", error);
router.push(`/auth/login?orgId=${orgId}`);
}
}
: undefined,
actionText: !maxSessionPolicy.compliant
? t("reauthenticateNow")
: undefined
});
requireedSteps += 1;
if (maxSessionPolicy.compliant) {
completedSteps += 1;
}
}
// Add password age policy if the organization has it enforced
if (accessRes.policies?.passwordAge) {
const passwordAgePolicy = accessRes.policies.passwordAge;
const maxDays = passwordAgePolicy.maxPasswordAgeDays;
const daysAgo = Math.round(passwordAgePolicy.passwordAgeDays);
policies.push({
id: "password-age",
name: t("passwordExpiryRequired"),
description: t("passwordExpiryDescription", {
maxDays,
daysAgo
}),
compliant: passwordAgePolicy.compliant,
action: !passwordAgePolicy.compliant
? () => setShowChangePasswordDialog(true)
: undefined,
actionText: !passwordAgePolicy.compliant
? t("changePasswordNow")
: undefined
});
requireedSteps += 1;
if (passwordAgePolicy.compliant) {
completedSteps += 1;
}
}
const progressPercentage =
requireedSteps === 0 ? 100 : (completedSteps / requireedSteps) * 100;
const allCompliant = completedSteps === requireedSteps;
return (
<>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{t("securityRequirements")}
</CardTitle>
<CardDescription>
{allCompliant
? t("allRequirementsMet")
: t("completeRequirementsToContinue")}
</CardDescription>
</CardHeader>
<div className="px-6 pb-4">
<div className="flex items-center justify-between text-sm text-muted-foreground mb-2">
<span>
{completedSteps} of {requireedSteps} 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>
<Enable2FaDialog
open={show2FaDialog}
setOpen={(val) => {
setShow2FaDialog(val);
router.refresh();
}}
/>
<ChangePasswordDialog
open={showChangePasswordDialog}
setOpen={(val) => {
setShowChangePasswordDialog(val);
router.refresh();
}}
/>
</>
);
}

View File

@@ -22,6 +22,7 @@ import { useUserContext } from "@app/hooks/useUserContext";
import Disable2FaForm from "./Disable2FaForm";
import SecurityKeyForm from "./SecurityKeyForm";
import Enable2FaDialog from "./Enable2FaDialog";
import ChangePasswordDialog from "./ChangePasswordDialog";
import SupporterStatus from "./SupporterStatus";
import { UserType } from "@server/types/UserTypes";
import LocaleSwitcher from "@app/components/LocaleSwitcher";
@@ -41,6 +42,7 @@ export default function ProfileIcon() {
const [openEnable2fa, setOpenEnable2fa] = useState(false);
const [openDisable2fa, setOpenDisable2fa] = useState(false);
const [openSecurityKey, setOpenSecurityKey] = useState(false);
const [openChangePassword, setOpenChangePassword] = useState(false);
const t = useTranslations();
@@ -78,6 +80,10 @@ export default function ProfileIcon() {
open={openSecurityKey}
setOpen={setOpenSecurityKey}
/>
<ChangePasswordDialog
open={openChangePassword}
setOpen={setOpenChangePassword}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -132,6 +138,11 @@ export default function ProfileIcon() {
>
<span>{t("securityKeyManage")}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setOpenChangePassword(true)}
>
<span>{t("changePassword")}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}

View File

@@ -0,0 +1,33 @@
"use client";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { build } from "@server/build";
import { useTranslations } from "next-intl";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
export function SecurityFeaturesAlert() {
const t = useTranslations();
const { isUnlocked } = useLicenseStatusContext();
const subscriptionStatus = useSubscriptionStatusContext();
return (
<>
{build === "saas" && !subscriptionStatus?.isSubscribed() ? (
<Alert variant="info" className="mb-6">
<AlertDescription>
{t("subscriptionRequiredToUse")}
</AlertDescription>
</Alert>
) : null}
{build === "enterprise" && !isUnlocked() ? (
<Alert variant="info" className="mb-6">
<AlertDescription>
{t("licenseRequiredToUse")}
</AlertDescription>
</Alert>
) : null}
</>
);
}