diff --git a/cli/commands/setAdminCredentials.ts b/cli/commands/setAdminCredentials.ts index 91a6bcf7..f8b1bd40 100644 --- a/cli/commands/setAdminCredentials.ts +++ b/cli/commands/setAdminCredentials.ts @@ -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"); diff --git a/messages/en-US.json b/messages/en-US.json index 41f03850..9fa8e16c 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -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", @@ -1753,6 +1765,9 @@ "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", + "passwordExpiryDays": "Password Expiry", + "passwordExpiryDescription": "Set the number of days before users are required to change their password.", + "selectPasswordExpiry": "Select password expiry", "subscriptionBadge": "Subscription Required", "authPageErrorUpdateMessage": "An error occurred while updating the auth page settings", "authPageUpdated": "Auth page updated successfully", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 9e094ba3..a1aaecf1 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -27,7 +27,8 @@ export const orgs = pgTable("orgs", { subnet: varchar("subnet"), createdAt: text("createdAt"), requireTwoFactor: boolean("requireTwoFactor"), - maxSessionLengthHours: integer("maxSessionLengthHours") + maxSessionLengthHours: integer("maxSessionLengthHours"), + passwordExpiryDays: integer("passwordExpiryDays") }); export const orgDomains = pgTable("orgDomains", { @@ -201,7 +202,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", { @@ -228,7 +230,7 @@ export const sessions = pgTable("session", { .notNull() .references(() => users.userId, { onDelete: "cascade" }), expiresAt: bigint("expiresAt", { mode: "number" }).notNull(), - issuedAt: bigint("expiresAt", { mode: "number" }) + issuedAt: bigint("issuedAt", { mode: "number" }) }); export const newtSessions = pgTable("newtSession", { diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 877cf5c5..1ab7d285 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -20,7 +20,8 @@ export const orgs = sqliteTable("orgs", { subnet: text("subnet"), createdAt: text("createdAt"), requireTwoFactor: integer("requireTwoFactor", { mode: "boolean" }), - maxSessionLengthHours: integer("maxSessionLengthHours") // hours + maxSessionLengthHours: integer("maxSessionLengthHours"), // hours + passwordExpiryDays: integer("passwordExpiryDays") // days }); export const userDomains = sqliteTable("userDomains", { @@ -229,7 +230,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", { diff --git a/server/lib/checkOrgAccessPolicy.ts b/server/lib/checkOrgAccessPolicy.ts index 14a49b4a..c74283cb 100644 --- a/server/lib/checkOrgAccessPolicy.ts +++ b/server/lib/checkOrgAccessPolicy.ts @@ -18,6 +18,11 @@ export type CheckOrgAccessPolicyResult = { compliant: boolean; maxSessionLengthHours: number; sessionAgeHours: number; + }; + passwordAge?: { + compliant: boolean; + maxPasswordAgeDays: number; + passwordAgeDays: number; } }; }; diff --git a/server/private/lib/checkOrgAccessPolicy.ts b/server/private/lib/checkOrgAccessPolicy.ts index da82e930..bc2d9cca 100644 --- a/server/private/lib/checkOrgAccessPolicy.ts +++ b/server/private/lib/checkOrgAccessPolicy.ts @@ -98,11 +98,12 @@ export async function checkOrgAccessPolicy( // now check the 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) { 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; @@ -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; if (policies.requiredTwoFactor === 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; } diff --git a/server/routers/auth/changePassword.ts b/server/routers/auth/changePassword.ts index 64efb696..6c9d0d7c 100644 --- a/server/routers/auth/changePassword.ts +++ b/server/routers/auth/changePassword.ts @@ -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,6 +14,8 @@ 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"; @@ -32,6 +33,46 @@ export type ChangePasswordResponse = { codeRequested?: boolean; }; +async function invalidateAllSessionsExceptCurrent( + userId: string, + currentSessionId: string +): Promise { + 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,11 +150,13 @@ 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 diff --git a/server/routers/auth/resetPassword.ts b/server/routers/auth/resetPassword.ts index 05293727..14b4236b 100644 --- a/server/routers/auth/resetPassword.ts +++ b/server/routers/auth/resetPassword.ts @@ -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 diff --git a/server/routers/auth/setServerAdmin.ts b/server/routers/auth/setServerAdmin.ts index 716feca4..307f5504 100644 --- a/server/routers/auth/setServerAdmin.ts +++ b/server/routers/auth/setServerAdmin.ts @@ -98,7 +98,8 @@ export async function setServerAdmin( passwordHash, dateCreated: moment().toISOString(), serverAdmin: true, - emailVerified: true + emailVerified: true, + lastPasswordChange: new Date().getTime() }); }); diff --git a/server/routers/auth/signup.ts b/server/routers/auth/signup.ts index 3e9a7aaa..e836d109 100644 --- a/server/routers/auth/signup.ts +++ b/server/routers/auth/signup.ts @@ -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: diff --git a/server/routers/external.ts b/server/routers/external.ts index 67daae77..74ad3b31 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -973,11 +973,11 @@ authRouter.post( auth.requestEmailVerificationCode ); -// authRouter.post( -// "/change-password", -// verifySessionUserMiddleware, -// auth.changePassword -// ); +authRouter.post( + "/change-password", + verifySessionUserMiddleware, + auth.changePassword +); authRouter.post( "/reset-password/request", diff --git a/server/routers/org/updateOrg.ts b/server/routers/org/updateOrg.ts index 06661bd6..88cd2df2 100644 --- a/server/routers/org/updateOrg.ts +++ b/server/routers/org/updateOrg.ts @@ -25,7 +25,8 @@ const updateOrgBodySchema = z .object({ name: z.string().min(1).max(255).optional(), requireTwoFactor: z.boolean().optional(), - maxSessionLengthHours: z.number().nullable().optional() + maxSessionLengthHours: z.number().nullable().optional(), + passwordExpiryDays: z.number().nullable().optional() }) .strict() .refine((data) => Object.keys(data).length > 0, { @@ -82,6 +83,7 @@ export async function updateOrg( if (!isLicensed) { parsedBody.data.requireTwoFactor = undefined; parsedBody.data.maxSessionLengthHours = undefined; + parsedBody.data.passwordExpiryDays = undefined; } if ( @@ -103,7 +105,8 @@ export async function updateOrg( .set({ name: parsedBody.data.name, requireTwoFactor: parsedBody.data.requireTwoFactor, - maxSessionLengthHours: parsedBody.data.maxSessionLengthHours + maxSessionLengthHours: parsedBody.data.maxSessionLengthHours, + passwordExpiryDays: parsedBody.data.passwordExpiryDays }) .where(eq(orgs.orgId, orgId)) .returning(); diff --git a/src/app/[orgId]/settings/general/page.tsx b/src/app/[orgId]/settings/general/page.tsx index 743cdfb6..e15a98ac 100644 --- a/src/app/[orgId]/settings/general/page.tsx +++ b/src/app/[orgId]/settings/general/page.tsx @@ -65,12 +65,23 @@ const SESSION_LENGTH_OPTIONS = [ { 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 const GeneralFormSchema = z.object({ name: z.string(), subnet: z.string().optional(), requireTwoFactor: z.boolean().optional(), - maxSessionLengthHours: z.number().nullable().optional() + maxSessionLengthHours: z.number().nullable().optional(), + passwordExpiryDays: z.number().nullable().optional() }); type GeneralFormValues = z.infer; @@ -87,6 +98,14 @@ export default function GeneralPage() { const { licenseStatus, isUnlocked } = useLicenseStatusContext(); 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 [loadingSave, setLoadingSave] = useState(false); const authPageSettingsRef = useRef(null); @@ -97,7 +116,8 @@ export default function GeneralPage() { name: org?.org.name, subnet: org?.org.subnet || "", // Add default value for subnet requireTwoFactor: org?.org.requireTwoFactor || false, - maxSessionLengthHours: org?.org.maxSessionLengthHours || null + maxSessionLengthHours: org?.org.maxSessionLengthHours || null, + passwordExpiryDays: org?.org.passwordExpiryDays || null }, mode: "onChange" }); @@ -163,6 +183,7 @@ export default function GeneralPage() { if (build !== "oss") { reqData.requireTwoFactor = data.requireTwoFactor || false; reqData.maxSessionLengthHours = data.maxSessionLengthHours; + reqData.passwordExpiryDays = data.passwordExpiryDays; } // Update organization @@ -303,16 +324,8 @@ export default function GeneralPage() { control={form.control} name="requireTwoFactor" render={({ field }) => { - const isEnterpriseNotLicensed = - build === "enterprise" && - !isUnlocked(); - const isSaasNotSubscribed = - build === "saas" && - !subscriptionStatus?.isSubscribed(); const isDisabled = - isEnterpriseNotLicensed || - isSaasNotSubscribed; - const shouldDisableToggle = isDisabled; + isSecurityFeatureDisabled(); return ( @@ -328,13 +341,13 @@ export default function GeneralPage() { "requireTwoFactorForAllUsers" )} disabled={ - shouldDisableToggle + isDisabled } onCheckedChange={( val ) => { if ( - !shouldDisableToggle + !isDisabled ) { form.setValue( "requireTwoFactor", @@ -347,13 +360,9 @@ export default function GeneralPage() { - {isDisabled - ? t( - "requireTwoFactorDisabledDescription" - ) - : t( - "requireTwoFactorDescription" - )} + {t( + "requireTwoFactorDescription" + )} ); @@ -363,15 +372,8 @@ export default function GeneralPage() { control={form.control} name="maxSessionLengthHours" render={({ field }) => { - const isEnterpriseNotLicensed = - build === "enterprise" && - !isUnlocked(); - const isSaasNotSubscribed = - build === "saas" && - !subscriptionStatus?.isSubscribed(); const isDisabled = - isEnterpriseNotLicensed || - isSaasNotSubscribed; + isSecurityFeatureDisabled(); return ( @@ -384,10 +386,13 @@ export default function GeneralPage() { field.value?.toString() || "null" } - onValueChange={(value) => { + onValueChange={( + value + ) => { if (!isDisabled) { const numValue = - value === "null" + value === + "null" ? null : parseInt( value, @@ -403,11 +408,9 @@ export default function GeneralPage() { > @@ -438,13 +441,90 @@ export default function GeneralPage() { - {isDisabled - ? t( - "maxSessionLengthDisabledDescription" - ) - : t( - "maxSessionLengthDescription" - )} + {t( + "maxSessionLengthDescription" + )} + + + ); + }} + /> + { + const isDisabled = + isSecurityFeatureDisabled(); + + return ( + + + {t("passwordExpiryDays")} + + + + + + + {t( + "passwordExpiryDescription" + )} ); diff --git a/src/components/ChangePasswordDialog.tsx b/src/components/ChangePasswordDialog.tsx new file mode 100644 index 00000000..85a55ab2 --- /dev/null +++ b/src/components/ChangePasswordDialog.tsx @@ -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 ( + { + setOpen(val); + reset(); + }} + > + + + + {t('changePassword')} + + + {t('changePasswordDescription')} + + + + setOpen(false)} + onStepChange={setCurrentStep} + onLoadingChange={setLoading} + /> + + + + + + {(currentStep === 1 || currentStep === 2) && ( + + )} + + + + ); +} diff --git a/src/components/ChangePasswordForm.tsx b/src/components/ChangePasswordForm.tsx new file mode 100644 index 00000000..5d1395bc --- /dev/null +++ b/src/components/ChangePasswordForm.tsx @@ -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) => { + setLoading(true); + + const endpoint = `/auth/change-password`; + const payload = { + oldPassword: values.oldPassword, + newPassword: values.newPassword + }; + + const res = await api + .post>(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) => { + 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>(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 ( +
+ {step === 1 && ( +
+ +
+ ( + + + {t("oldPassword")} + + + + + + + )} + /> + + ( + +
+ + {t("newPassword")} + + {passwordStrength.strength === + "strong" && ( + + )} +
+ +
+ { + 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" + /> +
+
+ + {newPasswordValue.length > 0 && ( +
+ {/* Password Strength Meter */} +
+
+ + {t("passwordStrength")} + + + {t( + `passwordStrength${passwordStrength.strength.charAt(0).toUpperCase() + passwordStrength.strength.slice(1)}` + )} + +
+ +
+ + {/* Requirements Checklist */} +
+
+ {t("passwordRequirements")} +
+
+
+ {passwordStrength + .requirements + .length ? ( + + ) : ( + + )} + + {t( + "passwordRequirementLengthText" + )} + +
+
+ {passwordStrength + .requirements + .uppercase ? ( + + ) : ( + + )} + + {t( + "passwordRequirementUppercaseText" + )} + +
+
+ {passwordStrength + .requirements + .lowercase ? ( + + ) : ( + + )} + + {t( + "passwordRequirementLowercaseText" + )} + +
+
+ {passwordStrength + .requirements + .number ? ( + + ) : ( + + )} + + {t( + "passwordRequirementNumberText" + )} + +
+
+ {passwordStrength + .requirements + .special ? ( + + ) : ( + + )} + + {t( + "passwordRequirementSpecialText" + )} + +
+
+
+
+ )} + + {/* Only show FormMessage when not showing our custom requirements */} + {newPasswordValue.length === 0 && ( + + )} +
+ )} + /> + + ( + +
+ + {t("confirmNewPassword")} + + {doPasswordsMatch && ( + + )} +
+ +
+ { + 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" + /> +
+
+ {confirmPasswordValue.length > 0 && + !doPasswordsMatch && ( +

+ {t("passwordsDoNotMatch")} +

+ )} + {/* Only show FormMessage when field is empty */} + {confirmPasswordValue.length === 0 && ( + + )} +
+ )} + /> +
+
+ + )} + + {step === 2 && ( +
+
+

{t("otpAuth")}

+

+ {t("otpAuthDescription")} +

+
+ +
+ + ( + + +
+ { + field.onChange(value); + if ( + value.length === 6 + ) { + mfaForm.handleSubmit( + confirmMfa + )(); + } + }} + > + + + + + + + + + +
+
+ +
+ )} + /> + + +
+ )} + + {step === 3 && ( +
+ +

+ {t("changePasswordSuccess")} +

+

{t("changePasswordSuccessDescription")}

+
+ )} + + {/* Action buttons - only show when not in dialog */} + {!isDialog && ( +
+ {showCancelButton && onCancel && ( + + )} + {(step === 1 || step === 2) && ( + + )} + {step === 3 && ( + + )} +
+ )} +
+ ); + } +); + +export default ChangePasswordForm; \ No newline at end of file diff --git a/src/components/OrgPolicyResult.tsx b/src/components/OrgPolicyResult.tsx index 93eb46a3..bedf1905 100644 --- a/src/components/OrgPolicyResult.tsx +++ b/src/components/OrgPolicyResult.tsx @@ -13,6 +13,7 @@ import { 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"; @@ -40,6 +41,7 @@ export default function OrgPolicyResult({ accessRes }: OrgPolicyResultProps) { const [show2FaDialog, setShow2FaDialog] = useState(false); + const [showChangePasswordDialog, setShowChangePasswordDialog] = useState(false); const t = useTranslations(); const { user } = useUserContext(); 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 = requireedSteps === 0 ? 100 : (completedSteps / requireedSteps) * 100; @@ -179,6 +208,14 @@ export default function OrgPolicyResult({ router.refresh(); }} /> + + { + setShowChangePasswordDialog(val); + router.refresh(); + }} + /> ); } diff --git a/src/components/ProfileIcon.tsx b/src/components/ProfileIcon.tsx index fc7803a0..5789a83c 100644 --- a/src/components/ProfileIcon.tsx +++ b/src/components/ProfileIcon.tsx @@ -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} /> + @@ -132,6 +138,11 @@ export default function ProfileIcon() { > {t("securityKeyManage")} + setOpenChangePassword(true)} + > + {t("changePassword")} + )}