mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-27 15:26:41 +00:00
add password expiry enforcement
This commit is contained in:
@@ -90,7 +90,8 @@ export const setAdminCredentials: CommandModule<{}, SetAdminCredentialsArgs> = {
|
|||||||
passwordHash,
|
passwordHash,
|
||||||
dateCreated: moment().toISOString(),
|
dateCreated: moment().toISOString(),
|
||||||
serverAdmin: true,
|
serverAdmin: true,
|
||||||
emailVerified: true
|
emailVerified: true,
|
||||||
|
lastPasswordChange: new Date().getTime()
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("Server admin created");
|
console.log("Server admin created");
|
||||||
|
|||||||
@@ -911,6 +911,18 @@
|
|||||||
"passwordResetCodeDescription": "Check your email for the reset code.",
|
"passwordResetCodeDescription": "Check your email for the reset code.",
|
||||||
"passwordNew": "New Password",
|
"passwordNew": "New Password",
|
||||||
"passwordNewConfirm": "Confirm 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",
|
"pincodeAuth": "Authenticator Code",
|
||||||
"pincodeSubmit2": "Submit Code",
|
"pincodeSubmit2": "Submit Code",
|
||||||
"passwordResetSubmit": "Request Reset",
|
"passwordResetSubmit": "Request Reset",
|
||||||
@@ -1753,6 +1765,9 @@
|
|||||||
"maxSessionLengthDescription": "Set the maximum duration for user sessions. After this time, users will need to re-authenticate.",
|
"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)",
|
"maxSessionLengthDisabledDescription": "This feature requires a valid license (Enterprise) or active subscription (SaaS)",
|
||||||
"selectSessionLength": "Select session length",
|
"selectSessionLength": "Select session length",
|
||||||
|
"passwordExpiryDays": "Password Expiry",
|
||||||
|
"passwordExpiryDescription": "Set the number of days before users are required to change their password.",
|
||||||
|
"selectPasswordExpiry": "Select password expiry",
|
||||||
"subscriptionBadge": "Subscription Required",
|
"subscriptionBadge": "Subscription Required",
|
||||||
"authPageErrorUpdateMessage": "An error occurred while updating the auth page settings",
|
"authPageErrorUpdateMessage": "An error occurred while updating the auth page settings",
|
||||||
"authPageUpdated": "Auth page updated successfully",
|
"authPageUpdated": "Auth page updated successfully",
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ export const orgs = pgTable("orgs", {
|
|||||||
subnet: varchar("subnet"),
|
subnet: varchar("subnet"),
|
||||||
createdAt: text("createdAt"),
|
createdAt: text("createdAt"),
|
||||||
requireTwoFactor: boolean("requireTwoFactor"),
|
requireTwoFactor: boolean("requireTwoFactor"),
|
||||||
maxSessionLengthHours: integer("maxSessionLengthHours")
|
maxSessionLengthHours: integer("maxSessionLengthHours"),
|
||||||
|
passwordExpiryDays: integer("passwordExpiryDays")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const orgDomains = pgTable("orgDomains", {
|
export const orgDomains = pgTable("orgDomains", {
|
||||||
@@ -201,7 +202,8 @@ export const users = pgTable("user", {
|
|||||||
dateCreated: varchar("dateCreated").notNull(),
|
dateCreated: varchar("dateCreated").notNull(),
|
||||||
termsAcceptedTimestamp: varchar("termsAcceptedTimestamp"),
|
termsAcceptedTimestamp: varchar("termsAcceptedTimestamp"),
|
||||||
termsVersion: varchar("termsVersion"),
|
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", {
|
export const newts = pgTable("newt", {
|
||||||
@@ -228,7 +230,7 @@ export const sessions = pgTable("session", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.userId, { onDelete: "cascade" }),
|
.references(() => users.userId, { onDelete: "cascade" }),
|
||||||
expiresAt: bigint("expiresAt", { mode: "number" }).notNull(),
|
expiresAt: bigint("expiresAt", { mode: "number" }).notNull(),
|
||||||
issuedAt: bigint("expiresAt", { mode: "number" })
|
issuedAt: bigint("issuedAt", { mode: "number" })
|
||||||
});
|
});
|
||||||
|
|
||||||
export const newtSessions = pgTable("newtSession", {
|
export const newtSessions = pgTable("newtSession", {
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ export const orgs = sqliteTable("orgs", {
|
|||||||
subnet: text("subnet"),
|
subnet: text("subnet"),
|
||||||
createdAt: text("createdAt"),
|
createdAt: text("createdAt"),
|
||||||
requireTwoFactor: integer("requireTwoFactor", { mode: "boolean" }),
|
requireTwoFactor: integer("requireTwoFactor", { mode: "boolean" }),
|
||||||
maxSessionLengthHours: integer("maxSessionLengthHours") // hours
|
maxSessionLengthHours: integer("maxSessionLengthHours"), // hours
|
||||||
|
passwordExpiryDays: integer("passwordExpiryDays") // days
|
||||||
});
|
});
|
||||||
|
|
||||||
export const userDomains = sqliteTable("userDomains", {
|
export const userDomains = sqliteTable("userDomains", {
|
||||||
@@ -229,7 +230,8 @@ export const users = sqliteTable("user", {
|
|||||||
termsVersion: text("termsVersion"),
|
termsVersion: text("termsVersion"),
|
||||||
serverAdmin: integer("serverAdmin", { mode: "boolean" })
|
serverAdmin: integer("serverAdmin", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false)
|
.default(false),
|
||||||
|
lastPasswordChange: integer("lastPasswordChange")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const securityKeys = sqliteTable("webauthnCredentials", {
|
export const securityKeys = sqliteTable("webauthnCredentials", {
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ export type CheckOrgAccessPolicyResult = {
|
|||||||
compliant: boolean;
|
compliant: boolean;
|
||||||
maxSessionLengthHours: number;
|
maxSessionLengthHours: number;
|
||||||
sessionAgeHours: number;
|
sessionAgeHours: number;
|
||||||
|
};
|
||||||
|
passwordAge?: {
|
||||||
|
compliant: boolean;
|
||||||
|
maxPasswordAgeDays: number;
|
||||||
|
passwordAgeDays: number;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -98,11 +98,12 @@ export async function checkOrgAccessPolicy(
|
|||||||
// now check the policies
|
// now check the policies
|
||||||
const policies: CheckOrgAccessPolicyResult["policies"] = {};
|
const policies: CheckOrgAccessPolicyResult["policies"] = {};
|
||||||
|
|
||||||
// only applies to internal users
|
// only applies to internal users; oidc users 2fa is managed by the IDP
|
||||||
if (props.user.type === UserType.Internal && props.org.requireTwoFactor) {
|
if (props.user.type === UserType.Internal && props.org.requireTwoFactor) {
|
||||||
policies.requiredTwoFactor = props.user.twoFactorEnabled || false;
|
policies.requiredTwoFactor = props.user.twoFactorEnabled || false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// applies to all users
|
||||||
if (props.org.maxSessionLengthHours) {
|
if (props.org.maxSessionLengthHours) {
|
||||||
const sessionIssuedAt = props.session.issuedAt; // may be null
|
const sessionIssuedAt = props.session.issuedAt; // may be null
|
||||||
const maxSessionLengthHours = props.org.maxSessionLengthHours;
|
const maxSessionLengthHours = props.org.maxSessionLengthHours;
|
||||||
@@ -124,11 +125,38 @@ export async function checkOrgAccessPolicy(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
let allowed = true;
|
||||||
if (policies.requiredTwoFactor === false) {
|
if (policies.requiredTwoFactor === false) {
|
||||||
allowed = false;
|
allowed = false;
|
||||||
}
|
}
|
||||||
if (policies.maxSessionLength && policies.maxSessionLength.compliant === false) {
|
if (
|
||||||
|
policies.maxSessionLength &&
|
||||||
|
policies.maxSessionLength.compliant === false
|
||||||
|
) {
|
||||||
|
allowed = false;
|
||||||
|
}
|
||||||
|
if (policies.passwordAge && policies.passwordAge.compliant === false) {
|
||||||
allowed = false;
|
allowed = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { fromError } from "zod-validation-error";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { User, users } from "@server/db";
|
import { User, users } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { response } from "@server/lib/response";
|
import { response } from "@server/lib/response";
|
||||||
import {
|
import {
|
||||||
hashPassword,
|
hashPassword,
|
||||||
@@ -15,6 +14,8 @@ import { verifyTotpCode } from "@server/auth/totp";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { unauthorized } from "@server/auth/unauthorizedResponse";
|
import { unauthorized } from "@server/auth/unauthorizedResponse";
|
||||||
import { invalidateAllSessions } from "@server/auth/sessions/app";
|
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 { passwordSchema } from "@server/auth/passwordSchema";
|
||||||
import { UserType } from "@server/types/UserTypes";
|
import { UserType } from "@server/types/UserTypes";
|
||||||
|
|
||||||
@@ -32,6 +33,46 @@ export type ChangePasswordResponse = {
|
|||||||
codeRequested?: boolean;
|
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(
|
export async function changePassword(
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
@@ -109,11 +150,13 @@ export async function changePassword(
|
|||||||
await db
|
await db
|
||||||
.update(users)
|
.update(users)
|
||||||
.set({
|
.set({
|
||||||
passwordHash: hash
|
passwordHash: hash,
|
||||||
|
lastPasswordChange: new Date().getTime()
|
||||||
})
|
})
|
||||||
.where(eq(users.userId, user.userId));
|
.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
|
// TODO: send email to user confirming password change
|
||||||
|
|
||||||
|
|||||||
@@ -19,10 +19,7 @@ import { passwordSchema } from "@server/auth/passwordSchema";
|
|||||||
|
|
||||||
export const resetPasswordBody = z
|
export const resetPasswordBody = z
|
||||||
.object({
|
.object({
|
||||||
email: z
|
email: z.string().toLowerCase().email(),
|
||||||
.string()
|
|
||||||
.toLowerCase()
|
|
||||||
.email(),
|
|
||||||
token: z.string(), // reset secret code
|
token: z.string(), // reset secret code
|
||||||
newPassword: passwordSchema,
|
newPassword: passwordSchema,
|
||||||
code: z.string().optional() // 2fa code
|
code: z.string().optional() // 2fa code
|
||||||
@@ -152,7 +149,7 @@ export async function resetPassword(
|
|||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
await trx
|
await trx
|
||||||
.update(users)
|
.update(users)
|
||||||
.set({ passwordHash })
|
.set({ passwordHash, lastPasswordChange: new Date().getTime() })
|
||||||
.where(eq(users.userId, resetRequest[0].userId));
|
.where(eq(users.userId, resetRequest[0].userId));
|
||||||
|
|
||||||
await trx
|
await trx
|
||||||
|
|||||||
@@ -98,7 +98,8 @@ export async function setServerAdmin(
|
|||||||
passwordHash,
|
passwordHash,
|
||||||
dateCreated: moment().toISOString(),
|
dateCreated: moment().toISOString(),
|
||||||
serverAdmin: true,
|
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 { UserType } from "@server/types/UserTypes";
|
||||||
import { createUserAccountOrg } from "@server/lib/createUserAccountOrg";
|
import { createUserAccountOrg } from "@server/lib/createUserAccountOrg";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import resend, {
|
import resend, { AudienceIds, moveEmailToAudience } from "#dynamic/lib/resend";
|
||||||
AudienceIds,
|
|
||||||
moveEmailToAudience
|
|
||||||
} from "#dynamic/lib/resend";
|
|
||||||
|
|
||||||
export const signupBodySchema = z.object({
|
export const signupBodySchema = z.object({
|
||||||
email: z.string().toLowerCase().email(),
|
email: z.string().toLowerCase().email(),
|
||||||
@@ -183,7 +180,8 @@ export async function signup(
|
|||||||
passwordHash,
|
passwordHash,
|
||||||
dateCreated: moment().toISOString(),
|
dateCreated: moment().toISOString(),
|
||||||
termsAcceptedTimestamp: termsAcceptedTimestamp || null,
|
termsAcceptedTimestamp: termsAcceptedTimestamp || null,
|
||||||
termsVersion: "1"
|
termsVersion: "1",
|
||||||
|
lastPasswordChange: new Date().getTime()
|
||||||
});
|
});
|
||||||
|
|
||||||
// give the user their default permissions:
|
// give the user their default permissions:
|
||||||
|
|||||||
@@ -973,11 +973,11 @@ authRouter.post(
|
|||||||
auth.requestEmailVerificationCode
|
auth.requestEmailVerificationCode
|
||||||
);
|
);
|
||||||
|
|
||||||
// authRouter.post(
|
authRouter.post(
|
||||||
// "/change-password",
|
"/change-password",
|
||||||
// verifySessionUserMiddleware,
|
verifySessionUserMiddleware,
|
||||||
// auth.changePassword
|
auth.changePassword
|
||||||
// );
|
);
|
||||||
|
|
||||||
authRouter.post(
|
authRouter.post(
|
||||||
"/reset-password/request",
|
"/reset-password/request",
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ const updateOrgBodySchema = z
|
|||||||
.object({
|
.object({
|
||||||
name: z.string().min(1).max(255).optional(),
|
name: z.string().min(1).max(255).optional(),
|
||||||
requireTwoFactor: z.boolean().optional(),
|
requireTwoFactor: z.boolean().optional(),
|
||||||
maxSessionLengthHours: z.number().nullable().optional()
|
maxSessionLengthHours: z.number().nullable().optional(),
|
||||||
|
passwordExpiryDays: z.number().nullable().optional()
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.refine((data) => Object.keys(data).length > 0, {
|
.refine((data) => Object.keys(data).length > 0, {
|
||||||
@@ -82,6 +83,7 @@ export async function updateOrg(
|
|||||||
if (!isLicensed) {
|
if (!isLicensed) {
|
||||||
parsedBody.data.requireTwoFactor = undefined;
|
parsedBody.data.requireTwoFactor = undefined;
|
||||||
parsedBody.data.maxSessionLengthHours = undefined;
|
parsedBody.data.maxSessionLengthHours = undefined;
|
||||||
|
parsedBody.data.passwordExpiryDays = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -103,7 +105,8 @@ export async function updateOrg(
|
|||||||
.set({
|
.set({
|
||||||
name: parsedBody.data.name,
|
name: parsedBody.data.name,
|
||||||
requireTwoFactor: parsedBody.data.requireTwoFactor,
|
requireTwoFactor: parsedBody.data.requireTwoFactor,
|
||||||
maxSessionLengthHours: parsedBody.data.maxSessionLengthHours
|
maxSessionLengthHours: parsedBody.data.maxSessionLengthHours,
|
||||||
|
passwordExpiryDays: parsedBody.data.passwordExpiryDays
|
||||||
})
|
})
|
||||||
.where(eq(orgs.orgId, orgId))
|
.where(eq(orgs.orgId, orgId))
|
||||||
.returning();
|
.returning();
|
||||||
|
|||||||
@@ -65,12 +65,23 @@ const SESSION_LENGTH_OPTIONS = [
|
|||||||
{ value: 4320, label: "180 days" } // 180 * 24 = 4320 hours
|
{ value: 4320, label: "180 days" } // 180 * 24 = 4320 hours
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Password expiry options in days
|
||||||
|
const PASSWORD_EXPIRY_OPTIONS = [
|
||||||
|
{ value: null, label: "Never Expire" },
|
||||||
|
{ value: 30, label: "30 days" },
|
||||||
|
{ value: 60, label: "60 days" },
|
||||||
|
{ value: 90, label: "90 days" },
|
||||||
|
{ value: 180, label: "180 days" },
|
||||||
|
{ value: 365, label: "1 year" }
|
||||||
|
];
|
||||||
|
|
||||||
// Schema for general organization settings
|
// Schema for general organization settings
|
||||||
const GeneralFormSchema = z.object({
|
const GeneralFormSchema = z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
subnet: z.string().optional(),
|
subnet: z.string().optional(),
|
||||||
requireTwoFactor: z.boolean().optional(),
|
requireTwoFactor: z.boolean().optional(),
|
||||||
maxSessionLengthHours: z.number().nullable().optional()
|
maxSessionLengthHours: z.number().nullable().optional(),
|
||||||
|
passwordExpiryDays: z.number().nullable().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
||||||
@@ -87,6 +98,14 @@ export default function GeneralPage() {
|
|||||||
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
|
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
|
||||||
const subscriptionStatus = useSubscriptionStatusContext();
|
const subscriptionStatus = useSubscriptionStatusContext();
|
||||||
|
|
||||||
|
// Check if security features are disabled due to licensing/subscription
|
||||||
|
const isSecurityFeatureDisabled = () => {
|
||||||
|
const isEnterpriseNotLicensed = build === "enterprise" && !isUnlocked();
|
||||||
|
const isSaasNotSubscribed =
|
||||||
|
build === "saas" && !subscriptionStatus?.isSubscribed();
|
||||||
|
return isEnterpriseNotLicensed || isSaasNotSubscribed;
|
||||||
|
};
|
||||||
|
|
||||||
const [loadingDelete, setLoadingDelete] = useState(false);
|
const [loadingDelete, setLoadingDelete] = useState(false);
|
||||||
const [loadingSave, setLoadingSave] = useState(false);
|
const [loadingSave, setLoadingSave] = useState(false);
|
||||||
const authPageSettingsRef = useRef<AuthPageSettingsRef>(null);
|
const authPageSettingsRef = useRef<AuthPageSettingsRef>(null);
|
||||||
@@ -97,7 +116,8 @@ export default function GeneralPage() {
|
|||||||
name: org?.org.name,
|
name: org?.org.name,
|
||||||
subnet: org?.org.subnet || "", // Add default value for subnet
|
subnet: org?.org.subnet || "", // Add default value for subnet
|
||||||
requireTwoFactor: org?.org.requireTwoFactor || false,
|
requireTwoFactor: org?.org.requireTwoFactor || false,
|
||||||
maxSessionLengthHours: org?.org.maxSessionLengthHours || null
|
maxSessionLengthHours: org?.org.maxSessionLengthHours || null,
|
||||||
|
passwordExpiryDays: org?.org.passwordExpiryDays || null
|
||||||
},
|
},
|
||||||
mode: "onChange"
|
mode: "onChange"
|
||||||
});
|
});
|
||||||
@@ -163,6 +183,7 @@ export default function GeneralPage() {
|
|||||||
if (build !== "oss") {
|
if (build !== "oss") {
|
||||||
reqData.requireTwoFactor = data.requireTwoFactor || false;
|
reqData.requireTwoFactor = data.requireTwoFactor || false;
|
||||||
reqData.maxSessionLengthHours = data.maxSessionLengthHours;
|
reqData.maxSessionLengthHours = data.maxSessionLengthHours;
|
||||||
|
reqData.passwordExpiryDays = data.passwordExpiryDays;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update organization
|
// Update organization
|
||||||
@@ -303,16 +324,8 @@ export default function GeneralPage() {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="requireTwoFactor"
|
name="requireTwoFactor"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
const isEnterpriseNotLicensed =
|
|
||||||
build === "enterprise" &&
|
|
||||||
!isUnlocked();
|
|
||||||
const isSaasNotSubscribed =
|
|
||||||
build === "saas" &&
|
|
||||||
!subscriptionStatus?.isSubscribed();
|
|
||||||
const isDisabled =
|
const isDisabled =
|
||||||
isEnterpriseNotLicensed ||
|
isSecurityFeatureDisabled();
|
||||||
isSaasNotSubscribed;
|
|
||||||
const shouldDisableToggle = isDisabled;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormItem className="col-span-2">
|
<FormItem className="col-span-2">
|
||||||
@@ -328,13 +341,13 @@ export default function GeneralPage() {
|
|||||||
"requireTwoFactorForAllUsers"
|
"requireTwoFactorForAllUsers"
|
||||||
)}
|
)}
|
||||||
disabled={
|
disabled={
|
||||||
shouldDisableToggle
|
isDisabled
|
||||||
}
|
}
|
||||||
onCheckedChange={(
|
onCheckedChange={(
|
||||||
val
|
val
|
||||||
) => {
|
) => {
|
||||||
if (
|
if (
|
||||||
!shouldDisableToggle
|
!isDisabled
|
||||||
) {
|
) {
|
||||||
form.setValue(
|
form.setValue(
|
||||||
"requireTwoFactor",
|
"requireTwoFactor",
|
||||||
@@ -347,13 +360,9 @@ export default function GeneralPage() {
|
|||||||
</div>
|
</div>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{isDisabled
|
{t(
|
||||||
? t(
|
"requireTwoFactorDescription"
|
||||||
"requireTwoFactorDisabledDescription"
|
)}
|
||||||
)
|
|
||||||
: t(
|
|
||||||
"requireTwoFactorDescription"
|
|
||||||
)}
|
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
);
|
);
|
||||||
@@ -363,15 +372,8 @@ export default function GeneralPage() {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="maxSessionLengthHours"
|
name="maxSessionLengthHours"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
const isEnterpriseNotLicensed =
|
|
||||||
build === "enterprise" &&
|
|
||||||
!isUnlocked();
|
|
||||||
const isSaasNotSubscribed =
|
|
||||||
build === "saas" &&
|
|
||||||
!subscriptionStatus?.isSubscribed();
|
|
||||||
const isDisabled =
|
const isDisabled =
|
||||||
isEnterpriseNotLicensed ||
|
isSecurityFeatureDisabled();
|
||||||
isSaasNotSubscribed;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormItem className="col-span-2">
|
<FormItem className="col-span-2">
|
||||||
@@ -384,10 +386,13 @@ export default function GeneralPage() {
|
|||||||
field.value?.toString() ||
|
field.value?.toString() ||
|
||||||
"null"
|
"null"
|
||||||
}
|
}
|
||||||
onValueChange={(value) => {
|
onValueChange={(
|
||||||
|
value
|
||||||
|
) => {
|
||||||
if (!isDisabled) {
|
if (!isDisabled) {
|
||||||
const numValue =
|
const numValue =
|
||||||
value === "null"
|
value ===
|
||||||
|
"null"
|
||||||
? null
|
? null
|
||||||
: parseInt(
|
: parseInt(
|
||||||
value,
|
value,
|
||||||
@@ -403,11 +408,9 @@ export default function GeneralPage() {
|
|||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue
|
<SelectValue
|
||||||
placeholder={
|
placeholder={t(
|
||||||
t(
|
"selectSessionLength"
|
||||||
"selectSessionLength"
|
)}
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -438,13 +441,90 @@ export default function GeneralPage() {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{isDisabled
|
{t(
|
||||||
? t(
|
"maxSessionLengthDescription"
|
||||||
"maxSessionLengthDisabledDescription"
|
)}
|
||||||
)
|
</FormDescription>
|
||||||
: t(
|
</FormItem>
|
||||||
"maxSessionLengthDescription"
|
);
|
||||||
)}
|
}}
|
||||||
|
/>
|
||||||
|
<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()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
option.label
|
||||||
|
}
|
||||||
|
</SelectItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
{t(
|
||||||
|
"passwordExpiryDescription"
|
||||||
|
)}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
);
|
);
|
||||||
|
|||||||
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;
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { CheckCircle2, XCircle, Shield } from "lucide-react";
|
import { CheckCircle2, XCircle, Shield } from "lucide-react";
|
||||||
import Enable2FaDialog from "./Enable2FaDialog";
|
import Enable2FaDialog from "./Enable2FaDialog";
|
||||||
|
import ChangePasswordDialog from "./ChangePasswordDialog";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useUserContext } from "@app/hooks/useUserContext";
|
import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
@@ -40,6 +41,7 @@ export default function OrgPolicyResult({
|
|||||||
accessRes
|
accessRes
|
||||||
}: OrgPolicyResultProps) {
|
}: OrgPolicyResultProps) {
|
||||||
const [show2FaDialog, setShow2FaDialog] = useState(false);
|
const [show2FaDialog, setShow2FaDialog] = useState(false);
|
||||||
|
const [showChangePasswordDialog, setShowChangePasswordDialog] = useState(false);
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const { user } = useUserContext();
|
const { user } = useUserContext();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -106,6 +108,33 @@ export default function OrgPolicyResult({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 =
|
const progressPercentage =
|
||||||
requireedSteps === 0 ? 100 : (completedSteps / requireedSteps) * 100;
|
requireedSteps === 0 ? 100 : (completedSteps / requireedSteps) * 100;
|
||||||
|
|
||||||
@@ -179,6 +208,14 @@ export default function OrgPolicyResult({
|
|||||||
router.refresh();
|
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 Disable2FaForm from "./Disable2FaForm";
|
||||||
import SecurityKeyForm from "./SecurityKeyForm";
|
import SecurityKeyForm from "./SecurityKeyForm";
|
||||||
import Enable2FaDialog from "./Enable2FaDialog";
|
import Enable2FaDialog from "./Enable2FaDialog";
|
||||||
|
import ChangePasswordDialog from "./ChangePasswordDialog";
|
||||||
import SupporterStatus from "./SupporterStatus";
|
import SupporterStatus from "./SupporterStatus";
|
||||||
import { UserType } from "@server/types/UserTypes";
|
import { UserType } from "@server/types/UserTypes";
|
||||||
import LocaleSwitcher from "@app/components/LocaleSwitcher";
|
import LocaleSwitcher from "@app/components/LocaleSwitcher";
|
||||||
@@ -41,6 +42,7 @@ export default function ProfileIcon() {
|
|||||||
const [openEnable2fa, setOpenEnable2fa] = useState(false);
|
const [openEnable2fa, setOpenEnable2fa] = useState(false);
|
||||||
const [openDisable2fa, setOpenDisable2fa] = useState(false);
|
const [openDisable2fa, setOpenDisable2fa] = useState(false);
|
||||||
const [openSecurityKey, setOpenSecurityKey] = useState(false);
|
const [openSecurityKey, setOpenSecurityKey] = useState(false);
|
||||||
|
const [openChangePassword, setOpenChangePassword] = useState(false);
|
||||||
|
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
@@ -78,6 +80,10 @@ export default function ProfileIcon() {
|
|||||||
open={openSecurityKey}
|
open={openSecurityKey}
|
||||||
setOpen={setOpenSecurityKey}
|
setOpen={setOpenSecurityKey}
|
||||||
/>
|
/>
|
||||||
|
<ChangePasswordDialog
|
||||||
|
open={openChangePassword}
|
||||||
|
setOpen={setOpenChangePassword}
|
||||||
|
/>
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -132,6 +138,11 @@ export default function ProfileIcon() {
|
|||||||
>
|
>
|
||||||
<span>{t("securityKeyManage")}</span>
|
<span>{t("securityKeyManage")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setOpenChangePassword(true)}
|
||||||
|
>
|
||||||
|
<span>{t("changePassword")}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user