monacousa-portal/components/AdminConfigurationDialog.vue

929 lines
29 KiB
Vue

<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-tab value="email">
<v-icon start>mdi-email</v-icon>
Email
</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>
<!-- Email Configuration Tab -->
<v-tabs-window-item value="email">
<v-alert
type="info"
variant="tonal"
class="mb-4"
>
<template #title>SMTP Email Configuration</template>
Configure SMTP settings for sending emails from the portal (registration confirmations, password resets, dues reminders).
</v-alert>
<v-form ref="emailFormRef" v-model="emailFormValid">
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="emailForm.host"
label="SMTP Host"
variant="outlined"
:rules="[rules.required]"
required
placeholder="smtp.gmail.com"
hint="Your SMTP server hostname"
persistent-hint
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model.number="emailForm.port"
label="Port"
variant="outlined"
:rules="[rules.required, rules.validPort]"
required
type="number"
min="1"
max="65535"
placeholder="587"
hint="Usually 587 (TLS) or 465 (SSL)"
persistent-hint
/>
</v-col>
<v-col cols="12">
<v-switch
v-model="emailForm.secure"
label="Use SSL/TLS encryption"
color="primary"
hide-details
class="mb-2"
/>
<div class="text-caption text-medium-emphasis">
Enable for secure connection (recommended for production)
</div>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="emailForm.username"
label="Username"
variant="outlined"
placeholder="your-email@domain.com"
hint="SMTP authentication username (usually your email)"
persistent-hint
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="emailForm.password"
label="Password"
variant="outlined"
:type="showEmailPassword ? 'text' : 'password'"
:append-inner-icon="showEmailPassword ? 'mdi-eye' : 'mdi-eye-off'"
@click:append-inner="showEmailPassword = !showEmailPassword"
placeholder="Enter SMTP password"
hint="SMTP authentication password or app password"
persistent-hint
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="emailForm.fromAddress"
label="From Email Address"
variant="outlined"
:rules="[rules.required, rules.email]"
required
type="email"
placeholder="noreply@monacousa.org"
hint="Email address that emails will be sent from"
persistent-hint
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="emailForm.fromName"
label="From Name"
variant="outlined"
:rules="[rules.required]"
required
placeholder="MonacoUSA Portal"
hint="Display name for outgoing emails"
persistent-hint
/>
</v-col>
<v-col cols="12">
<v-text-field
v-model="testEmailAddress"
label="Test Email Address"
variant="outlined"
:rules="[rules.email]"
type="email"
placeholder="admin@monacousa.org"
hint="Email address to send test email to"
persistent-hint
/>
</v-col>
<v-col cols="12" md="6">
<v-btn
@click="sendTestEmail"
:loading="emailTestLoading"
:disabled="!emailFormValid || emailLoading || !testEmailAddress"
color="info"
variant="outlined"
block
>
<v-icon start>mdi-email-send</v-icon>
Send Test Email
</v-btn>
</v-col>
<v-col cols="12" md="6">
<div class="d-flex align-center h-100">
<v-chip
v-if="emailTestStatus"
:color="emailTestStatus.success ? 'success' : 'error'"
variant="flat"
size="small"
>
<v-icon start size="14">
{{ emailTestStatus.success ? 'mdi-check-circle' : 'mdi-alert-circle' }}
</v-icon>
{{ emailTestStatus.message }}
</v-chip>
</div>
</v-col>
<v-col cols="12">
<v-alert
type="warning"
variant="outlined"
class="mt-2"
>
<div class="text-body-2">
<strong>Common SMTP Providers:</strong>
<ul class="mt-2 ml-4">
<li><strong>Gmail:</strong> smtp.gmail.com:587 (use App Password, not regular password)</li>
<li><strong>SendGrid:</strong> smtp.sendgrid.net:587</li>
<li><strong>Amazon SES:</strong> email-smtp.region.amazonaws.com:587</li>
<li><strong>Mailgun:</strong> smtp.mailgun.org:587</li>
</ul>
</div>
</v-alert>
</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, SMTPConfig } 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();
const emailFormRef = ref();
// Form validity
const nocodbFormValid = ref(false);
const recaptchaFormValid = ref(false);
const registrationFormValid = ref(false);
const emailFormValid = ref(false);
// Loading states
const nocodbLoading = ref(false);
const recaptchaLoading = ref(false);
const registrationLoading = ref(false);
const emailLoading = ref(false);
const nocodbTestLoading = ref(false);
const emailTestLoading = ref(false);
// Display states
const showNocodbApiKey = ref(false);
const showRecaptchaSecret = ref(false);
const showEmailPassword = ref(false);
const showSuccessMessage = ref(false);
const successMessage = ref('');
const errorMessage = ref('');
const nocodbConnectionStatus = ref<{ success: boolean; message: string } | null>(null);
const emailTestStatus = ref<{ success: boolean; message: string } | null>(null);
// Test email address
const testEmailAddress = ref('');
// 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: ''
});
const emailForm = ref<SMTPConfig>({
host: '',
port: 587,
secure: false,
username: '',
password: '',
fromAddress: '',
fromName: 'MonacoUSA Portal'
});
// 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';
},
validPort: (value: number) => {
return (value && value >= 1 && value <= 65535) || 'Port must be between 1 and 65535';
},
email: (value: string) => {
if (!value) return true;
const pattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return pattern.test(value) || 'Please enter a valid email address';
},
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;
case 'email':
return emailFormValid.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';
case 'email':
return 'Email 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;
}
};
// Send test email
const sendTestEmail = async () => {
if (!emailFormRef.value || !testEmailAddress.value) return;
const isValid = await emailFormRef.value.validate();
if (!isValid.valid) return;
// First save email configuration
emailLoading.value = true;
try {
await $fetch('/api/admin/smtp-config', {
method: 'POST',
body: emailForm.value
});
} catch (error: any) {
console.error('Failed to save SMTP config:', error);
errorMessage.value = 'Failed to save SMTP configuration: ' + error.message;
emailLoading.value = false;
return;
} finally {
emailLoading.value = false;
}
// Then send test email
emailTestLoading.value = true;
emailTestStatus.value = null;
try {
const response = await $fetch<{ success: boolean; message: string }>('/api/admin/test-email', {
method: 'POST',
body: { testEmail: testEmailAddress.value }
});
emailTestStatus.value = {
success: response.success,
message: response.message || (response.success ? 'Test email sent successfully' : 'Test email failed')
};
} catch (error: any) {
emailTestStatus.value = {
success: false,
message: error.message || 'Test email failed'
};
} finally {
emailTestLoading.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;
case 'email':
formRef = emailFormRef.value;
loading = emailLoading;
data = emailForm.value;
endpoint = '/api/admin/smtp-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>