Merge pull request #369 from the-luap/fix/admin-set-password-and-full-url-emails
fix(events): admin-set password on reset, full-URL gallery_link in all emails
This commit is contained in:
@@ -1369,7 +1369,7 @@ router.post('/:id/toggle-status', adminAuth, requirePermission('events.edit'), r
|
||||
router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { sendEmail = true } = req.body;
|
||||
const { sendEmail = true, password: clientPassword } = req.body;
|
||||
|
||||
let eventQuery = db('events').where('id', id);
|
||||
// Editor role can only edit their own events
|
||||
@@ -1385,10 +1385,29 @@ router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'),
|
||||
return res.status(400).json({ error: 'Cannot reset password for archived event' });
|
||||
}
|
||||
|
||||
// Generate new password
|
||||
const { generateReadablePassword } = require('../utils/passwordGenerator');
|
||||
const newPassword = generateReadablePassword();
|
||||
const passwordHash = await bcrypt.hash(newPassword, 10);
|
||||
// Use the admin-supplied password when provided; otherwise auto-generate
|
||||
// (preserves the previous one-click behaviour for callers/cron that don't
|
||||
// pass a body). Validation matches the create-event flow so the same
|
||||
// strength rules apply both ways.
|
||||
let newPassword;
|
||||
if (typeof clientPassword === 'string' && clientPassword.length > 0) {
|
||||
const passwordValidation = await validatePasswordInContext(clientPassword, 'gallery', {
|
||||
eventName: event.event_name
|
||||
});
|
||||
if (!passwordValidation.valid) {
|
||||
return res.status(400).json({
|
||||
error: 'Password does not meet security requirements',
|
||||
details: passwordValidation.errors,
|
||||
score: passwordValidation.score,
|
||||
feedback: passwordValidation.feedback
|
||||
});
|
||||
}
|
||||
newPassword = clientPassword;
|
||||
} else {
|
||||
const { generateReadablePassword } = require('../utils/passwordGenerator');
|
||||
newPassword = generateReadablePassword();
|
||||
}
|
||||
const passwordHash = await bcrypt.hash(newPassword, getBcryptRounds());
|
||||
|
||||
// Update event with new password
|
||||
await db('events')
|
||||
@@ -1408,6 +1427,9 @@ router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'),
|
||||
if (sendEmail) {
|
||||
const recipientEmail = event.customer_email || event.host_email;
|
||||
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
|
||||
// event.share_link is the path-only form (`/gallery/<slug>/<token>`).
|
||||
// Use the full URL so customers can click straight from the email.
|
||||
const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token });
|
||||
|
||||
await queueEmail(id, recipientEmail, 'gallery_created', {
|
||||
customer_name: recipientName,
|
||||
@@ -1415,13 +1437,13 @@ router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'),
|
||||
host_name: recipientName,
|
||||
event_name: event.event_name,
|
||||
event_date: event.event_date, // Pass raw date - will be formatted by email processor
|
||||
gallery_link: event.share_link,
|
||||
gallery_link: shareUrl,
|
||||
gallery_password: newPassword,
|
||||
expiry_date: event.expires_at // Pass raw date - will be formatted by email processor
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
res.json({
|
||||
message: 'Password reset successfully',
|
||||
newPassword: newPassword,
|
||||
emailSent: sendEmail
|
||||
@@ -1473,6 +1495,9 @@ router.post('/:id/resend-email', adminAuth, requirePermission('events.edit'), re
|
||||
// Queue the email
|
||||
const recipientEmail = event.customer_email || event.host_email;
|
||||
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
|
||||
// event.share_link is the path-only form; use the full URL so the
|
||||
// customer's mail client renders a clickable absolute link.
|
||||
const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token });
|
||||
|
||||
await queueEmail(id, recipientEmail, 'gallery_created', {
|
||||
customer_name: recipientName,
|
||||
@@ -1480,7 +1505,7 @@ router.post('/:id/resend-email', adminAuth, requirePermission('events.edit'), re
|
||||
host_name: recipientName,
|
||||
event_name: event.event_name,
|
||||
event_date: event.event_date, // Pass raw date - will be formatted by email processor
|
||||
gallery_link: event.share_link,
|
||||
gallery_link: shareUrl,
|
||||
gallery_password: galleryPassword,
|
||||
expiry_date: event.expires_at, // Pass raw date - will be formatted by email processor
|
||||
welcome_message: event.welcome_message || '',
|
||||
|
||||
@@ -2,6 +2,7 @@ const cron = require('node-cron');
|
||||
const { db } = require('../database/db');
|
||||
const { archiveEvent } = require('./archiveService');
|
||||
const { queueEmail, getSupportEmail } = require('./emailProcessor');
|
||||
const { buildShareLinkVariants } = require('./shareLinkService');
|
||||
const logger = require('../utils/logger');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
|
||||
@@ -62,6 +63,9 @@ async function queueExpirationWarning(event) {
|
||||
|
||||
const recipientEmail = event.customer_email || event.host_email;
|
||||
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
|
||||
// event.share_link is the path-only form; use the full URL so the
|
||||
// recipient's mail client renders a clickable absolute link.
|
||||
const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token });
|
||||
|
||||
// Date formatting + language detection happen inside processTemplate using
|
||||
// the recipient's resolved language — pass the raw ISO date and let the
|
||||
@@ -79,7 +83,7 @@ async function queueExpirationWarning(event) {
|
||||
event_date: event.event_date,
|
||||
days_remaining: daysRemaining.toString(),
|
||||
expiry_date: event.expires_at,
|
||||
gallery_link: event.share_link,
|
||||
gallery_link: shareUrl,
|
||||
gallery_password: '{{password_security_message}}'
|
||||
});
|
||||
|
||||
|
||||
@@ -1,53 +1,92 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, Key, Copy, CheckCircle, Mail } from 'lucide-react';
|
||||
import { X, Key, Copy, CheckCircle, Mail, Lock, Eye, EyeOff } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Button, Card } from '../common';
|
||||
import { Button, Card, Input, PasswordGenerator } from '../common';
|
||||
|
||||
interface PasswordResetModalProps {
|
||||
eventName: string;
|
||||
onConfirm: (sendEmail: boolean) => Promise<{ newPassword: string; emailSent: boolean }>;
|
||||
eventDate?: string;
|
||||
eventType?: string;
|
||||
onConfirm: (sendEmail: boolean, password?: string) => Promise<{ newPassword: string; emailSent: boolean }>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const PasswordResetModal: React.FC<PasswordResetModalProps> = ({
|
||||
eventName,
|
||||
eventDate,
|
||||
eventType,
|
||||
onConfirm,
|
||||
onClose
|
||||
}) => {
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [sendEmail, setSendEmail] = useState(true);
|
||||
const [newPassword, setNewPassword] = useState<string | null>(null);
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
const [errors, setErrors] = useState<{ password?: string; confirmPassword?: string }>({});
|
||||
const [resultPassword, setResultPassword] = useState<string | null>(null);
|
||||
const [resultWasGenerated, setResultWasGenerated] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const validate = (): boolean => {
|
||||
const next: typeof errors = {};
|
||||
// Empty is allowed → server auto-generates. Only validate when typed.
|
||||
if (password) {
|
||||
if (password.length < 6) {
|
||||
next.password = 'Password must be at least 6 characters';
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
next.confirmPassword = 'Passwords do not match';
|
||||
}
|
||||
}
|
||||
setErrors(next);
|
||||
return Object.keys(next).length === 0;
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
if (!validate()) return;
|
||||
setIsResetting(true);
|
||||
try {
|
||||
const result = await onConfirm(sendEmail);
|
||||
setNewPassword(result.newPassword);
|
||||
toast.success('Password reset successfully');
|
||||
} catch (error) {
|
||||
toast.error('Failed to reset password');
|
||||
onClose();
|
||||
const supplied = password.length > 0 ? password : undefined;
|
||||
const result = await onConfirm(sendEmail, supplied);
|
||||
setResultPassword(result.newPassword);
|
||||
setResultWasGenerated(!supplied);
|
||||
if (supplied) {
|
||||
toast.success('Password reset successfully');
|
||||
}
|
||||
} catch (error: any) {
|
||||
const serverError = error?.response?.data;
|
||||
if (serverError?.error === 'Password does not meet security requirements') {
|
||||
setErrors({ password: serverError.feedback?.join?.(' ') || 'Password does not meet security requirements' });
|
||||
} else {
|
||||
toast.error(serverError?.error || 'Failed to reset password');
|
||||
}
|
||||
} finally {
|
||||
setIsResetting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (newPassword) {
|
||||
await navigator.clipboard.writeText(newPassword);
|
||||
if (resultPassword) {
|
||||
await navigator.clipboard.writeText(resultPassword);
|
||||
setCopied(true);
|
||||
toast.success('Password copied to clipboard');
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordGenerated = (generated: string) => {
|
||||
setPassword(generated);
|
||||
setConfirmPassword(generated);
|
||||
setErrors({});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<Card className="max-w-md w-full">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold text-neutral-900">
|
||||
{newPassword ? 'New Password' : 'Reset Gallery Password'}
|
||||
{resultPassword ? 'New Password' : 'Reset Gallery Password'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
@@ -57,14 +96,66 @@ export const PasswordResetModal: React.FC<PasswordResetModalProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!newPassword ? (
|
||||
{!resultPassword ? (
|
||||
<>
|
||||
<p className="text-neutral-600 mb-6">
|
||||
Are you sure you want to reset the password for <strong>{eventName}</strong>?
|
||||
This will generate a new password for gallery access.
|
||||
<p className="text-neutral-600 mb-4">
|
||||
Set a new password for <strong>{eventName}</strong>, or leave both fields empty to have one auto-generated.
|
||||
</p>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="space-y-4 mb-4">
|
||||
<div>
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
label="New password"
|
||||
placeholder="Leave empty to auto-generate"
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
if (errors.password) setErrors((prev) => ({ ...prev, password: undefined }));
|
||||
}}
|
||||
error={errors.password}
|
||||
helperText="Use 6+ characters, or leave blank to auto-generate"
|
||||
leftIcon={<Lock className="w-5 h-5" />}
|
||||
rightIcon={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="p-1"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mt-2">
|
||||
<PasswordGenerator
|
||||
eventName={eventName}
|
||||
eventDate={eventDate}
|
||||
eventType={eventType}
|
||||
onPasswordGenerated={handlePasswordGenerated}
|
||||
passwordComplexity="moderate"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{password.length > 0 && (
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
label="Confirm password"
|
||||
placeholder="Confirm password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => {
|
||||
setConfirmPassword(e.target.value);
|
||||
if (errors.confirmPassword) setErrors((prev) => ({ ...prev, confirmPassword: undefined }));
|
||||
}}
|
||||
error={errors.confirmPassword}
|
||||
leftIcon={<Lock className="w-5 h-5" />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -88,7 +179,7 @@ export const PasswordResetModal: React.FC<PasswordResetModalProps> = ({
|
||||
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-6">
|
||||
<p className="text-sm text-amber-800">
|
||||
<strong>Note:</strong> The old password will no longer work.
|
||||
<strong>Note:</strong> The old password will no longer work.
|
||||
Make sure to share the new password with the host.
|
||||
</p>
|
||||
</div>
|
||||
@@ -128,32 +219,36 @@ export const PasswordResetModal: React.FC<PasswordResetModalProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
New Gallery Password
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newPassword}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 bg-neutral-50 border border-neutral-300 rounded-lg font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCopy}
|
||||
leftIcon={copied ? <CheckCircle className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{resultWasGenerated && (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
Auto-generated gallery password
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={resultPassword}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 bg-neutral-50 border border-neutral-300 rounded-lg font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCopy}
|
||||
leftIcon={copied ? <CheckCircle className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 mb-6">
|
||||
<p className="text-sm text-blue-800">
|
||||
<strong>Important:</strong> Save this password securely. It cannot be recovered once you close this window.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 mb-6">
|
||||
<p className="text-sm text-blue-800">
|
||||
<strong>Important:</strong> Save this password securely. It cannot be recovered once you close this window.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
@@ -167,4 +262,4 @@ export const PasswordResetModal: React.FC<PasswordResetModalProps> = ({
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2194,8 +2194,10 @@ export const EventDetailsPage: React.FC = () => {
|
||||
{showPasswordReset && (
|
||||
<PasswordResetModal
|
||||
eventName={event.event_name}
|
||||
onConfirm={async (sendEmail) => {
|
||||
const result = await eventsService.resetPassword(event.id, sendEmail);
|
||||
eventDate={event.event_date}
|
||||
eventType={event.event_type}
|
||||
onConfirm={async (sendEmail, password) => {
|
||||
const result = await eventsService.resetPassword(event.id, sendEmail, password);
|
||||
return result;
|
||||
}}
|
||||
onClose={() => setShowPasswordReset(false)}
|
||||
|
||||
@@ -162,9 +162,17 @@ export const eventsService = {
|
||||
return response.data || [];
|
||||
},
|
||||
|
||||
// Reset event password
|
||||
async resetPassword(eventId: number, sendEmail: boolean = true): Promise<{ message: string; newPassword: string; emailSent: boolean }> {
|
||||
const response = await api.post(`/admin/events/${eventId}/reset-password`, { sendEmail });
|
||||
// Reset event password. Pass `password` to set a specific value (validated
|
||||
// server-side with the same rules as create-event); omit it to have the
|
||||
// server auto-generate one.
|
||||
async resetPassword(
|
||||
eventId: number,
|
||||
sendEmail: boolean = true,
|
||||
password?: string
|
||||
): Promise<{ message: string; newPassword: string; emailSent: boolean }> {
|
||||
const body: { sendEmail: boolean; password?: string } = { sendEmail };
|
||||
if (password) body.password = password;
|
||||
const response = await api.post(`/admin/events/${eventId}/reset-password`, body);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user