Member Hub Architecture
The MyNATCA Member Hub is a modern single-page application built with Vue 3, Vuetify 3, and TypeScript, providing members with self-service access to their membership data and professional email management.
Technology Stack
Frontend Framework
-
Vue 3: Progressive JavaScript framework with Composition API
- Reactive state management
- Component-based architecture
- TypeScript support
- Composables for reusable logic
-
Vuetify 3: Material Design component library
- Pre-built UI components (cards, buttons, dialogs, etc.)
- Responsive grid system
- Theme customization
- Icon support (Material Design Icons)
-
Vite: Next-generation build tool
- Lightning-fast HMR (Hot Module Replacement)
- Optimized production builds
- Built-in dev server with proxy support
- TypeScript and Vue SFC support
State Management
- Pinia: Official state management for Vue 3
- Type-safe stores
- Devtools integration
- Composable-style API
- SSR support
Routing
- Vue Router 4: Official router for Vue 3
- Client-side routing
- Route guards for authentication
- Dynamic route matching
Application Architecture
Component Structure
src/
├── components/
│ ├── dashboard/
│ │ ├── WelcomeCard.vue # Personalized greeting
│ │ ├── RackspaceEmailCard.vue # Email management
│ │ └── QuickLinksCard.vue # Resource shortcuts
│ ├── member/
│ │ ├── ProfileCard.vue # Member details
│ │ └── EditProfileDialog.vue # Update profile
│ └── common/
│ ├── AppBar.vue # Navigation header
│ └── LoadingSpinner.vue # Loading indicator
├── pages/
│ ├── index.vue # Dashboard page
│ ├── login.vue # Login page
│ ├── callback.vue # Auth callback
│ └── profile.vue # Member profile
├── services/
│ ├── rackspaceEmailService.ts # Rackspace API client
│ ├── memberService.ts # Member API client
│ └── authService.ts # Auth API client
├── stores/
│ ├── auth.ts # Authentication state
│ └── member.ts # Member data state
├── composables/
│ ├── useAuth0.ts # Auth0 authentication
│ └── useMember.ts # Member data management
└── router/
└── index.ts # Route configurationRackspace Email Integration
Service Layer Architecture
The Rackspace email functionality is implemented as a dedicated service layer that communicates with the Platform API:
// /src/services/rackspaceEmailService.ts
interface EmailAvailability {
memberNumber: string;
firstName: string;
lastName: string;
options: Array<{
format: string;
email: string;
available: boolean;
}>;
}
interface EmailCreationResult {
success: boolean;
email?: string;
password?: string;
error?: string;
}
class RackspaceEmailService {
private baseUrl = '/api/rackspace';
/**
* Check availability of email format options
* Calls Platform: POST /api/rackspace/check-availability
*/
async checkAvailability(memberNumber: string): Promise<EmailAvailability> {
const response = await fetch(`${this.baseUrl}/check-availability`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include', // Include session cookies
body: JSON.stringify({ memberNumber })
});
if (!response.ok) {
throw new Error('Failed to check email availability');
}
return response.json();
}
/**
* Create new @natca.net email account
* Calls Platform: POST /api/rackspace/create-email
*/
async createEmail(
memberNumber: string,
emailFormat: string
): Promise<EmailCreationResult> {
const response = await fetch(`${this.baseUrl}/create-email`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ memberNumber, emailFormat })
});
if (!response.ok) {
throw new Error('Failed to create email account');
}
return response.json();
}
/**
* Reset password for existing email account
* Calls Platform: POST /api/rackspace/reset-password
*/
async resetPassword(memberNumber: string): Promise<EmailCreationResult> {
const response = await fetch(`${this.baseUrl}/reset-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ memberNumber })
});
if (!response.ok) {
throw new Error('Failed to reset email password');
}
return response.json();
}
/**
* Find first available email format option
*/
async findAvailableFormat(availability: EmailAvailability): Promise<string | null> {
const available = availability.options.find(opt => opt.available);
return available ? available.format : null;
}
/**
* Generate numbered email variants if base options are taken
*/
generateIncrementedOptions(baseFormat: string, start = 2, count = 5): string[] {
return Array.from(
{ length: count },
(_, i) => `${baseFormat}${start + i}`
);
}
}
export const rackspaceEmailService = new RackspaceEmailService();Component Architecture: RackspaceEmailCard
Location: /src/components/dashboard/RackspaceEmailCard.vue
Key Features:
-
Eligibility Checking
- Validates membertypeid === 6 (Professional Members)
- Validates status is 'Active' or 'Retired'
- Hides card if member is not eligible
-
Email Availability Display
- Shows firstname@natca.net option
- Shows firstname.lastname@natca.net option
- Visual indicators:
- Green checkmark (
mdi-check-circle) for available - Red X (
mdi-close-circle) for taken
- Green checkmark (
- Click available option to create
-
Email Creation Flow
- Confirmation dialog before creation
- Loading state during API call
- One-time password display in warning alert
- Copy-to-clipboard functionality
- Automatic member store refresh
- Error handling with user-friendly messages
-
Password Reset Flow
- Reset button for existing emails
- Confirmation dialog with security warning
- New password generation
- One-time password display
- Copy-to-clipboard functionality
Component Structure:
<template>
<v-card
v-if="isEligible"
class="mb-4"
variant="tonal"
>
<v-card-title>
<v-icon start>mdi-email</v-icon>
NATCA Email
</v-card-title>
<v-card-text>
<!-- Existing Email Display -->
<div v-if="existingEmail">
<div class="d-flex align-center justify-space-between">
<span>{{ existingEmail }}</span>
<v-btn
icon="mdi-content-copy"
size="small"
variant="text"
@click="copyEmail"
/>
</div>
<v-btn
block
color="warning"
variant="tonal"
prepend-icon="mdi-lock-reset"
@click="confirmReset"
:loading="loading"
>
Reset Password
</v-btn>
</div>
<!-- No Email - Check Availability -->
<div v-else-if="!availability">
<v-btn
block
color="primary"
variant="tonal"
prepend-icon="mdi-email-check"
@click="checkAvailability"
:loading="loading"
>
Check Availability
</v-btn>
</div>
<!-- Availability Results -->
<div v-else>
<v-list density="compact">
<v-list-item
v-for="option in availability.options"
:key="option.format"
@click="option.available ? confirmCreate(option) : null"
:class="{ 'cursor-pointer': option.available }"
>
<template #prepend>
<v-icon
:color="option.available ? 'success' : 'error'"
:icon="option.available ? 'mdi-check-circle' : 'mdi-close-circle'"
/>
</template>
<v-list-item-title>{{ option.email }}</v-list-item-title>
</v-list-item>
</v-list>
</div>
<!-- One-Time Password Display -->
<v-alert
v-if="oneTimePassword"
type="warning"
variant="tonal"
closable
class="mt-4"
>
<div class="text-subtitle-2">One-Time Password</div>
<div class="d-flex align-center justify-space-between mt-2">
<code>{{ oneTimePassword }}</code>
<v-btn
icon="mdi-content-copy"
size="small"
variant="text"
@click="copyPassword"
/>
</div>
<div class="text-caption mt-2">
Save this password now. It will not be shown again.
</div>
</v-alert>
</v-card-text>
<!-- Confirmation Dialogs -->
<v-dialog v-model="showCreateDialog" max-width="500">
<v-card>
<v-card-title>Create Email Account</v-card-title>
<v-card-text>
Create <strong>{{ selectedOption?.email }}</strong>?
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="showCreateDialog = false">Cancel</v-btn>
<v-btn color="primary" @click="createEmail">Create</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="showResetDialog" max-width="500">
<v-card>
<v-card-title>Reset Password</v-card-title>
<v-card-text>
Reset password for <strong>{{ existingEmail }}</strong>?
<v-alert type="warning" variant="tonal" class="mt-4">
Your current password will stop working immediately.
</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="showResetDialog = false">Cancel</v-btn>
<v-btn color="warning" @click="resetPassword">Reset</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-card>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { rackspaceEmailService } from '@/services/rackspaceEmailService';
import { useMemberStore } from '@/stores/member';
import { useAuth0 } from '@/composables/useAuth0';
const memberStore = useMemberStore();
const { memberNumber } = useAuth0();
const loading = ref(false);
const availability = ref(null);
const selectedOption = ref(null);
const oneTimePassword = ref('');
const showCreateDialog = ref(false);
const showResetDialog = ref(false);
// Computed properties
const isEligible = computed(() => {
const member = memberStore.memberData;
return member?.membertypeid === 6 &&
['Active', 'Retired'].includes(member?.status);
});
const existingEmail = computed(() => {
const emails = memberStore.memberData?.emails || [];
return emails.find(e => e.includes('@natca.net'));
});
// Methods
async function checkAvailability() {
loading.value = true;
try {
availability.value = await rackspaceEmailService.checkAvailability(
memberNumber.value
);
} catch (error) {
console.error('Failed to check availability:', error);
} finally {
loading.value = false;
}
}
function confirmCreate(option) {
selectedOption.value = option;
showCreateDialog.value = true;
}
async function createEmail() {
showCreateDialog.value = false;
loading.value = true;
try {
const result = await rackspaceEmailService.createEmail(
memberNumber.value,
selectedOption.value.format
);
if (result.success) {
oneTimePassword.value = result.password;
await memberStore.refreshMember();
availability.value = null; // Hide availability
}
} catch (error) {
console.error('Failed to create email:', error);
} finally {
loading.value = false;
}
}
function confirmReset() {
showResetDialog.value = true;
}
async function resetPassword() {
showResetDialog.value = false;
loading.value = true;
try {
const result = await rackspaceEmailService.resetPassword(
memberNumber.value
);
if (result.success) {
oneTimePassword.value = result.password;
}
} catch (error) {
console.error('Failed to reset password:', error);
} finally {
loading.value = false;
}
}
function copyEmail() {
navigator.clipboard.writeText(existingEmail.value);
// Show toast notification
}
function copyPassword() {
navigator.clipboard.writeText(oneTimePassword.value);
// Show toast notification
}
</script>Authentication Flow
Session-Based Authentication
The Hub uses session-based authentication through the Platform API:
// /src/composables/useAuth0.ts
import { ref, computed } from 'vue';
import { useRouter } from 'vue-router';
export function useAuth0() {
const router = useRouter();
const isAuthenticated = ref(false);
const user = ref(null);
const memberNumber = ref(null);
// Check session status
async function checkSession() {
try {
const response = await fetch('/api/auth/session', {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
isAuthenticated.value = true;
user.value = data.user;
memberNumber.value = data.memberNumber;
} else {
isAuthenticated.value = false;
user.value = null;
memberNumber.value = null;
}
} catch (error) {
console.error('Session check failed:', error);
isAuthenticated.value = false;
}
}
// Redirect to login
function login() {
window.location.href = '/api/auth/login';
}
// Logout
async function logout() {
try {
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include'
});
isAuthenticated.value = false;
user.value = null;
memberNumber.value = null;
router.push('/login');
} catch (error) {
console.error('Logout failed:', error);
}
}
return {
isAuthenticated,
user,
memberNumber,
checkSession,
login,
logout
};
}Route Guards
// /src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router';
import { useAuth0 } from '@/composables/useAuth0';
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
name: 'dashboard',
component: () => import('@/pages/index.vue'),
meta: { requiresAuth: true }
},
{
path: '/login',
name: 'login',
component: () => import('@/pages/login.vue')
},
{
path: '/callback',
name: 'callback',
component: () => import('@/pages/callback.vue')
}
]
});
router.beforeEach(async (to, from, next) => {
const { isAuthenticated, checkSession } = useAuth0();
if (to.meta.requiresAuth) {
await checkSession();
if (!isAuthenticated.value) {
next('/login');
} else {
next();
}
} else {
next();
}
});
export default router;API Proxy Configuration
Vite Dev Server Proxy
All /api/* requests are proxied to the Platform API running on localhost:1300:
// vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import vuetify from 'vite-plugin-vuetify';
export default defineConfig({
plugins: [
vue(),
vuetify({ autoImport: true })
],
server: {
port: 1302, // Changed from 1301 to avoid conflicts
proxy: {
'/api': {
target: 'http://localhost:1300', // Platform API
changeOrigin: true,
secure: false,
credentials: 'include', // Forward cookies
configure: (proxy, options) => {
proxy.on('error', (err, req, res) => {
console.log('Proxy error:', err);
});
proxy.on('proxyReq', (proxyReq, req, res) => {
console.log('Proxying request to:', req.url);
});
}
}
}
},
resolve: {
alias: {
'@': '/src'
}
}
});Production Proxy
In production, the Hub is served by Nginx which proxies API requests to the Platform:
# nginx.conf
server {
listen 80;
server_name hub.natca.org;
root /usr/share/nginx/html;
index index.html;
# Serve Vue app
location / {
try_files $uri $uri/ /index.html;
}
# Proxy API requests to Platform
location /api/ {
proxy_pass http://platform.natca.org:1300;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
# Forward cookies
proxy_pass_header Set-Cookie;
proxy_cookie_domain platform.natca.org hub.natca.org;
}
}State Management with Pinia
Member Store
// /src/stores/member.ts
import { defineStore } from 'pinia';
export const useMemberStore = defineStore('member', {
state: () => ({
memberData: null,
loading: false,
error: null
}),
getters: {
memberNumber: (state) => state.memberData?.membernumber,
fullName: (state) => {
const { firstname, lastname } = state.memberData || {};
return `${firstname} ${lastname}`;
},
emails: (state) => state.memberData?.emails || [],
hasNatcaEmail: (state) => {
const emails = state.memberData?.emails || [];
return emails.some(e => e.includes('@natca.net'));
}
},
actions: {
async fetchMember(memberNumber: string) {
this.loading = true;
try {
const response = await fetch(`/api/member/${memberNumber}`, {
credentials: 'include'
});
if (!response.ok) {
throw new Error('Failed to fetch member data');
}
this.memberData = await response.json();
this.error = null;
} catch (error) {
this.error = error.message;
console.error('Failed to fetch member:', error);
} finally {
this.loading = false;
}
},
async refreshMember() {
if (this.memberNumber) {
await this.fetchMember(this.memberNumber);
}
}
}
});Auth Store
// /src/stores/auth.ts
import { defineStore } from 'pinia';
export const useAuthStore = defineStore('auth', {
state: () => ({
isAuthenticated: false,
user: null,
memberNumber: null,
accessToken: null
}),
getters: {
isLoggedIn: (state) => state.isAuthenticated && !!state.user,
userEmail: (state) => state.user?.email
},
actions: {
setAuthState(authenticated: boolean, user: any, memberNumber: string) {
this.isAuthenticated = authenticated;
this.user = user;
this.memberNumber = memberNumber;
},
clearAuthState() {
this.isAuthenticated = false;
this.user = null;
this.memberNumber = null;
this.accessToken = null;
}
}
});Error Handling
Global Error Handler
// /src/main.ts
import { createApp } from 'vue';
import App from './App.vue';
const app = createApp(App);
app.config.errorHandler = (err, instance, info) => {
console.error('Global error:', err);
console.error('Error info:', info);
// Log to monitoring service (e.g., Sentry)
// logErrorToMonitoring(err, { instance, info });
};
app.mount('#app');API Error Handling
// Service layer error handling example
async function callApi(endpoint: string, options = {}) {
try {
const response = await fetch(endpoint, {
...options,
credentials: 'include'
});
if (!response.ok) {
if (response.status === 401) {
// Redirect to login
window.location.href = '/api/auth/login';
throw new Error('Unauthorized');
} else if (response.status === 403) {
throw new Error('Forbidden: Insufficient permissions');
} else if (response.status === 404) {
throw new Error('Resource not found');
} else {
throw new Error(`API error: ${response.status}`);
}
}
return response.json();
} catch (error) {
if (error.message === 'Unauthorized') {
// Already handling redirect
throw error;
}
console.error('API call failed:', error);
throw new Error('Network error: Please check your connection');
}
}Performance Optimization
Code Splitting
// Lazy load routes
const routes = [
{
path: '/',
component: () => import('@/pages/index.vue')
},
{
path: '/profile',
component: () => import('@/pages/profile.vue')
}
];
// Lazy load components
const HeavyComponent = defineAsyncComponent(() =>
import('@/components/HeavyComponent.vue')
);Vuetify Tree Shaking
// vite.config.ts - Vuetify plugin handles tree shaking automatically
import vuetify from 'vite-plugin-vuetify';
export default defineConfig({
plugins: [
vuetify({
autoImport: true, // Auto-import only used components
styles: { configFile: 'src/styles/settings.scss' }
})
]
});Related Documentation
- Hub Overview - Feature overview and capabilities
- Hub Deployment - Deployment procedures
- Hub Local Setup - Development environment setup
- Rackspace Email User Guide - End-user documentation
- Platform API - Platform API reference
- Rackspace Integration - Backend integration details
The Hub architecture provides a scalable, maintainable foundation for NATCA member self-service while integrating seamlessly with the MyNATCA ecosystem.