13 Commits

Author SHA1 Message Date
691dbad1b4 Merge pull request 'Merge from dev to loaf-prod for DEMO' (#25) from dev into loaf-prod
Reviewed-on: #25
2026-02-02 11:12:58 +00:00
68fc34d0a5 Update Stripe publishable key storage in Stripe Settings
1. Created src/hooks/use-stripe-config.js - New hook that:
	- Fetches publishable key from /api/config/stripe
	- Returns a pre-initialized stripePromise for use with Stripe Elements
	- Caches the result to avoid multiple API calls
	- Falls back to REACT_APP_STRIPE_PUBLISHABLE_KEY env var if API fails
2. Updated AdminSettings.js - Added publishable key input field in the Stripe settings form
3. Updated AdminPaymentMethodsPanel.js - Uses useStripeConfig hook instead of env variable
4. Updated PaymentMethodsSection.js - Uses useStripeConfig hook instead of env variable
2026-02-02 17:55:00 +07:00
82ef36b439 Conditional Rules in Registration Builder Fix
1. Trigger Field Selection - Dropdown to select which field triggers the rule (filters to checkbox, dropdown, radio, text fields)
2. Operator Selection - Dropdown with options:
	- equals
	- not equals
	- contains
	- is not empty
	- is empty
3. Value Input - Smart input based on field type:
	- Checkbox fields → dropdown with "Checked (true)" / "Unchecked (false)"
	- empty/not_empty operators → disabled (no value needed)
	- Other fields → text input
4. Action Selection - Dropdown with options:
	- Show fields
	- Hide fields
	- Make required
	- Make optional
5. Target Fields - Checkbox list of all fields (excluding the trigger field) to select which fields are affected
6. Rule Summary - A blue info box at the bottom of each rule showing a human-readable summary of the configured rule
2026-02-02 17:29:03 +07:00
b3e6cfba84 no message 2026-02-02 17:08:50 +07:00
d4acef8d90 - Created useDirectoryConfig hook (src/hooks/use-directory-config.js)
- Updated Profile.js - conditional rendering with isFieldEnabled()
- Updated MemberCard.js - conditional rendering for directory fields
- Updated MembersDirectory.js - conditional rendering in profile dialog
- Created AdminDirectorySettings.js - Admin UI for toggling fields
- Updated SettingsSidebar.js - Added Directory and Registration tabs
- Updated App.js - Added routes for new settings pages
2026-02-02 17:08:11 +07:00
48c5a916d9 Merge pull request 'dev' (#19) from dev into loaf-prod
Reviewed-on: #19
2026-01-26 11:22:19 +00:00
1f9e6ea191 Merge pull request 'Remove View Public Site on AdminSidebar' (#14) from dev into loaf-prod
Reviewed-on: #14
2026-01-08 17:24:35 +00:00
66c2bedbed Merge pull request 'Merge from Dev to LOAF Production' (#13) from dev into loaf-prod
Reviewed-on: #13
2026-01-07 08:44:10 +00:00
d94ea7b6d5 Merge pull request 'feat(frontend): Comprehensive RBAC implementation across admin pages' (#10) from dev into loaf-prod
Reviewed-on: #10
2026-01-06 08:35:56 +00:00
24519a7080 Merge pull request 'Improve UX with navigation, attendance management, and calendar fixes' (#9) from dev into loaf-prod
Reviewed-on: #9
2026-01-05 18:08:57 +00:00
b1b9a05d4f Merge pull request 'Merge from Dev' (#8) from dev into loaf-prod
Reviewed-on: #8
2026-01-05 08:49:42 +00:00
a2070b4e4e Merge pull request 'Fix staff invitation acceptance & add delete/deactivate buttons' (#7) from dev into loaf-prod
Reviewed-on: #7
2026-01-04 17:12:03 +00:00
6a21d32319 Merge pull request 'LOAF Prod' (#6) from dev into loaf-prod
Reviewed-on: #6
2026-01-04 12:48:26 +00:00
15 changed files with 1197 additions and 361 deletions

View File

@@ -48,6 +48,7 @@ import AdminNewsletters from './pages/admin/AdminNewsletters';
import AdminFinancials from './pages/admin/AdminFinancials'; import AdminFinancials from './pages/admin/AdminFinancials';
import AdminBylaws from './pages/admin/AdminBylaws'; import AdminBylaws from './pages/admin/AdminBylaws';
import AdminRegistrationBuilder from './pages/admin/AdminRegistrationBuilder'; import AdminRegistrationBuilder from './pages/admin/AdminRegistrationBuilder';
import AdminDirectorySettings from './pages/admin/AdminDirectorySettings';
import History from './pages/History'; import History from './pages/History';
import MissionValues from './pages/MissionValues'; import MissionValues from './pages/MissionValues';
import BoardOfDirectors from './pages/BoardOfDirectors'; import BoardOfDirectors from './pages/BoardOfDirectors';
@@ -319,6 +320,8 @@ function App() {
<Route path="stripe" element={<AdminSettings />} /> <Route path="stripe" element={<AdminSettings />} />
<Route path="permissions" element={<AdminRoles />} /> <Route path="permissions" element={<AdminRoles />} />
<Route path="theme" element={<AdminTheme />} /> <Route path="theme" element={<AdminTheme />} />
<Route path="directory" element={<AdminDirectorySettings />} />
<Route path="registration" element={<AdminRegistrationBuilder />} />
</Route> </Route>
{/* 404 - Catch all undefined routes */} {/* 404 - Catch all undefined routes */}

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import api from '../utils/api';
import logger from '../utils/logger'; import logger from '../utils/logger';
import { import {
Dialog, Dialog,
@@ -20,6 +21,7 @@ import { AlertTriangle, RefreshCw } from 'lucide-react';
* - Warns 1 minute before JWT expiry (at 29 minutes if JWT is 30 min) * - Warns 1 minute before JWT expiry (at 29 minutes if JWT is 30 min)
* - Auto-logout on expiration * - Auto-logout on expiration
* - "Stay Logged In" extends session * - "Stay Logged In" extends session
* - Checks session validity when tab becomes visible after being hidden
*/ */
const IdleSessionWarning = () => { const IdleSessionWarning = () => {
const { user, logout, refreshUser } = useAuth(); const { user, logout, refreshUser } = useAuth();
@@ -33,11 +35,13 @@ const IdleSessionWarning = () => {
const [showWarning, setShowWarning] = useState(false); const [showWarning, setShowWarning] = useState(false);
const [timeRemaining, setTimeRemaining] = useState(60); // seconds const [timeRemaining, setTimeRemaining] = useState(60); // seconds
const [isExtending, setIsExtending] = useState(false); const [isExtending, setIsExtending] = useState(false);
const [isCheckingSession, setIsCheckingSession] = useState(false);
const activityTimeoutRef = useRef(null); const activityTimeoutRef = useRef(null);
const warningTimeoutRef = useRef(null); const warningTimeoutRef = useRef(null);
const countdownIntervalRef = useRef(null); const countdownIntervalRef = useRef(null);
const lastActivityRef = useRef(Date.now()); const lastActivityRef = useRef(Date.now());
const lastVisibilityCheckRef = useRef(Date.now());
// Reset activity timer // Reset activity timer
const resetActivityTimer = useCallback(() => { const resetActivityTimer = useCallback(() => {
@@ -120,6 +124,83 @@ const IdleSessionWarning = () => {
} }
}; };
// Check if session is still valid (called when tab becomes visible)
const checkSessionValidity = useCallback(async () => {
if (!user || isCheckingSession) return;
const token = localStorage.getItem('token');
if (!token) {
logger.log('[IdleSessionWarning] No token found on visibility change');
handleSessionExpired();
return;
}
setIsCheckingSession(true);
try {
// Make a lightweight API call to verify token is still valid
await api.get('/auth/me');
logger.log('[IdleSessionWarning] Session still valid after visibility change');
// Reset the activity timer since user is back
resetActivityTimer();
} catch (error) {
logger.error('[IdleSessionWarning] Session invalid on visibility change:', error);
// If 401, the interceptor will handle redirect
// For other errors, still redirect to be safe
if (error.response?.status !== 401) {
handleSessionExpired();
}
} finally {
setIsCheckingSession(false);
}
}, [user, isCheckingSession, handleSessionExpired, resetActivityTimer]);
// Handle visibility change (when user returns to tab after being away)
useEffect(() => {
if (!user) return;
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
const now = Date.now();
const timeSinceLastCheck = now - lastVisibilityCheckRef.current;
// Only check if tab was hidden for more than 1 minute
// This prevents unnecessary API calls for quick tab switches
if (timeSinceLastCheck > 60 * 1000) {
logger.log('[IdleSessionWarning] Tab became visible after', Math.round(timeSinceLastCheck / 1000), 'seconds');
checkSessionValidity();
}
lastVisibilityCheckRef.current = now;
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [user, checkSessionValidity]);
// Listen for session expired event from API interceptor
useEffect(() => {
const handleAuthSessionExpired = () => {
logger.log('[IdleSessionWarning] Received auth:session-expired event');
// Clear all timers
if (activityTimeoutRef.current) clearTimeout(activityTimeoutRef.current);
if (warningTimeoutRef.current) clearTimeout(warningTimeoutRef.current);
if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current);
setShowWarning(false);
};
window.addEventListener('auth:session-expired', handleAuthSessionExpired);
return () => {
window.removeEventListener('auth:session-expired', handleAuthSessionExpired);
};
}, []);
// Track user activity // Track user activity
useEffect(() => { useEffect(() => {
if (!user) return; if (!user) return;

View File

@@ -3,6 +3,7 @@ import { Card } from './ui/card';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { Heart, Calendar, Mail, Phone, MapPin, Facebook, Instagram, Twitter, Linkedin, UserCircle } from 'lucide-react'; import { Heart, Calendar, Mail, Phone, MapPin, Facebook, Instagram, Twitter, Linkedin, UserCircle } from 'lucide-react';
import MemberBadge from './MemberBadge'; import MemberBadge from './MemberBadge';
import useDirectoryConfig from '../hooks/use-directory-config';
// Helper function to get initials // Helper function to get initials
const getInitials = (firstName, lastName) => { const getInitials = (firstName, lastName) => {
@@ -20,6 +21,7 @@ const getSocialMediaLink = (url) => {
const MemberCard = ({ member, onViewProfile, tiers }) => { const MemberCard = ({ member, onViewProfile, tiers }) => {
const memberSince = member.member_since || member.created_at; const memberSince = member.member_since || member.created_at;
const { isFieldEnabled } = useDirectoryConfig();
return ( return (
<Card className="p-6 bg-background rounded-3xl border border-[var(--neutral-800)] hover:shadow-lg transition-all h-full"> <Card className="p-6 bg-background rounded-3xl border border-[var(--neutral-800)] hover:shadow-lg transition-all h-full">
{/* Member Tier Badge */} {/* Member Tier Badge */}
@@ -48,7 +50,7 @@ const MemberCard = ({ member, onViewProfile, tiers }) => {
</h3> </h3>
{/* Partner Name */} {/* Partner Name */}
{member.directory_partner_name && ( {isFieldEnabled('directory_partner_name') && member.directory_partner_name && (
<div className="flex items-center justify-center gap-2 mb-4"> <div className="flex items-center justify-center gap-2 mb-4">
<Heart className="h-4 w-4 text-[var(--orange-light)]" /> <Heart className="h-4 w-4 text-[var(--orange-light)]" />
<span className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <span className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
@@ -58,7 +60,7 @@ const MemberCard = ({ member, onViewProfile, tiers }) => {
)} )}
{/* Bio */} {/* Bio */}
{member.directory_bio && ( {isFieldEnabled('directory_bio') && member.directory_bio && (
<p className="text-brand-purple text-center mb-4 line-clamp-3" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple text-center mb-4 line-clamp-3" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{member.directory_bio} {member.directory_bio}
</p> </p>
@@ -79,7 +81,7 @@ const MemberCard = ({ member, onViewProfile, tiers }) => {
{/* Contact Information */} {/* Contact Information */}
<div className="space-y-3 mb-4"> <div className="space-y-3 mb-4">
{member.directory_email && ( {isFieldEnabled('directory_email') && member.directory_email && (
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<Mail className="h-4 w-4 text-brand-purple flex-shrink-0" /> <Mail className="h-4 w-4 text-brand-purple flex-shrink-0" />
<a <a
@@ -92,7 +94,7 @@ const MemberCard = ({ member, onViewProfile, tiers }) => {
</div> </div>
)} )}
{member.directory_phone && ( {isFieldEnabled('directory_phone') && member.directory_phone && (
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<Phone className="h-4 w-4 text-brand-purple flex-shrink-0" /> <Phone className="h-4 w-4 text-brand-purple flex-shrink-0" />
<a <a
@@ -105,7 +107,7 @@ const MemberCard = ({ member, onViewProfile, tiers }) => {
</div> </div>
)} )}
{member.directory_address && ( {isFieldEnabled('directory_address') && member.directory_address && (
<div className="flex items-start gap-2 text-sm"> <div className="flex items-start gap-2 text-sm">
<MapPin className="h-4 w-4 text-brand-purple flex-shrink-0 mt-0.5" /> <MapPin className="h-4 w-4 text-brand-purple flex-shrink-0 mt-0.5" />
<span className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <span className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
@@ -116,7 +118,7 @@ const MemberCard = ({ member, onViewProfile, tiers }) => {
</div> </div>
{/* Social Media Links */} {/* Social Media Links */}
{(member.social_media_facebook || member.social_media_instagram || member.social_media_twitter || member.social_media_linkedin) && ( {isFieldEnabled('social_media') && (member.social_media_facebook || member.social_media_instagram || member.social_media_twitter || member.social_media_linkedin) && (
<div className="pt-4 border-t border-[var(--neutral-800)]"> <div className="pt-4 border-t border-[var(--neutral-800)]">
<div className="flex justify-center gap-3"> <div className="flex justify-center gap-3">
{member.social_media_facebook && ( {member.social_media_facebook && (

View File

@@ -1,18 +1,15 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { loadStripe } from '@stripe/stripe-js';
import { Elements } from '@stripe/react-stripe-js'; import { Elements } from '@stripe/react-stripe-js';
import { Card } from './ui/card'; import { Card } from './ui/card';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { CreditCard, Plus, Loader2, AlertCircle } from 'lucide-react'; import { CreditCard, Plus, Loader2, AlertCircle } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import api from '../utils/api'; import api from '../utils/api';
import useStripeConfig from '../hooks/use-stripe-config';
import PaymentMethodCard from './PaymentMethodCard'; import PaymentMethodCard from './PaymentMethodCard';
import AddPaymentMethodDialog from './AddPaymentMethodDialog'; import AddPaymentMethodDialog from './AddPaymentMethodDialog';
import ConfirmationDialog from './ConfirmationDialog'; import ConfirmationDialog from './ConfirmationDialog';
// Initialize Stripe with publishable key from environment
const stripePromise = loadStripe(process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY);
/** /**
* PaymentMethodsSection - Manages user payment methods * PaymentMethodsSection - Manages user payment methods
* *
@@ -28,6 +25,9 @@ const PaymentMethodsSection = () => {
const [actionLoading, setActionLoading] = useState(false); const [actionLoading, setActionLoading] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
// Get Stripe configuration from API
const { stripePromise, loading: stripeLoading, error: stripeError } = useStripeConfig();
// Dialog states // Dialog states
const [addDialogOpen, setAddDialogOpen] = useState(false); const [addDialogOpen, setAddDialogOpen] = useState(false);
const [clientSecret, setClientSecret] = useState(null); const [clientSecret, setClientSecret] = useState(null);

View File

@@ -1,12 +1,12 @@
import React from 'react'; import React from 'react';
import { NavLink, useLocation } from 'react-router-dom'; import { NavLink, useLocation } from 'react-router-dom';
import { CreditCard, Shield, Star, Palette, FileEdit } from 'lucide-react'; import { CreditCard, Shield, Star, Palette, FileEdit, BookUser } from 'lucide-react';
const settingsItems = [ const settingsItems = [
{ label: 'Stripe', path: '/admin/settings/stripe', icon: CreditCard }, { label: 'Stripe', path: '/admin/settings/stripe', icon: CreditCard },
{ label: 'Permissions', path: '/admin/settings/permissions', icon: Shield }, { label: 'Permissions', path: '/admin/settings/permissions', icon: Shield },
{ label: 'Theme', path: '/admin/settings/theme', icon: Palette }, { label: 'Theme', path: '/admin/settings/theme', icon: Palette },
{ label: 'Directory', path: '/admin/settings/directory', icon: BookUser },
]; ];
const SettingsTabs = () => { const SettingsTabs = () => {

View File

@@ -1,5 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { loadStripe } from '@stripe/stripe-js';
import { Elements } from '@stripe/react-stripe-js'; import { Elements } from '@stripe/react-stripe-js';
import { Card } from '../ui/card'; import { Card } from '../ui/card';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
@@ -26,13 +25,11 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import api from '../../utils/api'; import api from '../../utils/api';
import useStripeConfig from '../../hooks/use-stripe-config';
import ConfirmationDialog from '../ConfirmationDialog'; import ConfirmationDialog from '../ConfirmationDialog';
import PasswordConfirmDialog from '../PasswordConfirmDialog'; import PasswordConfirmDialog from '../PasswordConfirmDialog';
import AddPaymentMethodDialog from '../AddPaymentMethodDialog'; import AddPaymentMethodDialog from '../AddPaymentMethodDialog';
// Initialize Stripe with publishable key from environment
const stripePromise = loadStripe(process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY);
/** /**
* Get icon for payment method type * Get icon for payment method type
*/ */
@@ -76,6 +73,9 @@ const AdminPaymentMethodsPanel = ({ userId, userName }) => {
const [actionLoading, setActionLoading] = useState(false); const [actionLoading, setActionLoading] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
// Get Stripe configuration from API
const { stripePromise, loading: stripeLoading, error: stripeError } = useStripeConfig();
// Dialog states // Dialog states
const [addCardDialogOpen, setAddCardDialogOpen] = useState(false); const [addCardDialogOpen, setAddCardDialogOpen] = useState(false);
const [addManualDialogOpen, setAddManualDialogOpen] = useState(false); const [addManualDialogOpen, setAddManualDialogOpen] = useState(false);

View File

@@ -0,0 +1,92 @@
import { useState, useEffect, useCallback } from 'react';
import api from '../utils/api';
/**
* Default directory configuration - used as fallback if API fails
*/
const DEFAULT_DIRECTORY_CONFIG = {
fields: {
show_in_directory: { enabled: true, label: 'Show in Directory', required: false },
directory_email: { enabled: true, label: 'Directory Email', required: false },
directory_bio: { enabled: true, label: 'Bio', required: false },
directory_address: { enabled: true, label: 'Address', required: false },
directory_phone: { enabled: true, label: 'Phone', required: false },
directory_dob: { enabled: true, label: 'Birthday', required: false },
directory_partner_name: { enabled: true, label: 'Partner Name', required: false },
volunteer_interests: { enabled: true, label: 'Volunteer Interests', required: false },
social_media: { enabled: true, label: 'Social Media Links', required: false },
}
};
/**
* Hook to fetch and manage directory field configuration
* @returns {Object} - { config, loading, error, isFieldEnabled, getFieldLabel, refetch }
*/
const useDirectoryConfig = () => {
const [config, setConfig] = useState(DEFAULT_DIRECTORY_CONFIG);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const fetchConfig = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await api.get('/directory/config');
setConfig(response.data || DEFAULT_DIRECTORY_CONFIG);
} catch (err) {
console.error('Failed to fetch directory config:', err);
setError(err);
// Use default config on error
setConfig(DEFAULT_DIRECTORY_CONFIG);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchConfig();
}, [fetchConfig]);
/**
* Check if a field is enabled in the config
* @param {string} fieldId - The field ID to check (e.g., 'directory_email')
* @returns {boolean} - Whether the field is enabled
*/
const isFieldEnabled = useCallback((fieldId) => {
const field = config?.fields?.[fieldId];
return field?.enabled !== false; // Default to true if not specified
}, [config]);
/**
* Get the label for a field
* @param {string} fieldId - The field ID
* @param {string} defaultLabel - Default label if not in config
* @returns {string} - The field label
*/
const getFieldLabel = useCallback((fieldId, defaultLabel = '') => {
const field = config?.fields?.[fieldId];
return field?.label || defaultLabel;
}, [config]);
/**
* Check if a field is required
* @param {string} fieldId - The field ID
* @returns {boolean} - Whether the field is required
*/
const isFieldRequired = useCallback((fieldId) => {
const field = config?.fields?.[fieldId];
return field?.required === true;
}, [config]);
return {
config,
loading,
error,
isFieldEnabled,
getFieldLabel,
isFieldRequired,
refetch: fetchConfig
};
};
export default useDirectoryConfig;

View File

@@ -0,0 +1,91 @@
import { useState, useEffect, useCallback } from 'react';
import { loadStripe } from '@stripe/stripe-js';
import api from '../utils/api';
// Cache the stripe promise to avoid multiple loads
let stripePromiseCache = null;
let cachedPublishableKey = null;
/**
* Hook to get Stripe configuration from the backend.
*
* Returns the Stripe publishable key and a pre-initialized Stripe promise.
* The publishable key is fetched from the backend API, allowing admins
* to configure it through the admin panel instead of environment variables.
*/
const useStripeConfig = () => {
const [publishableKey, setPublishableKey] = useState(cachedPublishableKey);
const [stripePromise, setStripePromise] = useState(stripePromiseCache);
const [loading, setLoading] = useState(!cachedPublishableKey);
const [error, setError] = useState(null);
const [environment, setEnvironment] = useState(null);
const fetchConfig = useCallback(async () => {
// If we already have a cached key, use it
if (cachedPublishableKey && stripePromiseCache) {
setPublishableKey(cachedPublishableKey);
setStripePromise(stripePromiseCache);
setLoading(false);
return;
}
try {
setLoading(true);
setError(null);
const response = await api.get('/config/stripe');
const { publishable_key, environment: env } = response.data;
// Cache the key and stripe promise
cachedPublishableKey = publishable_key;
stripePromiseCache = loadStripe(publishable_key);
setPublishableKey(publishable_key);
setStripePromise(stripePromiseCache);
setEnvironment(env);
} catch (err) {
console.error('[useStripeConfig] Failed to fetch Stripe config:', err);
setError(err.response?.data?.detail || 'Failed to load Stripe configuration');
// Fallback to environment variable if available
const envKey = process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY;
if (envKey) {
console.warn('[useStripeConfig] Falling back to environment variable');
cachedPublishableKey = envKey;
stripePromiseCache = loadStripe(envKey);
setPublishableKey(envKey);
setStripePromise(stripePromiseCache);
setEnvironment(envKey.startsWith('pk_live_') ? 'live' : 'test');
setError(null);
}
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchConfig();
}, [fetchConfig]);
// Function to clear cache (useful after admin updates settings)
const clearCache = useCallback(() => {
cachedPublishableKey = null;
stripePromiseCache = null;
setPublishableKey(null);
setStripePromise(null);
fetchConfig();
}, [fetchConfig]);
return {
publishableKey,
stripePromise,
loading,
error,
environment,
refetch: fetchConfig,
clearCache,
isConfigured: !!publishableKey,
};
};
export default useStripeConfig;

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate, Link, useLocation, useSearchParams } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { Button } from '../components/ui/button'; import { Button } from '../components/ui/button';
import { Input } from '../components/ui/input'; import { Input } from '../components/ui/input';
@@ -13,6 +13,8 @@ import { ArrowRight, ArrowLeft } from 'lucide-react';
const Login = () => { const Login = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const [searchParams] = useSearchParams();
const { login } = useAuth(); const { login } = useAuth();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
@@ -20,6 +22,30 @@ const Login = () => {
password: '' password: ''
}); });
// Show session expiry message on mount
useEffect(() => {
const sessionParam = searchParams.get('session');
const stateMessage = location.state?.message;
if (sessionParam === 'expired') {
toast.info('Your session has expired. Please log in again.', {
duration: 5000,
});
// Clean up URL
window.history.replaceState({}, '', '/login');
} else if (sessionParam === 'idle') {
toast.info('You were logged out due to inactivity. Please log in again.', {
duration: 5000,
});
// Clean up URL
window.history.replaceState({}, '', '/login');
} else if (stateMessage) {
toast.info(stateMessage, {
duration: 5000,
});
}
}, [searchParams, location.state]);
const handleInputChange = (e) => { const handleInputChange = (e) => {
const { name, value } = e.target; const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value })); setFormData(prev => ({ ...prev, [name]: value }));

View File

@@ -13,6 +13,7 @@ import { Avatar, AvatarImage, AvatarFallback } from '../components/ui/avatar';
import ChangePasswordDialog from '../components/ChangePasswordDialog'; import ChangePasswordDialog from '../components/ChangePasswordDialog';
import PaymentMethodsSection from '../components/PaymentMethodsSection'; import PaymentMethodsSection from '../components/PaymentMethodsSection';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import useDirectoryConfig from '../hooks/use-directory-config';
const Profile = () => { const Profile = () => {
const { user } = useAuth(); const { user } = useAuth();
@@ -29,6 +30,7 @@ const Profile = () => {
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [initialFormData, setInitialFormData] = useState(null); const [initialFormData, setInitialFormData] = useState(null);
const navigate = useNavigate(); const navigate = useNavigate();
const { isFieldEnabled, loading: directoryConfigLoading } = useDirectoryConfig();
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
first_name: '', first_name: '',
last_name: '', last_name: '',
@@ -427,6 +429,7 @@ const Profile = () => {
</div> </div>
{/* Member Directory Settings */} {/* Member Directory Settings */}
{isFieldEnabled('show_in_directory') && (
<div className="pt-6 border-t border-[var(--neutral-800)] space-y-4"> <div className="pt-6 border-t border-[var(--neutral-800)] space-y-4">
<h4 className="text-lg font-semibold text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}> <h4 className="text-lg font-semibold text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<BookUser className="h-5 w-5 text-[var(--orange-light)]" /> <BookUser className="h-5 w-5 text-[var(--orange-light)]" />
@@ -452,6 +455,7 @@ const Profile = () => {
{formData.show_in_directory && ( {formData.show_in_directory && (
<div className="space-y-4 pl-4 border-l-4 border-[var(--neutral-800)]"> <div className="space-y-4 pl-4 border-l-4 border-[var(--neutral-800)]">
{isFieldEnabled('directory_email') && (
<div> <div>
<Label htmlFor="directory_email">Directory Email</Label> <Label htmlFor="directory_email">Directory Email</Label>
<Input <Input
@@ -464,7 +468,9 @@ const Profile = () => {
placeholder="Optional - email to show in directory" placeholder="Optional - email to show in directory"
/> />
</div> </div>
)}
{isFieldEnabled('directory_bio') && (
<div> <div>
<Label htmlFor="directory_bio">Bio</Label> <Label htmlFor="directory_bio">Bio</Label>
<Textarea <Textarea
@@ -476,7 +482,9 @@ const Profile = () => {
placeholder="Tell other members about yourself..." placeholder="Tell other members about yourself..."
/> />
</div> </div>
)}
{isFieldEnabled('directory_address') && (
<div> <div>
<Label htmlFor="directory_address">Address</Label> <Label htmlFor="directory_address">Address</Label>
<Input <Input
@@ -488,7 +496,9 @@ const Profile = () => {
placeholder="Optional - address to show in directory" placeholder="Optional - address to show in directory"
/> />
</div> </div>
)}
{isFieldEnabled('directory_phone') && (
<div> <div>
<Label htmlFor="directory_phone">Phone</Label> <Label htmlFor="directory_phone">Phone</Label>
<Input <Input
@@ -501,7 +511,9 @@ const Profile = () => {
placeholder="Optional - phone to show in directory" placeholder="Optional - phone to show in directory"
/> />
</div> </div>
)}
{isFieldEnabled('directory_dob') && (
<div> <div>
<Label htmlFor="directory_dob">Date of Birth</Label> <Label htmlFor="directory_dob">Date of Birth</Label>
<Input <Input
@@ -513,7 +525,9 @@ const Profile = () => {
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple" className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
/> />
</div> </div>
)}
{isFieldEnabled('directory_partner_name') && (
<div> <div>
<Label htmlFor="directory_partner_name">Partner Name</Label> <Label htmlFor="directory_partner_name">Partner Name</Label>
<Input <Input
@@ -525,9 +539,11 @@ const Profile = () => {
placeholder="Optional - partner name to show in directory" placeholder="Optional - partner name to show in directory"
/> />
</div> </div>
)}
</div> </div>
)} )}
</div> </div>
)}
</Card> </Card>
); );
@@ -664,6 +680,7 @@ const Profile = () => {
</div> </div>
{/* Volunteer Interests */} {/* Volunteer Interests */}
{isFieldEnabled('volunteer_interests') && (
<div className="pt-6 border-t border-[var(--neutral-800)] space-y-4"> <div className="pt-6 border-t border-[var(--neutral-800)] space-y-4">
<h4 className="text-lg font-semibold text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}> <h4 className="text-lg font-semibold text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Users className="h-5 w-5 text-brand-purple" /> <Users className="h-5 w-5 text-brand-purple" />
@@ -692,6 +709,7 @@ const Profile = () => {
))} ))}
</div> </div>
</div> </div>
)}
</Card> </Card>
); );

View File

@@ -0,0 +1,241 @@
import React, { useState, useEffect } from 'react';
import api from '../../utils/api';
import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Switch } from '../../components/ui/switch';
import { Label } from '../../components/ui/label';
import { toast } from 'sonner';
import { BookUser, Save, RotateCcw, Loader2, AlertCircle } from 'lucide-react';
import {
Alert,
AlertDescription,
} from '../../components/ui/alert';
const AdminDirectorySettings = () => {
const [config, setConfig] = useState(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const [initialConfig, setInitialConfig] = useState(null);
useEffect(() => {
fetchConfig();
}, []);
useEffect(() => {
if (initialConfig && config) {
const changed = JSON.stringify(config) !== JSON.stringify(initialConfig);
setHasChanges(changed);
}
}, [config, initialConfig]);
const fetchConfig = async () => {
try {
setLoading(true);
const response = await api.get('/admin/directory/config');
setConfig(response.data.config);
setInitialConfig(response.data.config);
} catch (error) {
console.error('Failed to fetch directory config:', error);
toast.error('Failed to load directory configuration');
} finally {
setLoading(false);
}
};
const handleToggleField = (fieldId) => {
// Don't allow disabling show_in_directory - it's required
if (fieldId === 'show_in_directory') {
toast.error('The "Show in Directory" field cannot be disabled');
return;
}
setConfig(prev => ({
...prev,
fields: {
...prev.fields,
[fieldId]: {
...prev.fields[fieldId],
enabled: !prev.fields[fieldId].enabled
}
}
}));
};
const handleSave = async () => {
try {
setSaving(true);
await api.put('/admin/directory/config', { config });
setInitialConfig(config);
setHasChanges(false);
toast.success('Directory configuration saved successfully');
} catch (error) {
console.error('Failed to save directory config:', error);
toast.error('Failed to save directory configuration');
} finally {
setSaving(false);
}
};
const handleReset = async () => {
if (!window.confirm('Are you sure you want to reset to default settings? This will enable all directory fields.')) {
return;
}
try {
setSaving(true);
const response = await api.post('/admin/directory/config/reset');
setConfig(response.data.config);
setInitialConfig(response.data.config);
setHasChanges(false);
toast.success('Directory configuration reset to defaults');
} catch (error) {
console.error('Failed to reset directory config:', error);
toast.error('Failed to reset directory configuration');
} finally {
setSaving(false);
}
};
const handleCancel = () => {
setConfig(initialConfig);
setHasChanges(false);
};
// Field descriptions for better UX
const fieldDescriptions = {
show_in_directory: 'Master toggle for members to opt-in to the directory (always enabled)',
directory_email: 'Email address visible to other members in the directory',
directory_bio: 'Short biography shown in directory profile and member cards',
directory_address: 'Physical address visible to other members',
directory_phone: 'Phone number visible to other members',
directory_dob: 'Birthday shown in directory profiles',
directory_partner_name: 'Partner name displayed in directory',
volunteer_interests: 'Volunteer interest badges shown in profiles',
social_media: 'Social media links (Facebook, Instagram, Twitter, LinkedIn)',
};
// Field icons for better UX
const fieldLabels = {
show_in_directory: 'Show in Directory Toggle',
directory_email: 'Directory Email',
directory_bio: 'Bio / About',
directory_address: 'Address',
directory_phone: 'Phone Number',
directory_dob: 'Birthday',
directory_partner_name: 'Partner Name',
volunteer_interests: 'Volunteer Interests',
social_media: 'Social Media Links',
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loader2 className="h-8 w-8 animate-spin text-brand-purple" />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-semibold text-foreground flex items-center gap-2">
<BookUser className="h-6 w-6 text-brand-purple" />
Directory Field Settings
</h2>
<p className="text-muted-foreground mt-1">
Configure which fields are available in member profiles and the directory
</p>
</div>
</div>
{/* Info Alert */}
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
These settings control which fields appear in the <strong>Profile page</strong> and <strong>Member Directory</strong>.
Disabling a field will hide it from both locations. Existing data will be preserved but not displayed.
</AlertDescription>
</Alert>
{/* Fields Configuration */}
<Card className="p-6">
<div className="space-y-6">
{config && Object.entries(config.fields).map(([fieldId, fieldData]) => (
<div
key={fieldId}
className={`flex items-center justify-between p-4 rounded-lg border ${
fieldData.enabled ? 'bg-background border-border' : 'bg-muted/50 border-muted'
}`}
>
<div className="flex-1">
<Label className="text-base font-medium text-foreground">
{fieldLabels[fieldId] || fieldData.label || fieldId}
</Label>
<p className="text-sm text-muted-foreground mt-1">
{fieldDescriptions[fieldId] || fieldData.description}
</p>
</div>
<Switch
checked={fieldData.enabled}
onCheckedChange={() => handleToggleField(fieldId)}
disabled={fieldId === 'show_in_directory'}
className="data-[state=checked]:bg-brand-purple"
/>
</div>
))}
</div>
</Card>
{/* Action Buttons */}
<div className="flex items-center justify-between pt-4 border-t">
<Button
variant="outline"
onClick={handleReset}
disabled={saving}
className="text-muted-foreground"
>
<RotateCcw className="h-4 w-4 mr-2" />
Reset to Defaults
</Button>
<div className="flex items-center gap-3">
{hasChanges && (
<span className="text-sm text-muted-foreground flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-orange-500"></span>
Unsaved changes
</span>
)}
<Button
variant="outline"
onClick={handleCancel}
disabled={!hasChanges || saving}
>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={!hasChanges || saving}
className="bg-brand-purple hover:bg-brand-purple/90 text-white"
>
{saving ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Saving...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
Save Changes
</>
)}
</Button>
</div>
</div>
</div>
);
};
export default AdminDirectorySettings;

View File

@@ -498,13 +498,12 @@ const AdminRegistrationBuilder = () => {
<Card className="p-4"> <Card className="p-4">
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold">Steps</h2> <h2 className="text-lg font-semibold">Steps</h2>
<Button size="sm" variant="ghost" className='w-32' onClick={() => setAddStepDialogOpen(true)}> <Button size="sm" variant="ghost" onClick={() => setAddStepDialogOpen(true)}>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
<p>Add Step</p>
</Button> </Button>
</div> </div>
<div className="flex"> <div className="flex space-y-2">
{sortedSteps.map((step, index) => ( {sortedSteps.map((step, index) => (
<div <div
key={step.id} key={step.id}
@@ -520,7 +519,8 @@ const AdminRegistrationBuilder = () => {
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="font-medium">Step: {index + 1} </div> <div className="font-medium">Step: </div>
<span className="font-medium text-sm">{step.title}</span>
</div> </div>
{/* Mod Buttons */} {/* Mod Buttons */}
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@@ -548,11 +548,10 @@ const AdminRegistrationBuilder = () => {
> >
<ChevronDown className="h-3 w-3" /> <ChevronDown className="h-3 w-3" />
</Button> */} </Button> */}
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
className="h-6 w-6 text-red-500 hover-text-background ml-2" className="h-6 w-6 text-red-500 hover:text-white ml-2"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleDeleteStep(step.id); handleDeleteStep(step.id);
@@ -569,66 +568,91 @@ const AdminRegistrationBuilder = () => {
{/* Sections for selected step */} {/* Sections for selected step */}
{currentStep && ( {currentStep && (
<> <>
<div className="flex justify-between flex-col items-center mt-6 mb-4"> <div className="flex justify-between items-center mt-6 mb-4">
<h2 className="text-lg font-semibold self-start">Sections</h2> <h2 className="text-lg font-semibold">Sections</h2>
<Button size="sm" className='w-full' variant="ghost" onClick={() => setAddSectionDialogOpen(true)}> <Button size="sm" variant="ghost" onClick={() => setAddSectionDialogOpen(true)}>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
<p>Add Section</p>
</Button> </Button>
</div> </div>
<div className="space-y-2 "> <div className="space-y-2">
{sortedSections.map((section) => { {sortedSections.map((section) => (
const sortedFields = section.fields?.sort((a, b) => a.order - b.order) || []; <div
return (<div
key={section.id} key={section.id}
className='p-3 rounded-lg bg-background' className={`p-3 rounded-lg border cursor-pointer transition-colors ${selectedSection === section.id
? 'border-brand-purple bg-brand-lavender/10'
: 'border-gray-200 hover:border-gray-300'
}`}
onClick={() => { onClick={() => {
setSelectedSection(section.id); setSelectedSection(section.id);
setSelectedField(null); setSelectedField(null);
}} }}
> >
<div className="flex items-center justify-between "> <div className="flex items-center justify-between">
<div className='flex flex-col'> <span className="text-sm">{section.title}</span>
<span className="text-xl font-semibold">{section.title}</span>
{section.description && (
<p className="text-sm text-muted-foreground mb-4">{section.description}</p>
)}
</div>
<Button <Button
size="sm" size="icon"
variant="ghost" variant="ghost"
className=" text-red-500 self-start hover-text-background" className="h-6 w-6 text-red-500 hover:text-red-700"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleDeleteSection(section.id); handleDeleteSection(section.id);
}} }}
> >
<p>Delete Section</p>
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
</Button> </Button>
</div> </div>
<div className="p-6 mt-4 border-brand-purple rounded-xl bg-brand-lavender/10 "> </div>
<div className="flex justify-between items-center mb-6 "> ))}
</div>
</>
)}
</Card>
</div>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
<div>test</div> {/* Center - Form Canvas */}
<Button size="sm" className='' onClick={() => setAddFieldDialogOpen(true)}> <div className="lg:col-span-9">
<Card className="p-6">
<div className="flex justify-between items-center mb-6">
<div className='relative -mx-11 -my-2 flex gap-2 items-center'>
<Grip className="size-10 text-gray-400 py-2 bg-background" />
<h2 className="text-xl font-semibold">
{currentStep?.title || 'Select a step'}
</h2>
</div>
{selectedSection && (
<Button size="sm" onClick={() => setAddFieldDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
Add Field Add Field
</Button> </Button>
)}
</div> </div>
{/* Fields */}
{currentStep?.description && (
<p className="text-muted-foreground mb-6">{currentStep.description}</p>
)}
{/* Sections and Fields */}
{sortedSections.map((section) => {
const sortedFields = section.fields?.sort((a, b) => a.order - b.order) || [];
return (
<div <div
className='mb-6 p-4 rounded-lg border-2 border-dashed bg-background border-gray-200' key={section.id}
className={`mb-6 p-4 rounded-lg border-2 ${selectedSection === section.id
? 'border-brand-purple'
: 'border-dashed border-gray-200'
}`}
onClick={() => setSelectedSection(section.id)}
> >
<h3 className="text-lg font-medium mb-4">title</h3> <h3 className="text-lg font-medium mb-4">{section.title}</h3>
{section.description && (
<p className="text-sm text-muted-foreground mb-4">{section.description}</p> <p className="text-sm text-muted-foreground mb-4">{section.description}</p>
)}
{/* Fields */} {/* Fields */}
<div className="space-y-3"> <div className="space-y-3">
@@ -668,7 +692,7 @@ const AdminRegistrationBuilder = () => {
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
className="h-6 w-6 text-red-500 hover-text-background" className="h-6 w-6 text-red-500 hover:text-red-700"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleDeleteField(field.id); handleDeleteField(field.id);
@@ -678,12 +702,10 @@ const AdminRegistrationBuilder = () => {
</Button> </Button>
)} )}
</div> </div>
</div> </div>
); );
})} })}
{sortedFields.length === 0 && ( {sortedFields.length === 0 && (
<div className="text-center py-8 text-muted-foreground"> <div className="text-center py-8 text-muted-foreground">
No fields in this section. Click "Add Field" to add one. No fields in this section. Click "Add Field" to add one.
@@ -691,21 +713,23 @@ const AdminRegistrationBuilder = () => {
)} )}
</div> </div>
</div> </div>
);
})}
{sortedSections.length === 0 && (
<div className="text-center py-12 text-muted-foreground">
No sections in this step. Add a section from the left sidebar.
</div> </div>
</div>)
}
)}
</div>
</>
)} )}
</Card> </Card>
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* Right Sidebar - Field Properties */} {/* Right Sidebar - Field Properties */}
<div className="lg:col-span-3"> <div className="lg:col-span-3">
<Card className="p-4"> <Card className="p-4">
<h2 className="text-lg font-semibold mb-4">Edit Options</h2> <h2 className="text-lg font-semibold mb-4">Field Properties</h2>
{selectedFieldData ? ( {selectedFieldData ? (
<div className="space-y-4"> <div className="space-y-4">
@@ -1002,19 +1026,55 @@ const AdminRegistrationBuilder = () => {
{/* Conditional Rules Dialog */} {/* Conditional Rules Dialog */}
<Dialog open={conditionalDialogOpen} onOpenChange={setConditionalDialogOpen}> <Dialog open={conditionalDialogOpen} onOpenChange={setConditionalDialogOpen}>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-3xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Conditional Rules</DialogTitle> <DialogTitle>Conditional Rules</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4 max-h-96 overflow-y-auto"> <div className="space-y-4 py-4 max-h-[60vh] overflow-y-auto">
{(schema?.conditional_rules || []).map((rule, index) => ( <p className="text-sm text-muted-foreground">
<div key={rule.id} className="p-4 border rounded-lg space-y-3"> Rules allow you to show or hide fields based on other field values. For example, show a "Scholarship Reason" field only when "Request Scholarship" is checked.
</p>
{(schema?.conditional_rules || []).map((rule, index) => {
// Get all available fields for dropdowns
const allFields = schema?.steps?.flatMap(step =>
step.sections?.flatMap(section =>
section.fields?.map(field => ({
id: field.id,
label: field.label,
type: field.type,
stepTitle: step.title,
sectionTitle: section.title,
})) || []
) || []
) || [];
// Filter trigger fields (checkbox, dropdown, radio work best)
const triggerFields = allFields.filter(f =>
['checkbox', 'dropdown', 'radio', 'text'].includes(f.type)
);
// Update a specific rule
const updateRule = (ruleId, updates) => {
updateSchema((prev) => ({
...prev,
conditional_rules: prev.conditional_rules.map((r) =>
r.id === ruleId ? { ...r, ...updates } : r
),
}));
};
// Get the trigger field's type to determine value input
const triggerFieldData = allFields.find(f => f.id === rule.trigger_field);
return (
<div key={rule.id} className="p-4 border rounded-lg space-y-4 bg-gray-50">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="font-medium">Rule {index + 1}</span> <span className="font-medium text-sm">Rule {index + 1}</span>
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
className="h-6 w-6 text-red-500 hover-text-background" className="h-6 w-6 text-red-500 hover:text-red-700"
onClick={() => { onClick={() => {
updateSchema((prev) => ({ updateSchema((prev) => ({
...prev, ...prev,
@@ -1025,22 +1085,154 @@ const AdminRegistrationBuilder = () => {
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</div> </div>
<div className="text-sm text-muted-foreground">
When <span className="font-mono bg-gray-100 px-1">{rule.trigger_field}</span>{' '} {/* Trigger Field Selection */}
{rule.trigger_operator}{' '} <div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<span className="font-mono bg-gray-100 px-1">{String(rule.trigger_value)}</span>,{' '} <div>
{rule.action} fields:{' '} <Label className="text-xs">When this field...</Label>
<span className="font-mono bg-gray-100 px-1"> <Select
{rule.target_fields?.join(', ')} value={rule.trigger_field || ''}
onValueChange={(value) => updateRule(rule.id, { trigger_field: value })}
>
<SelectTrigger className="mt-1">
<SelectValue placeholder="Select field" />
</SelectTrigger>
<SelectContent>
{triggerFields.map((field) => (
<SelectItem key={field.id} value={field.id}>
{field.label}
<span className="text-xs text-muted-foreground ml-1">
({field.type})
</span> </span>
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
<div>
<Label className="text-xs">Operator</Label>
<Select
value={rule.trigger_operator || 'equals'}
onValueChange={(value) => updateRule(rule.id, { trigger_operator: value })}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="equals">equals</SelectItem>
<SelectItem value="not_equals">not equals</SelectItem>
<SelectItem value="contains">contains</SelectItem>
<SelectItem value="not_empty">is not empty</SelectItem>
<SelectItem value="empty">is empty</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs">Value</Label>
{triggerFieldData?.type === 'checkbox' ? (
<Select
value={String(rule.trigger_value)}
onValueChange={(value) =>
updateRule(rule.id, { trigger_value: value === 'true' })
}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">Checked (true)</SelectItem>
<SelectItem value="false">Unchecked (false)</SelectItem>
</SelectContent>
</Select>
) : ['empty', 'not_empty'].includes(rule.trigger_operator) ? (
<Input
className="mt-1"
value="(no value needed)"
disabled
/>
) : (
<Input
className="mt-1"
value={rule.trigger_value || ''}
onChange={(e) => updateRule(rule.id, { trigger_value: e.target.value })}
placeholder="Enter value"
/>
)}
</div>
</div>
{/* Action Selection */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<Label className="text-xs">Action</Label>
<Select
value={rule.action || 'show'}
onValueChange={(value) => updateRule(rule.id, { action: value })}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="show">Show fields</SelectItem>
<SelectItem value="hide">Hide fields</SelectItem>
<SelectItem value="require">Make required</SelectItem>
<SelectItem value="optional">Make optional</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs">Target Fields</Label>
<div className="mt-1 border rounded-md p-2 bg-white max-h-32 overflow-y-auto">
{allFields.length === 0 ? (
<p className="text-xs text-muted-foreground">No fields available</p>
) : (
<div className="space-y-1">
{allFields
.filter(f => f.id !== rule.trigger_field) // Don't show trigger field as target
.map((field) => (
<div key={field.id} className="flex items-center gap-2">
<Checkbox
id={`${rule.id}-${field.id}`}
checked={(rule.target_fields || []).includes(field.id)}
onCheckedChange={(checked) => {
const currentTargets = rule.target_fields || [];
const newTargets = checked
? [...currentTargets, field.id]
: currentTargets.filter((id) => id !== field.id);
updateRule(rule.id, { target_fields: newTargets });
}}
/>
<Label
htmlFor={`${rule.id}-${field.id}`}
className="text-xs font-normal cursor-pointer"
>
{field.label}
</Label>
</div> </div>
))} ))}
</div>
)}
</div>
</div>
</div>
{/* Rule Summary */}
{rule.trigger_field && (rule.target_fields?.length || 0) > 0 && (
<div className="text-xs bg-blue-50 border border-blue-200 rounded p-2 text-blue-800">
<strong>Summary:</strong> When "{triggerFieldData?.label || rule.trigger_field}" {rule.trigger_operator} "{String(rule.trigger_value)}", {rule.action} the following fields: {rule.target_fields?.map(id => allFields.find(f => f.id === id)?.label || id).join(', ')}
</div>
)}
</div>
);
})}
{(schema?.conditional_rules || []).length === 0 && ( {(schema?.conditional_rules || []).length === 0 && (
<div className="text-center py-8 text-muted-foreground"> <div className="text-center py-8 text-muted-foreground border-2 border-dashed rounded-lg">
No conditional rules configured. Rules allow you to show or hide fields based on No conditional rules configured yet.<br />
other field values. Click "Add Rule" to create your first rule.
</div> </div>
)} )}

View File

@@ -16,11 +16,13 @@ export default function AdminSettings() {
// Form state // Form state
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
publishable_key: '',
secret_key: '', secret_key: '',
webhook_secret: '' webhook_secret: ''
}); });
// Show/hide sensitive values // Show/hide sensitive values
const [showPublishableKey, setShowPublishableKey] = useState(false);
const [showSecretKey, setShowSecretKey] = useState(false); const [showSecretKey, setShowSecretKey] = useState(false);
const [showWebhookSecret, setShowWebhookSecret] = useState(false); const [showWebhookSecret, setShowWebhookSecret] = useState(false);
@@ -57,6 +59,7 @@ export default function AdminSettings() {
const handleEditClick = () => { const handleEditClick = () => {
setIsEditing(true); setIsEditing(true);
setFormData({ setFormData({
publishable_key: '',
secret_key: '', secret_key: '',
webhook_secret: '' webhook_secret: ''
}); });
@@ -65,17 +68,24 @@ export default function AdminSettings() {
const handleCancelEdit = () => { const handleCancelEdit = () => {
setIsEditing(false); setIsEditing(false);
setFormData({ setFormData({
publishable_key: '',
secret_key: '', secret_key: '',
webhook_secret: '' webhook_secret: ''
}); });
setShowPublishableKey(false);
setShowSecretKey(false); setShowSecretKey(false);
setShowWebhookSecret(false); setShowWebhookSecret(false);
}; };
const handleSave = async () => { const handleSave = async () => {
// Validate inputs // Validate inputs
if (!formData.secret_key || !formData.webhook_secret) { if (!formData.publishable_key || !formData.secret_key || !formData.webhook_secret) {
toast.error('Both Secret Key and Webhook Secret are required'); toast.error('All three keys are required: Publishable Key, Secret Key, and Webhook Secret');
return;
}
if (!formData.publishable_key.startsWith('pk_test_') && !formData.publishable_key.startsWith('pk_live_')) {
toast.error('Invalid Publishable Key format. Must start with pk_test_ or pk_live_');
return; return;
} }
@@ -89,15 +99,25 @@ export default function AdminSettings() {
return; return;
} }
// Check environment consistency
const pkIsLive = formData.publishable_key.startsWith('pk_live_');
const skIsLive = formData.secret_key.startsWith('sk_live_');
if (pkIsLive !== skIsLive) {
toast.error('Publishable Key and Secret Key must be from the same environment (both test or both live)');
return;
}
setSaving(true); setSaving(true);
try { try {
await api.put('/admin/settings/stripe', formData); await api.put('/admin/settings/stripe', formData);
toast.success('Stripe settings updated successfully'); toast.success('Stripe settings updated successfully');
setIsEditing(false); setIsEditing(false);
setFormData({ setFormData({
publishable_key: '',
secret_key: '', secret_key: '',
webhook_secret: '' webhook_secret: ''
}); });
setShowPublishableKey(false);
setShowSecretKey(false); setShowSecretKey(false);
setShowWebhookSecret(false); setShowWebhookSecret(false);
// Refresh status // Refresh status
@@ -157,6 +177,31 @@ export default function AdminSettings() {
{isEditing ? ( {isEditing ? (
/* Edit Mode */ /* Edit Mode */
<div className="space-y-6"> <div className="space-y-6">
{/* Publishable Key Input */}
<div className="space-y-2">
<Label htmlFor="publishable_key">Stripe Publishable Key</Label>
<div className="relative">
<Input
id="publishable_key"
type={showPublishableKey ? 'text' : 'password'}
value={formData.publishable_key}
onChange={(e) => setFormData({ ...formData, publishable_key: e.target.value })}
placeholder="pk_test_... or pk_live_..."
className="pr-10"
/>
<button
type="button"
onClick={() => setShowPublishableKey(!showPublishableKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
>
{showPublishableKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<p className="text-xs text-gray-500">
Get this from your Stripe Dashboard Developers API keys (Publishable key)
</p>
</div>
{/* Secret Key Input */} {/* Secret Key Input */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="secret_key">Stripe Secret Key</Label> <Label htmlFor="secret_key">Stripe Secret Key</Label>
@@ -178,7 +223,7 @@ export default function AdminSettings() {
</button> </button>
</div> </div>
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
Get this from your Stripe Dashboard Developers API keys Get this from your Stripe Dashboard Developers API keys (Secret key)
</p> </p>
</div> </div>
@@ -267,7 +312,7 @@ export default function AdminSettings() {
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg"> <div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div> <div>
<p className="font-semibold text-gray-900">Environment</p> <p className="font-semibold text-gray-900">Environment</p>
<p className="text-sm text-gray-600">Detected from secret key prefix</p> <p className="text-sm text-gray-600">Detected from key prefixes</p>
</div> </div>
<span className={`px-3 py-1 rounded-full text-sm font-semibold ${ <span className={`px-3 py-1 rounded-full text-sm font-semibold ${
stripeStatus.environment === 'live' stripeStatus.environment === 'live'
@@ -278,6 +323,20 @@ export default function AdminSettings() {
</span> </span>
</div> </div>
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div>
<p className="font-semibold text-gray-900">Publishable Key</p>
<p className="text-sm text-gray-600 font-mono">
{stripeStatus.publishable_key_prefix}...
</p>
</div>
{stripeStatus.publishable_key_set ? (
<CheckCircle className="h-5 w-5 text-green-600" />
) : (
<AlertCircle className="h-5 w-5 text-amber-600" />
)}
</div>
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg"> <div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div> <div>
<p className="font-semibold text-gray-900">Secret Key</p> <p className="font-semibold text-gray-900">Secret Key</p>

View File

@@ -20,6 +20,7 @@ import MemberCard from '../../components/MemberCard';
import MemberBadge from '../../components/MemberBadge'; import MemberBadge from '../../components/MemberBadge';
import useMembers from '../../hooks/use-members'; import useMembers from '../../hooks/use-members';
import useMemberTiers from '../../hooks/use-member-tiers'; import useMemberTiers from '../../hooks/use-member-tiers';
import useDirectoryConfig from '../../hooks/use-directory-config';
const MembersDirectory = () => { const MembersDirectory = () => {
const [selectedMember, setSelectedMember] = useState(null); const [selectedMember, setSelectedMember] = useState(null);
@@ -28,6 +29,7 @@ const MembersDirectory = () => {
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const pageSize = 12; const pageSize = 12;
const { tiers } = useMemberTiers(); const { tiers } = useMemberTiers();
const { isFieldEnabled } = useDirectoryConfig();
const allowedRoles = useMemo(() => [], []); const allowedRoles = useMemo(() => [], []);
const normalizeStatus = useCallback((status) => { const normalizeStatus = useCallback((status) => {
if (typeof status === 'string') { if (typeof status === 'string') {
@@ -242,7 +244,7 @@ const MembersDirectory = () => {
{selectedMember.first_name} {selectedMember.last_name} {selectedMember.first_name} {selectedMember.last_name}
<MemberBadge memberSince={selectedMember.member_since || selectedMember.created_at} tiers={tiers} /> <MemberBadge memberSince={selectedMember.member_since || selectedMember.created_at} tiers={tiers} />
</DialogTitle> </DialogTitle>
{selectedMember.directory_partner_name && ( {isFieldEnabled('directory_partner_name') && selectedMember.directory_partner_name && (
<DialogDescription className="flex items-center gap-2 text-lg" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <DialogDescription className="flex items-center gap-2 text-lg" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<Heart className="h-5 w-5 text-[var(--orange-light)]" /> <Heart className="h-5 w-5 text-[var(--orange-light)]" />
<span className="text-brand-purple ">Partner: {selectedMember.directory_partner_name}</span> <span className="text-brand-purple ">Partner: {selectedMember.directory_partner_name}</span>
@@ -252,7 +254,7 @@ const MembersDirectory = () => {
<div className="space-y-6 py-4"> <div className="space-y-6 py-4">
{/* Bio */} {/* Bio */}
{selectedMember.directory_bio && ( {isFieldEnabled('directory_bio') && selectedMember.directory_bio && (
<div> <div>
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}> <h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
About About
@@ -264,12 +266,13 @@ const MembersDirectory = () => {
)} )}
{/* Contact Information */} {/* Contact Information */}
{(isFieldEnabled('directory_email') || isFieldEnabled('directory_phone') || isFieldEnabled('directory_address') || isFieldEnabled('directory_dob')) && (
<div> <div>
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}> <h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
Contact Information Contact Information
</h3> </h3>
<div className="space-y-3"> <div className="space-y-3">
{selectedMember.directory_email && ( {isFieldEnabled('directory_email') && selectedMember.directory_email && (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-[var(--lavender-500)]"> <div className="p-2 rounded-lg bg-[var(--lavender-500)]">
<Mail className="h-5 w-5 text-brand-purple " /> <Mail className="h-5 w-5 text-brand-purple " />
@@ -287,7 +290,7 @@ const MembersDirectory = () => {
</div> </div>
)} )}
{selectedMember.directory_phone && ( {isFieldEnabled('directory_phone') && selectedMember.directory_phone && (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-[var(--lavender-500)]"> <div className="p-2 rounded-lg bg-[var(--lavender-500)]">
<Phone className="h-5 w-5 text-brand-purple " /> <Phone className="h-5 w-5 text-brand-purple " />
@@ -305,7 +308,7 @@ const MembersDirectory = () => {
</div> </div>
)} )}
{selectedMember.directory_address && ( {isFieldEnabled('directory_address') && selectedMember.directory_address && (
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-[var(--lavender-500)]"> <div className="p-2 rounded-lg bg-[var(--lavender-500)]">
<MapPin className="h-5 w-5 text-brand-purple " /> <MapPin className="h-5 w-5 text-brand-purple " />
@@ -319,7 +322,7 @@ const MembersDirectory = () => {
</div> </div>
)} )}
{selectedMember.directory_dob && ( {isFieldEnabled('directory_dob') && selectedMember.directory_dob && (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-[var(--lavender-500)]"> <div className="p-2 rounded-lg bg-[var(--lavender-500)]">
<Heart className="h-5 w-5 text-[var(--orange-light)]" /> <Heart className="h-5 w-5 text-[var(--orange-light)]" />
@@ -334,9 +337,10 @@ const MembersDirectory = () => {
)} )}
</div> </div>
</div> </div>
)}
{/* Volunteer Interests */} {/* Volunteer Interests */}
{selectedMember.volunteer_interests && selectedMember.volunteer_interests.length > 0 && ( {isFieldEnabled('volunteer_interests') && selectedMember.volunteer_interests && selectedMember.volunteer_interests.length > 0 && (
<div> <div>
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}> <h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
Volunteer Interests Volunteer Interests
@@ -355,7 +359,7 @@ const MembersDirectory = () => {
)} )}
{/* Social Media */} {/* Social Media */}
{(selectedMember.social_media_facebook || selectedMember.social_media_instagram || {isFieldEnabled('social_media') && (selectedMember.social_media_facebook || selectedMember.social_media_instagram ||
selectedMember.social_media_twitter || selectedMember.social_media_linkedin) && ( selectedMember.social_media_twitter || selectedMember.social_media_linkedin) && (
<div> <div>
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}> <h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>

View File

@@ -30,6 +30,33 @@ api.interceptors.response.use(
async (error) => { async (error) => {
const config = error.config; const config = error.config;
// Handle 401 Unauthorized - session expired
if (error.response && error.response.status === 401) {
// Don't redirect if it's a login request or auth check
const isAuthRequest = config?.url?.includes('/auth/login') ||
config?.url?.includes('/auth/me') ||
config?.url?.includes('/auth/permissions');
if (!isAuthRequest) {
console.warn('[API] Session expired - redirecting to login');
// Clear auth state
localStorage.removeItem('token');
// Dispatch custom event for components to react
window.dispatchEvent(new CustomEvent('auth:session-expired'));
// Redirect to login with session expired message
// Use replace to prevent back button issues
const currentPath = window.location.pathname;
if (!currentPath.includes('/login')) {
window.location.replace('/login?session=expired');
}
}
return Promise.reject(error);
}
// Don't retry if we've already retried or if it's a client error (4xx) // Don't retry if we've already retried or if it's a client error (4xx)
if (!config || config.__isRetry || (error.response && error.response.status < 500)) { if (!config || config.__isRetry || (error.response && error.response.status < 500)) {
console.error('[API] Request failed:', { console.error('[API] Request failed:', {