mirror of
https://github.com/fosrl/pangolin.git
synced 2025-12-05 19:17:29 -06:00
add password expiry enforcement
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",
|
||||
@@ -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",
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -18,6 +18,11 @@ export type CheckOrgAccessPolicyResult = {
|
||||
compliant: boolean;
|
||||
maxSessionLengthHours: number;
|
||||
sessionAgeHours: number;
|
||||
};
|
||||
passwordAge?: {
|
||||
compliant: boolean;
|
||||
maxPasswordAgeDays: number;
|
||||
passwordAgeDays: number;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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,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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<typeof GeneralFormSchema>;
|
||||
@@ -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<AuthPageSettingsRef>(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 (
|
||||
<FormItem className="col-span-2">
|
||||
@@ -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() {
|
||||
</div>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{isDisabled
|
||||
? t(
|
||||
"requireTwoFactorDisabledDescription"
|
||||
)
|
||||
: t(
|
||||
"requireTwoFactorDescription"
|
||||
)}
|
||||
{t(
|
||||
"requireTwoFactorDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
);
|
||||
@@ -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 (
|
||||
<FormItem className="col-span-2">
|
||||
@@ -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() {
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
t(
|
||||
"selectSessionLength"
|
||||
)
|
||||
}
|
||||
placeholder={t(
|
||||
"selectSessionLength"
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -438,13 +441,90 @@ export default function GeneralPage() {
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{isDisabled
|
||||
? t(
|
||||
"maxSessionLengthDisabledDescription"
|
||||
)
|
||||
: t(
|
||||
"maxSessionLengthDescription"
|
||||
)}
|
||||
{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()
|
||||
}
|
||||
>
|
||||
{
|
||||
option.label
|
||||
}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<FormMessage />
|
||||
{t(
|
||||
"passwordExpiryDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</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 { 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();
|
||||
}}
|
||||
/>
|
||||
|
||||
<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 />
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user