#### __1. Role-Based Security Architecture__
All checks were successful
Build And Push Image / docker (push) Successful in 2m58s
All checks were successful
Build And Push Image / docker (push) Successful in 2m58s
- Replaces group-based tiers with proper Keycloak realm roles - `monaco-user`, `monaco-board`, `monaco-admin` roles - Backward compatibility with existing group system #### __2. Advanced User Management__ - Comprehensive user profile synchronization - Membership data stored in Keycloak user attributes - Bidirectional sync between NocoDB and Keycloak #### __3. Session Security & Monitoring__ - Real-time session tracking and management - Administrative session control capabilities - Enhanced security analytics foundation #### __4. Email Workflow System__ - Multiple email types: DUES_REMINDER, MEMBERSHIP_RENEWAL, WELCOME, VERIFICATION - Customizable email parameters and lifespans - Advanced email template support #### __5. Seamless Migration Path__ - All existing functionality continues to work - New users automatically get realm roles - Gradual migration from groups to roles - Zero breaking changes ### 🔧 __What You Can Do Now__ #### __For New Users:__ - Public registrations automatically assign `monaco-user` role - Portal account creation syncs member data to Keycloak attributes - Enhanced email verification and welcome workflows #### __For Administrators:__ - Session management and monitoring capabilities - Advanced user profile management with member data sync - Comprehensive role assignment and management - Enhanced email communication workflows #### __For Developers:__ - Use `hasRole('monaco-admin')` for role-based checks - Access `getAllRoles()` for debugging and analytics - Enhanced `useAuth()` composable with backward compatibility - Comprehensive TypeScript support throughout ### 🛡️ __Security & Reliability__ - __Backward Compatibility__: Existing users continue to work seamlessly - __Enhanced Security__: Proper realm role-based authorization - __Error Handling__: Comprehensive error handling and fallbacks - __Type Safety__: Full TypeScript support throughout the system
This commit is contained in:
669
components/AdminConfigurationDialog.vue
Normal file
669
components/AdminConfigurationDialog.vue
Normal file
@@ -0,0 +1,669 @@
|
||||
<template>
|
||||
<v-dialog
|
||||
:model-value="modelValue"
|
||||
@update:model-value="$emit('update:model-value', $event)"
|
||||
max-width="800"
|
||||
persistent
|
||||
scrollable
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title class="d-flex align-center pa-6 bg-primary">
|
||||
<v-icon class="mr-3 text-white">mdi-cog</v-icon>
|
||||
<h2 class="text-h5 text-white font-weight-bold flex-grow-1">
|
||||
Admin Configuration
|
||||
</h2>
|
||||
<v-btn
|
||||
icon
|
||||
variant="text"
|
||||
color="white"
|
||||
@click="closeDialog"
|
||||
>
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-tabs v-model="activeTab" class="border-b">
|
||||
<v-tab value="nocodb">
|
||||
<v-icon start>mdi-database</v-icon>
|
||||
NocoDB
|
||||
</v-tab>
|
||||
<v-tab value="recaptcha">
|
||||
<v-icon start>mdi-shield-check</v-icon>
|
||||
reCAPTCHA
|
||||
</v-tab>
|
||||
<v-tab value="registration">
|
||||
<v-icon start>mdi-account-plus</v-icon>
|
||||
Registration
|
||||
</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<v-card-text class="pa-6" style="min-height: 400px;">
|
||||
<v-tabs-window v-model="activeTab">
|
||||
<!-- NocoDB Configuration Tab -->
|
||||
<v-tabs-window-item value="nocodb">
|
||||
<v-alert
|
||||
type="info"
|
||||
variant="tonal"
|
||||
class="mb-4"
|
||||
>
|
||||
<template #title>Database Configuration</template>
|
||||
Configure the NocoDB database connection for the Member Management system.
|
||||
</v-alert>
|
||||
|
||||
<v-form ref="nocodbFormRef" v-model="nocodbFormValid">
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-text-field
|
||||
v-model="nocodbForm.url"
|
||||
label="NocoDB URL"
|
||||
variant="outlined"
|
||||
:rules="[rules.required, rules.url]"
|
||||
required
|
||||
placeholder="https://database.monacousa.org"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12">
|
||||
<v-text-field
|
||||
v-model="nocodbForm.apiKey"
|
||||
label="API Token"
|
||||
variant="outlined"
|
||||
:rules="[rules.required]"
|
||||
required
|
||||
:type="showNocodbApiKey ? 'text' : 'password'"
|
||||
:append-inner-icon="showNocodbApiKey ? 'mdi-eye' : 'mdi-eye-off'"
|
||||
@click:append-inner="showNocodbApiKey = !showNocodbApiKey"
|
||||
placeholder="Enter your NocoDB API token"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12">
|
||||
<v-text-field
|
||||
v-model="nocodbForm.baseId"
|
||||
label="Base ID"
|
||||
variant="outlined"
|
||||
:rules="[rules.required]"
|
||||
required
|
||||
placeholder="your-base-id"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12">
|
||||
<v-text-field
|
||||
v-model="nocodbForm.tables.members"
|
||||
label="Members Table ID"
|
||||
variant="outlined"
|
||||
:rules="[rules.required]"
|
||||
required
|
||||
placeholder="members-table-id"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<v-btn
|
||||
@click="testNocodbConnection"
|
||||
:loading="nocodbTestLoading"
|
||||
:disabled="!nocodbFormValid || nocodbLoading"
|
||||
color="info"
|
||||
variant="outlined"
|
||||
block
|
||||
>
|
||||
<v-icon start>mdi-database-check</v-icon>
|
||||
Test Connection
|
||||
</v-btn>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<div class="d-flex align-center h-100">
|
||||
<v-chip
|
||||
v-if="nocodbConnectionStatus"
|
||||
:color="nocodbConnectionStatus.success ? 'success' : 'error'"
|
||||
variant="flat"
|
||||
size="small"
|
||||
>
|
||||
<v-icon start size="14">
|
||||
{{ nocodbConnectionStatus.success ? 'mdi-check-circle' : 'mdi-alert-circle' }}
|
||||
</v-icon>
|
||||
{{ nocodbConnectionStatus.message }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-form>
|
||||
</v-tabs-window-item>
|
||||
|
||||
<!-- reCAPTCHA Configuration Tab -->
|
||||
<v-tabs-window-item value="recaptcha">
|
||||
<v-alert
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
class="mb-4"
|
||||
>
|
||||
<template #title>reCAPTCHA Integration</template>
|
||||
Configure Google reCAPTCHA v2 for spam protection on the registration form.
|
||||
Get your keys from <a href="https://www.google.com/recaptcha/admin" target="_blank">Google reCAPTCHA Admin</a>.
|
||||
</v-alert>
|
||||
|
||||
<v-form ref="recaptchaFormRef" v-model="recaptchaFormValid">
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-text-field
|
||||
v-model="recaptchaForm.siteKey"
|
||||
label="Site Key (Public)"
|
||||
variant="outlined"
|
||||
:rules="[rules.required]"
|
||||
required
|
||||
placeholder="6Lc..."
|
||||
hint="This key is visible to users on the frontend"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12">
|
||||
<v-text-field
|
||||
v-model="recaptchaForm.secretKey"
|
||||
label="Secret Key (Private)"
|
||||
variant="outlined"
|
||||
:rules="[rules.required]"
|
||||
required
|
||||
:type="showRecaptchaSecret ? 'text' : 'password'"
|
||||
:append-inner-icon="showRecaptchaSecret ? 'mdi-eye' : 'mdi-eye-off'"
|
||||
@click:append-inner="showRecaptchaSecret = !showRecaptchaSecret"
|
||||
placeholder="6Lc..."
|
||||
hint="This key is kept secret on the server"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12">
|
||||
<v-alert
|
||||
type="info"
|
||||
variant="outlined"
|
||||
class="mt-2"
|
||||
>
|
||||
<div class="text-body-2">
|
||||
<strong>Setup Instructions:</strong>
|
||||
<ol class="mt-2 ml-4">
|
||||
<li>Visit <a href="https://www.google.com/recaptcha/admin" target="_blank">Google reCAPTCHA Admin</a></li>
|
||||
<li>Create a new site with reCAPTCHA v2 "I'm not a robot" Checkbox</li>
|
||||
<li>Add your domain to the domains list</li>
|
||||
<li>Copy the Site Key and Secret Key here</li>
|
||||
</ol>
|
||||
</div>
|
||||
</v-alert>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-form>
|
||||
</v-tabs-window-item>
|
||||
|
||||
<!-- Registration Configuration Tab -->
|
||||
<v-tabs-window-item value="registration">
|
||||
<v-alert
|
||||
type="success"
|
||||
variant="tonal"
|
||||
class="mb-4"
|
||||
>
|
||||
<template #title>Member Registration Settings</template>
|
||||
Configure payment instructions that will be displayed to new members during registration.
|
||||
</v-alert>
|
||||
|
||||
<v-form ref="registrationFormRef" v-model="registrationFormValid">
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model.number="registrationForm.membershipFee"
|
||||
label="Annual Membership Fee (EUR)"
|
||||
variant="outlined"
|
||||
:rules="[rules.required, rules.positiveNumber]"
|
||||
required
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="50"
|
||||
prefix="€"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<!-- Spacer for alignment -->
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12">
|
||||
<v-text-field
|
||||
v-model="registrationForm.iban"
|
||||
label="Bank IBAN"
|
||||
variant="outlined"
|
||||
:rules="[rules.required, rules.iban]"
|
||||
required
|
||||
placeholder="DE89 3704 0044 0532 0130 00"
|
||||
hint="International Bank Account Number for membership dues"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12">
|
||||
<v-text-field
|
||||
v-model="registrationForm.accountHolder"
|
||||
label="Account Holder Name"
|
||||
variant="outlined"
|
||||
:rules="[rules.required]"
|
||||
required
|
||||
placeholder="MonacoUSA Association"
|
||||
hint="Name on the bank account"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12">
|
||||
<v-card variant="outlined" class="pa-4 bg-grey-lighten-5">
|
||||
<v-card-title class="text-h6 pb-2">
|
||||
<v-icon left color="primary">mdi-eye</v-icon>
|
||||
Preview
|
||||
</v-card-title>
|
||||
<v-card-text class="pt-2">
|
||||
<div class="text-body-2 mb-2">
|
||||
<strong>Payment Instructions as shown to new members:</strong>
|
||||
</div>
|
||||
<v-row dense>
|
||||
<v-col cols="4" class="text-body-2 font-weight-bold">Amount:</v-col>
|
||||
<v-col cols="8" class="text-body-2">€{{ registrationForm.membershipFee || '0' }}/year</v-col>
|
||||
</v-row>
|
||||
<v-row dense v-if="registrationForm.iban">
|
||||
<v-col cols="4" class="text-body-2 font-weight-bold">IBAN:</v-col>
|
||||
<v-col cols="8" class="text-body-2 font-family-monospace">{{ registrationForm.iban }}</v-col>
|
||||
</v-row>
|
||||
<v-row dense v-if="registrationForm.accountHolder">
|
||||
<v-col cols="4" class="text-body-2 font-weight-bold">Account:</v-col>
|
||||
<v-col cols="8" class="text-body-2">{{ registrationForm.accountHolder }}</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-form>
|
||||
</v-tabs-window-item>
|
||||
</v-tabs-window>
|
||||
|
||||
<!-- Success Message -->
|
||||
<v-alert
|
||||
v-if="showSuccessMessage"
|
||||
type="success"
|
||||
variant="tonal"
|
||||
class="mt-4"
|
||||
closable
|
||||
@click:close="showSuccessMessage = false"
|
||||
>
|
||||
{{ successMessage }}
|
||||
</v-alert>
|
||||
|
||||
<!-- Error Message -->
|
||||
<v-alert
|
||||
v-if="errorMessage"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
class="mt-4"
|
||||
closable
|
||||
@click:close="errorMessage = ''"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</v-alert>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="pa-6 pt-0">
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
variant="text"
|
||||
@click="closeDialog"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
Cancel
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
@click="saveCurrentTab"
|
||||
:loading="isLoading"
|
||||
:disabled="!isCurrentTabValid"
|
||||
>
|
||||
<v-icon start>mdi-content-save</v-icon>
|
||||
Save {{ getCurrentTabName }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { NocoDBSettings, RecaptchaConfig, RegistrationConfig } from '~/utils/types';
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:model-value', value: boolean): void;
|
||||
(e: 'settings-saved'): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
// Tab management
|
||||
const activeTab = ref('nocodb');
|
||||
|
||||
// Form refs
|
||||
const nocodbFormRef = ref();
|
||||
const recaptchaFormRef = ref();
|
||||
const registrationFormRef = ref();
|
||||
|
||||
// Form validity
|
||||
const nocodbFormValid = ref(false);
|
||||
const recaptchaFormValid = ref(false);
|
||||
const registrationFormValid = ref(false);
|
||||
|
||||
// Loading states
|
||||
const nocodbLoading = ref(false);
|
||||
const recaptchaLoading = ref(false);
|
||||
const registrationLoading = ref(false);
|
||||
const nocodbTestLoading = ref(false);
|
||||
|
||||
// Display states
|
||||
const showNocodbApiKey = ref(false);
|
||||
const showRecaptchaSecret = ref(false);
|
||||
const showSuccessMessage = ref(false);
|
||||
const successMessage = ref('');
|
||||
const errorMessage = ref('');
|
||||
const nocodbConnectionStatus = ref<{ success: boolean; message: string } | null>(null);
|
||||
|
||||
// Form data
|
||||
const nocodbForm = ref<NocoDBSettings>({
|
||||
url: 'https://database.monacousa.org',
|
||||
apiKey: '',
|
||||
baseId: '',
|
||||
tables: { members: '' }
|
||||
});
|
||||
|
||||
const recaptchaForm = ref<RecaptchaConfig>({
|
||||
siteKey: '',
|
||||
secretKey: ''
|
||||
});
|
||||
|
||||
const registrationForm = ref<RegistrationConfig>({
|
||||
membershipFee: 50,
|
||||
iban: '',
|
||||
accountHolder: ''
|
||||
});
|
||||
|
||||
// Validation rules
|
||||
const rules = {
|
||||
required: (value: string | number) => {
|
||||
return (!!value && value.toString().trim() !== '') || 'This field is required';
|
||||
},
|
||||
url: (value: string) => {
|
||||
if (!value) return true;
|
||||
const pattern = /^https?:\/\/.+/;
|
||||
return pattern.test(value) || 'Please enter a valid URL';
|
||||
},
|
||||
positiveNumber: (value: number) => {
|
||||
return (value && value > 0) || 'Must be a positive number';
|
||||
},
|
||||
iban: (value: string) => {
|
||||
if (!value) return true;
|
||||
// Basic IBAN validation (length and format)
|
||||
const cleaned = value.replace(/\s/g, '').toUpperCase();
|
||||
return (cleaned.length >= 15 && cleaned.length <= 34 && /^[A-Z]{2}[0-9]{2}[A-Z0-9]+$/.test(cleaned)) || 'Please enter a valid IBAN';
|
||||
}
|
||||
};
|
||||
|
||||
// Computed properties
|
||||
const isLoading = computed(() => {
|
||||
return nocodbLoading.value || recaptchaLoading.value || registrationLoading.value;
|
||||
});
|
||||
|
||||
const isCurrentTabValid = computed(() => {
|
||||
switch (activeTab.value) {
|
||||
case 'nocodb':
|
||||
return nocodbFormValid.value;
|
||||
case 'recaptcha':
|
||||
return recaptchaFormValid.value;
|
||||
case 'registration':
|
||||
return registrationFormValid.value;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const getCurrentTabName = computed(() => {
|
||||
switch (activeTab.value) {
|
||||
case 'nocodb':
|
||||
return 'NocoDB Settings';
|
||||
case 'recaptcha':
|
||||
return 'reCAPTCHA Settings';
|
||||
case 'registration':
|
||||
return 'Registration Settings';
|
||||
default:
|
||||
return 'Settings';
|
||||
}
|
||||
});
|
||||
|
||||
// Load configurations
|
||||
const loadConfigurations = async () => {
|
||||
try {
|
||||
// Load NocoDB config
|
||||
const nocodbResponse = await $fetch<{ success: boolean; data?: NocoDBSettings }>('/api/admin/nocodb-config');
|
||||
if (nocodbResponse.success && nocodbResponse.data) {
|
||||
nocodbForm.value = { ...nocodbResponse.data };
|
||||
if (!nocodbForm.value.tables) {
|
||||
nocodbForm.value.tables = { members: '' };
|
||||
}
|
||||
}
|
||||
|
||||
// Load reCAPTCHA config
|
||||
const recaptchaResponse = await $fetch<{ success: boolean; data?: RecaptchaConfig }>('/api/admin/recaptcha-config');
|
||||
if (recaptchaResponse.success && recaptchaResponse.data) {
|
||||
recaptchaForm.value = { ...recaptchaResponse.data };
|
||||
}
|
||||
|
||||
// Load registration config
|
||||
const registrationResponse = await $fetch<{ success: boolean; data?: RegistrationConfig }>('/api/admin/registration-config');
|
||||
if (registrationResponse.success && registrationResponse.data) {
|
||||
registrationForm.value = { ...registrationResponse.data };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load configurations:', error);
|
||||
errorMessage.value = 'Failed to load current settings';
|
||||
}
|
||||
};
|
||||
|
||||
// Test NocoDB connection
|
||||
const testNocodbConnection = async () => {
|
||||
if (!nocodbFormRef.value) return;
|
||||
|
||||
const isValid = await nocodbFormRef.value.validate();
|
||||
if (!isValid.valid) return;
|
||||
|
||||
nocodbTestLoading.value = true;
|
||||
nocodbConnectionStatus.value = null;
|
||||
|
||||
try {
|
||||
const response = await $fetch<{ success: boolean; message: string }>('/api/admin/nocodb-test', {
|
||||
method: 'POST',
|
||||
body: nocodbForm.value
|
||||
});
|
||||
|
||||
nocodbConnectionStatus.value = {
|
||||
success: response.success,
|
||||
message: response.message || (response.success ? 'Connection successful' : 'Connection failed')
|
||||
};
|
||||
} catch (error: any) {
|
||||
nocodbConnectionStatus.value = {
|
||||
success: false,
|
||||
message: error.message || 'Connection test failed'
|
||||
};
|
||||
} finally {
|
||||
nocodbTestLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Save current tab
|
||||
const saveCurrentTab = async () => {
|
||||
let formRef, loading, data, endpoint;
|
||||
|
||||
switch (activeTab.value) {
|
||||
case 'nocodb':
|
||||
formRef = nocodbFormRef.value;
|
||||
loading = nocodbLoading;
|
||||
data = nocodbForm.value;
|
||||
endpoint = '/api/admin/nocodb-config';
|
||||
break;
|
||||
case 'recaptcha':
|
||||
formRef = recaptchaFormRef.value;
|
||||
loading = recaptchaLoading;
|
||||
data = recaptchaForm.value;
|
||||
endpoint = '/api/admin/recaptcha-config';
|
||||
break;
|
||||
case 'registration':
|
||||
formRef = registrationFormRef.value;
|
||||
loading = registrationLoading;
|
||||
data = registrationForm.value;
|
||||
endpoint = '/api/admin/registration-config';
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formRef) return;
|
||||
|
||||
const isValid = await formRef.validate();
|
||||
if (!isValid.valid) return;
|
||||
|
||||
loading.value = true;
|
||||
errorMessage.value = '';
|
||||
|
||||
try {
|
||||
const response = await $fetch<{ success: boolean; message?: string }>(endpoint, {
|
||||
method: 'POST',
|
||||
body: data
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
successMessage.value = `${getCurrentTabName.value} saved successfully!`;
|
||||
showSuccessMessage.value = true;
|
||||
emit('settings-saved');
|
||||
|
||||
// Auto-hide success message
|
||||
setTimeout(() => {
|
||||
showSuccessMessage.value = false;
|
||||
}, 3000);
|
||||
} else {
|
||||
throw new Error(response.message || 'Failed to save settings');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`Error saving ${getCurrentTabName.value}:`, error);
|
||||
errorMessage.value = error.message || `Failed to save ${getCurrentTabName.value}. Please try again.`;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Dialog management
|
||||
const closeDialog = () => {
|
||||
emit('update:model-value', false);
|
||||
};
|
||||
|
||||
const resetForms = () => {
|
||||
// Reset form data to defaults
|
||||
nocodbForm.value = {
|
||||
url: 'https://database.monacousa.org',
|
||||
apiKey: '',
|
||||
baseId: '',
|
||||
tables: { members: '' }
|
||||
};
|
||||
|
||||
recaptchaForm.value = {
|
||||
siteKey: '',
|
||||
secretKey: ''
|
||||
};
|
||||
|
||||
registrationForm.value = {
|
||||
membershipFee: 50,
|
||||
iban: '',
|
||||
accountHolder: ''
|
||||
};
|
||||
|
||||
// Clear status and messages
|
||||
nocodbConnectionStatus.value = null;
|
||||
errorMessage.value = '';
|
||||
showSuccessMessage.value = false;
|
||||
activeTab.value = 'nocodb';
|
||||
|
||||
// Reset form validation
|
||||
nextTick(() => {
|
||||
nocodbFormRef.value?.resetValidation();
|
||||
recaptchaFormRef.value?.resetValidation();
|
||||
registrationFormRef.value?.resetValidation();
|
||||
});
|
||||
};
|
||||
|
||||
// Watch for dialog open
|
||||
watch(() => props.modelValue, async (newValue) => {
|
||||
if (newValue) {
|
||||
resetForms();
|
||||
await loadConfigurations();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bg-primary {
|
||||
background: linear-gradient(135deg, #a31515 0%, #d32f2f 100%) !important;
|
||||
}
|
||||
|
||||
.border-b {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.v-card {
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
|
||||
/* Tab styling */
|
||||
.v-tabs :deep(.v-tab) {
|
||||
text-transform: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Form styling */
|
||||
.v-card-text .v-row .v-col .v-alert {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* Password field styling */
|
||||
.v-text-field :deep(.v-input__append-inner) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Preview card styling */
|
||||
.bg-grey-lighten-5 {
|
||||
background-color: rgba(0, 0, 0, 0.04) !important;
|
||||
}
|
||||
|
||||
/* Connection status styling */
|
||||
.h-100 {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 600px) {
|
||||
.v-card-title {
|
||||
padding: 16px !important;
|
||||
}
|
||||
|
||||
.v-card-text {
|
||||
padding: 16px !important;
|
||||
}
|
||||
|
||||
.v-card-actions {
|
||||
padding: 16px !important;
|
||||
padding-top: 0 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
343
components/DuesPaymentBanner.vue
Normal file
343
components/DuesPaymentBanner.vue
Normal file
@@ -0,0 +1,343 @@
|
||||
<template>
|
||||
<v-banner
|
||||
v-if="showBanner"
|
||||
color="warning"
|
||||
icon="mdi-alert-circle"
|
||||
sticky
|
||||
class="dues-payment-banner"
|
||||
>
|
||||
<template #text>
|
||||
<div class="banner-content">
|
||||
<div class="text-h6 font-weight-bold mb-2">
|
||||
<v-icon left>mdi-credit-card-alert</v-icon>
|
||||
Membership Dues Payment Required
|
||||
</div>
|
||||
|
||||
<div class="text-body-1 mb-3">
|
||||
{{ paymentMessage }}
|
||||
</div>
|
||||
|
||||
<v-card
|
||||
class="payment-details-card pa-3"
|
||||
color="rgba(255,255,255,0.1)"
|
||||
variant="outlined"
|
||||
>
|
||||
<div class="text-subtitle-1 font-weight-bold mb-2">
|
||||
<v-icon left size="small">mdi-bank</v-icon>
|
||||
Payment Details
|
||||
</div>
|
||||
|
||||
<v-row dense>
|
||||
<v-col cols="12" sm="4" md="3">
|
||||
<div class="text-caption font-weight-bold">Amount:</div>
|
||||
<div class="text-body-2">€{{ config.membershipFee }}/year</div>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="8" md="5" v-if="config.iban">
|
||||
<div class="text-caption font-weight-bold">IBAN:</div>
|
||||
<div class="text-body-2 font-family-monospace">{{ config.iban }}</div>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="12" md="4" v-if="config.accountHolder">
|
||||
<div class="text-caption font-weight-bold">Account Holder:</div>
|
||||
<div class="text-body-2">{{ config.accountHolder }}</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-divider class="my-2" />
|
||||
|
||||
<div class="text-caption d-flex align-center">
|
||||
<v-icon size="small" class="mr-1">mdi-information-outline</v-icon>
|
||||
{{ daysRemaining > 0 ? `${daysRemaining} days remaining` : 'Payment overdue' }}
|
||||
before account suspension
|
||||
</div>
|
||||
</v-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<v-btn
|
||||
v-if="isAdmin"
|
||||
color="white"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
@click="markAsPaidDialog = true"
|
||||
class="mr-2"
|
||||
>
|
||||
<v-icon left size="small">mdi-check-circle</v-icon>
|
||||
Mark as Paid
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
color="white"
|
||||
variant="text"
|
||||
size="small"
|
||||
@click="dismissBanner"
|
||||
>
|
||||
<v-icon left size="small">mdi-close</v-icon>
|
||||
Dismiss
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-banner>
|
||||
|
||||
<!-- Mark as Paid Dialog -->
|
||||
<v-dialog v-model="markAsPaidDialog" max-width="400">
|
||||
<v-card>
|
||||
<v-card-title class="text-h6">
|
||||
<v-icon left color="success">mdi-check-circle</v-icon>
|
||||
Mark Dues as Paid
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<p>Are you sure you want to mark the dues as paid for this member?</p>
|
||||
<p class="text-body-2 text-medium-emphasis">
|
||||
This will remove the payment banner and update the member's status.
|
||||
</p>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
color="grey"
|
||||
variant="text"
|
||||
@click="markAsPaidDialog = false"
|
||||
>
|
||||
Cancel
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="success"
|
||||
variant="flat"
|
||||
:loading="updating"
|
||||
@click="markDuesAsPaid"
|
||||
>
|
||||
Mark as Paid
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Snackbar for notifications -->
|
||||
<v-snackbar
|
||||
v-model="snackbar.show"
|
||||
:color="snackbar.color"
|
||||
:timeout="4000"
|
||||
>
|
||||
{{ snackbar.message }}
|
||||
<template #actions>
|
||||
<v-btn
|
||||
variant="text"
|
||||
@click="snackbar.show = false"
|
||||
>
|
||||
Close
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { RegistrationConfig, Member } from '~/utils/types';
|
||||
|
||||
// Get auth state
|
||||
const { user, isAdmin } = useAuth();
|
||||
|
||||
// Reactive state
|
||||
const showBanner = ref(false);
|
||||
const dismissed = ref(false);
|
||||
const markAsPaidDialog = ref(false);
|
||||
const updating = ref(false);
|
||||
const memberData = ref<Member | null>(null);
|
||||
const config = ref<RegistrationConfig>({
|
||||
membershipFee: 50,
|
||||
iban: '',
|
||||
accountHolder: ''
|
||||
});
|
||||
|
||||
const snackbar = ref({
|
||||
show: false,
|
||||
message: '',
|
||||
color: 'success'
|
||||
});
|
||||
|
||||
// Computed properties
|
||||
const shouldShowBanner = computed(() => {
|
||||
if (!user.value || !memberData.value) return false;
|
||||
if (dismissed.value) return false;
|
||||
|
||||
// Show banner if member exists and has unpaid dues
|
||||
return memberData.value.current_year_dues_paid === 'false';
|
||||
});
|
||||
|
||||
const daysRemaining = computed(() => {
|
||||
if (!memberData.value?.payment_due_date) return 0;
|
||||
|
||||
const dueDate = new Date(memberData.value.payment_due_date);
|
||||
const today = new Date();
|
||||
const diffTime = dueDate.getTime() - today.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
return Math.max(0, diffDays);
|
||||
});
|
||||
|
||||
const paymentMessage = computed(() => {
|
||||
if (daysRemaining.value > 30) {
|
||||
return `Your annual membership dues of €${config.value.membershipFee} are due in ${daysRemaining.value} days.`;
|
||||
} else if (daysRemaining.value > 0) {
|
||||
return `Your annual membership dues of €${config.value.membershipFee} are due in ${daysRemaining.value} days. Please pay soon to avoid account suspension.`;
|
||||
} else {
|
||||
return `Your annual membership dues of €${config.value.membershipFee} are overdue. Your account may be suspended soon.`;
|
||||
}
|
||||
});
|
||||
|
||||
// Methods
|
||||
function dismissBanner() {
|
||||
dismissed.value = true;
|
||||
showBanner.value = false;
|
||||
|
||||
// Store dismissal in localStorage (expires after 24 hours)
|
||||
const dismissalData = {
|
||||
timestamp: Date.now(),
|
||||
userId: user.value?.id
|
||||
};
|
||||
localStorage.setItem('dues-banner-dismissed', JSON.stringify(dismissalData));
|
||||
}
|
||||
|
||||
async function markDuesAsPaid() {
|
||||
if (!memberData.value?.Id) return;
|
||||
|
||||
updating.value = true;
|
||||
|
||||
try {
|
||||
// Update member's dues status
|
||||
await $fetch(`/api/members/${memberData.value.Id}`, {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
current_year_dues_paid: 'true',
|
||||
membership_date_paid: new Date().toISOString(),
|
||||
payment_due_date: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString() // Next year
|
||||
}
|
||||
});
|
||||
|
||||
// Update local member state
|
||||
if (memberData.value) {
|
||||
memberData.value.current_year_dues_paid = 'true';
|
||||
memberData.value.membership_date_paid = new Date().toISOString();
|
||||
}
|
||||
|
||||
// Hide banner
|
||||
showBanner.value = false;
|
||||
markAsPaidDialog.value = false;
|
||||
|
||||
// Show success message
|
||||
snackbar.value = {
|
||||
show: true,
|
||||
message: 'Dues marked as paid successfully!',
|
||||
color: 'success'
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Failed to mark dues as paid:', error);
|
||||
snackbar.value = {
|
||||
show: true,
|
||||
message: 'Failed to update payment status. Please try again.',
|
||||
color: 'error'
|
||||
};
|
||||
} finally {
|
||||
updating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Load member data for the current user
|
||||
async function loadMemberData() {
|
||||
if (!user.value?.email) return;
|
||||
|
||||
try {
|
||||
const response = await $fetch('/api/members') as any;
|
||||
const members = response?.data || response?.list || [];
|
||||
|
||||
// Find member by email
|
||||
const member = members.find((m: any) => m.email === user.value?.email);
|
||||
if (member) {
|
||||
memberData.value = member;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load member data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Load configuration and check banner visibility
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const response = await $fetch('/api/admin/registration-config') as any;
|
||||
if (response?.success) {
|
||||
config.value = response.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load registration config:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if banner was recently dismissed
|
||||
function checkDismissalStatus() {
|
||||
try {
|
||||
const stored = localStorage.getItem('dues-banner-dismissed');
|
||||
if (stored) {
|
||||
const dismissalData = JSON.parse(stored);
|
||||
const hoursSinceDismissal = (Date.now() - dismissalData.timestamp) / (1000 * 60 * 60);
|
||||
|
||||
// Reset dismissal after 24 hours or if different user
|
||||
if (hoursSinceDismissal > 24 || dismissalData.userId !== user.value?.id) {
|
||||
localStorage.removeItem('dues-banner-dismissed');
|
||||
dismissed.value = false;
|
||||
} else {
|
||||
dismissed.value = true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to check dismissal status:', error);
|
||||
dismissed.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Watchers
|
||||
watch(shouldShowBanner, (newVal) => {
|
||||
showBanner.value = newVal;
|
||||
}, { immediate: true });
|
||||
|
||||
watch(user, () => {
|
||||
checkDismissalStatus();
|
||||
loadMemberData();
|
||||
}, { immediate: true });
|
||||
|
||||
// Initialize
|
||||
onMounted(() => {
|
||||
loadConfig();
|
||||
checkDismissalStatus();
|
||||
loadMemberData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dues-payment-banner {
|
||||
border-left: 4px solid #ff9800;
|
||||
}
|
||||
|
||||
.banner-content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.payment-details-card {
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2) !important;
|
||||
}
|
||||
|
||||
/* Mobile responsiveness */
|
||||
@media (max-width: 600px) {
|
||||
.banner-content .text-h6 {
|
||||
font-size: 1.1rem !important;
|
||||
}
|
||||
|
||||
.payment-details-card {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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>();
|
||||
|
||||
Reference in New Issue
Block a user