Merge pull request #130 from the-luap/feat/optional-event-date-expiration-beta

fix: resolve admin invitation flow issues and improve STORAGE_PATH documentation
This commit is contained in:
Paul Nothaft
2026-01-18 15:35:15 +01:00
committed by GitHub
6 changed files with 31 additions and 21 deletions
+1
View File
@@ -44,6 +44,7 @@ SMTP_PASS=your-sendgrid-api-key
EMAIL_FROM=[email protected] EMAIL_FROM=[email protected]
# Storage Paths # Storage Paths
# IMPORTANT: STORAGE_PATH must be set to avoid file path resolution issues
# Docker deployment: # Docker deployment:
STORAGE_PATH=/app/storage STORAGE_PATH=/app/storage
EVENTS_PATH=/app/storage/events EVENTS_PATH=/app/storage/events
@@ -60,7 +60,7 @@ async function createInvitation({ email, roleId, invitedById }) {
// Queue invitation email // Queue invitation email
const frontendUrl = process.env.FRONTEND_URL || process.env.ADMIN_URL || 'http://localhost:3005'; const frontendUrl = process.env.FRONTEND_URL || process.env.ADMIN_URL || 'http://localhost:3005';
await queueEmail(null, email, 'admin_invitation', { await queueEmail(null, email, 'admin_invitation', {
invite_link: `${frontendUrl}/admin/accept-invite/${token}`, invite_link: `${frontendUrl}/invite/${token}`,
role_name: role.display_name, role_name: role.display_name,
expires_at: expiresAt.toISOString() expires_at: expiresAt.toISOString()
}); });
+1
View File
@@ -46,6 +46,7 @@ services:
- NODE_ENV=production - NODE_ENV=production
- DB_HOST=${DB_HOST:-postgres} - DB_HOST=${DB_HOST:-postgres}
- REDIS_HOST=redis - REDIS_HOST=redis
- STORAGE_PATH=/app/storage
- PHOTOS_DIR=/app/storage/events - PHOTOS_DIR=/app/storage/events
- PICPEAK_RELEASE_CHANNEL=${PICPEAK_CHANNEL:-stable} - PICPEAK_RELEASE_CHANNEL=${PICPEAK_CHANNEL:-stable}
volumes: volumes:
+4
View File
@@ -1523,6 +1523,10 @@
"successMessage": "Ihr Konto wurde erfolgreich erstellt. Sie können sich jetzt mit Ihren Zugangsdaten anmelden.", "successMessage": "Ihr Konto wurde erfolgreich erstellt. Sie können sich jetzt mit Ihren Zugangsdaten anmelden.",
"redirecting": "Weiterleitung zur Anmeldung in {{seconds}}...", "redirecting": "Weiterleitung zur Anmeldung in {{seconds}}...",
"goToLogin": "Zur Anmeldung", "goToLogin": "Zur Anmeldung",
"contactAdminMessage": "Bitte kontaktieren Sie Ihren Administrator, um eine neue Einladung anzufordern.",
"passwordsMatch": "Passwörter stimmen überein",
"alreadyHaveAccount": "Bereits ein Konto?",
"signIn": "Anmelden",
"errors": { "errors": {
"usernameRequired": "Benutzername ist erforderlich", "usernameRequired": "Benutzername ist erforderlich",
"usernameTooShort": "Benutzername muss mindestens 3 Zeichen lang sein", "usernameTooShort": "Benutzername muss mindestens 3 Zeichen lang sein",
+4
View File
@@ -1284,6 +1284,10 @@
"successMessage": "Your account has been created successfully. You can now log in with your credentials.", "successMessage": "Your account has been created successfully. You can now log in with your credentials.",
"redirecting": "Redirecting to login in {{seconds}}...", "redirecting": "Redirecting to login in {{seconds}}...",
"goToLogin": "Go to Login", "goToLogin": "Go to Login",
"contactAdminMessage": "Please contact your administrator to request a new invitation.",
"passwordsMatch": "Passwords match",
"alreadyHaveAccount": "Already have an account?",
"signIn": "Sign in",
"errors": { "errors": {
"usernameRequired": "Username is required", "usernameRequired": "Username is required",
"usernameTooShort": "Username must be at least 3 characters", "usernameTooShort": "Username must be at least 3 characters",
+20 -20
View File
@@ -94,16 +94,16 @@ export const AcceptInvitePage: React.FC = () => {
}); });
setErrors(validationErrors); setErrors(validationErrors);
} else { } else {
toast.error(errorMessage || t('acceptInvitation.validationError')); toast.error(errorMessage || t('acceptInvitation.errors.genericError'));
} }
} else if (error.response?.status === 422) { } else if (error.response?.status === 422) {
toast.error(errorMessage || t('acceptInvitation.validationError')); toast.error(errorMessage || t('acceptInvitation.errors.genericError'));
} else if (error.response?.status === 404) { } else if (error.response?.status === 404) {
toast.error(t('acceptInvitation.invalidOrExpired')); toast.error(t('acceptInvitation.invalidTokenMessage'));
} else if (error.response?.status === 409) { } else if (error.response?.status === 409) {
toast.error(errorMessage || t('acceptInvitation.alreadyUsed')); toast.error(errorMessage || t('acceptInvitation.alreadyUsedMessage'));
} else { } else {
toast.error(t('acceptInvitation.generalError')); toast.error(t('acceptInvitation.errors.genericError'));
} }
}, },
}); });
@@ -113,7 +113,7 @@ export const AcceptInvitePage: React.FC = () => {
{ {
label: t('acceptInvitation.requirements.minLength'), label: t('acceptInvitation.requirements.minLength'),
met: false, met: false,
test: (pwd: string) => pwd.length >= 8, test: (pwd: string) => pwd.length >= 12,
}, },
{ {
label: t('acceptInvitation.requirements.uppercase'), label: t('acceptInvitation.requirements.uppercase'),
@@ -166,16 +166,16 @@ export const AcceptInvitePage: React.FC = () => {
// Validate username // Validate username
const validateUsername = (username: string): string | null => { const validateUsername = (username: string): string | null => {
if (!username) { if (!username) {
return t('acceptInvitation.usernameRequired'); return t('acceptInvitation.errors.usernameRequired');
} }
if (username.length < 3) { if (username.length < 3) {
return t('acceptInvitation.usernameTooShort'); return t('acceptInvitation.errors.usernameTooShort');
} }
if (username.length > 50) { if (username.length > 50) {
return t('acceptInvitation.usernameTooLong'); return t('acceptInvitation.errors.usernameTooLong');
} }
if (!/^[a-zA-Z0-9_-]+$/.test(username)) { if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
return t('acceptInvitation.usernameInvalid'); return t('acceptInvitation.errors.usernameInvalid');
} }
return null; return null;
}; };
@@ -190,18 +190,18 @@ export const AcceptInvitePage: React.FC = () => {
} }
if (!formData.password) { if (!formData.password) {
newErrors.password = t('acceptInvitation.passwordRequired'); newErrors.password = t('acceptInvitation.errors.passwordRequired');
} else { } else {
const allRequirementsMet = passwordRequirements.every(req => req.test(formData.password)); const allRequirementsMet = passwordRequirements.every(req => req.test(formData.password));
if (!allRequirementsMet) { if (!allRequirementsMet) {
newErrors.password = t('acceptInvitation.passwordRequirements'); newErrors.password = t('acceptInvitation.errors.passwordTooShort');
} }
} }
if (!formData.confirmPassword) { if (!formData.confirmPassword) {
newErrors.confirmPassword = t('acceptInvitation.confirmPasswordRequired'); newErrors.confirmPassword = t('acceptInvitation.errors.confirmPasswordRequired');
} else if (formData.password !== formData.confirmPassword) { } else if (formData.password !== formData.confirmPassword) {
newErrors.confirmPassword = t('acceptInvitation.passwordsDoNotMatch'); newErrors.confirmPassword = t('acceptInvitation.errors.passwordsDoNotMatch');
} }
setErrors(newErrors); setErrors(newErrors);
@@ -269,7 +269,7 @@ export const AcceptInvitePage: React.FC = () => {
// Error state - invalid or expired token // Error state - invalid or expired token
if (isError || !invitation?.valid) { if (isError || !invitation?.valid) {
const errorMessage = (validationError as any)?.response?.data?.error || t('acceptInvitation.invalidOrExpired'); const errorMessage = (validationError as any)?.response?.data?.error || t('acceptInvitation.invalidTokenMessage');
return ( return (
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}> <div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
@@ -280,13 +280,13 @@ export const AcceptInvitePage: React.FC = () => {
<XCircle className="w-8 h-8 text-red-600" /> <XCircle className="w-8 h-8 text-red-600" />
</div> </div>
<h1 className="text-2xl font-bold text-neutral-900 mb-2"> <h1 className="text-2xl font-bold text-neutral-900 mb-2">
{t('acceptInvitation.invalidTitle')} {t('acceptInvitation.invalidToken')}
</h1> </h1>
<p className="text-neutral-600 mb-6"> <p className="text-neutral-600 mb-6">
{errorMessage} {errorMessage}
</p> </p>
<p className="text-sm text-neutral-500 mb-6"> <p className="text-sm text-neutral-500 mb-6">
{t('acceptInvitation.contactAdmin')} {t('acceptInvitation.contactAdminMessage')}
</p> </p>
<Button <Button
variant="primary" variant="primary"
@@ -312,7 +312,7 @@ export const AcceptInvitePage: React.FC = () => {
<CheckCircle className="w-8 h-8 text-green-600" /> <CheckCircle className="w-8 h-8 text-green-600" />
</div> </div>
<h1 className="text-2xl font-bold text-neutral-900 mb-2"> <h1 className="text-2xl font-bold text-neutral-900 mb-2">
{t('acceptInvitation.successTitle')} {t('acceptInvitation.success')}
</h1> </h1>
<p className="text-neutral-600 mb-6"> <p className="text-neutral-600 mb-6">
{t('acceptInvitation.successMessage')} {t('acceptInvitation.successMessage')}
@@ -324,7 +324,7 @@ export const AcceptInvitePage: React.FC = () => {
variant="primary" variant="primary"
onClick={() => navigate('/admin/login')} onClick={() => navigate('/admin/login')}
> >
{t('acceptInvitation.goToLoginNow')} {t('acceptInvitation.goToLogin')}
</Button> </Button>
</div> </div>
</Card> </Card>
@@ -466,7 +466,7 @@ export const AcceptInvitePage: React.FC = () => {
{/* Password Requirements */} {/* Password Requirements */}
<div className="mt-3 space-y-1.5"> <div className="mt-3 space-y-1.5">
<p className="text-xs font-medium text-neutral-600">{t('acceptInvitation.requirementsTitle')}</p> <p className="text-xs font-medium text-neutral-600">{t('acceptInvitation.requirements.title')}</p>
{passwordRequirements.map((req, index) => { {passwordRequirements.map((req, index) => {
const isMet = req.test(formData.password); const isMet = req.test(formData.password);
return ( return (