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

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

#### __2. Advanced User Management__

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

#### __3. Session Security & Monitoring__

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

#### __4. Email Workflow System__

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

#### __5. Seamless Migration Path__

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

### 🔧 __What You Can Do Now__

#### __For New Users:__

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

#### __For Administrators:__

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

#### __For Developers:__

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

### 🛡️ __Security & Reliability__

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

View File

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

View File

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

View File

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