#### __1. Role-Based Security Architecture__
Build And Push Image / docker (push) Successful in 2m58s
Details
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:
parent
b308b8272c
commit
5535b7905d
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -159,6 +159,9 @@
|
|||
</v-app-bar>
|
||||
|
||||
<v-main>
|
||||
<!-- Dues Payment Banner -->
|
||||
<DuesPaymentBanner />
|
||||
|
||||
<v-container fluid class="pa-0">
|
||||
<slot />
|
||||
</v-container>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
215
utils/types.ts
215
utils/types.ts
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue