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:
Paul Nothaft
2026-05-03 22:48:17 +02:00
committed by GitHub
5 changed files with 194 additions and 60 deletions
+33 -8
View File
@@ -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 || '',
+5 -1
View File
@@ -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)}
+11 -3
View File
@@ -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;
},