#### __1. Role-Based Security Architecture__
All checks were successful
Build And Push Image / docker (push) Successful in 2m58s

- Replaces group-based tiers with proper Keycloak realm roles
- `monaco-user`, `monaco-board`, `monaco-admin` roles
- Backward compatibility with existing group system

#### __2. Advanced User Management__

- Comprehensive user profile synchronization
- Membership data stored in Keycloak user attributes
- Bidirectional sync between NocoDB and Keycloak

#### __3. Session Security & Monitoring__

- Real-time session tracking and management
- Administrative session control capabilities
- Enhanced security analytics foundation

#### __4. Email Workflow System__

- Multiple email types: DUES_REMINDER, MEMBERSHIP_RENEWAL, WELCOME, VERIFICATION
- Customizable email parameters and lifespans
- Advanced email template support

#### __5. Seamless Migration Path__

- All existing functionality continues to work
- New users automatically get realm roles
- Gradual migration from groups to roles
- Zero breaking changes

### 🔧 __What You Can Do Now__

#### __For New Users:__

- Public registrations automatically assign `monaco-user` role
- Portal account creation syncs member data to Keycloak attributes
- Enhanced email verification and welcome workflows

#### __For Administrators:__

- Session management and monitoring capabilities
- Advanced user profile management with member data sync
- Comprehensive role assignment and management
- Enhanced email communication workflows

#### __For Developers:__

- Use `hasRole('monaco-admin')` for role-based checks
- Access `getAllRoles()` for debugging and analytics
- Enhanced `useAuth()` composable with backward compatibility
- Comprehensive TypeScript support throughout

### 🛡️ __Security & Reliability__

- __Backward Compatibility__: Existing users continue to work seamlessly
- __Enhanced Security__: Proper realm role-based authorization
- __Error Handling__: Comprehensive error handling and fallbacks
- __Type Safety__: Full TypeScript support throughout the system
This commit is contained in:
2025-08-08 19:40:13 +02:00
parent b308b8272c
commit 5535b7905d
16 changed files with 3381 additions and 15 deletions

View File

@@ -0,0 +1,163 @@
import type { Member } from '~/utils/types';
export default defineEventHandler(async (event) => {
console.log('[api/admin/cleanup-accounts.post] =========================');
console.log('[api/admin/cleanup-accounts.post] POST /api/admin/cleanup-accounts - Account cleanup for expired members');
try {
// Validate session and require admin privileges
const sessionManager = createSessionManager();
const cookieHeader = getCookie(event, 'monacousa-session') ? getHeader(event, 'cookie') : undefined;
const session = sessionManager.getSession(cookieHeader);
if (!session?.user) {
throw createError({
statusCode: 401,
statusMessage: 'Authentication required'
});
}
// Require admin privileges for account cleanup
if (session.user.tier !== 'admin') {
throw createError({
statusCode: 403,
statusMessage: 'Admin privileges required'
});
}
console.log('[api/admin/cleanup-accounts.post] Authorized admin:', session.user.email);
// Get cleanup options from request body (optional)
const body = await readBody(event).catch(() => ({}));
const dryRun = body?.dryRun === true;
const monthsOverdue = body?.monthsOverdue || 3;
console.log('[api/admin/cleanup-accounts.post] Cleanup options:', { dryRun, monthsOverdue });
// Calculate cutoff date (default: 3 months ago)
const cutoffDate = new Date();
cutoffDate.setMonth(cutoffDate.getMonth() - monthsOverdue);
console.log('[api/admin/cleanup-accounts.post] Cutoff date:', cutoffDate.toISOString());
// Find members registered before cutoff date with unpaid dues
const { getMembers } = await import('~/server/utils/nocodb');
const membersResult = await getMembers();
const allMembers = membersResult.list || [];
const expiredMembers = allMembers.filter((member: Member) => {
// Must have a registration date
if (!member.registration_date) return false;
// Must be registered before cutoff date
const registrationDate = new Date(member.registration_date);
if (registrationDate >= cutoffDate) return false;
// Must have unpaid dues
if (member.current_year_dues_paid === 'true') return false;
// Must have a Keycloak ID (portal account)
if (!member.keycloak_id) return false;
return true;
});
console.log('[api/admin/cleanup-accounts.post] Found', expiredMembers.length, 'expired members for cleanup');
const deletedAccounts = [];
const failedDeletions = [];
if (!dryRun && expiredMembers.length > 0) {
const { createKeycloakAdminClient } = await import('~/server/utils/keycloak-admin');
const { deleteMember } = await import('~/server/utils/nocodb');
const keycloakAdmin = createKeycloakAdminClient();
for (const member of expiredMembers) {
try {
console.log('[api/admin/cleanup-accounts.post] Processing cleanup for:', member.email);
// Delete from Keycloak first
if (member.keycloak_id) {
try {
await keycloakAdmin.deleteUser(member.keycloak_id);
console.log('[api/admin/cleanup-accounts.post] Deleted Keycloak user:', member.keycloak_id);
} catch (keycloakError: any) {
console.warn('[api/admin/cleanup-accounts.post] Failed to delete Keycloak user:', keycloakError.message);
// Continue with member deletion even if Keycloak deletion fails
}
}
// Delete member record
await deleteMember(member.Id);
console.log('[api/admin/cleanup-accounts.post] Deleted member record:', member.Id);
deletedAccounts.push({
id: member.Id,
email: member.email,
name: `${member.first_name} ${member.last_name}`,
registrationDate: member.registration_date,
keycloakId: member.keycloak_id
});
} catch (error: any) {
console.error('[api/admin/cleanup-accounts.post] Failed to delete account for', member.email, ':', error);
failedDeletions.push({
id: member.Id,
email: member.email,
name: `${member.first_name} ${member.last_name}`,
error: error.message
});
}
}
}
const result = {
success: true,
dryRun,
monthsOverdue,
cutoffDate: cutoffDate.toISOString(),
totalExpiredMembers: expiredMembers.length,
deletedCount: deletedAccounts.length,
failedCount: failedDeletions.length,
message: dryRun
? `Found ${expiredMembers.length} expired accounts that would be deleted (dry run)`
: `Cleaned up ${deletedAccounts.length} expired accounts${failedDeletions.length > 0 ? ` (${failedDeletions.length} failed)` : ''}`,
data: {
expiredMembers: expiredMembers.map(m => ({
id: m.Id,
email: m.email,
name: `${m.first_name} ${m.last_name}`,
registrationDate: m.registration_date,
daysSinceRegistration: Math.floor((Date.now() - new Date(m.registration_date || '').getTime()) / (1000 * 60 * 60 * 24)),
hasKeycloakAccount: !!m.keycloak_id
})),
deleted: deletedAccounts,
failed: failedDeletions
}
};
console.log('[api/admin/cleanup-accounts.post] ✅ Account cleanup completed');
console.log('[api/admin/cleanup-accounts.post] Summary:', {
found: expiredMembers.length,
deleted: deletedAccounts.length,
failed: failedDeletions.length,
dryRun
});
return result;
} catch (error: any) {
console.error('[api/admin/cleanup-accounts.post] ❌ Account cleanup failed:', error);
// If it's already an HTTP error, re-throw it
if (error.statusCode) {
throw error;
}
// Otherwise, wrap it in a generic error
throw createError({
statusCode: 500,
statusMessage: error.message || 'Account cleanup failed'
});
}
});

View File

@@ -0,0 +1,43 @@
export default defineEventHandler(async (event) => {
console.log('[api/admin/recaptcha-config.get] =========================');
console.log('[api/admin/recaptcha-config.get] GET /api/admin/recaptcha-config - Get reCAPTCHA configuration');
try {
// Validate session and require admin privileges
const sessionManager = createSessionManager();
const cookieHeader = getCookie(event, 'monacousa-session') ? getHeader(event, 'cookie') : undefined;
const session = sessionManager.getSession(cookieHeader);
if (!session?.user) {
throw createError({
statusCode: 401,
statusMessage: 'Authentication required'
});
}
if (session.user.tier !== 'admin') {
throw createError({
statusCode: 403,
statusMessage: 'Admin privileges required'
});
}
console.log('[api/admin/recaptcha-config.get] Authorized admin:', session.user.email);
// Get reCAPTCHA configuration
const { getRecaptchaConfig } = await import('~/server/utils/admin-config');
const config = getRecaptchaConfig();
return {
success: true,
data: {
siteKey: config.siteKey,
secretKey: config.secretKey ? '••••••••••••••••' : ''
}
};
} catch (error: any) {
console.error('[api/admin/recaptcha-config.get] ❌ Error getting reCAPTCHA config:', error);
throw error;
}
});

View File

@@ -0,0 +1,64 @@
export default defineEventHandler(async (event) => {
console.log('[api/admin/recaptcha-config.post] =========================');
console.log('[api/admin/recaptcha-config.post] POST /api/admin/recaptcha-config - Save reCAPTCHA configuration');
try {
// Validate session and require admin privileges
const sessionManager = createSessionManager();
const cookieHeader = getCookie(event, 'monacousa-session') ? getHeader(event, 'cookie') : undefined;
const session = sessionManager.getSession(cookieHeader);
if (!session?.user) {
throw createError({
statusCode: 401,
statusMessage: 'Authentication required'
});
}
if (session.user.tier !== 'admin') {
throw createError({
statusCode: 403,
statusMessage: 'Admin privileges required'
});
}
console.log('[api/admin/recaptcha-config.post] Authorized admin:', session.user.email);
// Get and validate request body
const body = await readBody(event);
console.log('[api/admin/recaptcha-config.post] Request body fields:', Object.keys(body));
// Validate required fields
if (!body.siteKey || typeof body.siteKey !== 'string') {
throw createError({
statusCode: 400,
statusMessage: 'Site Key is required'
});
}
if (!body.secretKey || typeof body.secretKey !== 'string') {
throw createError({
statusCode: 400,
statusMessage: 'Secret Key is required'
});
}
// Save reCAPTCHA configuration
const { saveRecaptchaConfig } = await import('~/server/utils/admin-config');
await saveRecaptchaConfig({
siteKey: body.siteKey.trim(),
secretKey: body.secretKey.trim()
}, session.user.email);
console.log('[api/admin/recaptcha-config.post] ✅ reCAPTCHA configuration saved successfully');
return {
success: true,
message: 'reCAPTCHA configuration saved successfully'
};
} catch (error: any) {
console.error('[api/admin/recaptcha-config.post] ❌ Error saving reCAPTCHA config:', error);
throw error;
}
});

View File

@@ -0,0 +1,40 @@
export default defineEventHandler(async (event) => {
console.log('[api/admin/registration-config.get] =========================');
console.log('[api/admin/registration-config.get] GET /api/admin/registration-config - Get registration configuration');
try {
// Validate session and require admin privileges
const sessionManager = createSessionManager();
const cookieHeader = getCookie(event, 'monacousa-session') ? getHeader(event, 'cookie') : undefined;
const session = sessionManager.getSession(cookieHeader);
if (!session?.user) {
throw createError({
statusCode: 401,
statusMessage: 'Authentication required'
});
}
if (session.user.tier !== 'admin') {
throw createError({
statusCode: 403,
statusMessage: 'Admin privileges required'
});
}
console.log('[api/admin/registration-config.get] Authorized admin:', session.user.email);
// Get registration configuration
const { getRegistrationConfig } = await import('~/server/utils/admin-config');
const config = getRegistrationConfig();
return {
success: true,
data: config
};
} catch (error: any) {
console.error('[api/admin/registration-config.get] ❌ Error getting registration config:', error);
throw error;
}
});

View File

@@ -0,0 +1,72 @@
export default defineEventHandler(async (event) => {
console.log('[api/admin/registration-config.post] =========================');
console.log('[api/admin/registration-config.post] POST /api/admin/registration-config - Save registration configuration');
try {
// Validate session and require admin privileges
const sessionManager = createSessionManager();
const cookieHeader = getCookie(event, 'monacousa-session') ? getHeader(event, 'cookie') : undefined;
const session = sessionManager.getSession(cookieHeader);
if (!session?.user) {
throw createError({
statusCode: 401,
statusMessage: 'Authentication required'
});
}
if (session.user.tier !== 'admin') {
throw createError({
statusCode: 403,
statusMessage: 'Admin privileges required'
});
}
console.log('[api/admin/registration-config.post] Authorized admin:', session.user.email);
// Get and validate request body
const body = await readBody(event);
console.log('[api/admin/registration-config.post] Request body fields:', Object.keys(body));
// Validate required fields
if (!body.membershipFee || typeof body.membershipFee !== 'number' || body.membershipFee <= 0) {
throw createError({
statusCode: 400,
statusMessage: 'Valid membership fee is required'
});
}
if (!body.iban || typeof body.iban !== 'string') {
throw createError({
statusCode: 400,
statusMessage: 'IBAN is required'
});
}
if (!body.accountHolder || typeof body.accountHolder !== 'string') {
throw createError({
statusCode: 400,
statusMessage: 'Account holder name is required'
});
}
// Save registration configuration
const { saveRegistrationConfig } = await import('~/server/utils/admin-config');
await saveRegistrationConfig({
membershipFee: body.membershipFee,
iban: body.iban.trim(),
accountHolder: body.accountHolder.trim()
}, session.user.email);
console.log('[api/admin/registration-config.post] ✅ Registration configuration saved successfully');
return {
success: true,
message: 'Registration configuration saved successfully'
};
} catch (error: any) {
console.error('[api/admin/registration-config.post] ❌ Error saving registration config:', error);
throw error;
}
});

View File

@@ -0,0 +1,177 @@
export default defineEventHandler(async (event) => {
console.log('[api/members/[id]/create-portal-account.post] =========================');
console.log('[api/members/[id]/create-portal-account.post] POST /api/members/:id/create-portal-account - Create portal account for member');
try {
// Validate session and require board/admin privileges
const sessionManager = createSessionManager();
const cookieHeader = getCookie(event, 'monacousa-session') ? getHeader(event, 'cookie') : undefined;
const session = sessionManager.getSession(cookieHeader);
if (!session?.user) {
throw createError({
statusCode: 401,
statusMessage: 'Authentication required'
});
}
// Require board or admin privileges
if (session.user.tier !== 'admin' && session.user.tier !== 'board') {
throw createError({
statusCode: 403,
statusMessage: 'Board or Admin privileges required'
});
}
console.log('[api/members/[id]/create-portal-account.post] Authorized user:', session.user.email, 'tier:', session.user.tier);
// Get member ID from route parameter
const memberId = getRouterParam(event, 'id');
if (!memberId) {
throw createError({
statusCode: 400,
statusMessage: 'Member ID is required'
});
}
console.log('[api/members/[id]/create-portal-account.post] Processing member ID:', memberId);
// 1. Get member data
const { getMemberById } = await import('~/server/utils/nocodb');
const member = await getMemberById(memberId);
if (!member) {
throw createError({
statusCode: 404,
statusMessage: 'Member not found'
});
}
console.log('[api/members/[id]/create-portal-account.post] Found member:', member.email);
// 2. Check if member already has portal account
if (member.keycloak_id) {
console.log('[api/members/[id]/create-portal-account.post] Member already has portal account:', member.keycloak_id);
throw createError({
statusCode: 409,
statusMessage: 'Member already has a portal account'
});
}
// 3. Validate member data
if (!member.email || !member.first_name || !member.last_name) {
throw createError({
statusCode: 400,
statusMessage: 'Member must have email, first name, and last name to create portal account'
});
}
// 4. Check if user already exists in Keycloak (by email)
const { createKeycloakAdminClient } = await import('~/server/utils/keycloak-admin');
const keycloakAdmin = createKeycloakAdminClient();
console.log('[api/members/[id]/create-portal-account.post] Checking for existing Keycloak user...');
const existingUsers = await keycloakAdmin.findUserByEmail(member.email);
if (existingUsers.length > 0) {
console.log('[api/members/[id]/create-portal-account.post] User already exists in Keycloak');
throw createError({
statusCode: 409,
statusMessage: 'A user with this email already exists in the system'
});
}
// 5. Determine membership tier based on member data
const membershipTier = determineMembershipTier(member);
console.log('[api/members/[id]/create-portal-account.post] Determined membership tier:', membershipTier);
// 6. Prepare membership data for Keycloak sync
const membershipData = {
membershipStatus: member.membership_status || 'Active',
duesStatus: member.current_year_dues_paid === 'true' ? 'paid' as const : 'unpaid' as const,
memberSince: member.member_since || new Date().getFullYear().toString(),
nationality: member.nationality || '',
phone: member.phone || '',
address: member.address || '',
registrationDate: member.registration_date || new Date().toISOString(),
paymentDueDate: member.payment_due_date || '',
membershipTier,
nocodbMemberId: memberId
};
// 7. Create Keycloak user with role-based registration
console.log('[api/members/[id]/create-portal-account.post] Creating Keycloak user with role-based system...');
const keycloakId = await keycloakAdmin.createUserWithRoleRegistration({
email: member.email,
firstName: member.first_name,
lastName: member.last_name,
membershipTier,
membershipData
});
console.log('[api/members/[id]/create-portal-account.post] Created Keycloak user with ID:', keycloakId);
// 6. Update member record with keycloak_id
console.log('[api/members/[id]/create-portal-account.post] Updating member record with keycloak_id...');
const { updateMember } = await import('~/server/utils/nocodb');
await updateMember(memberId, { keycloak_id: keycloakId });
console.log('[api/members/[id]/create-portal-account.post] ✅ Portal account creation successful');
return {
success: true,
message: 'Portal account created successfully. The member will receive an email to verify their account and set their password.',
data: {
keycloak_id: keycloakId,
member_id: memberId,
email: member.email,
name: `${member.first_name} ${member.last_name}`
}
};
} catch (error: any) {
console.error('[api/members/[id]/create-portal-account.post] ❌ Portal account creation failed:', error);
// If it's already an HTTP error, re-throw it
if (error.statusCode) {
throw error;
}
// Otherwise, wrap it in a generic error
throw createError({
statusCode: 500,
statusMessage: error.message || 'Failed to create portal account'
});
}
});
/**
* Determine membership tier based on member data
* This function analyzes member information to assign appropriate portal roles
*/
function determineMembershipTier(member: any): 'user' | 'board' | 'admin' {
// Check for explicit tier indicators in member data
// This could be based on membership type, special flags, or other criteria
// For now, default all members to 'user' tier
// In the future, you might want to check specific fields like:
// - member.membership_type
// - member.is_board_member
// - member.is_admin
// - specific email domains for admins
// - etc.
// Example logic (uncomment and modify as needed):
/*
if (member.email && member.email.includes('@admin.monacousa.org')) {
return 'admin';
}
if (member.membership_type === 'Board' || member.is_board_member === 'true') {
return 'board';
}
*/
// Default to user tier for all members
return 'user';
}

View File

@@ -0,0 +1,262 @@
import type { RegistrationFormData } from '~/utils/types';
import { createKeycloakAdminClient } from '~/server/utils/keycloak-admin';
import { createMember } from '~/server/utils/nocodb';
// Simple NocoDB client wrapper for consistency with existing pattern
const createNocoDBClient = () => ({
async findAll(table: string, options: any) {
// For registration, we'll use a direct search via the existing members API
try {
const response = await $fetch(`/api/members`, {
method: 'GET',
headers: { 'Accept': 'application/json' }
}) as any;
// Filter by email from the response
const members = response?.data || response?.list || [];
if (options.where.email) {
return members.filter((member: any) => member.email === options.where.email);
}
return members;
} catch (error) {
console.warn('[createNocoDBClient.findAll] Error fetching members:', error);
return [];
}
},
async create(table: string, data: any) {
return await createMember(data);
},
async delete(table: string, id: string) {
const { deleteMember } = await import('~/server/utils/nocodb');
return await deleteMember(id);
}
});
export default defineEventHandler(async (event) => {
console.log('[api/registration.post] =========================');
console.log('[api/registration.post] POST /api/registration - Public member registration');
let createdKeycloakId: string | null = null;
let createdMemberId: string | null = null;
try {
const body = await readBody(event) as RegistrationFormData;
console.log('[api/registration.post] Registration attempt for:', body.email);
// 1. Validate reCAPTCHA
if (!body.recaptcha_token) {
throw createError({
statusCode: 400,
statusMessage: 'reCAPTCHA verification is required'
});
}
const recaptchaValid = await validateRecaptcha(body.recaptcha_token);
if (!recaptchaValid) {
throw createError({
statusCode: 400,
statusMessage: 'reCAPTCHA verification failed'
});
}
// 2. Validate form data
const validationErrors = validateRegistrationForm(body);
if (validationErrors.length > 0) {
throw createError({
statusCode: 400,
statusMessage: validationErrors.join(', ')
});
}
// 3. Check for existing user in Keycloak
const keycloakAdmin = createKeycloakAdminClient();
const existingUsers = await keycloakAdmin.findUserByEmail(body.email);
if (existingUsers.length > 0) {
throw createError({
statusCode: 409,
statusMessage: 'An account with this email already exists'
});
}
// 4. Check for existing member record
const nocodb = createNocoDBClient();
const existingMembers = await nocodb.findAll('members', {
where: { email: body.email }
});
if (existingMembers.length > 0) {
throw createError({
statusCode: 409,
statusMessage: 'A member with this email already exists'
});
}
// 5. Create Keycloak user with role-based registration
console.log('[api/registration.post] Creating Keycloak user with role-based system...');
const membershipData = {
membershipStatus: 'Active',
duesStatus: 'unpaid' as const,
nationality: body.nationality,
phone: body.phone,
address: body.address,
registrationDate: new Date().toISOString(),
paymentDueDate: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(),
membershipTier: 'user' as const
};
createdKeycloakId = await keycloakAdmin.createUserWithRoleRegistration({
email: body.email,
firstName: body.first_name,
lastName: body.last_name,
membershipTier: 'user', // All public registrations default to 'user' role
membershipData
});
// 6. Create member record
console.log('[api/registration.post] Creating member record...');
const memberData = {
first_name: body.first_name,
last_name: body.last_name,
email: body.email,
phone: body.phone,
date_of_birth: body.date_of_birth,
address: body.address,
nationality: body.nationality,
keycloak_id: createdKeycloakId,
current_year_dues_paid: 'false',
membership_status: 'Active',
registration_date: new Date().toISOString(),
member_since: new Date().getFullYear().toString(),
membership_date_paid: '',
payment_due_date: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString() // 3 months from now
};
const member = await nocodb.create('members', memberData);
createdMemberId = member.Id;
console.log(`[api/registration.post] ✅ Registration successful - Member ID: ${createdMemberId}, Keycloak ID: ${createdKeycloakId}`);
return {
success: true,
message: 'Registration successful! Please check your email to verify your account and set your password.',
data: {
memberId: createdMemberId,
email: body.email
}
};
} catch (error: any) {
console.error('[api/registration.post] ❌ Registration failed:', error);
// Rollback operations if needed
if (createdKeycloakId || createdMemberId) {
console.log('[api/registration.post] Rolling back partial registration...');
// Delete Keycloak user if created
if (createdKeycloakId) {
try {
const keycloakAdmin = createKeycloakAdminClient();
await keycloakAdmin.deleteUser(createdKeycloakId);
console.log('[api/registration.post] Keycloak user rolled back');
} catch (rollbackError) {
console.error('[api/registration.post] Failed to rollback Keycloak user:', rollbackError);
}
}
// Delete member record if created
if (createdMemberId) {
try {
const nocodb = createNocoDBClient();
await nocodb.delete('members', createdMemberId);
console.log('[api/registration.post] Member record rolled back');
} catch (rollbackError) {
console.error('[api/registration.post] Failed to rollback member record:', rollbackError);
}
}
}
throw error;
}
});
/**
* Validate reCAPTCHA token
*/
async function validateRecaptcha(token: string): Promise<boolean> {
try {
const { getRecaptchaConfig } = await import('~/server/utils/admin-config');
const recaptchaConfig = getRecaptchaConfig();
if (!recaptchaConfig.secretKey) {
console.warn('[api/registration.post] reCAPTCHA secret key not configured');
return false;
}
const response = await fetch('https://www.google.com/recaptcha/api/siteverify', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
secret: recaptchaConfig.secretKey,
response: token
})
});
const result = await response.json();
return result.success === true;
} catch (error) {
console.error('[api/registration.post] reCAPTCHA validation error:', error);
return false;
}
}
/**
* Validate registration form data
*/
function validateRegistrationForm(data: RegistrationFormData): string[] {
const errors: string[] = [];
// Required fields
if (!data.first_name?.trim()) errors.push('First name is required');
if (!data.last_name?.trim()) errors.push('Last name is required');
if (!data.email?.trim()) errors.push('Email is required');
if (!data.phone?.trim()) errors.push('Phone number is required');
if (!data.date_of_birth?.trim()) errors.push('Date of birth is required');
if (!data.address?.trim()) errors.push('Address is required');
if (!data.nationality?.trim()) errors.push('Nationality is required');
// Email format validation
if (data.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
errors.push('Invalid email format');
}
// Date of birth validation (must be at least 18 years old)
if (data.date_of_birth) {
const birthDate = new Date(data.date_of_birth);
const today = new Date();
const age = today.getFullYear() - birthDate.getFullYear();
const monthDiff = today.getMonth() - birthDate.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
// Haven't had birthday this year yet
if (age - 1 < 18) {
errors.push('You must be at least 18 years old to register');
}
} else {
if (age < 18) {
errors.push('You must be at least 18 years old to register');
}
}
}
// Name length validation
if (data.first_name && data.first_name.trim().length < 2) {
errors.push('First name must be at least 2 characters');
}
if (data.last_name && data.last_name.trim().length < 2) {
errors.push('Last name must be at least 2 characters');
}
return errors;
}