Member Hub
Architecture

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 configuration

Rackspace 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:

  1. Eligibility Checking

    • Validates membertypeid === 6 (Professional Members)
    • Validates status is 'Active' or 'Retired'
    • Hides card if member is not eligible
  2. Email Availability Display

  3. 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
  4. 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


The Hub architecture provides a scalable, maintainable foundation for NATCA member self-service while integrating seamlessly with the MyNATCA ecosystem.