mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-01 15:49:08 +00:00
Merge pull request #1757 from fosrl/user-compliance
Enforce org user compliance
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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", {
|
||||
|
||||
41
server/lib/checkOrgAccessPolicy.ts
Normal file
41
server/lib/checkOrgAccessPolicy.ts
Normal 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 };
|
||||
}
|
||||
@@ -40,6 +40,10 @@ export class License {
|
||||
public setServerSecret(secret: string) {
|
||||
this.serverSecret = secret;
|
||||
}
|
||||
|
||||
public async isUnlocked() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
await setHostMeta();
|
||||
|
||||
@@ -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(
|
||||
|
||||
201
server/private/lib/checkOrgAccessPolicy.ts
Normal file
201
server/private/lib/checkOrgAccessPolicy.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -98,7 +98,8 @@ export async function setServerAdmin(
|
||||
passwordHash,
|
||||
dateCreated: moment().toISOString(),
|
||||
serverAdmin: true,
|
||||
emailVerified: true
|
||||
emailVerified: true,
|
||||
lastPasswordChange: new Date().getTime()
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
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 {
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,3 +8,4 @@ export * from "./getOrgOverview";
|
||||
export * from "./listOrgs";
|
||||
export * from "./pickOrgDefaults";
|
||||
export * from "./applyBlueprint";
|
||||
export * from "./checkOrgUserAccess";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
87
src/components/ChangePasswordDialog.tsx
Normal file
87
src/components/ChangePasswordDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
647
src/components/ChangePasswordForm.tsx
Normal file
647
src/components/ChangePasswordForm.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
229
src/components/OrgPolicyResult.tsx
Normal file
229
src/components/OrgPolicyResult.tsx
Normal 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();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
)}
|
||||
|
||||
33
src/components/SecurityFeaturesAlert.tsx
Normal file
33
src/components/SecurityFeaturesAlert.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user