From d664eb2a8d48075b6629eeeb67e6cea0bb2f21d3 Mon Sep 17 00:00:00 2001 From: Adrian Astles <49412215+adrianeastles@users.noreply.github.com> Date: Fri, 25 Jul 2025 20:34:24 +0800 Subject: [PATCH] - Add API endpoint POST /admin/user/{userId}/password for server admins - Create AdminPasswordReset component with email/manual link options - Integrate password reset into admin user detail pages - Add translation keys for internationalization (EN-US-ONLY) - Support both email sending and manual link generation" --- messages/en-US.json | 8 + server/routers/external.ts | 5 + server/routers/user/adminResetUserPassword.ts | 212 ++++++++++++++++ server/routers/user/index.ts | 1 + src/app/admin/users/[userId]/general/page.tsx | 29 +++ src/components/AdminPasswordReset.tsx | 232 ++++++++++++++++++ 6 files changed, 487 insertions(+) create mode 100644 server/routers/user/adminResetUserPassword.ts create mode 100644 src/components/AdminPasswordReset.tsx diff --git a/messages/en-US.json b/messages/en-US.json index ed004d99..4cf65626 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1247,6 +1247,14 @@ "adminEnabled2FaOnYourAccount": "Your administrator has enabled two-factor authentication for {email}. Please complete the setup process to continue.", "continueToApplication": "Continue to Application", "securityKeyAdd": "Add Security Key", + "passwordResetAdminInstructions": "Clicking the button below will send a password reset email to the user. They will be able to set a new password using the link provided.", + "passwordResetError": "Failed to reset password", + "passwordResetErrorDescription": "An error occurred while resetting the user's password", + "passwordResetSending": "Sending...", + "passwordResetSendEmail": "Send Reset Email", + "sendEmailNotification": "Send email notification", + "linkCopied": "Link copied", + "linkCopiedDescription": "The reset link has been copied to your clipboard", "securityKeyRegisterTitle": "Register New Security Key", "securityKeyRegisterDescription": "Connect your security key and enter a name to identify it", "securityKeyTwoFactorRequired": "Two-Factor Authentication Required", diff --git a/server/routers/external.ts b/server/routers/external.ts index 6f0b04dc..0aab2d04 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -540,6 +540,11 @@ authenticated.delete( verifyUserIsServerAdmin, user.adminRemoveUser ); +authenticated.post( + "/admin/user/:userId/password", + verifyUserIsServerAdmin, + user.adminResetUserPassword +); authenticated.put( "/org/:orgId/user", diff --git a/server/routers/user/adminResetUserPassword.ts b/server/routers/user/adminResetUserPassword.ts new file mode 100644 index 00000000..a6784156 --- /dev/null +++ b/server/routers/user/adminResetUserPassword.ts @@ -0,0 +1,212 @@ +import { NextFunction, Request, Response } from "express"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import createHttpError from "http-errors"; +import { db } from "@server/db"; +import { passwordResetTokens, users } from "@server/db"; +import { eq } from "drizzle-orm"; +import { alphabet, generateRandomString } from "oslo/crypto"; +import { createDate, TimeSpan } from "oslo"; +import { hashPassword } from "@server/auth/password"; +import { sendEmail } from "@server/emails"; +import ResetPasswordCode from "@server/emails/templates/ResetPasswordCode"; +import config from "@server/lib/config"; +import logger from "@server/logger"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import { OpenAPITags, registry } from "@server/openApi"; +import { UserType } from "@server/types/UserTypes"; + +const adminResetUserPasswordParamsSchema = z + .object({ + userId: z.string() + }) + .strict(); + +const adminResetUserPasswordBodySchema = z + .object({ + sendEmail: z.boolean().optional().default(true), + expirationHours: z.number().int().positive().optional().default(24) + }) + .strict(); + +export type AdminResetUserPasswordBody = z.infer; +export type AdminResetUserPasswordResponse = { + resetLink?: string; + emailSent: boolean; +}; + +registry.registerPath({ + method: "post", + path: "/admin/user/{userId}/password", + description: "Generate a password reset link for a user (server admin only).", + tags: [OpenAPITags.User], + request: { + params: adminResetUserPasswordParamsSchema, + body: { + content: { + "application/json": { + schema: adminResetUserPasswordBodySchema + } + } + } + }, + responses: {} +}); + +export async function adminResetUserPassword( + req: Request, + res: Response, + next: NextFunction +): Promise { + const parsedParams = adminResetUserPasswordParamsSchema.safeParse(req.params); + + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = adminResetUserPasswordBodySchema.safeParse(req.body); + + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { userId } = parsedParams.data; + const { sendEmail: shouldSendEmail, expirationHours } = parsedBody.data; + + try { + // Get the target user + const targetUser = await db + .select() + .from(users) + .where(eq(users.userId, userId)); + + if (!targetUser || !targetUser.length) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "User not found" + ) + ); + } + + const user = targetUser[0]; + + // Only allow resetting passwords for internal users + if (user.type !== UserType.Internal) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Password reset is only available for internal users" + ) + ); + } + + if (!user.email) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "User does not have an email address" + ) + ); + } + + // Generate reset token + const token = generateRandomString(16, alphabet("0-9", "A-Z", "a-z")); + const tokenHash = await hashPassword(token); + + // Store reset token in database + await db.transaction(async (trx) => { + // Delete any existing reset tokens for this user + await trx + .delete(passwordResetTokens) + .where(eq(passwordResetTokens.userId, userId)); + + // Insert new reset token + await trx.insert(passwordResetTokens).values({ + userId: userId, + email: user.email!, + tokenHash, + expiresAt: createDate(new TimeSpan(expirationHours, "h")).getTime() + }); + }); + + const resetUrl = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?email=${encodeURIComponent(user.email!)}&token=${token}`; + + let emailSent = false; + + // Get the admin identifier (either user ID or API key ID) + const adminId = req.user?.userId || req.apiKey?.apiKeyId || 'unknown'; + + // Send email if requested + if (shouldSendEmail) { + // Check if email is configured + if (!config.getRawConfig().email) { + logger.info( + `Server admin ${adminId} generated password reset link for user ${userId}. Email not configured, no email sent. Token expires in ${expirationHours} hours.` + ); + emailSent = false; + } else { + try { + await sendEmail( + ResetPasswordCode({ + email: user.email!, + code: token, + link: resetUrl + }), + { + from: config.getNoReplyEmail(), + to: user.email!, + subject: "Password Reset - Initiated by Server Administrator" + } + ); + emailSent = true; + + logger.info( + `Server admin ${adminId} initiated password reset for user ${userId}. Email sent to ${user.email}. Token expires in ${expirationHours} hours.` + ); + } catch (e) { + logger.error("Failed to send server admin-initiated password reset email", e); + // Don't fail the request if email fails, just log it + emailSent = false; + } + } + } else { + logger.info( + `Server admin ${adminId} generated password reset link for user ${userId}. No email sent. Token expires in ${expirationHours} hours.` + ); + } + + return response(res, { + data: { + resetLink: resetUrl, + emailSent + }, + success: true, + error: false, + message: emailSent + ? `Password reset email sent to ${user.email}` + : "Password reset link generated successfully", + status: HttpCode.OK + }); + + } catch (e) { + logger.error("Failed to generate server admin password reset", e); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to generate password reset" + ) + ); + } +} \ No newline at end of file diff --git a/server/routers/user/index.ts b/server/routers/user/index.ts index 6d342ad3..f755a478 100644 --- a/server/routers/user/index.ts +++ b/server/routers/user/index.ts @@ -13,3 +13,4 @@ export * from "./removeInvitation"; export * from "./createOrgUser"; export * from "./adminUpdateUser2FA"; export * from "./adminGetUser"; +export * from "./adminResetUserPassword"; diff --git a/src/app/admin/users/[userId]/general/page.tsx b/src/app/admin/users/[userId]/general/page.tsx index ae720a6f..6117a540 100644 --- a/src/app/admin/users/[userId]/general/page.tsx +++ b/src/app/admin/users/[userId]/general/page.tsx @@ -19,6 +19,7 @@ import { SettingsSectionForm } from "@app/components/Settings"; import { UserType } from "@server/types/UserTypes"; +import AdminPasswordReset from "@app/components/AdminPasswordReset"; export default function GeneralPage() { const { userId } = useParams(); @@ -29,6 +30,8 @@ export default function GeneralPage() { const [loading, setLoading] = useState(false); const [twoFactorEnabled, setTwoFactorEnabled] = useState(false); const [userType, setUserType] = useState(null); + const [userEmail, setUserEmail] = useState(""); + const [userName, setUserName] = useState(""); useEffect(() => { // Fetch current user 2FA status @@ -43,6 +46,8 @@ export default function GeneralPage() { userData.twoFactorSetupRequested ); setUserType(userData.type); + setUserEmail(userData.email || ""); + setUserName(userData.name || userData.username || ""); } } catch (error) { console.error("Failed to fetch user data:", error); @@ -117,6 +122,30 @@ export default function GeneralPage() { + + + + + {t("passwordReset")} + + + {t("passwordResetAdminInstructions")} + + + + + +
+ +
+
+
+
diff --git a/src/components/AdminPasswordReset.tsx b/src/components/AdminPasswordReset.tsx new file mode 100644 index 00000000..2b0acdf4 --- /dev/null +++ b/src/components/AdminPasswordReset.tsx @@ -0,0 +1,232 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@app/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@app/components/ui/dialog"; +import { Input } from "@app/components/ui/input"; +import { Checkbox } from "@app/components/ui/checkbox"; +import { Label } from "@app/components/ui/label"; +import { toast } from "@app/hooks/useToast"; +import { formatAxiosError } from "@app/lib/api"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useTranslations } from "next-intl"; +import { Key, Mail, Copy, Link } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; + +type AdminPasswordResetProps = { + userId: string; + userEmail: string; + userName: string; + userType: string; +}; + +export default function AdminPasswordReset({ + userId, + userEmail, + userName, + userType, +}: AdminPasswordResetProps) { + const api = createApiClient(useEnvContext()); + const { env } = useEnvContext(); + const t = useTranslations(); + + const [open, setOpen] = useState(false); + const [passwordLoading, setPasswordLoading] = useState(false); + const [sendEmail, setSendEmail] = useState(env.email.emailEnabled); + const [resetLink, setResetLink] = useState(); + + const isExternalUser = userType !== "internal"; + + // Don't render the button for external users + if (isExternalUser) { + return null; + } + + const handleResetPassword = async () => { + setPasswordLoading(true); + + try { + const response = await api.post(`/admin/user/${userId}/password`, { + sendEmail, + expirationHours: 24 + }); + + const data = response.data.data; + + if (data.resetLink) { + setResetLink(data.resetLink); + } + + toast({ + title: t('passwordResetSuccess'), + description: data.emailSent + ? `Password reset email sent to ${userEmail}` + : data.message, + }); + + if (env.email.emailEnabled && sendEmail && data.emailSent) { + setOpen(false); + } + } catch (error) { + toast({ + variant: "destructive", + title: t('passwordResetError'), + description: formatAxiosError(error, t('passwordResetErrorDescription')), + }); + } finally { + setPasswordLoading(false); + } + }; + + const copyToClipboard = async (text: string) => { + try { + await navigator.clipboard.writeText(text); + toast({ + title: t('linkCopied'), + description: t('linkCopiedDescription'), + }); + } catch (e) { + toast({ + variant: "destructive", + title: "Copy failed", + description: "Failed to copy to clipboard. Please copy manually.", + }); + } + }; + + const handleClose = () => { + setOpen(false); + setResetLink(undefined); + setSendEmail(true); + }; + + return ( + <> + + + + + + + + + {t('passwordReset')} + + + Reset password for {userName || userEmail} + + + +
+ {!env.email.emailEnabled && ( + + + {t('otpEmailSmtpRequired')} + + + Email is not configured. A reset link will be generated for you to share manually. + + + )} + + {env.email.emailEnabled && ( +
+
+ +

Email Notification

+
+

+ Send a password reset email to the user with a secure reset link. +

+
+ setSendEmail(checked as boolean)} + /> + +
+
+ )} + + {resetLink && (!sendEmail || !env.email.emailEnabled) && ( + + + Reset Link Generated + +
+

Share this link with the user:

+
+ + +
+

+ This link expires in 24 hours. +

+
+
+
+ )} +
+ + + + {!resetLink && ( + + )} + +
+
+ + ); +} \ No newline at end of file