diff --git a/messages/en-US.json b/messages/en-US.json index 7726dbcf..895c8b3f 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -924,6 +924,10 @@ "passwordResetSent": "We'll send a password reset code to this email address.", "passwordResetCode": "Reset Code", "passwordResetCodeDescription": "Check your email for the reset code.", + "generatePasswordResetCode": "Generate Password Reset Code", + "passwordResetCodeGenerated": "Password Reset Code Generated", + "passwordResetCodeGeneratedDescription": "Share this code with the user. They can use it to reset their password.", + "passwordResetUrl": "Reset URL", "passwordNew": "New Password", "passwordNewConfirm": "Confirm New Password", "changePassword": "Change Password", @@ -941,8 +945,9 @@ "pincodeAuth": "Authenticator Code", "pincodeSubmit2": "Submit Code", "passwordResetSubmit": "Request Reset", + "passwordResetAlreadyHaveCode": "Enter Password Reset Code", "passwordResetSmtpRequired": "Please contact your administrator", - "passwordResetSmtpRequiredDescription": "Password reset is not available because no SMTP server is configured. Please contact your administrator for assistance.", + "passwordResetSmtpRequiredDescription": "A password reset code is required to reset your password. Please contact your administrator for assistance.", "passwordBack": "Back to Password", "loginBack": "Go back to log in", "signup": "Sign up", diff --git a/server/routers/external.ts b/server/routers/external.ts index 857a99b3..188654bc 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -715,6 +715,11 @@ unauthenticated.get("/my-device", verifySessionMiddleware, user.myDevice); authenticated.get("/users", verifyUserIsServerAdmin, user.adminListUsers); authenticated.get("/user/:userId", verifyUserIsServerAdmin, user.adminGetUser); +authenticated.post( + "/user/:userId/generate-password-reset-code", + verifyUserIsServerAdmin, + user.adminGeneratePasswordResetCode +); authenticated.delete( "/user/:userId", verifyUserIsServerAdmin, diff --git a/server/routers/user/adminGeneratePasswordResetCode.ts b/server/routers/user/adminGeneratePasswordResetCode.ts new file mode 100644 index 00000000..5d283c5c --- /dev/null +++ b/server/routers/user/adminGeneratePasswordResetCode.ts @@ -0,0 +1,125 @@ +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import HttpCode from "@server/types/HttpCode"; +import { response } from "@server/lib/response"; +import { db } from "@server/db"; +import { passwordResetTokens, users } from "@server/db"; +import { eq } from "drizzle-orm"; +import { alphabet, generateRandomString } from "oslo/crypto"; +import { createDate } from "oslo"; +import logger from "@server/logger"; +import { TimeSpan } from "oslo"; +import { hashPassword } from "@server/auth/password"; +import { UserType } from "@server/types/UserTypes"; +import config from "@server/lib/config"; + +const adminGeneratePasswordResetCodeSchema = z.strictObject({ + userId: z.string().min(1) +}); + +export type AdminGeneratePasswordResetCodeBody = z.infer; + +export type AdminGeneratePasswordResetCodeResponse = { + token: string; + email: string; + url: string; +}; + +export async function adminGeneratePasswordResetCode( + req: Request, + res: Response, + next: NextFunction +): Promise { + const parsedParams = adminGeneratePasswordResetCodeSchema.safeParse(req.params); + + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { userId } = parsedParams.data; + + try { + const existingUser = await db + .select() + .from(users) + .where(eq(users.userId, userId)); + + if (!existingUser || !existingUser.length) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "User not found" + ) + ); + } + + if (existingUser[0].type !== UserType.Internal) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Password reset codes can only be generated for internal users" + ) + ); + } + + if (!existingUser[0].email) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "User does not have an email address" + ) + ); + } + + const token = generateRandomString(8, alphabet("0-9", "A-Z", "a-z")); + + await db.transaction(async (trx) => { + await trx + .delete(passwordResetTokens) + .where(eq(passwordResetTokens.userId, existingUser[0].userId)); + + const tokenHash = await hashPassword(token); + + await trx.insert(passwordResetTokens).values({ + userId: existingUser[0].userId, + email: existingUser[0].email!, + tokenHash, + expiresAt: createDate(new TimeSpan(2, "h")).getTime() + }); + }); + + const url = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?email=${existingUser[0].email}&token=${token}`; + + logger.info( + `Admin generated password reset code for user ${existingUser[0].email} (${userId})` + ); + + return response(res, { + data: { + token, + email: existingUser[0].email!, + url + }, + success: true, + error: false, + message: "Password reset code generated successfully", + status: HttpCode.OK + }); + } catch (e) { + logger.error(e); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to generate password reset code" + ) + ); + } +} + diff --git a/server/routers/user/index.ts b/server/routers/user/index.ts index 78587f3d..35c5c4a7 100644 --- a/server/routers/user/index.ts +++ b/server/routers/user/index.ts @@ -8,6 +8,7 @@ export * from "./getOrgUser"; export * from "./adminListUsers"; export * from "./adminRemoveUser"; export * from "./adminGetUser"; +export * from "./adminGeneratePasswordResetCode"; export * from "./listInvitations"; export * from "./removeInvitation"; export * from "./createOrgUser"; diff --git a/src/app/auth/reset-password/page.tsx b/src/app/auth/reset-password/page.tsx index 79b9e53e..1245ca09 100644 --- a/src/app/auth/reset-password/page.tsx +++ b/src/app/auth/reset-password/page.tsx @@ -7,16 +7,6 @@ import { cleanRedirect } from "@app/lib/cleanRedirect"; import { getTranslations } from "next-intl/server"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; -import { pullEnv } from "@app/lib/pullEnv"; -import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; -import { InfoIcon } from "lucide-react"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle -} from "@/components/ui/card"; export const dynamic = "force-dynamic"; @@ -32,7 +22,6 @@ export default async function Page(props: { const getUser = cache(verifySession); const user = await getUser(); const t = await getTranslations(); - const env = pullEnv(); if (user) { let loggedOut = false; @@ -55,48 +44,6 @@ export default async function Page(props: { redirectUrl = cleanRedirect(searchParams.redirect); } - // If email is not enabled, show a message instead of the form - if (!env.email.emailEnabled) { - return ( - <> -
- - - {t("passwordReset")} - - {t("passwordResetDescription")} - - - - - - - {t("passwordResetSmtpRequired")} - - - {t("passwordResetSmtpRequiredDescription")} - - - - -
- -

- - {t("loginBack")} - -

- - ); - } - return ( <> (null); + const [isGeneratingCode, setIsGeneratingCode] = useState(false); const refreshData = async () => { console.log("Data refreshed"); @@ -86,6 +109,29 @@ export default function UsersTable({ users }: Props) { }); }; + const generatePasswordResetCode = async (userId: string) => { + setIsGeneratingCode(true); + try { + const res = await api.post< + AxiosResponse + >(`/user/${userId}/generate-password-reset-code`); + + if (res.data?.data) { + setPasswordResetCodeData(res.data.data); + setIsPasswordResetCodeDialogOpen(true); + } + } catch (e) { + console.error("Failed to generate password reset code", e); + toast({ + variant: "destructive", + title: t("error"), + description: formatAxiosError(e, t("errorOccurred")) + }); + } finally { + setIsGeneratingCode(false); + } + }; + const columns: ExtendedColumnDef[] = [ { accessorKey: "id", @@ -195,7 +241,7 @@ export default function UsersTable({ users }: Props) {
{userRow.twoFactorEnabled || - userRow.twoFactorSetupRequested ? ( + userRow.twoFactorSetupRequested ? ( {t("enabled")} @@ -217,17 +263,21 @@ export default function UsersTable({ users }: Props) {
- + {r.type !== "internal" && ( + { + generatePasswordResetCode(r.id); + }} + > + {t("generatePasswordResetCode")} + + )} { setSelected(r); @@ -295,6 +345,58 @@ export default function UsersTable({ users }: Props) { onRefresh={refreshData} isRefreshing={isRefreshing} /> + + + + + + {t("passwordResetCodeGenerated")} + + + {t("passwordResetCodeGeneratedDescription")} + + + + {passwordResetCodeData && ( +
+
+ + +
+
+ + +
+
+ + +
+
+ )} +
+ + + + + +
+
); } diff --git a/src/components/CopyToClipboard.tsx b/src/components/CopyToClipboard.tsx index b187e6c6..5cdeed55 100644 --- a/src/components/CopyToClipboard.tsx +++ b/src/components/CopyToClipboard.tsx @@ -26,24 +26,21 @@ const CopyToClipboard = ({ text, displayText, isLink }: CopyToClipboardProps) => const t = useTranslations(); return ( -
+
{isLink ? ( {displayValue} ) : ( )} )} {state === "request" && ( - )} - {quickstart - ? t('accountSetupSubmit') - : t('passwordResetSubmit') - } - + +
)} {state === "mfa" && ( @@ -507,7 +568,7 @@ export default function ResetPasswordForm({ mfaForm.reset(); }} > - {t('passwordBack')} + {t("passwordBack")} )} @@ -521,7 +582,7 @@ export default function ResetPasswordForm({ form.reset(); }} > - {t('backToEmail')} + {t("backToEmail")} )}