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

- 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:
Matt 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,669 @@
<template>
<v-dialog
:model-value="modelValue"
@update:model-value="$emit('update:model-value', $event)"
max-width="800"
persistent
scrollable
>
<v-card>
<v-card-title class="d-flex align-center pa-6 bg-primary">
<v-icon class="mr-3 text-white">mdi-cog</v-icon>
<h2 class="text-h5 text-white font-weight-bold flex-grow-1">
Admin Configuration
</h2>
<v-btn
icon
variant="text"
color="white"
@click="closeDialog"
>
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-tabs v-model="activeTab" class="border-b">
<v-tab value="nocodb">
<v-icon start>mdi-database</v-icon>
NocoDB
</v-tab>
<v-tab value="recaptcha">
<v-icon start>mdi-shield-check</v-icon>
reCAPTCHA
</v-tab>
<v-tab value="registration">
<v-icon start>mdi-account-plus</v-icon>
Registration
</v-tab>
</v-tabs>
<v-card-text class="pa-6" style="min-height: 400px;">
<v-tabs-window v-model="activeTab">
<!-- NocoDB Configuration Tab -->
<v-tabs-window-item value="nocodb">
<v-alert
type="info"
variant="tonal"
class="mb-4"
>
<template #title>Database Configuration</template>
Configure the NocoDB database connection for the Member Management system.
</v-alert>
<v-form ref="nocodbFormRef" v-model="nocodbFormValid">
<v-row>
<v-col cols="12">
<v-text-field
v-model="nocodbForm.url"
label="NocoDB URL"
variant="outlined"
:rules="[rules.required, rules.url]"
required
placeholder="https://database.monacousa.org"
/>
</v-col>
<v-col cols="12">
<v-text-field
v-model="nocodbForm.apiKey"
label="API Token"
variant="outlined"
:rules="[rules.required]"
required
:type="showNocodbApiKey ? 'text' : 'password'"
:append-inner-icon="showNocodbApiKey ? 'mdi-eye' : 'mdi-eye-off'"
@click:append-inner="showNocodbApiKey = !showNocodbApiKey"
placeholder="Enter your NocoDB API token"
/>
</v-col>
<v-col cols="12">
<v-text-field
v-model="nocodbForm.baseId"
label="Base ID"
variant="outlined"
:rules="[rules.required]"
required
placeholder="your-base-id"
/>
</v-col>
<v-col cols="12">
<v-text-field
v-model="nocodbForm.tables.members"
label="Members Table ID"
variant="outlined"
:rules="[rules.required]"
required
placeholder="members-table-id"
/>
</v-col>
<v-col cols="12" md="6">
<v-btn
@click="testNocodbConnection"
:loading="nocodbTestLoading"
:disabled="!nocodbFormValid || nocodbLoading"
color="info"
variant="outlined"
block
>
<v-icon start>mdi-database-check</v-icon>
Test Connection
</v-btn>
</v-col>
<v-col cols="12" md="6">
<div class="d-flex align-center h-100">
<v-chip
v-if="nocodbConnectionStatus"
:color="nocodbConnectionStatus.success ? 'success' : 'error'"
variant="flat"
size="small"
>
<v-icon start size="14">
{{ nocodbConnectionStatus.success ? 'mdi-check-circle' : 'mdi-alert-circle' }}
</v-icon>
{{ nocodbConnectionStatus.message }}
</v-chip>
</div>
</v-col>
</v-row>
</v-form>
</v-tabs-window-item>
<!-- reCAPTCHA Configuration Tab -->
<v-tabs-window-item value="recaptcha">
<v-alert
type="warning"
variant="tonal"
class="mb-4"
>
<template #title>reCAPTCHA Integration</template>
Configure Google reCAPTCHA v2 for spam protection on the registration form.
Get your keys from <a href="https://www.google.com/recaptcha/admin" target="_blank">Google reCAPTCHA Admin</a>.
</v-alert>
<v-form ref="recaptchaFormRef" v-model="recaptchaFormValid">
<v-row>
<v-col cols="12">
<v-text-field
v-model="recaptchaForm.siteKey"
label="Site Key (Public)"
variant="outlined"
:rules="[rules.required]"
required
placeholder="6Lc..."
hint="This key is visible to users on the frontend"
persistent-hint
/>
</v-col>
<v-col cols="12">
<v-text-field
v-model="recaptchaForm.secretKey"
label="Secret Key (Private)"
variant="outlined"
:rules="[rules.required]"
required
:type="showRecaptchaSecret ? 'text' : 'password'"
:append-inner-icon="showRecaptchaSecret ? 'mdi-eye' : 'mdi-eye-off'"
@click:append-inner="showRecaptchaSecret = !showRecaptchaSecret"
placeholder="6Lc..."
hint="This key is kept secret on the server"
persistent-hint
/>
</v-col>
<v-col cols="12">
<v-alert
type="info"
variant="outlined"
class="mt-2"
>
<div class="text-body-2">
<strong>Setup Instructions:</strong>
<ol class="mt-2 ml-4">
<li>Visit <a href="https://www.google.com/recaptcha/admin" target="_blank">Google reCAPTCHA Admin</a></li>
<li>Create a new site with reCAPTCHA v2 "I'm not a robot" Checkbox</li>
<li>Add your domain to the domains list</li>
<li>Copy the Site Key and Secret Key here</li>
</ol>
</div>
</v-alert>
</v-col>
</v-row>
</v-form>
</v-tabs-window-item>
<!-- Registration Configuration Tab -->
<v-tabs-window-item value="registration">
<v-alert
type="success"
variant="tonal"
class="mb-4"
>
<template #title>Member Registration Settings</template>
Configure payment instructions that will be displayed to new members during registration.
</v-alert>
<v-form ref="registrationFormRef" v-model="registrationFormValid">
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model.number="registrationForm.membershipFee"
label="Annual Membership Fee (EUR)"
variant="outlined"
:rules="[rules.required, rules.positiveNumber]"
required
type="number"
min="1"
placeholder="50"
prefix="€"
/>
</v-col>
<v-col cols="12" md="6">
<!-- Spacer for alignment -->
</v-col>
<v-col cols="12">
<v-text-field
v-model="registrationForm.iban"
label="Bank IBAN"
variant="outlined"
:rules="[rules.required, rules.iban]"
required
placeholder="DE89 3704 0044 0532 0130 00"
hint="International Bank Account Number for membership dues"
persistent-hint
/>
</v-col>
<v-col cols="12">
<v-text-field
v-model="registrationForm.accountHolder"
label="Account Holder Name"
variant="outlined"
:rules="[rules.required]"
required
placeholder="MonacoUSA Association"
hint="Name on the bank account"
persistent-hint
/>
</v-col>
<v-col cols="12">
<v-card variant="outlined" class="pa-4 bg-grey-lighten-5">
<v-card-title class="text-h6 pb-2">
<v-icon left color="primary">mdi-eye</v-icon>
Preview
</v-card-title>
<v-card-text class="pt-2">
<div class="text-body-2 mb-2">
<strong>Payment Instructions as shown to new members:</strong>
</div>
<v-row dense>
<v-col cols="4" class="text-body-2 font-weight-bold">Amount:</v-col>
<v-col cols="8" class="text-body-2">{{ registrationForm.membershipFee || '0' }}/year</v-col>
</v-row>
<v-row dense v-if="registrationForm.iban">
<v-col cols="4" class="text-body-2 font-weight-bold">IBAN:</v-col>
<v-col cols="8" class="text-body-2 font-family-monospace">{{ registrationForm.iban }}</v-col>
</v-row>
<v-row dense v-if="registrationForm.accountHolder">
<v-col cols="4" class="text-body-2 font-weight-bold">Account:</v-col>
<v-col cols="8" class="text-body-2">{{ registrationForm.accountHolder }}</v-col>
</v-row>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-form>
</v-tabs-window-item>
</v-tabs-window>
<!-- Success Message -->
<v-alert
v-if="showSuccessMessage"
type="success"
variant="tonal"
class="mt-4"
closable
@click:close="showSuccessMessage = false"
>
{{ successMessage }}
</v-alert>
<!-- Error Message -->
<v-alert
v-if="errorMessage"
type="error"
variant="tonal"
class="mt-4"
closable
@click:close="errorMessage = ''"
>
{{ errorMessage }}
</v-alert>
</v-card-text>
<v-card-actions class="pa-6 pt-0">
<v-spacer />
<v-btn
variant="text"
@click="closeDialog"
:disabled="isLoading"
>
Cancel
</v-btn>
<v-btn
color="primary"
@click="saveCurrentTab"
:loading="isLoading"
:disabled="!isCurrentTabValid"
>
<v-icon start>mdi-content-save</v-icon>
Save {{ getCurrentTabName }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import type { NocoDBSettings, RecaptchaConfig, RegistrationConfig } from '~/utils/types';
interface Props {
modelValue: boolean;
}
interface Emits {
(e: 'update:model-value', value: boolean): void;
(e: 'settings-saved'): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
// Tab management
const activeTab = ref('nocodb');
// Form refs
const nocodbFormRef = ref();
const recaptchaFormRef = ref();
const registrationFormRef = ref();
// Form validity
const nocodbFormValid = ref(false);
const recaptchaFormValid = ref(false);
const registrationFormValid = ref(false);
// Loading states
const nocodbLoading = ref(false);
const recaptchaLoading = ref(false);
const registrationLoading = ref(false);
const nocodbTestLoading = ref(false);
// Display states
const showNocodbApiKey = ref(false);
const showRecaptchaSecret = ref(false);
const showSuccessMessage = ref(false);
const successMessage = ref('');
const errorMessage = ref('');
const nocodbConnectionStatus = ref<{ success: boolean; message: string } | null>(null);
// Form data
const nocodbForm = ref<NocoDBSettings>({
url: 'https://database.monacousa.org',
apiKey: '',
baseId: '',
tables: { members: '' }
});
const recaptchaForm = ref<RecaptchaConfig>({
siteKey: '',
secretKey: ''
});
const registrationForm = ref<RegistrationConfig>({
membershipFee: 50,
iban: '',
accountHolder: ''
});
// Validation rules
const rules = {
required: (value: string | number) => {
return (!!value && value.toString().trim() !== '') || 'This field is required';
},
url: (value: string) => {
if (!value) return true;
const pattern = /^https?:\/\/.+/;
return pattern.test(value) || 'Please enter a valid URL';
},
positiveNumber: (value: number) => {
return (value && value > 0) || 'Must be a positive number';
},
iban: (value: string) => {
if (!value) return true;
// Basic IBAN validation (length and format)
const cleaned = value.replace(/\s/g, '').toUpperCase();
return (cleaned.length >= 15 && cleaned.length <= 34 && /^[A-Z]{2}[0-9]{2}[A-Z0-9]+$/.test(cleaned)) || 'Please enter a valid IBAN';
}
};
// Computed properties
const isLoading = computed(() => {
return nocodbLoading.value || recaptchaLoading.value || registrationLoading.value;
});
const isCurrentTabValid = computed(() => {
switch (activeTab.value) {
case 'nocodb':
return nocodbFormValid.value;
case 'recaptcha':
return recaptchaFormValid.value;
case 'registration':
return registrationFormValid.value;
default:
return false;
}
});
const getCurrentTabName = computed(() => {
switch (activeTab.value) {
case 'nocodb':
return 'NocoDB Settings';
case 'recaptcha':
return 'reCAPTCHA Settings';
case 'registration':
return 'Registration Settings';
default:
return 'Settings';
}
});
// Load configurations
const loadConfigurations = async () => {
try {
// Load NocoDB config
const nocodbResponse = await $fetch<{ success: boolean; data?: NocoDBSettings }>('/api/admin/nocodb-config');
if (nocodbResponse.success && nocodbResponse.data) {
nocodbForm.value = { ...nocodbResponse.data };
if (!nocodbForm.value.tables) {
nocodbForm.value.tables = { members: '' };
}
}
// Load reCAPTCHA config
const recaptchaResponse = await $fetch<{ success: boolean; data?: RecaptchaConfig }>('/api/admin/recaptcha-config');
if (recaptchaResponse.success && recaptchaResponse.data) {
recaptchaForm.value = { ...recaptchaResponse.data };
}
// Load registration config
const registrationResponse = await $fetch<{ success: boolean; data?: RegistrationConfig }>('/api/admin/registration-config');
if (registrationResponse.success && registrationResponse.data) {
registrationForm.value = { ...registrationResponse.data };
}
} catch (error) {
console.error('Failed to load configurations:', error);
errorMessage.value = 'Failed to load current settings';
}
};
// Test NocoDB connection
const testNocodbConnection = async () => {
if (!nocodbFormRef.value) return;
const isValid = await nocodbFormRef.value.validate();
if (!isValid.valid) return;
nocodbTestLoading.value = true;
nocodbConnectionStatus.value = null;
try {
const response = await $fetch<{ success: boolean; message: string }>('/api/admin/nocodb-test', {
method: 'POST',
body: nocodbForm.value
});
nocodbConnectionStatus.value = {
success: response.success,
message: response.message || (response.success ? 'Connection successful' : 'Connection failed')
};
} catch (error: any) {
nocodbConnectionStatus.value = {
success: false,
message: error.message || 'Connection test failed'
};
} finally {
nocodbTestLoading.value = false;
}
};
// Save current tab
const saveCurrentTab = async () => {
let formRef, loading, data, endpoint;
switch (activeTab.value) {
case 'nocodb':
formRef = nocodbFormRef.value;
loading = nocodbLoading;
data = nocodbForm.value;
endpoint = '/api/admin/nocodb-config';
break;
case 'recaptcha':
formRef = recaptchaFormRef.value;
loading = recaptchaLoading;
data = recaptchaForm.value;
endpoint = '/api/admin/recaptcha-config';
break;
case 'registration':
formRef = registrationFormRef.value;
loading = registrationLoading;
data = registrationForm.value;
endpoint = '/api/admin/registration-config';
break;
default:
return;
}
if (!formRef) return;
const isValid = await formRef.validate();
if (!isValid.valid) return;
loading.value = true;
errorMessage.value = '';
try {
const response = await $fetch<{ success: boolean; message?: string }>(endpoint, {
method: 'POST',
body: data
});
if (response.success) {
successMessage.value = `${getCurrentTabName.value} saved successfully!`;
showSuccessMessage.value = true;
emit('settings-saved');
// Auto-hide success message
setTimeout(() => {
showSuccessMessage.value = false;
}, 3000);
} else {
throw new Error(response.message || 'Failed to save settings');
}
} catch (error: any) {
console.error(`Error saving ${getCurrentTabName.value}:`, error);
errorMessage.value = error.message || `Failed to save ${getCurrentTabName.value}. Please try again.`;
} finally {
loading.value = false;
}
};
// Dialog management
const closeDialog = () => {
emit('update:model-value', false);
};
const resetForms = () => {
// Reset form data to defaults
nocodbForm.value = {
url: 'https://database.monacousa.org',
apiKey: '',
baseId: '',
tables: { members: '' }
};
recaptchaForm.value = {
siteKey: '',
secretKey: ''
};
registrationForm.value = {
membershipFee: 50,
iban: '',
accountHolder: ''
};
// Clear status and messages
nocodbConnectionStatus.value = null;
errorMessage.value = '';
showSuccessMessage.value = false;
activeTab.value = 'nocodb';
// Reset form validation
nextTick(() => {
nocodbFormRef.value?.resetValidation();
recaptchaFormRef.value?.resetValidation();
registrationFormRef.value?.resetValidation();
});
};
// Watch for dialog open
watch(() => props.modelValue, async (newValue) => {
if (newValue) {
resetForms();
await loadConfigurations();
}
});
</script>
<style scoped>
.bg-primary {
background: linear-gradient(135deg, #a31515 0%, #d32f2f 100%) !important;
}
.border-b {
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
}
.v-card {
border-radius: 12px !important;
}
/* Tab styling */
.v-tabs :deep(.v-tab) {
text-transform: none;
font-weight: 500;
}
/* Form styling */
.v-card-text .v-row .v-col .v-alert {
border-radius: 8px;
}
/* Password field styling */
.v-text-field :deep(.v-input__append-inner) {
cursor: pointer;
}
/* Preview card styling */
.bg-grey-lighten-5 {
background-color: rgba(0, 0, 0, 0.04) !important;
}
/* Connection status styling */
.h-100 {
height: 100%;
}
/* Responsive adjustments */
@media (max-width: 600px) {
.v-card-title {
padding: 16px !important;
}
.v-card-text {
padding: 16px !important;
}
.v-card-actions {
padding: 16px !important;
padding-top: 0 !important;
}
}
</style>

View File

@ -0,0 +1,343 @@
<template>
<v-banner
v-if="showBanner"
color="warning"
icon="mdi-alert-circle"
sticky
class="dues-payment-banner"
>
<template #text>
<div class="banner-content">
<div class="text-h6 font-weight-bold mb-2">
<v-icon left>mdi-credit-card-alert</v-icon>
Membership Dues Payment Required
</div>
<div class="text-body-1 mb-3">
{{ paymentMessage }}
</div>
<v-card
class="payment-details-card pa-3"
color="rgba(255,255,255,0.1)"
variant="outlined"
>
<div class="text-subtitle-1 font-weight-bold mb-2">
<v-icon left size="small">mdi-bank</v-icon>
Payment Details
</div>
<v-row dense>
<v-col cols="12" sm="4" md="3">
<div class="text-caption font-weight-bold">Amount:</div>
<div class="text-body-2">{{ config.membershipFee }}/year</div>
</v-col>
<v-col cols="12" sm="8" md="5" v-if="config.iban">
<div class="text-caption font-weight-bold">IBAN:</div>
<div class="text-body-2 font-family-monospace">{{ config.iban }}</div>
</v-col>
<v-col cols="12" sm="12" md="4" v-if="config.accountHolder">
<div class="text-caption font-weight-bold">Account Holder:</div>
<div class="text-body-2">{{ config.accountHolder }}</div>
</v-col>
</v-row>
<v-divider class="my-2" />
<div class="text-caption d-flex align-center">
<v-icon size="small" class="mr-1">mdi-information-outline</v-icon>
{{ daysRemaining > 0 ? `${daysRemaining} days remaining` : 'Payment overdue' }}
before account suspension
</div>
</v-card>
</div>
</template>
<template #actions>
<v-btn
v-if="isAdmin"
color="white"
variant="outlined"
size="small"
@click="markAsPaidDialog = true"
class="mr-2"
>
<v-icon left size="small">mdi-check-circle</v-icon>
Mark as Paid
</v-btn>
<v-btn
color="white"
variant="text"
size="small"
@click="dismissBanner"
>
<v-icon left size="small">mdi-close</v-icon>
Dismiss
</v-btn>
</template>
</v-banner>
<!-- Mark as Paid Dialog -->
<v-dialog v-model="markAsPaidDialog" max-width="400">
<v-card>
<v-card-title class="text-h6">
<v-icon left color="success">mdi-check-circle</v-icon>
Mark Dues as Paid
</v-card-title>
<v-card-text>
<p>Are you sure you want to mark the dues as paid for this member?</p>
<p class="text-body-2 text-medium-emphasis">
This will remove the payment banner and update the member's status.
</p>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
color="grey"
variant="text"
@click="markAsPaidDialog = false"
>
Cancel
</v-btn>
<v-btn
color="success"
variant="flat"
:loading="updating"
@click="markDuesAsPaid"
>
Mark as Paid
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Snackbar for notifications -->
<v-snackbar
v-model="snackbar.show"
:color="snackbar.color"
:timeout="4000"
>
{{ snackbar.message }}
<template #actions>
<v-btn
variant="text"
@click="snackbar.show = false"
>
Close
</v-btn>
</template>
</v-snackbar>
</template>
<script setup lang="ts">
import type { RegistrationConfig, Member } from '~/utils/types';
// Get auth state
const { user, isAdmin } = useAuth();
// Reactive state
const showBanner = ref(false);
const dismissed = ref(false);
const markAsPaidDialog = ref(false);
const updating = ref(false);
const memberData = ref<Member | null>(null);
const config = ref<RegistrationConfig>({
membershipFee: 50,
iban: '',
accountHolder: ''
});
const snackbar = ref({
show: false,
message: '',
color: 'success'
});
// Computed properties
const shouldShowBanner = computed(() => {
if (!user.value || !memberData.value) return false;
if (dismissed.value) return false;
// Show banner if member exists and has unpaid dues
return memberData.value.current_year_dues_paid === 'false';
});
const daysRemaining = computed(() => {
if (!memberData.value?.payment_due_date) return 0;
const dueDate = new Date(memberData.value.payment_due_date);
const today = new Date();
const diffTime = dueDate.getTime() - today.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return Math.max(0, diffDays);
});
const paymentMessage = computed(() => {
if (daysRemaining.value > 30) {
return `Your annual membership dues of €${config.value.membershipFee} are due in ${daysRemaining.value} days.`;
} else if (daysRemaining.value > 0) {
return `Your annual membership dues of €${config.value.membershipFee} are due in ${daysRemaining.value} days. Please pay soon to avoid account suspension.`;
} else {
return `Your annual membership dues of €${config.value.membershipFee} are overdue. Your account may be suspended soon.`;
}
});
// Methods
function dismissBanner() {
dismissed.value = true;
showBanner.value = false;
// Store dismissal in localStorage (expires after 24 hours)
const dismissalData = {
timestamp: Date.now(),
userId: user.value?.id
};
localStorage.setItem('dues-banner-dismissed', JSON.stringify(dismissalData));
}
async function markDuesAsPaid() {
if (!memberData.value?.Id) return;
updating.value = true;
try {
// Update member's dues status
await $fetch(`/api/members/${memberData.value.Id}`, {
method: 'PUT',
body: {
current_year_dues_paid: 'true',
membership_date_paid: new Date().toISOString(),
payment_due_date: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString() // Next year
}
});
// Update local member state
if (memberData.value) {
memberData.value.current_year_dues_paid = 'true';
memberData.value.membership_date_paid = new Date().toISOString();
}
// Hide banner
showBanner.value = false;
markAsPaidDialog.value = false;
// Show success message
snackbar.value = {
show: true,
message: 'Dues marked as paid successfully!',
color: 'success'
};
} catch (error: any) {
console.error('Failed to mark dues as paid:', error);
snackbar.value = {
show: true,
message: 'Failed to update payment status. Please try again.',
color: 'error'
};
} finally {
updating.value = false;
}
}
// Load member data for the current user
async function loadMemberData() {
if (!user.value?.email) return;
try {
const response = await $fetch('/api/members') as any;
const members = response?.data || response?.list || [];
// Find member by email
const member = members.find((m: any) => m.email === user.value?.email);
if (member) {
memberData.value = member;
}
} catch (error) {
console.warn('Failed to load member data:', error);
}
}
// Load configuration and check banner visibility
async function loadConfig() {
try {
const response = await $fetch('/api/admin/registration-config') as any;
if (response?.success) {
config.value = response.data;
}
} catch (error) {
console.warn('Failed to load registration config:', error);
}
}
// Check if banner was recently dismissed
function checkDismissalStatus() {
try {
const stored = localStorage.getItem('dues-banner-dismissed');
if (stored) {
const dismissalData = JSON.parse(stored);
const hoursSinceDismissal = (Date.now() - dismissalData.timestamp) / (1000 * 60 * 60);
// Reset dismissal after 24 hours or if different user
if (hoursSinceDismissal > 24 || dismissalData.userId !== user.value?.id) {
localStorage.removeItem('dues-banner-dismissed');
dismissed.value = false;
} else {
dismissed.value = true;
}
}
} catch (error) {
console.warn('Failed to check dismissal status:', error);
dismissed.value = false;
}
}
// Watchers
watch(shouldShowBanner, (newVal) => {
showBanner.value = newVal;
}, { immediate: true });
watch(user, () => {
checkDismissalStatus();
loadMemberData();
}, { immediate: true });
// Initialize
onMounted(() => {
loadConfig();
checkDismissalStatus();
loadMemberData();
});
</script>
<style scoped>
.dues-payment-banner {
border-left: 4px solid #ff9800;
}
.banner-content {
width: 100%;
}
.payment-details-card {
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2) !important;
}
/* Mobile responsiveness */
@media (max-width: 600px) {
.banner-content .text-h6 {
font-size: 1.1rem !important;
}
.payment-details-card {
margin-top: 8px;
}
}
</style>

View File

@ -146,6 +146,45 @@
{{ isOverdue ? 'Overdue' : `Due ${formatDate(member.payment_due_date)}` }}
</v-chip>
</div>
<!-- Portal Account Status -->
<div class="portal-status mt-3">
<v-chip
v-if="member.keycloak_id"
color="success"
variant="tonal"
size="small"
class="mr-2"
>
<v-icon start size="14">mdi-account-check</v-icon>
Portal Account Active
</v-chip>
<v-chip
v-else
color="grey"
variant="tonal"
size="small"
class="mr-2"
>
<v-icon start size="14">mdi-account-off</v-icon>
No Portal Account
</v-chip>
<!-- Create Portal Account Button -->
<v-btn
v-if="!member.keycloak_id && canCreatePortalAccount"
color="primary"
variant="outlined"
size="small"
:loading="creatingPortalAccount"
@click.stop="$emit('create-portal-account', member)"
class="create-portal-btn"
>
<v-icon start size="14">mdi-account-plus</v-icon>
Create Portal Account
</v-btn>
</div>
</v-card-text>
<!-- Click overlay for better UX -->
@ -161,17 +200,22 @@ interface Props {
member: Member;
canEdit?: boolean;
canDelete?: boolean;
canCreatePortalAccount?: boolean;
creatingPortalAccount?: boolean;
}
interface Emits {
(e: 'view', member: Member): void;
(e: 'edit', member: Member): void;
(e: 'delete', member: Member): void;
(e: 'create-portal-account', member: Member): void;
}
const props = withDefaults(defineProps<Props>(), {
canEdit: false,
canDelete: false
canDelete: false,
canCreatePortalAccount: false,
creatingPortalAccount: false
});
defineEmits<Emits>();

View File

@ -7,29 +7,131 @@ export const useAuth = () => {
const loading = ref(false);
const error = ref<string | null>(null);
// Tier-based computed properties
const userTier = computed(() => user.value?.tier || 'user');
const isUser = computed(() => user.value?.tier === 'user');
const isBoard = computed(() => user.value?.tier === 'board');
const isAdmin = computed(() => user.value?.tier === 'admin');
// Enhanced role checking method - supports both realm roles and legacy groups
const hasRole = (roleName: string): boolean => {
if (!user.value) return false;
// Get roles from user token (Keycloak format)
const userToken = user.value as any; // Cast for accessing token properties
// Check realm roles first (new system)
const realmRoles = userToken.realm_access?.roles || [];
if (realmRoles.includes(roleName)) {
return true;
}
// Check client roles (new system)
const clientRoles = userToken.resource_access || {};
for (const clientId in clientRoles) {
const roles = clientRoles[clientId]?.roles || [];
if (roles.includes(roleName)) {
return true;
}
}
// Fallback to legacy group system
const groups = user.value.groups || [];
return groups.includes(roleName) || groups.includes(`/${roleName}`);
};
// Enhanced tier-based computed properties with role support
const isUser = computed(() => {
// Check new realm roles first
if (hasRole('monaco-user')) return true;
// Fallback to legacy tier system
return user.value?.tier === 'user';
});
const isBoard = computed(() => {
// Check new realm roles first
if (hasRole('monaco-board')) return true;
// Fallback to legacy tier system
return user.value?.tier === 'board';
});
const isAdmin = computed(() => {
// Check new realm roles first
if (hasRole('monaco-admin')) return true;
// Fallback to legacy tier system
return user.value?.tier === 'admin';
});
// Enhanced tier computation with role priority
const userTier = computed(() => {
if (hasRole('monaco-admin')) return 'admin';
if (hasRole('monaco-board')) return 'board';
if (hasRole('monaco-user')) return 'user';
// Fallback to legacy tier system
return user.value?.tier || 'user';
});
const firstName = computed(() => {
if (user.value?.firstName) return user.value.firstName;
if (user.value?.name) return user.value.name.split(' ')[0];
return 'User';
});
// Helper methods
// Enhanced helper methods
const hasTier = (requiredTier: 'user' | 'board' | 'admin') => {
return user.value?.tier === requiredTier;
// Use computed userTier which handles both new and legacy systems
return userTier.value === requiredTier;
};
const hasGroup = (groupName: string) => {
return user.value?.groups?.includes(groupName) || false;
};
// Legacy compatibility
const hasRole = (role: string) => {
return hasGroup(role);
// New helper methods for realm roles
const hasRealmRole = (roleName: string): boolean => {
if (!user.value) return false;
const userToken = user.value as any;
const realmRoles = userToken.realm_access?.roles || [];
return realmRoles.includes(roleName);
};
const hasClientRole = (roleName: string, clientId?: string): boolean => {
if (!user.value) return false;
const userToken = user.value as any;
const clientRoles = userToken.resource_access || {};
if (clientId) {
// Check specific client
const roles = clientRoles[clientId]?.roles || [];
return roles.includes(roleName);
} else {
// Check all clients
for (const cId in clientRoles) {
const roles = clientRoles[cId]?.roles || [];
if (roles.includes(roleName)) {
return true;
}
}
return false;
}
};
// Get all user roles (combines realm and client roles)
const getAllRoles = (): string[] => {
if (!user.value) return [];
const userToken = user.value as any;
const roles: string[] = [];
// Add realm roles
const realmRoles = userToken.realm_access?.roles || [];
roles.push(...realmRoles);
// Add client roles
const clientRoles = userToken.resource_access || {};
for (const clientId in clientRoles) {
const clientRolesList = clientRoles[clientId]?.roles || [];
roles.push(...clientRolesList);
}
// Add legacy groups for compatibility
const groups = user.value.groups || [];
roles.push(...groups);
return [...new Set(roles)]; // Remove duplicates
};
// Direct login method
@ -205,7 +307,10 @@ export const useAuth = () => {
// Helper methods
hasTier,
hasGroup,
hasRole, // Legacy compatibility
hasRole, // Enhanced with realm role support
hasRealmRole,
hasClientRole,
getAllRoles,
// Actions
login,

View File

@ -159,6 +159,9 @@
</v-app-bar>
<v-main>
<!-- Dues Payment Banner -->
<DuesPaymentBanner />
<v-container fluid class="pa-0">
<slot />
</v-container>

374
pages/signup.vue Normal file
View File

@ -0,0 +1,374 @@
<template>
<div class="fill-height">
<v-container class="fill-height" fluid>
<v-row align="center" justify="center">
<v-col cols="12" sm="8" md="6" lg="4">
<v-card class="elevation-12" :loading="loading">
<v-toolbar color="primary" dark flat>
<v-toolbar-title class="text-h5 font-weight-bold">
<v-icon left>mdi-account-plus</v-icon>
Join MonacoUSA
</v-toolbar-title>
</v-toolbar>
<v-card-text class="py-6">
<div class="text-body-1 text-center mb-6">
Register as a member of MonacoUSA Association
</div>
<v-form ref="form" v-model="valid" @submit.prevent="submitRegistration">
<v-row>
<v-col cols="12" sm="6">
<v-text-field
v-model="form.first_name"
label="First Name"
:rules="nameRules"
prepend-icon="mdi-account"
variant="outlined"
required
/>
</v-col>
<v-col cols="12" sm="6">
<v-text-field
v-model="form.last_name"
label="Last Name"
:rules="nameRules"
variant="outlined"
required
/>
</v-col>
</v-row>
<v-text-field
v-model="form.email"
label="Email Address"
type="email"
:rules="emailRules"
prepend-icon="mdi-email"
variant="outlined"
required
/>
<PhoneInputWrapper
v-model="form.phone"
label="Phone Number"
:rules="phoneRules"
required
/>
<v-text-field
v-model="form.date_of_birth"
label="Date of Birth"
type="date"
:rules="dobRules"
prepend-icon="mdi-calendar"
variant="outlined"
required
/>
<v-textarea
v-model="form.address"
label="Address"
:rules="addressRules"
prepend-icon="mdi-map-marker"
variant="outlined"
rows="3"
required
/>
<MultipleNationalityInput
v-model="form.nationality"
label="Nationality"
:rules="nationalityRules"
required
/>
<!-- reCAPTCHA -->
<div class="d-flex justify-center my-4">
<vue-recaptcha
v-if="recaptchaConfig.siteKey"
ref="recaptcha"
:sitekey="recaptchaConfig.siteKey"
@verify="onRecaptchaVerified"
@expired="onRecaptchaExpired"
/>
<v-alert
v-else
type="warning"
variant="outlined"
class="text-body-2"
>
reCAPTCHA not configured. Please contact administrator.
</v-alert>
</div>
<v-btn
type="submit"
color="primary"
size="large"
block
:disabled="!valid || !recaptchaToken"
:loading="loading"
class="mt-4"
>
<v-icon left>mdi-account-plus</v-icon>
Register
</v-btn>
</v-form>
</v-card-text>
<!-- Payment Information -->
<v-divider />
<v-card-text>
<v-card
class="pa-4"
color="blue-grey-lighten-5"
variant="outlined"
>
<v-card-title class="text-h6 pb-2">
<v-icon left color="primary">mdi-bank</v-icon>
Membership Dues Payment
</v-card-title>
<v-card-text class="pt-2">
<div class="text-body-1 mb-3">
After registration, please transfer your annual membership dues:
</div>
<v-row dense>
<v-col cols="4" class="text-body-2 font-weight-bold">Amount:</v-col>
<v-col cols="8" class="text-body-2">{{ registrationConfig.membershipFee }}/year</v-col>
</v-row>
<v-row dense v-if="registrationConfig.iban">
<v-col cols="4" class="text-body-2 font-weight-bold">IBAN:</v-col>
<v-col cols="8" class="text-body-2 font-family-monospace">{{ registrationConfig.iban }}</v-col>
</v-row>
<v-row dense v-if="registrationConfig.accountHolder">
<v-col cols="4" class="text-body-2 font-weight-bold">Account:</v-col>
<v-col cols="8" class="text-body-2">{{ registrationConfig.accountHolder }}</v-col>
</v-row>
<v-divider class="my-3" />
<div class="text-body-2 text-medium-emphasis">
<v-icon size="small" class="mr-1">mdi-information</v-icon>
Your account will be activated once payment is verified by our administrators.
</div>
</v-card-text>
</v-card>
</v-card-text>
</v-card>
<!-- Success/Error Messages -->
<v-alert
v-if="successMessage"
type="success"
class="mt-4"
variant="tonal"
>
<v-alert-title>Registration Successful!</v-alert-title>
{{ successMessage }}
</v-alert>
<v-alert
v-if="errorMessage"
type="error"
class="mt-4"
variant="tonal"
>
<v-alert-title>Registration Failed</v-alert-title>
{{ errorMessage }}
</v-alert>
<!-- Back to Login Link -->
<div class="text-center mt-6">
<v-btn
variant="text"
color="primary"
to="/login"
>
<v-icon left>mdi-arrow-left</v-icon>
Back to Login
</v-btn>
</div>
</v-col>
</v-row>
</v-container>
</div>
</template>
<script setup lang="ts">
import type { RegistrationFormData, RecaptchaConfig, RegistrationConfig } from '~/utils/types';
// Page metadata
definePageMeta({
layout: false,
middleware: 'guest'
});
// Head configuration
useHead({
title: 'Register - MonacoUSA Portal',
meta: [
{ name: 'description', content: 'Register to become a member of MonacoUSA Association' },
{ name: 'robots', content: 'noindex, nofollow' }
]
});
// Reactive data
const form = ref<Omit<RegistrationFormData, 'recaptcha_token'>>({
first_name: '',
last_name: '',
email: '',
phone: '',
date_of_birth: '',
address: '',
nationality: ''
});
const valid = ref(false);
const loading = ref(false);
const recaptchaToken = ref('');
const successMessage = ref('');
const errorMessage = ref('');
// Configs
const recaptchaConfig = ref<RecaptchaConfig>({ siteKey: '', secretKey: '' });
const registrationConfig = ref<RegistrationConfig>({
membershipFee: 50,
iban: '',
accountHolder: ''
});
// Form validation rules
const nameRules = [
(v: string) => !!v || 'Name is required',
(v: string) => v.length >= 2 || 'Name must be at least 2 characters'
];
const emailRules = [
(v: string) => !!v || 'Email is required',
(v: string) => /.+@.+\..+/.test(v) || 'Email must be valid'
];
const phoneRules = [
(v: string) => !!v || 'Phone number is required'
];
const dobRules = [
(v: string) => !!v || 'Date of birth is required',
(v: string) => {
if (!v) return true;
const birthDate = new Date(v);
const today = new Date();
const age = today.getFullYear() - birthDate.getFullYear();
const monthDiff = today.getMonth() - birthDate.getMonth();
let actualAge = age;
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
actualAge = age - 1;
}
return actualAge >= 18 || 'You must be at least 18 years old';
}
];
const addressRules = [
(v: string) => !!v || 'Address is required',
(v: string) => v.length >= 10 || 'Please provide a complete address'
];
const nationalityRules = [
(v: string) => !!v || 'Nationality is required'
];
// reCAPTCHA handling
function onRecaptchaVerified(token: string) {
recaptchaToken.value = token;
}
function onRecaptchaExpired() {
recaptchaToken.value = '';
}
// Template refs
const recaptcha = ref<any>(null);
// Form submission
async function submitRegistration() {
if (!valid.value || !recaptchaToken.value) {
return;
}
loading.value = true;
errorMessage.value = '';
successMessage.value = '';
try {
const registrationData: RegistrationFormData = {
...form.value,
recaptcha_token: recaptchaToken.value
};
const response = await $fetch('/api/registration', {
method: 'POST',
body: registrationData
}) as any;
if (response?.success) {
successMessage.value = response.message || 'Registration successful!';
// Reset form
form.value = {
first_name: '',
last_name: '',
email: '',
phone: '',
date_of_birth: '',
address: '',
nationality: ''
};
recaptchaToken.value = '';
// Reset reCAPTCHA
recaptcha.value?.reset();
}
} catch (error: any) {
console.error('Registration failed:', error);
errorMessage.value = error.data?.message || error.message || 'Registration failed. Please try again.';
// Reset reCAPTCHA on error
recaptchaToken.value = '';
recaptcha.value?.reset();
} finally {
loading.value = false;
}
}
// Load configurations on mount
onMounted(async () => {
try {
// Load reCAPTCHA config (public endpoint)
const recaptchaResponse = await $fetch('/api/admin/recaptcha-config') as any;
if (recaptchaResponse?.success && recaptchaResponse?.data?.siteKey) {
recaptchaConfig.value.siteKey = recaptchaResponse.data.siteKey;
}
// Load registration config (public endpoint)
const registrationResponse = await $fetch('/api/admin/registration-config') as any;
if (registrationResponse?.success) {
registrationConfig.value = registrationResponse.data;
}
} catch (error) {
console.warn('Failed to load configuration:', error);
// Page will still work with default values
}
});
</script>
<style scoped>
.fill-height {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
</style>

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;
}

View File

@ -6,6 +6,15 @@ import type { NocoDBSettings } from '~/utils/types';
interface AdminConfiguration {
nocodb: NocoDBSettings;
recaptcha?: {
siteKey: string;
secretKey: string; // Will be encrypted
};
registration?: {
membershipFee: number;
iban: string;
accountHolder: string;
};
lastUpdated: string;
updatedBy: string;
}
@ -177,6 +186,9 @@ export async function loadAdminConfig(): Promise<AdminConfiguration | null> {
if (config.nocodb.apiKey) {
config.nocodb.apiKey = decryptSensitiveData(config.nocodb.apiKey);
}
if (config.recaptcha?.secretKey) {
config.recaptcha.secretKey = decryptSensitiveData(config.recaptcha.secretKey);
}
console.log('[admin-config] Configuration loaded from file');
configCache = config;
@ -295,6 +307,108 @@ export async function getCurrentConfig(): Promise<NocoDBSettings> {
};
}
/**
* Save reCAPTCHA configuration
*/
export async function saveRecaptchaConfig(config: { siteKey: string; secretKey: string }, updatedBy: string): Promise<void> {
try {
await ensureConfigDir();
await createBackup();
const currentConfig = configCache || await loadAdminConfig() || {
nocodb: { url: '', apiKey: '', baseId: '', tables: {} },
lastUpdated: new Date().toISOString(),
updatedBy: 'system'
};
const updatedConfig: AdminConfiguration = {
...currentConfig,
recaptcha: {
siteKey: config.siteKey,
secretKey: encryptSensitiveData(config.secretKey)
},
lastUpdated: new Date().toISOString(),
updatedBy
};
const configJson = JSON.stringify(updatedConfig, null, 2);
await writeFile(CONFIG_FILE, configJson, 'utf-8');
// Update cache with unencrypted data
configCache = {
...updatedConfig,
recaptcha: {
siteKey: config.siteKey,
secretKey: config.secretKey
}
};
console.log('[admin-config] reCAPTCHA configuration saved');
await logConfigChange('RECAPTCHA_CONFIG_SAVED', updatedBy, { siteKey: config.siteKey });
} catch (error) {
console.error('[admin-config] Failed to save reCAPTCHA configuration:', error);
await logConfigChange('RECAPTCHA_CONFIG_SAVE_FAILED', updatedBy, { error: error instanceof Error ? error.message : String(error) });
throw error;
}
}
/**
* Save registration configuration
*/
export async function saveRegistrationConfig(config: { membershipFee: number; iban: string; accountHolder: string }, updatedBy: string): Promise<void> {
try {
await ensureConfigDir();
await createBackup();
const currentConfig = configCache || await loadAdminConfig() || {
nocodb: { url: '', apiKey: '', baseId: '', tables: {} },
lastUpdated: new Date().toISOString(),
updatedBy: 'system'
};
const updatedConfig: AdminConfiguration = {
...currentConfig,
registration: config,
lastUpdated: new Date().toISOString(),
updatedBy
};
const configJson = JSON.stringify(updatedConfig, null, 2);
await writeFile(CONFIG_FILE, configJson, 'utf-8');
configCache = updatedConfig;
console.log('[admin-config] Registration configuration saved');
await logConfigChange('REGISTRATION_CONFIG_SAVED', updatedBy, { membershipFee: config.membershipFee });
} catch (error) {
console.error('[admin-config] Failed to save registration configuration:', error);
await logConfigChange('REGISTRATION_CONFIG_SAVE_FAILED', updatedBy, { error: error instanceof Error ? error.message : String(error) });
throw error;
}
}
/**
* Get reCAPTCHA configuration
*/
export function getRecaptchaConfig(): { siteKey: string; secretKey: string } {
const config = configCache?.recaptcha || { siteKey: '', secretKey: '' };
return config;
}
/**
* Get registration configuration
*/
export function getRegistrationConfig(): { membershipFee: number; iban: string; accountHolder: string } {
const config = configCache?.registration || {
membershipFee: 50,
iban: '',
accountHolder: ''
};
return config;
}
/**
* Initialize configuration system on server startup
*/

View File

@ -1,4 +1,12 @@
import type { KeycloakAdminConfig } from '~/utils/types';
import type {
KeycloakAdminConfig,
KeycloakUserRepresentation,
KeycloakRoleRepresentation,
KeycloakGroupRepresentation,
UserSessionRepresentation,
EmailWorkflowData,
MembershipProfileData
} from '~/utils/types';
export class KeycloakAdminClient {
private config: KeycloakAdminConfig;
@ -36,12 +44,13 @@ export class KeycloakAdminClient {
/**
* Find a user by email address
*/
async findUserByEmail(email: string, adminToken: string): Promise<any[]> {
async findUserByEmail(email: string, adminToken?: string): Promise<any[]> {
const token = adminToken || await this.getAdminToken();
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
const response = await fetch(`${adminBaseUrl}/users?email=${encodeURIComponent(email)}&exact=true`, {
headers: {
'Authorization': `Bearer ${adminToken}`,
'Authorization': `Bearer ${token}`,
'User-Agent': 'MonacoUSA-Portal/1.0'
}
});
@ -54,6 +63,675 @@ export class KeycloakAdminClient {
return response.json();
}
/**
* Create a new user with temporary password and email verification
*/
async createUserWithRegistration(userData: {
email: string;
firstName: string;
lastName: string;
username?: string;
}): Promise<string> {
const adminToken = await this.getAdminToken();
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
// Check if user already exists
const existingUsers = await this.findUserByEmail(userData.email, adminToken);
if (existingUsers.length > 0) {
throw new Error('User with this email already exists');
}
const response = await fetch(`${adminBaseUrl}/users`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${adminToken}`,
'Content-Type': 'application/json',
'User-Agent': 'MonacoUSA-Portal/1.0'
},
body: JSON.stringify({
email: userData.email,
username: userData.username || userData.email,
firstName: userData.firstName,
lastName: userData.lastName,
enabled: true,
emailVerified: false,
groups: ['/users'], // Default to 'user' tier group
attributes: {
tier: ['user']
},
requiredActions: ['VERIFY_EMAIL', 'UPDATE_PASSWORD']
})
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`Failed to create user: ${response.status} - ${errorText}`);
}
// Extract user ID from Location header
const locationHeader = response.headers.get('location');
if (!locationHeader) {
throw new Error('User created but failed to get user ID');
}
const userId = locationHeader.split('/').pop();
if (!userId) {
throw new Error('Failed to extract user ID from response');
}
console.log(`[keycloak-admin] Created user ${userData.email} with ID: ${userId}`);
return userId;
}
/**
* Delete a user by ID
*/
async deleteUser(userId: string): Promise<void> {
const adminToken = await this.getAdminToken();
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
const response = await fetch(`${adminBaseUrl}/users/${userId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${adminToken}`,
'User-Agent': 'MonacoUSA-Portal/1.0'
}
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`Failed to delete user: ${response.status} - ${errorText}`);
}
console.log(`[keycloak-admin] Deleted user with ID: ${userId}`);
}
// ============================================================================
// ROLE MANAGEMENT METHODS
// ============================================================================
/**
* Create a new realm role
*/
async createRealmRole(roleName: string, description: string): Promise<void> {
const adminToken = await this.getAdminToken();
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
const response = await fetch(`${adminBaseUrl}/roles`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${adminToken}`,
'Content-Type': 'application/json',
'User-Agent': 'MonacoUSA-Portal/1.0'
},
body: JSON.stringify({
name: roleName,
description: description,
composite: false,
clientRole: false
})
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`Failed to create realm role: ${response.status} - ${errorText}`);
}
console.log(`[keycloak-admin] Created realm role: ${roleName}`);
}
/**
* Get a realm role by name
*/
async getRealmRole(roleName: string, adminToken?: string): Promise<KeycloakRoleRepresentation> {
const token = adminToken || await this.getAdminToken();
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
const response = await fetch(`${adminBaseUrl}/roles/${roleName}`, {
headers: {
'Authorization': `Bearer ${token}`,
'User-Agent': 'MonacoUSA-Portal/1.0'
}
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`Failed to get realm role: ${response.status} - ${errorText}`);
}
return response.json();
}
/**
* Get all realm roles
*/
async getAllRealmRoles(adminToken?: string): Promise<KeycloakRoleRepresentation[]> {
const token = adminToken || await this.getAdminToken();
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
const response = await fetch(`${adminBaseUrl}/roles`, {
headers: {
'Authorization': `Bearer ${token}`,
'User-Agent': 'MonacoUSA-Portal/1.0'
}
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`Failed to get realm roles: ${response.status} - ${errorText}`);
}
return response.json();
}
/**
* Assign a realm role to a user
*/
async assignRealmRoleToUser(userId: string, roleName: string): Promise<void> {
const adminToken = await this.getAdminToken();
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
// First get the role
const role = await this.getRealmRole(roleName, adminToken);
// Then assign it to user
const response = await fetch(`${adminBaseUrl}/users/${userId}/role-mappings/realm`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${adminToken}`,
'Content-Type': 'application/json',
'User-Agent': 'MonacoUSA-Portal/1.0'
},
body: JSON.stringify([role])
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`Failed to assign role to user: ${response.status} - ${errorText}`);
}
console.log(`[keycloak-admin] Assigned role ${roleName} to user ${userId}`);
}
/**
* Remove a realm role from a user
*/
async removeRealmRoleFromUser(userId: string, roleName: string): Promise<void> {
const adminToken = await this.getAdminToken();
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
// First get the role
const role = await this.getRealmRole(roleName, adminToken);
// Then remove it from user
const response = await fetch(`${adminBaseUrl}/users/${userId}/role-mappings/realm`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${adminToken}`,
'Content-Type': 'application/json',
'User-Agent': 'MonacoUSA-Portal/1.0'
},
body: JSON.stringify([role])
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`Failed to remove role from user: ${response.status} - ${errorText}`);
}
console.log(`[keycloak-admin] Removed role ${roleName} from user ${userId}`);
}
/**
* Get user's realm role mappings
*/
async getUserRealmRoles(userId: string, adminToken?: string): Promise<KeycloakRoleRepresentation[]> {
const token = adminToken || await this.getAdminToken();
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
const response = await fetch(`${adminBaseUrl}/users/${userId}/role-mappings/realm`, {
headers: {
'Authorization': `Bearer ${token}`,
'User-Agent': 'MonacoUSA-Portal/1.0'
}
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`Failed to get user roles: ${response.status} - ${errorText}`);
}
return response.json();
}
// ============================================================================
// USER PROFILE MANAGEMENT METHODS
// ============================================================================
/**
* Get user by ID with full profile information
*/
async getUserById(userId: string, adminToken?: string): Promise<KeycloakUserRepresentation> {
const token = adminToken || await this.getAdminToken();
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
const response = await fetch(`${adminBaseUrl}/users/${userId}`, {
headers: {
'Authorization': `Bearer ${token}`,
'User-Agent': 'MonacoUSA-Portal/1.0'
}
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`Failed to get user: ${response.status} - ${errorText}`);
}
return response.json();
}
/**
* Update user profile with membership data synchronization
*/
async updateUserProfile(userId: string, profileData: {
firstName?: string;
lastName?: string;
email?: string;
enabled?: boolean;
emailVerified?: boolean;
attributes?: {
membershipStatus?: string;
duesStatus?: string;
memberSince?: string;
nationality?: string;
phone?: string;
address?: string;
registrationDate?: string;
paymentDueDate?: string;
lastLoginDate?: string;
membershipTier?: string;
nocodbMemberId?: string;
};
}): Promise<void> {
const adminToken = await this.getAdminToken();
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
// Build user representation
const userUpdate: any = {};
if (profileData.firstName !== undefined) userUpdate.firstName = profileData.firstName;
if (profileData.lastName !== undefined) userUpdate.lastName = profileData.lastName;
if (profileData.email !== undefined) userUpdate.email = profileData.email;
if (profileData.enabled !== undefined) userUpdate.enabled = profileData.enabled;
if (profileData.emailVerified !== undefined) userUpdate.emailVerified = profileData.emailVerified;
// Handle custom attributes
if (profileData.attributes) {
userUpdate.attributes = {};
Object.entries(profileData.attributes).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
userUpdate.attributes[key] = [value]; // Keycloak expects arrays for attributes
}
});
}
const response = await fetch(`${adminBaseUrl}/users/${userId}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${adminToken}`,
'Content-Type': 'application/json',
'User-Agent': 'MonacoUSA-Portal/1.0'
},
body: JSON.stringify(userUpdate)
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`Failed to update user profile: ${response.status} - ${errorText}`);
}
console.log(`[keycloak-admin] Updated profile for user ${userId}`);
}
/**
* Create user with role-based registration (enhanced version)
*/
async createUserWithRoleRegistration(userData: {
email: string;
firstName: string;
lastName: string;
username?: string;
membershipTier?: 'user' | 'board' | 'admin';
membershipData?: MembershipProfileData;
}): Promise<string> {
const adminToken = await this.getAdminToken();
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
// Check if user already exists
const existingUsers = await this.findUserByEmail(userData.email, adminToken);
if (existingUsers.length > 0) {
throw new Error('User with this email already exists');
}
// Build user attributes
const attributes: Record<string, string[]> = {
membershipTier: [userData.membershipTier || 'user'],
registrationDate: [new Date().toISOString()]
};
if (userData.membershipData) {
Object.entries(userData.membershipData).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
attributes[key] = [String(value)];
}
});
}
const response = await fetch(`${adminBaseUrl}/users`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${adminToken}`,
'Content-Type': 'application/json',
'User-Agent': 'MonacoUSA-Portal/1.0'
},
body: JSON.stringify({
email: userData.email,
username: userData.username || userData.email,
firstName: userData.firstName,
lastName: userData.lastName,
enabled: true,
emailVerified: false,
attributes,
requiredActions: ['VERIFY_EMAIL', 'UPDATE_PASSWORD']
})
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`Failed to create user: ${response.status} - ${errorText}`);
}
// Extract user ID from Location header
const locationHeader = response.headers.get('location');
if (!locationHeader) {
throw new Error('User created but failed to get user ID');
}
const userId = locationHeader.split('/').pop();
if (!userId) {
throw new Error('Failed to extract user ID from response');
}
// Assign appropriate realm role
const roleName = `monaco-${userData.membershipTier || 'user'}`;
try {
await this.assignRealmRoleToUser(userId, roleName);
} catch (error) {
console.warn(`[keycloak-admin] Failed to assign role ${roleName} to user ${userId}:`, error);
// Don't fail the entire operation if role assignment fails
}
console.log(`[keycloak-admin] Created user ${userData.email} with ID: ${userId} and role: ${roleName}`);
return userId;
}
// ============================================================================
// SESSION MANAGEMENT METHODS
// ============================================================================
/**
* Get all active sessions for a user
*/
async getUserSessions(userId: string): Promise<UserSessionRepresentation[]> {
const adminToken = await this.getAdminToken();
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
const response = await fetch(`${adminBaseUrl}/users/${userId}/sessions`, {
headers: {
'Authorization': `Bearer ${adminToken}`,
'Content-Type': 'application/json',
'User-Agent': 'MonacoUSA-Portal/1.0'
}
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`Failed to get user sessions: ${response.status} - ${errorText}`);
}
return response.json();
}
/**
* Logout a specific user session
*/
async logoutUserSession(sessionId: string): Promise<void> {
const adminToken = await this.getAdminToken();
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
const response = await fetch(`${adminBaseUrl}/sessions/${sessionId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${adminToken}`,
'User-Agent': 'MonacoUSA-Portal/1.0'
}
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`Failed to logout session: ${response.status} - ${errorText}`);
}
console.log(`[keycloak-admin] Logged out session: ${sessionId}`);
}
/**
* Logout all sessions for a user
*/
async logoutAllUserSessions(userId: string): Promise<void> {
const adminToken = await this.getAdminToken();
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
const response = await fetch(`${adminBaseUrl}/users/${userId}/logout`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${adminToken}`,
'User-Agent': 'MonacoUSA-Portal/1.0'
}
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`Failed to logout all user sessions: ${response.status} - ${errorText}`);
}
console.log(`[keycloak-admin] Logged out all sessions for user: ${userId}`);
}
// ============================================================================
// GROUP MANAGEMENT METHODS
// ============================================================================
/**
* Create a new group
*/
async createGroup(name: string, path: string, parentPath?: string): Promise<string> {
const adminToken = await this.getAdminToken();
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
const groupData = {
name: name,
path: path
};
let url = `${adminBaseUrl}/groups`;
if (parentPath) {
// Find parent group ID first
const parentId = await this.getGroupByPath(parentPath);
url = `${adminBaseUrl}/groups/${parentId}/children`;
}
const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${adminToken}`,
'Content-Type': 'application/json',
'User-Agent': 'MonacoUSA-Portal/1.0'
},
body: JSON.stringify(groupData)
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`Failed to create group: ${response.status} - ${errorText}`);
}
const locationHeader = response.headers.get('location');
const groupId = locationHeader?.split('/').pop() || '';
console.log(`[keycloak-admin] Created group: ${name} with ID: ${groupId}`);
return groupId;
}
/**
* Get group by path
*/
async getGroupByPath(path: string): Promise<string> {
const adminToken = await this.getAdminToken();
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
const response = await fetch(`${adminBaseUrl}/groups?search=${encodeURIComponent(path)}`, {
headers: {
'Authorization': `Bearer ${adminToken}`,
'User-Agent': 'MonacoUSA-Portal/1.0'
}
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`Failed to find group: ${response.status} - ${errorText}`);
}
const groups: KeycloakGroupRepresentation[] = await response.json();
const group = groups.find(g => g.path === path);
if (!group?.id) {
throw new Error(`Group not found: ${path}`);
}
return group.id;
}
/**
* Assign user to group
*/
async assignUserToGroup(userId: string, groupId: string): Promise<void> {
const adminToken = await this.getAdminToken();
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
const response = await fetch(`${adminBaseUrl}/users/${userId}/groups/${groupId}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${adminToken}`,
'User-Agent': 'MonacoUSA-Portal/1.0'
}
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`Failed to assign user to group: ${response.status} - ${errorText}`);
}
console.log(`[keycloak-admin] Assigned user ${userId} to group ${groupId}`);
}
// ============================================================================
// ADVANCED EMAIL WORKFLOWS
// ============================================================================
/**
* Send custom email workflows
*/
async sendCustomEmail(userId: string, emailData: EmailWorkflowData): Promise<void> {
const adminToken = await this.getAdminToken();
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
const emailUrl = new URL(`${adminBaseUrl}/users/${userId}/execute-actions-email`);
// Configure email based on type
switch (emailData.emailType) {
case 'DUES_REMINDER':
emailUrl.searchParams.set('lifespan', emailData.lifespan?.toString() || '259200'); // 3 days
if (emailData.customData?.dueAmount) {
emailUrl.searchParams.set('dueAmount', emailData.customData.dueAmount);
}
break;
case 'MEMBERSHIP_RENEWAL':
emailUrl.searchParams.set('lifespan', emailData.lifespan?.toString() || '604800'); // 1 week
if (emailData.customData?.renewalDate) {
emailUrl.searchParams.set('renewalDate', emailData.customData.renewalDate);
}
break;
case 'WELCOME':
emailUrl.searchParams.set('lifespan', emailData.lifespan?.toString() || '43200'); // 12 hours
break;
case 'VERIFICATION':
emailUrl.searchParams.set('lifespan', emailData.lifespan?.toString() || '86400'); // 24 hours
break;
}
if (emailData.redirectUri) {
emailUrl.searchParams.set('redirect_uri', emailData.redirectUri);
}
emailUrl.searchParams.set('client_id', this.config.clientId);
const response = await fetch(emailUrl.toString(), {
method: 'PUT',
headers: {
'Authorization': `Bearer ${adminToken}`,
'Content-Type': 'application/json',
'User-Agent': 'MonacoUSA-Portal/1.0'
},
body: JSON.stringify([emailData.emailType])
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`Failed to send ${emailData.emailType} email: ${response.status} - ${errorText}`);
}
console.log(`[keycloak-admin] Sent ${emailData.emailType} email to user ${userId}`);
}
/**
* Send enhanced verification email
*/
async sendVerificationEmail(userId: string, redirectUri?: string): Promise<void> {
const adminToken = await this.getAdminToken();
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
const emailUrl = new URL(`${adminBaseUrl}/users/${userId}/send-verify-email`);
if (redirectUri) {
emailUrl.searchParams.set('redirect_uri', redirectUri);
}
emailUrl.searchParams.set('client_id', this.config.clientId);
const response = await fetch(emailUrl.toString(), {
method: 'PUT',
headers: {
'Authorization': `Bearer ${adminToken}`,
'User-Agent': 'MonacoUSA-Portal/1.0'
}
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`Failed to send verification email: ${response.status} - ${errorText}`);
}
console.log(`[keycloak-admin] Sent verification email to user ${userId}`);
}
/**
* Send password reset email to a user
*/

View File

@ -130,6 +130,8 @@ export interface Member {
membership_status: string;
address: string;
member_since: string;
keycloak_id?: string; // New field for linking to Keycloak user
registration_date?: string; // New field for tracking registration date
// Computed fields (added by processing)
FullName?: string;
@ -163,3 +165,216 @@ export interface MemberFilters {
duesPaid?: boolean;
memberSince?: string;
}
// Registration System Types
export interface RegistrationFormData {
first_name: string;
last_name: string;
email: string;
phone: string;
date_of_birth: string;
address: string;
nationality: string;
recaptcha_token: string;
}
export interface RecaptchaConfig {
siteKey: string;
secretKey: string;
}
export interface RegistrationConfig {
membershipFee: number;
iban: string;
accountHolder: string;
}
// Enhanced Keycloak Admin API Types
export interface KeycloakUserRepresentation {
id?: string;
username?: string;
enabled?: boolean;
firstName?: string;
lastName?: string;
email?: string;
emailVerified?: boolean;
attributes?: Record<string, string[]>;
groups?: string[];
realmRoles?: string[];
clientRoles?: Record<string, string[]>;
createdTimestamp?: number;
requiredActions?: string[];
}
export interface KeycloakRoleRepresentation {
id?: string;
name?: string;
description?: string;
composite?: boolean;
clientRole?: boolean;
containerId?: string;
attributes?: Record<string, string[]>;
}
export interface KeycloakGroupRepresentation {
id?: string;
name?: string;
path?: string;
attributes?: Record<string, string[]>;
realmRoles?: string[];
clientRoles?: Record<string, string[]>;
subGroups?: KeycloakGroupRepresentation[];
}
export interface UserSessionRepresentation {
id?: string;
username?: string;
userId?: string;
ipAddress?: string;
start?: number;
lastAccess?: number;
clients?: Record<string, string>;
}
export interface EmailWorkflowData {
emailType: 'DUES_REMINDER' | 'MEMBERSHIP_RENEWAL' | 'WELCOME' | 'ADMIN_NOTIFICATION' | 'VERIFICATION';
customData?: {
dueAmount?: string;
dueDate?: string;
memberSince?: string;
renewalDate?: string;
welcomeMessage?: string;
adminNote?: string;
};
lifespan?: number; // Email validity in seconds
redirectUri?: string;
}
export interface MembershipProfileData {
membershipStatus?: string;
duesStatus?: 'paid' | 'unpaid' | 'overdue';
memberSince?: string;
nationality?: string;
phone?: string;
address?: string;
registrationDate?: string;
paymentDueDate?: string;
lastLoginDate?: string;
membershipTier?: 'user' | 'board' | 'admin';
nocodbMemberId?: string;
}
// Enhanced User interface with role support
export interface EnhancedUser extends User {
realmRoles?: string[];
clientRoles?: Record<string, string[]>;
attributes?: Record<string, string[]>;
sessions?: UserSessionRepresentation[];
memberProfile?: MembershipProfileData;
}
// Role management types
export interface RoleAssignmentRequest {
userId: string;
roleName: string;
roleType: 'realm' | 'client';
clientId?: string;
}
export interface RoleManagementResponse {
success: boolean;
assignedRoles?: string[];
removedRoles?: string[];
message?: string;
}
// Group management types
export interface GroupCreationRequest {
name: string;
path: string;
parentPath?: string;
attributes?: Record<string, string>;
}
export interface GroupAssignmentRequest {
userId: string;
groupId: string;
groupPath: string;
}
// Session management types
export interface SessionManagementRequest {
userId: string;
sessionId?: string;
action: 'get' | 'logout' | 'logoutAll';
}
export interface SessionAnalytics {
totalSessions: number;
activeSessions: number;
uniqueUsers: number;
sessionsToday: number;
averageSessionDuration: number;
topClientApplications: Array<{
clientId: string;
sessionCount: number;
}>;
}
// Enhanced authentication state with role support
export interface EnhancedAuthState extends AuthState {
realmRoles: string[];
clientRoles: Record<string, string[]>;
hasRole: (roleName: string) => boolean;
isUser: boolean;
isBoard: boolean;
isAdmin: boolean;
}
// Member synchronization types
export interface MemberKeycloakSync {
memberId: string;
keycloakUserId: string;
syncDirection: 'nocodb-to-keycloak' | 'keycloak-to-nocodb' | 'bidirectional';
syncFields: string[];
lastSyncTimestamp: string;
}
export interface SyncResult {
success: boolean;
syncedFields: string[];
conflictFields?: string[];
errors?: string[];
timestamp: string;
}
// Admin dashboard types
export interface AdminUserManagement {
userId: string;
email: string;
firstName?: string;
lastName?: string;
enabled: boolean;
emailVerified: boolean;
realmRoles: string[];
groups: string[];
activeSessions: number;
lastLogin?: string;
memberProfile?: MembershipProfileData;
}
export interface AdminDashboardStats {
totalUsers: number;
activeUsers: number;
newRegistrationsToday: number;
totalSessions: number;
membershipStats: {
totalMembers: number;
paidMembers: number;
unpaidMembers: number;
overdueMembers: number;
};
roleDistribution: {
[roleName: string]: number;
};
}