Fix poll vote submission failure and add pridecorner.ca nginx routing

Users could not submit scheduling poll votes when an invalid or partial
email was entered — Zod rejected empty strings and non-email text with a
generic validation error. Added client-side email validation in both
SchedulingPollPage and SchedulingPollWidget, plus z.preprocess() on the
backend to coerce empty strings to undefined. Also added pridecorner.ca
to all nginx server blocks and added generate_nginx_configs() to
config.sh so template-based configs are generated during setup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
admin 2026-03-02 14:15:26 -07:00
parent 62f906d6f0
commit f57a6d07f5
6 changed files with 53 additions and 12 deletions

View File

@ -184,6 +184,11 @@ export function SchedulingPollWidget({ pollSlug, showComments = true, title }: S
setSubmitMsg({ type: 'error', text: 'Please vote on at least one option' }); setSubmitMsg({ type: 'error', text: 'Please vote on at least one option' });
return; return;
} }
const trimmedEmail = voterEmail.trim();
if (trimmedEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmedEmail)) {
setSubmitMsg({ type: 'error', text: 'Please enter a valid email address, or leave the email field blank' });
return;
}
setSubmitting(true); setSubmitting(true);
setSubmitMsg(null); setSubmitMsg(null);
@ -191,7 +196,7 @@ export function SchedulingPollWidget({ pollSlug, showComments = true, title }: S
const storedToken = localStorage.getItem(VOTER_TOKEN_KEY + pollSlug); const storedToken = localStorage.getItem(VOTER_TOKEN_KEY + pollSlug);
const { data } = await axios.post(`${apiBase}/meeting-planner/public/${pollSlug}/vote`, { const { data } = await axios.post(`${apiBase}/meeting-planner/public/${pollSlug}/vote`, {
voterName: voterName.trim(), voterName: voterName.trim(),
voterEmail: voterEmail.trim() || undefined, voterEmail: trimmedEmail || undefined,
voterToken: storedToken || undefined, voterToken: storedToken || undefined,
votes: Object.entries(votes).map(([optionId, value]) => ({ optionId, value })), votes: Object.entries(votes).map(([optionId, value]) => ({ optionId, value })),
}); });

View File

@ -110,13 +110,18 @@ export default function SchedulingPollPage() {
message.warning('Please vote on at least one option'); message.warning('Please vote on at least one option');
return; return;
} }
const trimmedEmail = voterEmail.trim();
if (trimmedEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmedEmail)) {
message.warning('Please enter a valid email address, or leave the email field blank');
return;
}
setSubmitting(true); setSubmitting(true);
try { try {
const storedToken = localStorage.getItem(VOTER_TOKEN_KEY + slug); const storedToken = localStorage.getItem(VOTER_TOKEN_KEY + slug);
const { data } = await axios.post(`/api/meeting-planner/public/${slug}/vote`, { const { data } = await axios.post(`/api/meeting-planner/public/${slug}/vote`, {
voterName: voterName.trim(), voterName: voterName.trim(),
voterEmail: voterEmail.trim() || undefined, voterEmail: trimmedEmail || undefined,
voterToken: storedToken || undefined, voterToken: storedToken || undefined,
votes: Object.entries(votes).map(([optionId, value]) => ({ optionId, value })), votes: Object.entries(votes).map(([optionId, value]) => ({ optionId, value })),
}); });

View File

@ -37,7 +37,10 @@ export const addOptionsSchema = z.object({
export const submitVotesSchema = z.object({ export const submitVotesSchema = z.object({
voterName: z.string().min(1, 'Name is required').max(100), voterName: z.string().min(1, 'Name is required').max(100),
voterEmail: z.string().email().max(200).optional(), voterEmail: z.preprocess(
(val) => (typeof val === 'string' && val.trim() === '' ? undefined : val),
z.string().email('Please enter a valid email address').max(200).optional(),
),
voterToken: z.string().optional(), voterToken: z.string().optional(),
votes: z.array(z.object({ votes: z.array(z.object({
optionId: z.string().min(1), optionId: z.string().min(1),

View File

@ -559,6 +559,33 @@ configure_cors() {
success "CORS origins set for $domain" success "CORS origins set for $domain"
} }
generate_nginx_configs() {
header "Nginx Configuration"
local domain="${CONFIGURED_DOMAIN:-cmlite.org}"
local template_dir="$SCRIPT_DIR/nginx/conf.d"
local templates_found=0
for template in "$template_dir"/*.conf.template; do
[[ -f "$template" ]] || continue
templates_found=$((templates_found + 1))
local conf_file="${template%.template}"
local conf_name
conf_name=$(basename "$conf_file")
# Substitute ${DOMAIN} while preserving nginx variables ($host, $scheme, etc.)
sed "s/\${DOMAIN}/$domain/g" "$template" > "$conf_file"
success "Generated $conf_name"
done
if [[ $templates_found -eq 0 ]]; then
warn "No nginx .conf.template files found — skipping"
else
success "Generated $templates_found nginx configs for domain: $domain"
info "Restart nginx to apply: docker compose restart nginx"
fi
}
# ============================================================================= # =============================================================================
# Homepage services.yaml # Homepage services.yaml
# ============================================================================= # =============================================================================
@ -1053,6 +1080,7 @@ main() {
configure_features configure_features
configure_pangolin configure_pangolin
configure_cors configure_cors
generate_nginx_configs
generate_services_yaml generate_services_yaml
fix_container_permissions fix_container_permissions

View File

@ -1,6 +1,6 @@
server { server {
listen 80; listen 80;
server_name api.cmlite.org api.betteredmonton.org; server_name api.cmlite.org api.betteredmonton.org api.pridecorner.ca;
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "SAMEORIGIN" always;
# Media API endpoints (must come BEFORE / for longest prefix match) # Media API endpoints (must come BEFORE / for longest prefix match)

View File

@ -40,7 +40,7 @@ server {
# Grafana — allows iframe embedding from admin (app.cmlite.org) # Grafana — allows iframe embedding from admin (app.cmlite.org)
server { server {
listen 80; listen 80;
server_name grafana.cmlite.org grafana.betteredmonton.org; server_name grafana.cmlite.org grafana.betteredmonton.org grafana.pridecorner.ca;
add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org app.betteredmonton.org" always; add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org app.betteredmonton.org" always;
location / { location / {
@ -59,7 +59,7 @@ server {
# NocoDB (data browser) — allows iframe embedding from admin # NocoDB (data browser) — allows iframe embedding from admin
server { server {
listen 80; listen 80;
server_name db.cmlite.org db.betteredmonton.org; server_name db.cmlite.org db.betteredmonton.org db.pridecorner.ca;
add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org app.betteredmonton.org" always; add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org app.betteredmonton.org" always;
location / { location / {
@ -76,7 +76,7 @@ server {
# Listmonk # Listmonk
server { server {
listen 80; listen 80;
server_name listmonk.cmlite.org listmonk.betteredmonton.org; server_name listmonk.cmlite.org listmonk.betteredmonton.org listmonk.pridecorner.ca;
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "SAMEORIGIN" always;
location / { location / {
@ -92,7 +92,7 @@ server {
# MkDocs — allows iframe embedding from admin # MkDocs — allows iframe embedding from admin
server { server {
listen 80; listen 80;
server_name docs.cmlite.org docs.betteredmonton.org; server_name docs.cmlite.org docs.betteredmonton.org docs.pridecorner.ca;
add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org app.betteredmonton.org" always; add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org app.betteredmonton.org" always;
location / { location / {
@ -207,7 +207,7 @@ server {
# Rocket.Chat (team chat) — allows iframe embedding from admin # Rocket.Chat (team chat) — allows iframe embedding from admin
server { server {
listen 80; listen 80;
server_name chat.cmlite.org chat.betteredmonton.org; server_name chat.cmlite.org chat.betteredmonton.org chat.pridecorner.ca;
add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org app.betteredmonton.org" always; add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org app.betteredmonton.org" always;
location / { location / {
@ -246,7 +246,7 @@ server {
# Jitsi Meet (video conferencing) — allows iframe embedding from admin (app.cmlite.org) # Jitsi Meet (video conferencing) — allows iframe embedding from admin (app.cmlite.org)
server { server {
listen 80; listen 80;
server_name meet.cmlite.org meet.betteredmonton.org; server_name meet.cmlite.org meet.betteredmonton.org meet.pridecorner.ca;
add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org app.betteredmonton.org" always; add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org app.betteredmonton.org" always;
location / { location / {
@ -366,7 +366,7 @@ server {
# Admin GUI — app subdomain # Admin GUI — app subdomain
server { server {
listen 80; listen 80;
server_name app.cmlite.org app.betteredmonton.org; server_name app.cmlite.org app.betteredmonton.org app.pridecorner.ca;
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "SAMEORIGIN" always;
# Social media bot detection for OG meta tags # Social media bot detection for OG meta tags
@ -513,7 +513,7 @@ server {
# Root domain — routes to admin GUI (supports custom DOMAIN env var) # Root domain — routes to admin GUI (supports custom DOMAIN env var)
server { server {
listen 80; listen 80;
server_name betteredmonton.org; server_name betteredmonton.org pridecorner.ca;
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "SAMEORIGIN" always;
location / { location / {