diff --git a/api/src/api/emailReset.ts b/api/src/api/emailReset.ts index 7c2a3759b..c644e9336 100644 --- a/api/src/api/emailReset.ts +++ b/api/src/api/emailReset.ts @@ -7,18 +7,23 @@ import { } from '@faims3/data-model'; import express, {Response} from 'express'; import {processRequest} from 'zod-express-middleware'; +import {DEFAULT_REDIRECT_URL} from '../auth/authRoutes'; +import {validateRedirect} from '../auth/helpers'; import { - buildCodeIntoUrl, createNewEmailCode, markCodeAsUsed, validateEmailCode, -} from '../couchdb/emailCodes'; +} from '../couchdb/emailReset'; import { getCouchUserFromEmailOrUserId, updateUserPassword, } from '../couchdb/users'; import * as Exceptions from '../exceptions'; import {isAllowedToMiddleware, requireAuthenticationAPI} from '../middleware'; +import { + buildPasswordResetUrl, + sendPasswordResetEmail, +} from '../utils/emailHelpers'; export const api = express.Router(); @@ -48,7 +53,12 @@ api.post( }, }), async (req, res: Response) => { - const {email} = req.body; + const {email, redirect} = req.body; + + // Don't throw error here if invalid - just make a more sensible one + const {redirect: validatedRedirect} = validateRedirect( + redirect || DEFAULT_REDIRECT_URL + ); // Get the user by email const user = await getCouchUserFromEmailOrUserId(email); @@ -59,8 +69,19 @@ api.post( } // Generate reset code - const {code} = await createNewEmailCode(user.user_id); - const url = buildCodeIntoUrl(code); + const {code, record} = await createNewEmailCode({userId: user.user_id}); + const url = buildPasswordResetUrl({code, redirect: validatedRedirect}); + + // NOTE: we intentionally don't await this + // so that it is harder as an attacker to tell if something happened - + // this will complete in the background + sendPasswordResetEmail({ + recipientEmail: email, + username: user.name || user.user_id, + resetCode: code, + expiryTimestampMs: record.expiryTimestampMs, + redirect: validatedRedirect, + }); res.json({ code, diff --git a/api/src/auth/authPages.ts b/api/src/auth/authPages.ts index 1cd9931e5..5aeef0c1a 100644 --- a/api/src/auth/authPages.ts +++ b/api/src/auth/authPages.ts @@ -33,8 +33,9 @@ import { validateRedirect, } from './helpers'; -import patch from '../utils/patchExpressAsync'; import {verifyEmailWithCode} from '../api/verificationChallenges'; +import patch from '../utils/patchExpressAsync'; +import {validateEmailCode} from '../couchdb/emailReset'; // This must occur before express app is used patch(); @@ -172,6 +173,44 @@ export function addAuthPages(app: Router, socialProviders: AuthProvider[]) { ); /** + * PAGE: Change password form for local users + */ + app.get( + '/change-password', + processRequest({ + query: z.object({ + // Where should we go once finished? + redirect: z.string().optional(), + // Require username as query param - this lets us know who the user is + username: z.string(), + }), + }), + (req, res) => { + const username = req.query.username; + + const {valid, redirect} = validateRedirect( + req.query.redirect || DEFAULT_REDIRECT_URL + ); + + if (!valid) { + return res.render('redirect-error', {redirect}); + } + + // Render the change password form + return res.render('change-password', { + // The POST endpoint to handle password change + postUrl: '/auth/changePassword', + changePasswordPostPayload: { + username, + redirect, + }, + username, + messages: req.flash(), + }); + } + ); + + /* * PAGE: Email verification landing page * This renders a view showing the result of the email verification process */ @@ -229,4 +268,74 @@ export function addAuthPages(app: Router, socialProviders: AuthProvider[]) { }); } ); + + /** + * PAGE: Forgot password form + * Allows the user to enter their email to receive a password reset link + */ + app.get( + '/forgot-password', + processRequest({ + query: z.object({ + redirect: z.string().optional(), + }), + }), + (req, res) => { + const {valid, redirect} = validateRedirect( + req.query.redirect || DEFAULT_REDIRECT_URL + ); + + if (!valid) { + return res.render('redirect-error', {redirect}); + } + + return res.render('forgot-password', { + postUrl: '/auth/forgotPassword', + forgotPasswordPostPayload: { + redirect, + }, + messages: req.flash(), + }); + } + ); + + /** + * PAGE: Reset password form + * Allows the user to set a new password using a reset code + */ + app.get( + '/auth/reset-password', + processRequest({ + query: z.object({ + code: z.string(), + redirect: z.string(), + }), + }), + async (req, res) => { + const code = req.query.code; + const {valid, redirect} = validateRedirect(req.query.redirect); + + if (!valid) { + return res.render('redirect-error', {redirect}); + } + + // Validate the code + const validationResult = await validateEmailCode(code); + + if (!validationResult.valid || !validationResult.user) { + return res.render('reset-password-error', { + error: validationResult.validationError || 'Invalid reset code.', + loginUrl: '/login', + forgotPasswordUrl: '/forgot-password', + }); + } + + return res.render('reset-password', { + postUrl: '/auth/resetPassword', + resetCode: code, + redirect, + messages: req.flash(), + }); + } + ); } diff --git a/api/src/auth/authRoutes.ts b/api/src/auth/authRoutes.ts index 3a5d80447..5bf57bd57 100644 --- a/api/src/auth/authRoutes.ts +++ b/api/src/auth/authRoutes.ts @@ -22,22 +22,39 @@ import { AuthContextSchema, PeopleDBDocument, + PostChangePasswordInput, + PostChangePasswordInputSchema, + PostForgotPasswordInputSchema, PostLoginInput, PostLoginInputSchema, PostRegisterInput, PostRegisterInputSchema, + PostResetPasswordInput, + PostResetPasswordInputSchema, } from '@faims3/data-model'; import {NextFunction, Router} from 'express'; import passport from 'passport'; import {processRequest} from 'zod-express-middleware'; -import {upgradeCouchUserToExpressUser} from './keySigning/create'; import {AuthProvider, WEBAPP_PUBLIC_URL} from '../buildconfig'; +import { + createNewEmailCode, + markCodeAsUsed, + validateEmailCode, +} from '../couchdb/emailReset'; import { getCouchUserFromEmailOrUserId, saveCouchUser, saveExpressUser, + updateUserPassword, } from '../couchdb/users'; +import {createVerificationChallenge} from '../couchdb/verificationChallenges'; +import {TooManyRequestsException} from '../exceptions'; import {AuthAction, CustomSessionData} from '../types'; +import { + sendEmailVerificationChallenge, + sendPasswordResetEmail, +} from '../utils/emailHelpers'; +import patch from '../utils/patchExpressAsync'; import { buildQueryString, handleZodErrors, @@ -46,11 +63,9 @@ import { validateAndApplyInviteToUser, validateRedirect, } from './helpers'; +import {upgradeCouchUserToExpressUser} from './keySigning/create'; import {AUTH_PROVIDER_DETAILS} from './strategies/applyStrategies'; - -import patch from '../utils/patchExpressAsync'; -import {createVerificationChallenge} from '../couchdb/verificationChallenges'; -import {sendEmailVerificationChallenge} from '../utils/emailHelpers'; +import {verifyUserCredentials} from './strategies/localStrategy'; // This must occur before express app is used patch(); @@ -347,6 +362,299 @@ export function addAuthRoutes(app: Router, socialProviders: AuthProvider[]) { } }); + /** + * Handle password change for local users + */ + app.post('/auth/changePassword', async (req, res) => { + // Parse the body of the payload - we do this here so we can flash err messages + let payload: PostChangePasswordInput = req.body; + const errRedirect = `/change-password${buildQueryString({values: {username: payload.username, redirect: payload.redirect}})}`; + + // If anything goes wrong - flash back to form fields + try { + payload = PostChangePasswordInputSchema.parse(req.body); + } catch (validationError) { + const handled = handleZodErrors({ + error: validationError, + // type hacking here due to override not being picked up + req: req as unknown as Request, + res, + formData: {}, + redirect: errRedirect, + }); + if (!handled) { + req.flash('error', { + changePasswordError: {msg: 'An unexpected error occurred'}, + }); + res.status(500).redirect(errRedirect); + return; + } + return; + } + + // We now have a validated payload + const {username, currentPassword, newPassword, confirmPassword, redirect} = + payload; + + // Validate the redirect URL + const {valid, redirect: validatedRedirect} = validateRedirect( + redirect || DEFAULT_REDIRECT_URL + ); + + if (!valid) { + return res.render('redirect-error', {redirect: validatedRedirect}); + } + + // Check if passwords match + if (newPassword !== confirmPassword) { + req.flash('error', { + changePasswordError: {msg: 'New passwords do not match'}, + }); + return res.redirect(errRedirect); + } + + try { + // Verify current password using our standalone verification function + const verificationResult = await verifyUserCredentials({ + username, + password: currentPassword, + }); + + if (!verificationResult.success) { + req.flash('error', { + currentPassword: { + msg: verificationResult.error || 'Password incorrect.', + }, + }); + return res.redirect(errRedirect); + } + + const dbUser = verificationResult.user; + if (!dbUser) { + req.flash('error', { + changePasswordError: {msg: 'Failed to change password.'}, + }); + return res.redirect(errRedirect); + } + + // Check the db user has a local profile + if (!dbUser.profiles.local) { + req.flash('error', { + changePasswordError: { + msg: 'You are trying to change the password of a social provider account!', + }, + }); + return res.redirect(errRedirect); + } + + // Apply change (this also saves) + await updateUserPassword(username, newPassword); + + // Flash success message and redirect + req.flash('success', 'Password changed successfully'); + return res.redirect(validatedRedirect); + } catch (error) { + console.error('Password change error:', error); + req.flash('error', { + changePasswordError: { + msg: 'An error occurred while changing password. Contact a system administrator.', + }, + }); + return res.redirect(errRedirect); + } + }); + + /** + * Handle forgot password requests + * Generates a password reset code and sends an email to the user + * Includes rate limiting to prevent abuse + */ + app.post( + '/auth/forgotPassword', + processRequest({ + body: PostForgotPasswordInputSchema, + }), + async (req, res) => { + const {email, redirect} = req.body; + + // Validate the redirect URL + const {valid, redirect: validatedRedirect} = validateRedirect( + redirect || DEFAULT_REDIRECT_URL + ); + + if (!valid) { + return res.render('redirect-error', {redirect: validatedRedirect}); + } + + const errRedirect = `/forgot-password${buildQueryString({ + values: {redirect: validatedRedirect}, + })}`; + + try { + // Get the user by email + const user = await getCouchUserFromEmailOrUserId(email); + + if (!user) { + // For security reasons, don't reveal if the email exists or not + // Just show the success page as if we sent the email + req.flash( + 'success', + 'If an account exists with that email, you will receive password reset instructions shortly.' + ); + return res.redirect(errRedirect); + } + + // Check if the user has a local profile + if (!user.profiles.local) { + req.flash('error', { + forgotPasswordError: { + msg: 'This account uses social login. Please sign in with your social provider instead.', + }, + }); + return res.redirect(errRedirect); + } + + try { + // Generate reset code with email and purpose for rate limiting + const {code, record} = await createNewEmailCode({ + userId: user.user_id, + }); + + // Send the password reset email. We don't await to keep response fast. + sendPasswordResetEmail({ + recipientEmail: email, + username: user.name || user.user_id, + resetCode: code, + expiryTimestampMs: record.expiryTimestampMs, + redirect: validatedRedirect, + }); + + // Flash success message + req.flash( + 'success', + 'If an account exists with that email, you will receive password reset instructions shortly.' + ); + } catch (error) { + if (error instanceof TooManyRequestsException) { + req.flash('error', { + forgotPasswordError: { + msg: 'Too many password reset attempts.', + }, + }); + } else { + // For other errors, log but don't reveal details to user + console.error('Password reset error:', error); + req.flash('error', { + forgotPasswordError: { + msg: 'An error occurred while processing your request. Please try again later.', + }, + }); + } + } + + return res.redirect(errRedirect); + } catch (error) { + console.error('Password reset error:', error); + req.flash('error', { + forgotPasswordError: { + msg: 'An error occurred while processing your request. Please try again later.', + }, + }); + return res.redirect(errRedirect); + } + } + ); + + /** + * Handle password reset submissions + */ + app.post('/auth/resetPassword', async (req, res) => { + // Get redirect and code for error handling redirects + const code = req.body.code; + let payload: PostResetPasswordInput = req.body; + const redirect = payload.redirect || DEFAULT_REDIRECT_URL; + + // Validate the redirect URL + const {valid, redirect: validatedRedirect} = validateRedirect(redirect); + + if (!valid) { + return res.render('redirect-error', {redirect: validatedRedirect}); + } + + // Create the error redirect URL + const errRedirect = `/auth/reset-password${buildQueryString({ + values: {code, redirect: validatedRedirect}, + })}`; + + // Parse and validate the request body + try { + payload = PostResetPasswordInputSchema.parse(req.body); + } catch (validationError) { + const handled = handleZodErrors({ + error: validationError, + req: req as unknown as Request, + res, + formData: {}, // No form data to preserve + redirect: errRedirect, + }); + + if (!handled) { + console.error('Reset password validation error:', validationError); + req.flash('error', { + resetPasswordError: { + msg: 'An unexpected error occurred during validation', + }, + }); + res.status(500).redirect(errRedirect); + } + return; + } + + try { + // Validate the reset code + const validationResult = await validateEmailCode(payload.code); + + if (!validationResult.valid || !validationResult.user) { + req.flash('error', { + resetPasswordError: { + msg: validationResult.validationError || 'Invalid reset code.', + }, + }); + return res.redirect(errRedirect); + } + + // Update the user's password + await updateUserPassword( + validationResult.user.user_id, + payload.newPassword + ); + + // Mark the code as used + await markCodeAsUsed(payload.code); + + // Flash success message and redirect to login page + req.flash( + 'success', + 'Your password has been successfully reset. You can now log in with your new password.' + ); + + // Redirect to login page with the original redirect parameter + return res.redirect( + `/login${buildQueryString({ + values: {redirect: validatedRedirect}, + })}` + ); + } catch (error) { + console.error('Password reset error:', error); + req.flash('error', { + resetPasswordError: { + msg: 'An error occurred while resetting your password. Please try again.', + }, + }); + return res.redirect(errRedirect); + } + }); + // For each handler, deploy an auth route + auth return route for (const handler of socialProviders) { const handlerDetails = AUTH_PROVIDER_DETAILS[handler]; diff --git a/api/src/auth/strategies/localStrategy.ts b/api/src/auth/strategies/localStrategy.ts index 9fbf757b7..d8d697ff8 100644 --- a/api/src/auth/strategies/localStrategy.ts +++ b/api/src/auth/strategies/localStrategy.ts @@ -18,10 +18,10 @@ * Description: Implements the validate callback for the local passport auth * strategy */ - import {pbkdf2Sync} from 'crypto'; import {Strategy, VerifyFunction} from 'passport-local'; import {upgradeCouchUserToExpressUser} from '../keySigning/create'; +import {PeopleDBDocument} from '@faims3/data-model'; import {getCouchUserFromEmailOrUserId} from '../../couchdb/users'; /** @@ -36,46 +36,63 @@ type LocalProfile = { }; /** - * Validates a user attempting to log in with local credentials + * Result of verifying a user's credentials + */ +export type VerifyUserResult = { + // Whether the verification succeeded + success: boolean; + // Error message if verification failed + error?: string; + // The user document if verification succeeded + user?: PeopleDBDocument; +}; + +/** + * Verifies a user's credentials by username/email and password * - * @param email - User's email or username + * @param username - User's email or username * @param password - User's plaintext password - * @param done - Passport callback function - * @returns The run done callback with (err?, user?) + * @returns Promise resolving to the verification result */ -export const validateLocalUser: VerifyFunction = async ( - email, +export const verifyUserCredentials = async ({ + username, password, - done -): Promise => { +}: { + username: string; + password: string; +}): Promise => { const ambiguousErrorMessage = 'Username or password incorrect.'; // Look up user in database by email or username - const dbUser = await getCouchUserFromEmailOrUserId(email); + const dbUser = await getCouchUserFromEmailOrUserId(username); // Handle case where user doesn't exist if (!dbUser) { - return done(ambiguousErrorMessage, false); + return { + success: false, + error: ambiguousErrorMessage, + }; } // Get the local authentication profile for the user const profile = dbUser.profiles['local'] as LocalProfile; - // Handle case where user exists but has no local profile (uses social auth - // instead) + // Handle case where user exists but has no local profile (uses social auth instead) if (!profile) { - return done( - 'You are trying to login to an account which has been created using a social provider. Please login using the social provider instead.', - false - ); + return { + success: false, + error: + 'You are trying to login to an account which has been created using a social provider. Please login using the social provider instead.', + }; } // Handle case where profile exists but salt is missing (corrupted user data) if (!profile.salt) { - return done( - 'Please contact a system administrator. There was an issue logging you in.', - false - ); + return { + success: false, + error: + 'Please contact a system administrator. There was an issue logging you in.', + }; } // Hash the provided password with the stored salt using PBKDF2 @@ -90,11 +107,44 @@ export const validateLocalUser: VerifyFunction = async ( // Compare the computed hash with the stored password hash if (hashedPassword.toString('hex') === profile.password) { - // Password matches - convert CouchDB user to Express user and authenticate - return done(null, await upgradeCouchUserToExpressUser({dbUser})); + // Password matches + return { + success: true, + user: dbUser, + }; } else { // Password doesn't match - return done(ambiguousErrorMessage, false); + return { + success: false, + error: ambiguousErrorMessage, + }; + } +}; + +/** + * Validates a user attempting to log in with local credentials + * + * @param email - User's email or username + * @param password - User's plaintext password + * @param done - Passport callback function + * @returns The run done callback with (err?, user?) + */ +export const validateLocalUser: VerifyFunction = async ( + email, + password, + done +): Promise => { + const result = await verifyUserCredentials({username: email, password}); + + if (result.success && result.user) { + // If verification succeeded, upgrade to Express user and authenticate + return done( + null, + await upgradeCouchUserToExpressUser({dbUser: result.user}) + ); + } else { + // If verification failed, pass the error message + return done(result.error, false); } }; diff --git a/api/src/couchdb/emailCodes.ts b/api/src/couchdb/emailReset.ts similarity index 64% rename from api/src/couchdb/emailCodes.ts rename to api/src/couchdb/emailReset.ts index d45597a61..ffc2508ea 100644 --- a/api/src/couchdb/emailCodes.ts +++ b/api/src/couchdb/emailReset.ts @@ -15,23 +15,120 @@ import { } from '@faims3/data-model'; import {v4 as uuidv4} from 'uuid'; import {getAuthDB} from '.'; -import {EMAIL_CODE_EXPIRY_MINUTES, NEW_CONDUCTOR_URL} from '../buildconfig'; -import {InternalSystemError, ItemNotFoundException} from '../exceptions'; +import {buildQueryString} from '../auth/helpers'; +import {CONDUCTOR_PUBLIC_URL, EMAIL_CODE_EXPIRY_MINUTES} from '../buildconfig'; +import { + InternalSystemError, + ItemNotFoundException, + TooManyRequestsException, +} from '../exceptions'; import {generateVerificationCode, hashVerificationCode} from '../utils'; import {getCouchUserFromEmailOrUserId} from './users'; // Expiry time in milliseconds const CODE_EXPIRY_MS = EMAIL_CODE_EXPIRY_MINUTES * 60 * 1000; +/** + * Configuration constants for email code rate limiting + */ + +// Maximum number of email codes a user can request within the rate limit window +export const MAX_EMAIL_CODE_ATTEMPTS = 5; +// Rate limit window in milliseconds (30 minutes) +export const EMAIL_CODE_RATE_LIMIT_WINDOW_MS = 30 * 60 * 1000; +// Cooldown period in milliseconds (2 hours) after reaching max attempts +export const EMAIL_CODE_COOLDOWN_MS = 2 * 60 * 60 * 1000; + /** * Takes a reset code and embeds into URL * @param code The unhashed code to embed into the URL * @returns The URL to present to the user */ -export function buildCodeIntoUrl(code: string): string { - return `${NEW_CONDUCTOR_URL}/auth/resetPassword?code=${code}`; +export function buildCodeIntoUrl({ + code, + redirect, +}: { + code: string; + redirect: string; +}): string { + return `${CONDUCTOR_PUBLIC_URL}/reset-password${buildQueryString({values: {code, redirect}})}`; } +/** + * Checks if a user can create a new email code based on previous attempts. + * + * @param userId - The ID of the user requesting the code + * @param email - The email address for the code (if applicable) + * @param purpose - The purpose of the code (e.g., 'password-reset') + * @param maxAttempts - Maximum number of allowed attempts in the rate limit window + * @param rateLimitWindowMs - Time window for rate limiting in milliseconds + * @param cooldownMs - Cooldown period after max attempts in milliseconds + * + * @returns A Promise that resolves to an object indicating if the user can create a code + */ +export const checkCanCreateEmailCode = async ({ + userId, + maxAttempts = MAX_EMAIL_CODE_ATTEMPTS, + rateLimitWindowMs = EMAIL_CODE_RATE_LIMIT_WINDOW_MS, + cooldownMs = EMAIL_CODE_COOLDOWN_MS, +}: { + userId: string; + maxAttempts?: number; + rateLimitWindowMs?: number; + cooldownMs?: number; +}): Promise<{ + canCreate: boolean; + reason?: string; + nextAttemptAllowedAt?: number; +}> => { + // Get all email codes for this user within the rate limit window + const timeThreshold = Date.now() - rateLimitWindowMs; + + // Get the user's email codes + const codes = await getCodesByUserId(userId); + + // Filter codes by purpose and/or email if provided + const recentCodes = codes.filter(code => { + // Basic time filter + return (code.createdTimestampMs || 0) > timeThreshold; + }); + + // Count the number of attempts + const attemptCount = recentCodes.length; + + // If user has not exceeded max attempts, they can create + if (attemptCount < maxAttempts) { + return {canCreate: true}; + } + + // Find the most recent code to calculate cooldown + if (recentCodes.length === 0) { + return {canCreate: true}; // Shouldn't happen but safety check + } + + const mostRecentCode = recentCodes.reduce((latest, current) => { + const latestCreated = latest.createdTimestampMs || 0; + const currentCreated = current.createdTimestampMs || 0; + return currentCreated > latestCreated ? current : latest; + }, recentCodes[0]); + + // Calculate when the cooldown period ends + const mostRecentTimestamp = mostRecentCode.createdTimestampMs || 0; + const cooldownEndsAt = mostRecentTimestamp + cooldownMs; + + // If cooldown period has passed, they can create + if (Date.now() > cooldownEndsAt) { + return {canCreate: true}; + } + + // User must wait until cooldown ends + return { + canCreate: false, + reason: 'Too many reset requests. Please try again later.', + nextAttemptAllowedAt: cooldownEndsAt, + }; +}; + /** * Generates an expiry timestamp for an email verification code. * @@ -48,18 +145,34 @@ function generateExpiryTimestamp(expiryMs: number): number { /** * Creates a new email verification code for a given user. * @param userId The ID of the user for whom the code is being created. - * @param purpose The purpose of the email code (e.g., 'verification', 'password-reset') + * @param expiryMs The duration in milliseconds until the code expires * @returns A Promise that resolves to an object containing the AuthRecord and the raw verification code + * @throws Error if rate limiting prevents code creation */ -export const createNewEmailCode = async ( - userId: string, - expiryMs: number = CODE_EXPIRY_MS -): Promise<{record: EmailCodeExistingDocument; code: string}> => { +export const createNewEmailCode = async ({ + userId, + expiryMs = CODE_EXPIRY_MS, +}: { + userId: string; + expiryMs?: number; +}): Promise<{record: EmailCodeExistingDocument; code: string}> => { + // Check rate limiting constraints + const rateCheckResult = await checkCanCreateEmailCode({ + userId, + }); + + if (!rateCheckResult.canCreate) { + throw new TooManyRequestsException( + rateCheckResult.reason || 'Rate limit exceeded for email code creation.' + ); + } + const authDB = getAuthDB(); const code = generateVerificationCode(); const hash = hashVerificationCode(code); const dbId = AUTH_RECORD_ID_PREFIXES.emailcode + uuidv4(); const expiryTimestampMs = generateExpiryTimestamp(expiryMs); + const currentTimestamp = Date.now(); const newEmailCode: EmailCodeFields = { documentType: 'emailcode', @@ -67,6 +180,7 @@ export const createNewEmailCode = async ( code: hash, used: false, expiryTimestampMs, + createdTimestampMs: currentTimestamp, }; const response = await authDB.put({_id: dbId, ...newEmailCode}); diff --git a/api/src/utils/emailHelpers.ts b/api/src/utils/emailHelpers.ts index f362d29f5..a82009916 100644 --- a/api/src/utils/emailHelpers.ts +++ b/api/src/utils/emailHelpers.ts @@ -162,3 +162,156 @@ If you did not request this verification, please ignore this email. await emailService.sendEmail({options: emailOptions}); } + +/** + * Builds a password reset URL with the reset code embedded + * + * @param code The reset code to embed in the URL + * @returns The complete password reset URL + */ +export function buildPasswordResetUrl({ + code, + redirect, +}: { + code: string; + redirect: string; +}): string { + return `${CONDUCTOR_PUBLIC_URL}/auth/reset-password?code=${encodeURIComponent(code)}&redirect=${redirect}`; +} + +/** + * Sends a password reset email to a user. + * + * @param recipientEmail - The recipient's email address + * @param username - The recipient's username or name + * @param resetCode - The password reset code + * @param expiryTimestampMs - Timestamp in milliseconds when the code expires + * @returns A Promise that resolves when the email has been sent + */ +export async function sendPasswordResetEmail({ + recipientEmail, + username, + resetCode, + redirect, + expiryTimestampMs, +}: { + recipientEmail: string; + username: string; + resetCode: string; + expiryTimestampMs: number; + redirect: string; +}): Promise { + // Calculate expiry in hours from milliseconds + const expiryMs = expiryTimestampMs - Date.now(); + // Convert ms to hours and round up + const expiryHours = Math.ceil(expiryMs / (1000 * 60 * 60)); + const emailService = EMAIL_SERVICE; + const resetUrl = buildPasswordResetUrl({code: resetCode, redirect}); + + const subject = 'Reset Your Password'; + + // Plain text version of the email + const textContent = ` +Hello ${username}, + +We received a request to reset your password. To proceed with the password reset, please click the link below: +${resetUrl} + +This link will expire in ${expiryHours} hours. + +If you did not request a password reset, you can safely ignore this email. Your account security has not been compromised. + `.trim(); + + // HTML version of the email + const htmlContent = ` + + + + + + Reset Your Password + + + +
+
+

Reset Your Password

+
+

Hello ${username},

+

We received a request to reset your password. To proceed with the password reset, please click the button below:

+ Reset Password +

This link will expire in ${expiryHours} hours.

+
+

Note: If you did not request a password reset, you can safely ignore this email. Your account security has not been compromised.

+
+ +
+ + + `.trim(); + + const emailOptions: EmailOptions = { + to: recipientEmail, + subject, + text: textContent, + html: htmlContent, + }; + + await emailService.sendEmail({options: emailOptions}); +} diff --git a/api/test/emailReset.test.ts b/api/test/emailReset.test.ts index d7df2a833..02a2dbd31 100644 --- a/api/test/emailReset.test.ts +++ b/api/test/emailReset.test.ts @@ -15,7 +15,7 @@ import { getCodeByCode, markCodeAsUsed, validateEmailCode, -} from '../src/couchdb/emailCodes'; +} from '../src/couchdb/emailReset'; import {getExpressUserFromEmailOrUserId} from '../src/couchdb/users'; import {app} from '../src/expressSetup'; import {hashVerificationCode} from '../src/utils'; @@ -82,7 +82,7 @@ describe('password reset tests', () => { const localUser = await getExpressUserFromEmailOrUserId(localUserName); expect(localUser).to.not.be.undefined; - const {code} = await createNewEmailCode(localUser!.user_id!); + const {code} = await createNewEmailCode({userId: localUser!.user_id!}); // Now try to reset the password const newPassword = 'NewSecurePassword123!'; @@ -122,7 +122,10 @@ describe('password reset tests', () => { it('complete password reset fails with expired code', async () => { // Create a code with very short expiry const localUser = await getExpressUserFromEmailOrUserId(localUserName); - const {code} = await createNewEmailCode(localUser!.user_id!, 10); // 10ms expiry + const {code} = await createNewEmailCode({ + userId: localUser!.user_id!, + expiryMs: 10, + }); // 10ms expiry // Wait for code to expire await new Promise(resolve => setTimeout(resolve, 100)); @@ -138,7 +141,7 @@ describe('password reset tests', () => { it('email code validation works correctly', async () => { const localUser = await getExpressUserFromEmailOrUserId(localUserName); - const {code} = await createNewEmailCode(localUser!.user_id!); + const {code} = await createNewEmailCode({userId: localUser!.user_id!}); // Valid code check let validation = await validateEmailCode(code); diff --git a/api/views/change-password.handlebars b/api/views/change-password.handlebars new file mode 100644 index 000000000..5864e1643 --- /dev/null +++ b/api/views/change-password.handlebars @@ -0,0 +1,85 @@ +
+

Change Password for {{changePasswordPostPayload.username}}

+

Update your account password below

+
+
+ + + + {{#if messages.error.changePasswordError}} +
{{messages.error.changePasswordError.msg}}
+ {{/if}} + + {{#if messages.success}} +
{{messages.success}}
+ {{/if}} + +
+ + + {{#if messages.error.currentPassword}} +
{{messages.error.currentPassword.msg}}
+ {{/if}} +
+ +
+ +

Choose a password with at least 10 characters

+ + {{#if messages.error.newPassword}} +
{{messages.error.newPassword.msg}}
+ {{/if}} +
+ +
+ + + {{#if messages.error.confirmPassword}} +
{{messages.error.confirmPassword.msg}}
+ {{/if}} +
+ +
+ + Cancel +
+
\ No newline at end of file diff --git a/api/views/forgot-password.handlebars b/api/views/forgot-password.handlebars new file mode 100644 index 000000000..205f6b364 --- /dev/null +++ b/api/views/forgot-password.handlebars @@ -0,0 +1,36 @@ +
+

Reset Password

+

Enter your email address below and we'll send you instructions to reset your password.

+
+ +
+ {{#if messages.error.forgotPasswordError}} +
{{messages.error.forgotPasswordError.msg}}
+ {{/if}} + + {{#if messages.success}} +
{{messages.success}}
+ {{/if}} + +
+ + + {{#if messages.error.email}} +
{{messages.error.email.msg}}
+ {{/if}} +
+ +
+ + Back to Login +
+
\ No newline at end of file diff --git a/api/views/layouts/main.handlebars b/api/views/layouts/main.handlebars index ce87cd6ca..8f7f12aa4 100644 --- a/api/views/layouts/main.handlebars +++ b/api/views/layouts/main.handlebars @@ -1,202 +1,219 @@ - - - - - + + + + Conductor - {{pageTitle}} - - - - + + + + {{{extraStyles}}} - - -
-
- {{{body}}} -
+ + +
+
+ {{{body}}} +
- + {{{scripts}}} - + \ No newline at end of file diff --git a/api/views/login.handlebars b/api/views/login.handlebars index 33edc0a17..6a6286317 100644 --- a/api/views/login.handlebars +++ b/api/views/login.handlebars @@ -1,12 +1,13 @@ {{#if messages.message}}
{{messages.message}}
{{/if}} - +{{#if messages.success}} +
{{messages.success}}
+{{/if}}

Welcome

Sign in to continue

- {{#if localAuth}}
{{messages.error.password.msg}}
{{/if}} + {{#if messages.error.loginError}} @@ -58,13 +62,11 @@ {{/if}} {{/if}} - {{#if (and localAuth providers)}}
or
{{/if}} - {{#if providers}}
{{#each providers}} diff --git a/api/views/reset-password-error.handlebars b/api/views/reset-password-error.handlebars new file mode 100644 index 000000000..0c078a39c --- /dev/null +++ b/api/views/reset-password-error.handlebars @@ -0,0 +1,19 @@ +
+

Password Reset Error

+
+ +
+

{{error}}

+
+ +

This could be because:

+
    +
  • The reset link has expired (links are valid for a limited time)
  • +
  • The reset link has already been used
  • +
  • The reset link was incorrectly copied or typed
  • +
+ + \ No newline at end of file diff --git a/api/views/reset-password.handlebars b/api/views/reset-password.handlebars new file mode 100644 index 000000000..f16957eb3 --- /dev/null +++ b/api/views/reset-password.handlebars @@ -0,0 +1,54 @@ +
+

Reset Your Password

+

Please enter your new password below.

+
+ +
+ + + + {{#if messages.error.resetPasswordError}} +
{{messages.error.resetPasswordError.msg}}
+ {{/if}} + + {{#if messages.success}} +
{{messages.success}}
+ {{/if}} + +
+ +

Choose a password with at least 10 characters

+ + {{#if messages.error.newPassword}} +
{{messages.error.newPassword.msg}}
+ {{/if}} +
+ +
+ + + {{#if messages.error.confirmPassword}} +
{{messages.error.confirmPassword.msg}}
+ {{/if}} +
+ +
+ + Back to Login +
+
\ No newline at end of file diff --git a/app/src/gui/components/authentication/cluster_card.tsx b/app/src/gui/components/authentication/cluster_card.tsx index 7a5d2c1ee..2cc02d37d 100644 --- a/app/src/gui/components/authentication/cluster_card.tsx +++ b/app/src/gui/components/authentication/cluster_card.tsx @@ -15,14 +15,16 @@ * * Filename: cluster_card.tsx * Description: - * TODO + * Cluster card component for displaying server connections and user authentication status */ import {Browser} from '@capacitor/browser'; import {Person2Sharp} from '@mui/icons-material'; import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import KeyIcon from '@mui/icons-material/Key'; import LoginIcon from '@mui/icons-material/Login'; import LogoutIcon from '@mui/icons-material/Logout'; +import MoreVertIcon from '@mui/icons-material/MoreVert'; import PersonAddIcon from '@mui/icons-material/PersonAdd'; import RefreshIcon from '@mui/icons-material/Refresh'; import { @@ -32,10 +34,18 @@ import { Chip, Divider, Grid, + IconButton, + ListItemIcon, + ListItemText, + Menu, + MenuItem, Paper, Stack, Typography, + useMediaQuery, + useTheme, } from '@mui/material'; +import {useState} from 'react'; import {APP_ID} from '../../../buildconfig'; import { isTokenValid, @@ -61,6 +71,8 @@ type ClusterCardProps = { export default function ClusterCard(props: ClusterCardProps) { // Auth store interactions const dispatch = useAppDispatch(); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); // For the current server, get logged in usernames const usernames = useAppSelector(selectAllServerUsers).filter( @@ -69,11 +81,59 @@ export default function ClusterCard(props: ClusterCardProps) { const activeUser = useAppSelector(selectActiveUser); const authServers = useAppSelector(state => state.auth.servers); + // State for action menu + const [anchorEl, setAnchorEl] = useState(null); + const [menuUser, setMenuUser] = useState(null); + const open = Boolean(anchorEl); + + const handleActionMenuOpen = ( + event: React.MouseEvent, + username: string + ) => { + setAnchorEl(event.currentTarget); + setMenuUser(username); + }; + + const handleActionMenuClose = () => { + setAnchorEl(null); + setMenuUser(null); + }; + const handleLogout = async (username: string) => { + // close menu if open + handleActionMenuClose(); // remove the server connection on logout dispatch(removeServerConnection({serverId: props.serverId, username})); }; + const handleActivateUser = (username: string) => { + handleActionMenuClose(); + const identity = usernames.find(user => user.username === username); + if (identity) { + dispatch(setActiveUser(identity)); + } + }; + + const handleChangePassword = (username: string) => { + handleActionMenuClose(); + // Create the redirect URL back to the current page + const redirectUrl = window.location.href; + + // Build the URL for the change password page + const changePasswordUrl = `${props.conductor_url}/change-password?username=${encodeURIComponent( + username + )}&redirect=${encodeURIComponent(redirectUrl)}`; + + // Navigate to the change password page + if (isWeb()) { + window.location.href = changePasswordUrl; + } else { + Browser.open({ + url: changePasswordUrl, + }); + } + }; + const handleAddNewUser = async () => { if (isWeb()) { const redirect = `${window.location.protocol}//${window.location.host}/auth-return`; @@ -103,12 +163,14 @@ export default function ClusterCard(props: ClusterCardProps) { content={true} > {usernames.length === 0 ? ( - } - /> + + } + /> + ) : ( <> {ADD_NEW_USER_FOR_LOGGED_IN_SERVER_ENABLED && ( @@ -158,23 +220,103 @@ export default function ClusterCard(props: ClusterCardProps) { }} > - {/* User Info Section */} - - - {username} - - {isActive && ( - } - label="Active User" - color="primary" - size="small" - /> + {/* User Header - Username, Active Status, and Action Button */} + + + + + {username} + + + + {isActive && ( + } + label="Active User" + color="primary" + size="small" + /> + )} + + {isMobile && isLoggedIn && ( + handleActionMenuOpen(e, username)} + aria-label="user actions" + sx={{ + border: '1px solid', + borderColor: 'divider', + backgroundColor: 'background.paper', + '&:hover': { + backgroundColor: 'action.hover', + }, + p: 1, + }} + > + + + )} + + + {!isMobile && isLoggedIn && ( + handleActionMenuOpen(e, username)} + aria-label="user actions" + sx={{ + border: '1px solid', + borderColor: 'divider', + backgroundColor: 'background.paper', + '&:hover': { + backgroundColor: 'action.hover', + }, + p: 1, + }} + > + + )} + {/* User Name Display */} {tokenInfo?.parsedToken?.name && ( - + {tokenInfo.parsedToken.name} )} @@ -187,7 +329,17 @@ export default function ClusterCard(props: ClusterCardProps) { '& .MuiAlert-action': { alignItems: 'center', pt: 0, + ml: isMobile ? 0 : 'auto', + mt: isMobile ? 1 : 0, + display: isMobile ? 'flex' : 'inline-flex', + width: isMobile ? '100%' : 'auto', + justifyContent: isMobile ? 'center' : 'flex-end', + }, + '& .MuiAlert-message': { + overflow: 'hidden', }, + flexDirection: isMobile ? 'column' : 'row', + alignItems: isMobile ? 'stretch' : 'center', }} action={ } /> } @@ -209,55 +364,68 @@ export default function ClusterCard(props: ClusterCardProps) { )} {/* Button Section */} - {!isLoggedIn ? ( + {!isLoggedIn && ( } - /> - ) : ( - button': { - flex: { - xs: '1 1 100%', - sm: '1 1 auto', - }, - minWidth: { - sm: '120px', - }, - }, + width: isMobile ? '100%' : 'auto', }} - > - {!isActive && ( - - )} - - + /> )} ); })} + + {/* Action Menu */} + + {menuUser && activeUser?.username !== menuUser && ( + handleActivateUser(menuUser)}> + + + + Activate User + + )} + + {menuUser && ( + handleChangePassword(menuUser)}> + + + + Change Password + + )} + + {menuUser && ( + handleLogout(menuUser)} + sx={{color: 'error.main'}} + > + + + + Log Out + + )} + )} diff --git a/library/data-model/src/api.ts b/library/data-model/src/api.ts index da4fb083d..373ca0988 100644 --- a/library/data-model/src/api.ts +++ b/library/data-model/src/api.ts @@ -11,12 +11,65 @@ import {EncodedUISpecificationSchema} from './types'; // WIP USERS // ================== +// Change Password +export const PostChangePasswordInputSchema = z + .object({ + username: z.string().trim(), + currentPassword: z.string().trim(), + newPassword: z + .string() + .trim() + .min(10, 'New password must be at least 10 characters in length.'), + confirmPassword: z.string().trim(), + redirect: z.string().trim().optional(), + }) + .refine(data => data.newPassword === data.confirmPassword, { + message: 'New passwords do not match', + path: ['confirmPassword'], + }); + +export type PostChangePasswordInput = z.infer< + typeof PostChangePasswordInputSchema +>; + +// Forgot password +export const PostForgotPasswordInputSchema = z.object({ + email: z.string().trim().email('Please enter a valid email address.'), + redirect: z.string().trim().optional(), +}); + +export type PostForgotPasswordInput = z.infer< + typeof PostForgotPasswordInputSchema +>; + +// Reset Password +export const PostResetPasswordInputSchema = z + .object({ + code: z.string().trim(), + newPassword: z + .string() + .trim() + .min(10, 'Password must be at least 10 characters in length.'), + confirmPassword: z.string().trim(), + redirect: z.string().trim().optional(), + }) + .refine(data => data.newPassword === data.confirmPassword, { + message: 'Passwords do not match', + path: ['confirmPassword'], + }); + +export type PostResetPasswordInput = z.infer< + typeof PostResetPasswordInputSchema +>; + +// Get current user export const GetCurrentUserResponseSchema = z.object({ id: z.string(), name: z.string(), email: z.string(), isVerified: z.boolean(), }); + export type GetCurrentUserResponse = z.infer< typeof GetCurrentUserResponseSchema >; @@ -336,6 +389,7 @@ export type GetTemplateByIdResponse = z.infer< // POST /reset request schema export const PostRequestPasswordResetRequestSchema = z.object({ email: z.string(), + redirect: z.string().optional(), }); export type PostRequestPasswordResetRequest = z.infer< typeof PostRequestPasswordResetRequestSchema diff --git a/library/data-model/src/data_storage/authDB/types.ts b/library/data-model/src/data_storage/authDB/types.ts index c9c8b7eae..9d8c90d12 100644 --- a/library/data-model/src/data_storage/authDB/types.ts +++ b/library/data-model/src/data_storage/authDB/types.ts @@ -256,49 +256,140 @@ export type VerificationChallengeV3ExistingDocument = z.infer< typeof VerificationChallengeV3ExistingDocumentSchema >; +// ============= +// V4 Definition +// ============= + +// V4 - Refresh token schema remains the same as V2 +export const RefreshRecordV4FieldsSchema = RefreshRecordV3FieldsSchema; + +// V4 - Email code schema extends V3 with creation +export const EmailCodeV4FieldsSchema = EmailCodeV3FieldsSchema.extend({ + // When was it created? unix timestamp in ms + createdTimestampMs: z.number(), +}); + +// V4 - no change +export const VerificationChallengeV4FieldsSchema = + VerificationChallengeV3FieldsSchema; + +export const AuthRecordV4FieldsSchema = z.discriminatedUnion('documentType', [ + RefreshRecordV4FieldsSchema, + EmailCodeV4FieldsSchema, + VerificationChallengeV4FieldsSchema, +]); + +export const AuthRecordV4DocumentSchema = z.discriminatedUnion('documentType', [ + CouchDocumentSchema.extend(RefreshRecordV4FieldsSchema.shape), + CouchDocumentSchema.extend(EmailCodeV4FieldsSchema.shape), + CouchDocumentSchema.extend(VerificationChallengeV4FieldsSchema.shape), +]); +export type AuthRecordV4Document = z.infer; + +export const AuthRecordV4ExistingDocumentSchema = z.discriminatedUnion( + 'documentType', + [ + CouchExistingDocumentSchema.extend(RefreshRecordV4FieldsSchema.shape), + CouchExistingDocumentSchema.extend(EmailCodeV4FieldsSchema.shape), + CouchExistingDocumentSchema.extend( + VerificationChallengeV4FieldsSchema.shape + ), + ] +); +export type AuthRecordV4ExistingDocument = z.infer< + typeof AuthRecordV4ExistingDocumentSchema +>; + +export type RefreshRecordV4Fields = z.infer; +export type EmailCodeV4Fields = z.infer; +export type VerificationChallengeV4Fields = z.infer< + typeof VerificationChallengeV4FieldsSchema +>; +export type AuthRecordV4Fields = z.infer; + +// refresh token +export const RefreshRecordV4DocumentSchema = CouchDocumentSchema.extend( + RefreshRecordV4FieldsSchema.shape +); +export type RefreshRecordV4Document = z.infer< + typeof RefreshRecordV4DocumentSchema +>; + +export const RefreshRecordV4ExistingDocumentSchema = + CouchExistingDocumentSchema.extend(RefreshRecordV4FieldsSchema.shape); +export type RefreshRecordV4ExistingDocument = z.infer< + typeof RefreshRecordV4ExistingDocumentSchema +>; + +// email code +export const EmailCodeV4DocumentSchema = CouchDocumentSchema.extend( + EmailCodeV4FieldsSchema.shape +); +export type EmailCodeV4Document = z.infer; + +export const EmailCodeV4ExistingDocumentSchema = + CouchExistingDocumentSchema.extend(EmailCodeV4FieldsSchema.shape); +export type EmailCodeV4ExistingDocument = z.infer< + typeof EmailCodeV4ExistingDocumentSchema +>; + +// verification challenge +export const VerificationChallengeV4DocumentSchema = CouchDocumentSchema.extend( + VerificationChallengeV4FieldsSchema.shape +); +export type VerificationChallengeV4Document = z.infer< + typeof VerificationChallengeV4DocumentSchema +>; + +export const VerificationChallengeV4ExistingDocumentSchema = + CouchExistingDocumentSchema.extend(VerificationChallengeV4FieldsSchema.shape); +export type VerificationChallengeV4ExistingDocument = z.infer< + typeof VerificationChallengeV4ExistingDocumentSchema +>; + // CURRENT EXPORTS // =============== // Fields -export const AuthRecordFieldsSchema = AuthRecordV3FieldsSchema; -export type AuthRecordFields = AuthRecordV3Fields; +export const AuthRecordFieldsSchema = AuthRecordV4FieldsSchema; +export type AuthRecordFields = AuthRecordV4Fields; // possibly existing document schemas -export const AuthRecordDocumentSchema = AuthRecordV3DocumentSchema; -export type AuthRecordDocument = AuthRecordV3Document; +export const AuthRecordDocumentSchema = AuthRecordV4DocumentSchema; +export type AuthRecordDocument = AuthRecordV4Document; // existing document schemas export const AuthRecordExistingDocumentSchema = - AuthRecordV3ExistingDocumentSchema; -export type AuthRecordExistingDocument = AuthRecordV3ExistingDocument; + AuthRecordV4ExistingDocumentSchema; +export type AuthRecordExistingDocument = AuthRecordV4ExistingDocument; // Helper types for specific record documents -export type RefreshRecordFields = RefreshRecordV3Fields; -export type EmailCodeFields = EmailCodeV3Fields; -export type VerificationChallengeFields = VerificationChallengeV3Fields; +export type RefreshRecordFields = RefreshRecordV4Fields; +export type EmailCodeFields = EmailCodeV4Fields; +export type VerificationChallengeFields = VerificationChallengeV4Fields; // refresh token -export const RefreshRecordDocumentSchema = RefreshRecordV3DocumentSchema; -export type RefreshRecordDocument = RefreshRecordV3Document; +export const RefreshRecordDocumentSchema = RefreshRecordV4DocumentSchema; +export type RefreshRecordDocument = RefreshRecordV4Document; export const RefreshRecordExistingDocumentSchema = - RefreshRecordV3ExistingDocumentSchema; -export type RefreshRecordExistingDocument = RefreshRecordV3ExistingDocument; + RefreshRecordV4ExistingDocumentSchema; +export type RefreshRecordExistingDocument = RefreshRecordV4ExistingDocument; // email code -export const EmailCodeDocumentSchema = EmailCodeV3DocumentSchema; -export type EmailCodeDocument = EmailCodeV3Document; +export const EmailCodeDocumentSchema = EmailCodeV4DocumentSchema; +export type EmailCodeDocument = EmailCodeV4Document; export const EmailCodeExistingDocumentSchema = - EmailCodeV3ExistingDocumentSchema; -export type EmailCodeExistingDocument = EmailCodeV3ExistingDocument; + EmailCodeV4ExistingDocumentSchema; +export type EmailCodeExistingDocument = EmailCodeV4ExistingDocument; // verification challenge export const VerificationChallengeDocumentSchema = - VerificationChallengeV3DocumentSchema; -export type VerificationChallengeDocument = VerificationChallengeV3Document; + VerificationChallengeV4DocumentSchema; +export type VerificationChallengeDocument = VerificationChallengeV4Document; export const VerificationChallengeExistingDocumentSchema = - VerificationChallengeV3ExistingDocumentSchema; + VerificationChallengeV4ExistingDocumentSchema; export type VerificationChallengeExistingDocument = - VerificationChallengeV3ExistingDocument; + VerificationChallengeV4ExistingDocument; // ID prefix map export const AUTH_RECORD_ID_PREFIXES = { diff --git a/library/data-model/src/data_storage/migrations/migrations.ts b/library/data-model/src/data_storage/migrations/migrations.ts index 89a474d6c..7efb59915 100644 --- a/library/data-model/src/data_storage/migrations/migrations.ts +++ b/library/data-model/src/data_storage/migrations/migrations.ts @@ -2,6 +2,8 @@ import {Resource, ResourceRole, Role} from '../../permission'; import { AuthRecordV1ExistingDocumentSchema, AuthRecordV2ExistingDocumentSchema, + AuthRecordV3ExistingDocumentSchema, + AuthRecordV4ExistingDocument, RefreshRecordV2ExistingDocument, } from '../authDB'; import { @@ -154,6 +156,30 @@ export const authV2toV3Migration: MigrationFunc = doc => { return {action: 'none'}; }; +/** + * Migration from V3 to V4 of the Auth database + */ +export const authV3toV4Migration: MigrationFunc = doc => { + const v3inputDoc = AuthRecordV3ExistingDocumentSchema.parse(doc); + if (v3inputDoc.documentType === 'refresh') { + // noop + return {action: 'none'}; + } else if (v3inputDoc.documentType === 'verification') { + // noop + return {action: 'none'}; + } else { + // email code + return { + action: 'update', + updatedRecord: { + ...v3inputDoc, + // imagine it was created now + createdTimestampMs: Date.now(), + } satisfies AuthRecordV4ExistingDocument, + }; + } +}; + /** * Converts old invites into new ones based on mapping invites + renaming field * @returns new invite doc @@ -337,7 +363,7 @@ export const authV1toV2Migration: MigrationFunc = doc => { // If we want to promote a database for migration- increment the targetVersion // and ensure a migration is defined. export const DB_TARGET_VERSIONS: DBTargetVersions = { - [DatabaseType.AUTH]: {defaultVersion: 1, targetVersion: 3}, + [DatabaseType.AUTH]: {defaultVersion: 1, targetVersion: 4}, [DatabaseType.DATA]: {defaultVersion: 1, targetVersion: 1}, [DatabaseType.DIRECTORY]: {defaultVersion: 1, targetVersion: 1}, // invites v3 @@ -421,4 +447,12 @@ export const DB_MIGRATIONS: MigrationDetails[] = [ 'No-op migration to prompt V3 of schema which includes the new verification email document.', migrationFunction: authV2toV3Migration, }, + { + dbType: DatabaseType.AUTH, + from: 3, + to: 4, + description: + 'Adds a created timestamp to email codes such that we can determine rate limiting', + migrationFunction: authV3toV4Migration, + }, ]; diff --git a/library/data-model/tests/migrations.test.ts b/library/data-model/tests/migrations.test.ts index f13335c29..40f450e48 100644 --- a/library/data-model/tests/migrations.test.ts +++ b/library/data-model/tests/migrations.test.ts @@ -1,10 +1,13 @@ import PouchDB from 'pouchdb'; import PouchDBMemoryAdapter from 'pouchdb-adapter-memory'; +import {EncodedProjectUIModel, Resource, Role} from '../src'; import { AUTH_RECORD_ID_PREFIXES, DB_MIGRATIONS, DatabaseType, EmailCodeV1ExistingDocument, + EmailCodeV3ExistingDocument, + EmailCodeV4ExistingDocument, MigrationFuncReturn, PeopleV1Document, PeopleV2Document, @@ -15,16 +18,17 @@ import { ProjectV2Fields, RefreshRecordV1ExistingDocument, RefreshRecordV2ExistingDocument, + RefreshRecordV3ExistingDocument, V1InviteDBFields, V2InviteDBFields, V3InviteDBFields, + VerificationChallengeV3ExistingDocument, } from '../src/data_storage'; -import {EncodedProjectUIModel, Resource, Role} from '../src'; -import {areDocsEqual} from './utils'; import { TemplateV1Fields, TemplateV2Fields, } from '../src/data_storage/templatesDB/types'; +import {areDocsEqual} from './utils'; // Register memory adapter PouchDB.plugin(PouchDBMemoryAdapter); @@ -1517,6 +1521,91 @@ const AUTH_V2_TO_V3_MIGRATION_TEST_CASES: MigrationTestCase[] = [ }, ]; +// Test cases for authV3toV4Migration +const AUTH_V3_TO_V4_MIGRATION_TEST_CASES: MigrationTestCase[] = [ + // Test case 1: Email code migration - should add createdTimestampMs + { + name: 'authV3toV4Migration - email code', + dbType: DatabaseType.AUTH, + from: 3, + to: 4, + inputDoc: { + _id: `${AUTH_RECORD_ID_PREFIXES.emailcode}123456`, + _rev: '3-abc123', + documentType: 'emailcode', + userId: 'user123', + code: 'hashed_code_abc', + used: false, + expiryTimestampMs: Date.now() + 3600000, // 1 hour from now + } satisfies EmailCodeV3ExistingDocument, + expectedOutputDoc: { + _id: `${AUTH_RECORD_ID_PREFIXES.emailcode}123456`, + _rev: '3-abc123', + documentType: 'emailcode', + userId: 'user123', + code: 'hashed_code_abc', + used: false, + expiryTimestampMs: Date.now() + 3600000, + createdTimestampMs: Date.now(), + } satisfies Partial, + expectedResult: {action: 'update'}, + equalityFunction: (a, b) => { + const {createdTimestampMs: createdA, ...otherA} = a; + const {createdTimestampMs: createdB, ...otherB} = b; + return ( + createdA !== undefined && + createdB !== undefined && + areDocsEqual(otherA, otherB) + ); + }, + }, + + // Test case 2: Refresh token - should remain unchanged (no-op) + { + name: 'authV3toV4Migration - refresh token (no changes)', + dbType: DatabaseType.AUTH, + from: 3, + to: 4, + inputDoc: { + _id: `${AUTH_RECORD_ID_PREFIXES.refresh}345678`, + _rev: '3-def456', + documentType: 'refresh', + userId: 'user789', + token: 'token_xyz', + enabled: true, + expiryTimestampMs: Date.now() + 86400000, // 24 hours from now + exchangeTokenHash: 'hash123', + exchangeTokenUsed: false, + exchangeTokenExpiryTimestampMs: Date.now() + 3600000, // 1 hour from now + } satisfies RefreshRecordV3ExistingDocument, + expectedOutputDoc: undefined, // No changes expected + expectedResult: {action: 'none'}, + }, + + // Test case 3: Verification challenge - should remain unchanged (no-op) + { + name: 'authV3toV4Migration - verification challenge (no changes)', + dbType: DatabaseType.AUTH, + from: 3, + to: 4, + inputDoc: { + _id: `${AUTH_RECORD_ID_PREFIXES.verification}901234`, + _rev: '3-jkl012', + documentType: 'verification', + userId: 'user012', + email: 'user@example.com', + code: 'hashed_verification_code', + used: false, + createdTimestampMs: Date.now() - 3600000, // 1 hour ago + expiryTimestampMs: Date.now() + 86400000, // 24 hours from now + } satisfies VerificationChallengeV3ExistingDocument, + expectedOutputDoc: undefined, // No changes expected + expectedResult: {action: 'none'}, + }, +]; + +// Add the new test cases to the MIGRATION_TEST_CASES array +MIGRATION_TEST_CASES.push(...AUTH_V3_TO_V4_MIGRATION_TEST_CASES); MIGRATION_TEST_CASES.push(...AUTH_V2_TO_V3_MIGRATION_TEST_CASES); MIGRATION_TEST_CASES.push(...PROJECT_MIGRATION_TEST_CASES); MIGRATION_TEST_CASES.push(...INVITES_MIGRATION_TEST_CASES); diff --git a/package-lock.json b/package-lock.json index 9c774a574..3bda7137a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18834,16 +18834,6 @@ "kuler": "^2.0.0" } }, - "node_modules/appium-uiautomator2-driver/node_modules/@emnapi/runtime": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz", - "integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/appium-uiautomator2-driver/node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -23447,41 +23437,6 @@ "proxy-from-env": "^1.1.0" } }, - "node_modules/appium-xcuitest-driver/node_modules/@appium/docutils": { - "version": "1.0.33", - "resolved": "https://registry.npmjs.org/@appium/docutils/-/docutils-1.0.33.tgz", - "integrity": "sha512-wY/bkewxrqUIvY69/LvYz1c7/XRFqqn/SND+EGvwWyyxzIfXTTN57SXZmRqWK8HO8v0hk5RMOvaNH+3Me94kTQ==", - "extraneous": true, - "license": "Apache-2.0", - "dependencies": { - "@appium/support": "^6.0.7", - "@appium/tsconfig": "^0.3.5", - "@sliphua/lilconfig-ts-loader": "3.2.2", - "chalk": "4.1.2", - "consola": "3.4.0", - "diff": "7.0.0", - "json5": "2.2.3", - "lilconfig": "3.1.3", - "lodash": "4.17.21", - "pkg-dir": "5.0.0", - "read-pkg": "5.2.0", - "semver": "7.7.1", - "source-map-support": "0.5.21", - "teen_process": "2.3.1", - "type-fest": "4.37.0", - "typescript": "5.8.2", - "yaml": "2.7.0", - "yargs": "17.7.2", - "yargs-parser": "21.1.1" - }, - "bin": { - "appium-docs": "bin/appium-docs.js" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0", - "npm": ">=8" - } - }, "node_modules/appium-xcuitest-driver/node_modules/@appium/logger": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/@appium/logger/-/logger-1.6.1.tgz", @@ -23719,62 +23674,6 @@ "node": ">=14" } }, - "node_modules/appium-xcuitest-driver/node_modules/@sliphua/lilconfig-ts-loader": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@sliphua/lilconfig-ts-loader/-/lilconfig-ts-loader-3.2.2.tgz", - "integrity": "sha512-nX2aBwAykiG50fSUzK9eyA5UvWcrEKzA0ZzCq9mLwHMwpKxM+U05YH8PHba1LJrbeZ7R1HSjJagWKMqFyq8cxw==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "lodash.get": "^4", - "make-error": "^1", - "ts-node": "^9", - "tslib": "^2" - }, - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "lilconfig": ">=2" - } - }, - "node_modules/appium-xcuitest-driver/node_modules/@sliphua/lilconfig-ts-loader/node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "extraneous": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/appium-xcuitest-driver/node_modules/@sliphua/lilconfig-ts-loader/node_modules/ts-node": { - "version": "9.1.1", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-9.1.1.tgz", - "integrity": "sha512-hPlt7ZACERQGf03M253ytLY3dHbGNGrAq9qIHWUY9XHYl1z7wYngSr3OQ5xmui8o2AaxsONxIzjafLUiWBo1Fg==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "source-map-support": "^0.5.17", - "yn": "3.1.1" - }, - "bin": { - "ts-node": "dist/bin.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" - }, - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "typescript": ">=2.7" - } - }, "node_modules/appium-xcuitest-driver/node_modules/@tsconfig/node14": { "version": "14.1.3", "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-14.1.3.tgz", @@ -24056,20 +23955,6 @@ "node": ">= 14" } }, - "node_modules/appium-xcuitest-driver/node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "extraneous": true, - "license": "MIT" - }, - "node_modules/appium-xcuitest-driver/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "extraneous": true, - "license": "Python-2.0" - }, "node_modules/appium-xcuitest-driver/node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -24376,66 +24261,6 @@ "node": ">=8" } }, - "node_modules/appium-xcuitest-driver/node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "extraneous": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/appium-xcuitest-driver/node_modules/cliui/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "extraneous": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/appium-xcuitest-driver/node_modules/cliui/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "extraneous": true, - "license": "MIT" - }, - "node_modules/appium-xcuitest-driver/node_modules/cliui/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/appium-xcuitest-driver/node_modules/cliui/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/appium-xcuitest-driver/node_modules/color": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", @@ -24520,16 +24345,6 @@ "dev": true, "license": "MIT" }, - "node_modules/appium-xcuitest-driver/node_modules/consola": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.0.tgz", - "integrity": "sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==", - "extraneous": true, - "license": "MIT", - "engines": { - "node": "^14.18.0 || >=16.10.0" - } - }, "node_modules/appium-xcuitest-driver/node_modules/console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", @@ -24611,13 +24426,6 @@ "node": ">= 14" } }, - "node_modules/appium-xcuitest-driver/node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "extraneous": true, - "license": "MIT" - }, "node_modules/appium-xcuitest-driver/node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -24742,16 +24550,6 @@ "license": "MIT", "optional": true }, - "node_modules/appium-xcuitest-driver/node_modules/diff": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", - "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", - "extraneous": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, "node_modules/appium-xcuitest-driver/node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -24874,16 +24672,6 @@ "node": ">= 0.4" } }, - "node_modules/appium-xcuitest-driver/node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "extraneous": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/appium-xcuitest-driver/node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -25198,16 +24986,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/appium-xcuitest-driver/node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "extraneous": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, "node_modules/appium-xcuitest-driver/node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -25698,19 +25476,6 @@ "dev": true, "license": "(AFL-2.1 OR BSD-3-Clause)" }, - "node_modules/appium-xcuitest-driver/node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "extraneous": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/appium-xcuitest-driver/node_modules/klaw": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/klaw/-/klaw-4.1.0.tgz", @@ -25774,19 +25539,6 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/appium-xcuitest-driver/node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "extraneous": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, "node_modules/appium-xcuitest-driver/node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -25827,14 +25579,6 @@ "dev": true, "license": "MIT" }, - "node_modules/appium-xcuitest-driver/node_modules/lodash.get": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", - "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", - "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", - "extraneous": true, - "license": "MIT" - }, "node_modules/appium-xcuitest-driver/node_modules/lodash.isfinite": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/lodash.isfinite/-/lodash.isfinite-3.3.2.tgz", @@ -25866,13 +25610,6 @@ "dev": true, "license": "ISC" }, - "node_modules/appium-xcuitest-driver/node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "extraneous": true, - "license": "ISC" - }, "node_modules/appium-xcuitest-driver/node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -25969,16 +25706,6 @@ "node": ">= 0.6" } }, - "node_modules/appium-xcuitest-driver/node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "extraneous": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/appium-xcuitest-driver/node_modules/minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", @@ -26303,22 +26030,6 @@ "wrappy": "1" } }, - "node_modules/appium-xcuitest-driver/node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/appium-xcuitest-driver/node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -26728,16 +26439,6 @@ "node": ">=10" } }, - "node_modules/appium-xcuitest-driver/node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "extraneous": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/appium-xcuitest-driver/node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -27520,13 +27221,6 @@ "utf8-byte-length": "^1.0.1" } }, - "node_modules/appium-xcuitest-driver/node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "extraneous": true, - "license": "0BSD" - }, "node_modules/appium-xcuitest-driver/node_modules/type-fest": { "version": "4.37.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.37.0.tgz", @@ -27554,20 +27248,6 @@ "node": ">= 0.6" } }, - "node_modules/appium-xcuitest-driver/node_modules/typescript": { - "version": "5.8.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", - "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", - "extraneous": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "node_modules/appium-xcuitest-driver/node_modules/undici-types": { "version": "6.20.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", @@ -27688,24 +27368,6 @@ "node": "^16.13.0 || >=18.0.0" } }, - "node_modules/appium-xcuitest-driver/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/appium-xcuitest-driver/node_modules/wrap-ansi-cjs": { "name": "wrap-ansi", "version": "7.0.0", @@ -27770,51 +27432,6 @@ "node": ">=8" } }, - "node_modules/appium-xcuitest-driver/node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "extraneous": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/appium-xcuitest-driver/node_modules/wrap-ansi/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "extraneous": true, - "license": "MIT" - }, - "node_modules/appium-xcuitest-driver/node_modules/wrap-ansi/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/appium-xcuitest-driver/node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/appium-xcuitest-driver/node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -27854,103 +27471,6 @@ "node": ">=8.0" } }, - "node_modules/appium-xcuitest-driver/node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "extraneous": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/appium-xcuitest-driver/node_modules/yaml": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz", - "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==", - "extraneous": true, - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/appium-xcuitest-driver/node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/appium-xcuitest-driver/node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "extraneous": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/appium-xcuitest-driver/node_modules/yargs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "extraneous": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/appium-xcuitest-driver/node_modules/yargs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "extraneous": true, - "license": "MIT" - }, - "node_modules/appium-xcuitest-driver/node_modules/yargs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/appium-xcuitest-driver/node_modules/yargs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/appium-xcuitest-driver/node_modules/yauzl": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.2.0.tgz", @@ -27975,16 +27495,6 @@ "node": "*" } }, - "node_modules/appium-xcuitest-driver/node_modules/yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "extraneous": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/appium-xcuitest-driver/node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/web/src/components/dialogs/generate-password-reset.tsx b/web/src/components/dialogs/generate-password-reset.tsx index ed468a66a..1e9364275 100644 --- a/web/src/components/dialogs/generate-password-reset.tsx +++ b/web/src/components/dialogs/generate-password-reset.tsx @@ -8,12 +8,14 @@ import { } from '@/components/ui/dialog'; import {useAuth} from '@/context/auth-provider'; import {useMutation} from '@tanstack/react-query'; +import {AlertCircle, LinkIcon, QrCode, RefreshCw} from 'lucide-react'; +import QRCode from 'qrcode'; import React, {useEffect, useState} from 'react'; +import {Alert, AlertDescription, AlertTitle} from '../ui/alert'; import {Button} from '../ui/button'; -import {Spinner} from '../ui/spinner'; import {CopyButton} from '../ui/copy-button'; -import QRCode from 'qrcode'; -import {WEB_URL} from '@/constants'; +import {Spinner} from '../ui/spinner'; +import {Card, CardContent} from '../ui/card'; /** * Displays a QR code in a clickable format that opens a larger view in a dialog. @@ -23,11 +25,14 @@ const QRCodeViewDialog = ({qrData}: {qrData: string}) => { return ( - @@ -73,7 +78,7 @@ export const GeneratePasswordReset = ({ if (!userId) return null; const [qrCodeData, setQrCodeData] = useState(''); - const {data, isPending, mutate} = useMutation({ + const {data, isPending, mutate, error, isError, reset} = useMutation({ mutationKey: ['resetpassword', userId], mutationFn: async ({id}: {id: string}) => { return await fetch(`${import.meta.env.VITE_API_URL}/api/reset`, { @@ -86,61 +91,111 @@ export const GeneratePasswordReset = ({ email: id, }), }).then(async res => { - return (await res.json()) as {code: string; url: string}; + if (res.ok) { + return (await res.json()) as {code: string; url: string}; + } else { + const errorData = await res.json().catch(() => ({})); + throw new Error( + errorData.message || + `Failed to generate reset link (${res.status}: ${res.statusText})` + ); + } }); }, }); useEffect(() => { if (data?.code) { - QRCode.toDataURL(`${WEB_URL}/reset-password?code=${data.code}`) + QRCode.toDataURL(data.url) .then(url => setQrCodeData(url)) .catch(err => console.error('Error generating QR code:', err)); } }, [data?.code]); + const handleRetry = () => { + reset(); + mutate({id: userId}); + }; + return ( - + Reset User Password - Generate a password reset link for user: {userId} + Generate a password reset link for user:{' '} + {userId} + {isError && ( + + + Error + + {error?.message || 'Failed to generate reset link'} + + + + )} + {isPending ? ( -
- +
+ + + Generating reset link... +
) : data ? ( -
-
-
- {`${WEB_URL}/reset-password?code=${data.code}`} -
- -
+
+ + +
+
+ + Reset Link +
+
+

+ Copy +

+ +
+
+
+
{qrCodeData && ( -
-
- Click QR code to enlarge +
+
+ + Click QR code to enlarge +
+
+
-
)} -
+
) : ( -
+
+
+ +

Generate a secure password reset link with QR code

+
diff --git a/web/src/components/tables/users.tsx b/web/src/components/tables/users.tsx index 2196807ee..0b178f74c 100644 --- a/web/src/components/tables/users.tsx +++ b/web/src/components/tables/users.tsx @@ -1,5 +1,10 @@ import {useAuth} from '@/context/auth-provider'; -import {Role, roleDetails, RoleScope} from '@faims3/data-model'; +import { + GetListAllUsersResponse, + Role, + roleDetails, + RoleScope, +} from '@faims3/data-model'; import {useQueryClient} from '@tanstack/react-query'; import {ColumnDef} from '@tanstack/react-table'; import {KeyRound} from 'lucide-react'; @@ -14,7 +19,7 @@ export const getColumns = ({ onReset, }: { onReset: (id: string) => void; -}): ColumnDef[] => { +}): ColumnDef[] => { const {user} = useAuth(); const queryClient = useQueryClient(); @@ -30,6 +35,11 @@ export const getColumns = ({ header: ({column}) => ( ), + cell: ({ + row: { + original: {emails}, + }, + }) =>

{emails[0]?.email ?? 'No email address'}

, }, { accessorKey: 'globalRoles', @@ -38,7 +48,7 @@ export const getColumns = ({ row: { original: {globalRoles, _id: userId}, }, - }: any) => ( + }) => (
{userId !== user?.user.id && ( rootRoute, -} as any) - const ProtectedRoute = ProtectedImport.update({ id: '/_protected', getParentRoute: () => rootRoute, @@ -110,13 +103,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProtectedImport parentRoute: typeof rootRoute } - '/reset-password': { - id: '/reset-password' - path: '/reset-password' - fullPath: '/reset-password' - preLoaderRoute: typeof ResetPasswordImport - parentRoute: typeof rootRoute - } '/_protected/_admin': { id: '/_protected/_admin' path: '' @@ -234,7 +220,6 @@ const ProtectedRouteWithChildren = ProtectedRoute._addFileChildren( export interface FileRoutesByFullPath { '': typeof ProtectedAdminRouteWithChildren - '/reset-password': typeof ResetPasswordRoute '/profile': typeof ProtectedProfileRoute '/': typeof ProtectedIndexRoute '/users': typeof ProtectedAdminUsersRoute @@ -247,7 +232,6 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { - '/reset-password': typeof ResetPasswordRoute '': typeof ProtectedAdminRouteWithChildren '/profile': typeof ProtectedProfileRoute '/': typeof ProtectedIndexRoute @@ -263,7 +247,6 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRoute '/_protected': typeof ProtectedRouteWithChildren - '/reset-password': typeof ResetPasswordRoute '/_protected/_admin': typeof ProtectedAdminRouteWithChildren '/_protected/profile': typeof ProtectedProfileRoute '/_protected/': typeof ProtectedIndexRoute @@ -280,7 +263,6 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '' - | '/reset-password' | '/profile' | '/' | '/users' @@ -292,7 +274,6 @@ export interface FileRouteTypes { | '/templates' fileRoutesByTo: FileRoutesByTo to: - | '/reset-password' | '' | '/profile' | '/' @@ -306,7 +287,6 @@ export interface FileRouteTypes { id: | '__root__' | '/_protected' - | '/reset-password' | '/_protected/_admin' | '/_protected/profile' | '/_protected/' @@ -322,12 +302,10 @@ export interface FileRouteTypes { export interface RootRouteChildren { ProtectedRoute: typeof ProtectedRouteWithChildren - ResetPasswordRoute: typeof ResetPasswordRoute } const rootRouteChildren: RootRouteChildren = { ProtectedRoute: ProtectedRouteWithChildren, - ResetPasswordRoute: ResetPasswordRoute, } export const routeTree = rootRoute @@ -340,8 +318,7 @@ export const routeTree = rootRoute "__root__": { "filePath": "__root.tsx", "children": [ - "/_protected", - "/reset-password" + "/_protected" ] }, "/_protected": { @@ -358,9 +335,6 @@ export const routeTree = rootRoute "/_protected/templates/" ] }, - "/reset-password": { - "filePath": "reset-password.tsx" - }, "/_protected/_admin": { "filePath": "_protected/_admin.tsx", "parent": "/_protected", diff --git a/web/src/routes/_protected/profile.tsx b/web/src/routes/_protected/profile.tsx index b6603be8e..05226a434 100644 --- a/web/src/routes/_protected/profile.tsx +++ b/web/src/routes/_protected/profile.tsx @@ -1,9 +1,10 @@ import {Button} from '@/components/ui/button'; import {Card} from '@/components/ui/card'; import {List, ListDescription, ListItem, ListLabel} from '@/components/ui/list'; +import {API_URL, WEB_URL} from '@/constants'; import {useAuth, User} from '@/context/auth-provider'; import {createFileRoute} from '@tanstack/react-router'; -import {CheckCircle, XCircle} from 'lucide-react'; +import {CheckCircle, Key, ShieldAlert, XCircle} from 'lucide-react'; import React from 'react'; import {toast} from 'sonner'; @@ -50,6 +51,28 @@ const userFields: { function RouteComponent() { const {user} = useAuth(); + /** + * Redirects to the change password page with the appropriate username and redirect URL + */ + const handleChangePassword = () => { + // Get the username (email) from the user object + const username = user?.user.id; + + if (!username) { + toast.error('Unable to identify user for password change'); + return; + } + + // Create the redirect URL back to the profile page + const redirectUrl = WEB_URL + '/profile'; + + // Build the URL for the change password page + const changePasswordUrl = `${API_URL}/change-password?username=${encodeURIComponent(username)}&redirect=${encodeURIComponent(redirectUrl)}`; + + // Navigate to the change password page + window.location.href = changePasswordUrl; + }; + return (
@@ -64,9 +87,32 @@ function RouteComponent() { ))} + + + + + + Password + + + Click the button below to change the password for your account. + + + + + - Bearer Token + + + Bearer Token + Click below to copy the token that can be used to authenticate in scripts that use the API. @@ -81,6 +127,7 @@ function RouteComponent() { toast.error('Failed to copy bearer token to clipboard'); } }} + className="mt-2" > Copy Bearer Token to Clipboard diff --git a/web/src/routes/reset-password.tsx b/web/src/routes/reset-password.tsx deleted file mode 100644 index ee0355be8..000000000 --- a/web/src/routes/reset-password.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import {Form} from '@/components/form'; -import {Button} from '@/components/ui/button'; -import {Alert, AlertDescription, AlertTitle} from '@/components/ui/alert'; -import {createFileRoute, useNavigate} from '@tanstack/react-router'; -import {zodValidator} from '@tanstack/zod-adapter'; -import {z} from 'zod'; -import {AlertTriangle} from 'lucide-react'; - -const ResetPasswordSearchSchema = z.object({ - code: z.preprocess(val => (val === undefined ? '' : String(val)), z.string()), -}); - -export const Route = createFileRoute('/reset-password')({ - component: RouteComponent, - validateSearch: zodValidator(ResetPasswordSearchSchema), -}); - -function ErrorDisplay() { - const navigate = useNavigate(); - - return ( -
- - - Invalid Reset Link - - The password reset link you're trying to use is invalid. Please - request a new password reset link. - - - - -
- ); -} - -function RouteComponent() { - const navigate = useNavigate(); - const {code} = Route.useSearch(); - - if (!code) { - return ; - } - - const fields = [ - { - name: 'newPassword', - label: 'New Password', - type: 'password', - schema: z.string().min(10, 'Password must be at least 10 characters'), - }, - { - name: 'repeatedPassword', - label: 'Repeat Password', - type: 'password', - schema: z.string().min(10, 'Password must be at least 10 characters'), - }, - ]; - - const onSubmit = async ({ - newPassword, - repeatedPassword, - }: { - newPassword: string; - repeatedPassword: string; - }) => { - if (newPassword !== repeatedPassword) { - return { - type: 'error', - message: 'Passwords do not match', - }; - } - - const response = await fetch(`${import.meta.env.VITE_API_URL}/api/reset`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - code, - newPassword, - }), - }); - - if (!response.ok) { - return { - type: 'error', - message: 'Error resetting password.', - }; - } - - navigate({to: '/'}); - }; - - return ( -
-
-

Reset Password

- -
- -

- Using the form below, enter your new password twice. The password must - be 10 characters or longer. -

- -
-
- ); -}