From 96d8bedf6d8d90387231dfb56a261a94e4a80496 Mon Sep 17 00:00:00 2001 From: laxmanhalaki Date: Wed, 29 Oct 2025 19:40:16 +0530 Subject: [PATCH] login flow added --- .cursor/project_setup.md | 969 ++++++++++++++++++ src/App.tsx | 43 +- .../layout/PageLayout/PageLayout.tsx | 92 +- src/components/ui/alert-dialog.tsx | 58 +- src/contexts/AuthContext.tsx | 618 +++++++++++ src/main.tsx | 27 +- src/pages/Auth/Auth.tsx | 24 +- src/pages/Auth/AuthCallback.tsx | 19 +- src/pages/Auth/AuthenticatedApp.tsx | 62 +- src/pages/Profile/Profile.tsx | 281 +++++ src/pages/Profile/index.ts | 2 + src/pages/Settings/Settings.tsx | 146 +++ src/pages/Settings/index.ts | 2 + src/services/authApi.ts | 236 +++++ src/utils/tokenManager.ts | 400 ++++++++ 15 files changed, 2867 insertions(+), 112 deletions(-) create mode 100644 .cursor/project_setup.md create mode 100644 src/contexts/AuthContext.tsx create mode 100644 src/pages/Profile/Profile.tsx create mode 100644 src/pages/Profile/index.ts create mode 100644 src/pages/Settings/Settings.tsx create mode 100644 src/pages/Settings/index.ts create mode 100644 src/services/authApi.ts create mode 100644 src/utils/tokenManager.ts diff --git a/.cursor/project_setup.md b/.cursor/project_setup.md new file mode 100644 index 0000000..a53b117 --- /dev/null +++ b/.cursor/project_setup.md @@ -0,0 +1,969 @@ +# RE Workflow Management System - Frontend Setup Guide +**Version:** 1.0 +**Date:** October 16, 2025 +**Technology Stack:** React 19 + TypeScript 5.7 + Vite 6.0 + Redux Toolkit 2.5 + Material-UI 6.3 + +--- + +## Table of Contents +1. [Frontend Architecture Overview](#frontend-architecture-overview) +2. [Technology Stack](#technology-stack) +3. [Project Folder Structure](#project-folder-structure) +4. [TypeScript Configuration](#typescript-configuration) +5. [State Management (Redux Toolkit)](#state-management-redux-toolkit) +6. [Component Structure](#component-structure) +7. [Configuration Management](#configuration-management) +8. [Deployment Architecture](#deployment-architecture) +9. [Development Setup Instructions](#development-setup-instructions) + +--- + +## 1. Frontend Architecture Overview + +### High-Level Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ CLIENT LAYER │ +│ React.js SPA (Single Page Application) │ +│ - Material-UI / Ant Design Components │ +│ - Redux Toolkit for State Management │ +│ - React Router for Navigation │ +│ - Axios for API Communication │ +│ - Vite for Build & Development │ +└─────────────────────────────────────────────────────────────┘ + ↕ HTTPS/REST API +┌─────────────────────────────────────────────────────────────┐ +│ BACKEND API LAYER │ +│ Express.js REST API Server │ +│ - Node.js + TypeScript │ +│ - PostgreSQL Database │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. Technology Stack + +| Component | Technology | Version | Purpose | +|-----------|-----------|---------|---------| +| **Frontend Library** | React.js | 19.0.x | UI component library | +| **Frontend Language** | TypeScript (TSX) | 5.7.x | Type-safe frontend development | +| **UI Framework** | Material-UI (MUI) | 6.3.x | Component design system | +| **Build Tool** | Vite | 6.0.x | Ultra-fast build & dev server | +| **State Management** | Redux Toolkit | 2.5.x | Application state management | +| **Routing** | React Router DOM | 7.1.x | Client-side routing | +| **HTTP Client** | Axios | 1.7.x | API communication | +| **Form Management** | Formik | 2.4.x | Form state management | +| **Form Validation** | Yup | 1.6.x | Schema validation | +| **Date Utilities** | date-fns | 4.1.x | Date manipulation | +| **Charts** | Recharts | 2.15.x | Data visualization | +| **Notifications** | React Toastify | 11.0.x | Toast notifications | +| **File Upload** | React Dropzone | 14.3.x | File upload UI | +| **Testing** | Vitest + React Testing Library | 2.1.x/16.1.x | Component testing | +| **Code Quality** | ESLint + Prettier | 9.x/3.x | Code linting & formatting | + +--- + +## 3. Project Folder Structure + +### Frontend Repository (`re-workflow-frontend`) + +``` +re-workflow-frontend/ +│ +├── public/ # Static assets +│ ├── index.html +│ ├── favicon.ico +│ ├── robots.txt +│ └── manifest.json +│ +├── src/ # Source code +│ ├── index.tsx # Application entry point +│ ├── App.tsx # Root component +│ ├── App.css +│ ├── react-app-env.d.ts # React TypeScript declarations +│ │ +│ ├── types/ # TypeScript type definitions +│ │ ├── index.ts # Main types export +│ │ ├── user.types.ts # User types +│ │ ├── workflow.types.ts # Workflow types +│ │ ├── approval.types.ts # Approval types +│ │ ├── document.types.ts # Document types +│ │ ├── notification.types.ts # Notification types +│ │ ├── common.types.ts # Common types +│ │ └── api.types.ts # API response types +│ │ +│ ├── assets/ # Static assets +│ │ ├── images/ +│ │ ├── icons/ +│ │ ├── fonts/ +│ │ └── styles/ +│ │ ├── global.css +│ │ ├── variables.css +│ │ └── theme.ts +│ │ +│ ├── components/ # Reusable components +│ │ ├── common/ # Generic components +│ │ │ ├── Button/ +│ │ │ │ ├── Button.tsx +│ │ │ │ ├── Button.module.css +│ │ │ │ ├── Button.types.ts +│ │ │ │ └── Button.test.tsx +│ │ │ ├── Input/ +│ │ │ ├── Dropdown/ +│ │ │ ├── Modal/ +│ │ │ ├── Card/ +│ │ │ ├── Table/ +│ │ │ ├── Tabs/ +│ │ │ ├── Pagination/ +│ │ │ ├── Loader/ +│ │ │ ├── Notification/ +│ │ │ └── ErrorBoundary/ +│ │ │ +│ │ ├── layout/ # Layout components +│ │ │ ├── Header/ +│ │ │ │ ├── Header.tsx +│ │ │ │ ├── Header.module.css +│ │ │ │ └── Header.types.ts +│ │ │ ├── Sidebar/ +│ │ │ ├── Footer/ +│ │ │ ├── Navigation/ +│ │ │ └── PageLayout/ +│ │ │ +│ │ ├── workflow/ # Workflow-specific components +│ │ │ ├── TemplateSelector/ +│ │ │ ├── BasicInformation/ +│ │ │ ├── ApprovalWorkflow/ +│ │ │ │ ├── ApprovalWorkflow.tsx +│ │ │ │ ├── ApproverLevel.tsx +│ │ │ │ ├── TATCalculator.tsx +│ │ │ │ ├── ApprovalSummary.tsx +│ │ │ │ └── types.ts +│ │ │ ├── ParticipantAccess/ +│ │ │ │ ├── ParticipantAccess.tsx +│ │ │ │ ├── SpectatorList.tsx +│ │ │ │ ├── UserTagging.tsx +│ │ │ │ └── types.ts +│ │ │ ├── DocumentUpload/ +│ │ │ │ ├── DocumentUpload.tsx +│ │ │ │ ├── FilePreview.tsx +│ │ │ │ ├── GoogleDocsLink.tsx +│ │ │ │ └── types.ts +│ │ │ ├── ReviewSubmit/ +│ │ │ └── WizardStepper/ +│ │ │ +│ │ ├── request/ # Request management components +│ │ │ ├── RequestCard/ +│ │ │ ├── RequestList/ +│ │ │ ├── RequestDetail/ +│ │ │ │ ├── RequestOverview.tsx +│ │ │ │ ├── WorkflowTab.tsx +│ │ │ │ ├── DocumentTab.tsx +│ │ │ │ ├── ActivityTab.tsx +│ │ │ │ ├── TATProgressBar.tsx +│ │ │ │ └── types.ts +│ │ │ ├── StatusBadge/ +│ │ │ └── PriorityIndicator/ +│ │ │ +│ │ ├── approval/ # Approval action components +│ │ │ ├── ApprovalModal/ +│ │ │ ├── RejectionModal/ +│ │ │ ├── ApprovalHistory/ +│ │ │ └── ConclusionRemark/ +│ │ │ +│ │ ├── workNote/ # Work notes / chat components +│ │ │ ├── WorkNoteChat/ +│ │ │ ├── MessageItem/ +│ │ │ ├── MessageComposer/ +│ │ │ ├── FileAttachment/ +│ │ │ └── UserMention/ +│ │ │ +│ │ ├── notification/ # Notification components +│ │ │ ├── NotificationBell/ +│ │ │ ├── NotificationList/ +│ │ │ ├── NotificationItem/ +│ │ │ └── NotificationSettings/ +│ │ │ +│ │ └── dashboard/ # Dashboard components +│ │ ├── DashboardCard/ +│ │ ├── StatisticsWidget/ +│ │ ├── RecentRequests/ +│ │ └── QuickActions/ +│ │ +│ ├── pages/ # Page components (routes) +│ │ ├── Auth/ +│ │ │ ├── Login.tsx +│ │ │ └── SSOCallback.tsx +│ │ ├── Dashboard/ +│ │ │ └── Dashboard.tsx +│ │ ├── CreateRequest/ +│ │ │ └── CreateRequest.tsx +│ │ ├── MyRequests/ +│ │ │ └── MyRequests.tsx +│ │ ├── OpenRequests/ +│ │ │ └── OpenRequests.tsx +│ │ ├── ClosedRequests/ +│ │ │ └── ClosedRequests.tsx +│ │ ├── RequestDetail/ +│ │ │ └── RequestDetail.tsx +│ │ ├── NotFound/ +│ │ │ └── NotFound.tsx +│ │ └── Unauthorized/ +│ │ └── Unauthorized.tsx +│ │ +│ ├── redux/ # Redux state management +│ │ ├── store.ts # Redux store configuration +│ │ ├── hooks.ts # Typed Redux hooks +│ │ ├── slices/ +│ │ │ ├── authSlice.ts +│ │ │ ├── workflowSlice.ts +│ │ │ ├── approvalSlice.ts +│ │ │ ├── notificationSlice.ts +│ │ │ ├── documentSlice.ts +│ │ │ ├── workNoteSlice.ts +│ │ │ ├── participantSlice.ts +│ │ │ └── uiSlice.ts +│ │ └── middleware/ +│ │ └── apiMiddleware.ts +│ │ +│ ├── services/ # API service layer +│ │ ├── api.ts # Axios instance configuration +│ │ ├── auth.service.ts +│ │ ├── workflow.service.ts +│ │ ├── approval.service.ts +│ │ ├── document.service.ts +│ │ ├── notification.service.ts +│ │ ├── workNote.service.ts +│ │ ├── participant.service.ts +│ │ ├── dashboard.service.ts +│ │ └── user.service.ts +│ │ +│ ├── hooks/ # Custom React hooks +│ │ ├── useAuth.ts +│ │ ├── useWorkflow.ts +│ │ ├── useNotification.ts +│ │ ├── useDebounce.ts +│ │ ├── useInfiniteScroll.ts +│ │ ├── useLocalStorage.ts +│ │ └── useWebSocket.ts +│ │ +│ ├── utils/ # Utility functions +│ │ ├── constants.ts +│ │ ├── validators.ts +│ │ ├── formatters.ts +│ │ ├── dateUtils.ts +│ │ ├── fileUtils.ts +│ │ ├── errorHandler.ts +│ │ └── helpers.ts +│ │ +│ ├── routes/ # React Router configuration +│ │ ├── AppRoutes.tsx +│ │ ├── PrivateRoute.tsx +│ │ └── PublicRoute.tsx +│ │ +│ └── config/ # Frontend configuration +│ ├── api.config.ts +│ ├── theme.config.ts +│ └── constants.config.ts +│ +├── dist/ # Build output (Vite) +│ +├── tests/ # Test files +│ ├── components/ +│ ├── pages/ +│ ├── redux/ +│ ├── services/ +│ └── setup.js +│ +├── docs/ # Component documentation +│ └── storybook/ +│ +├── nginx/ # Nginx configuration for production +│ └── default.conf +│ +├── .env.example +├── .env.development +├── .env.production +├── .eslintrc.json +├── .prettierrc +├── .gitignore +├── .dockerignore +├── Dockerfile +├── vite.config.ts # Vite configuration +├── tsconfig.json # TypeScript configuration +├── package.json +├── package-lock.json +└── README.md +``` + +--- + +## 4. TypeScript Configuration + +### `tsconfig.json` + +```json +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "allowJs": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "allowSyntheticDefaultImports": true, + "isolatedModules": true, + "noEmit": true, + "baseUrl": "./src", + "paths": { + "@/*": ["./*"], + "@components/*": ["components/*"], + "@pages/*": ["pages/*"], + "@services/*": ["services/*"], + "@redux/*": ["redux/*"], + "@hooks/*": ["hooks/*"], + "@utils/*": ["utils/*"], + "@types/*": ["types/*"], + "@config/*": ["config/*"], + "@assets/*": ["assets/*"] + } + }, + "include": ["src"], + "exclude": ["node_modules", "build", "dist"] +} +``` + +### `vite.config.ts` + +```typescript +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'path'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + '@components': path.resolve(__dirname, './src/components'), + '@pages': path.resolve(__dirname, './src/pages'), + '@services': path.resolve(__dirname, './src/services'), + '@redux': path.resolve(__dirname, './src/redux'), + '@hooks': path.resolve(__dirname, './src/hooks'), + '@utils': path.resolve(__dirname, './src/utils'), + '@types': path.resolve(__dirname, './src/types'), + '@config': path.resolve(__dirname, './src/config'), + '@assets': path.resolve(__dirname, './src/assets'), + }, + }, + server: { + port: 3000, + proxy: { + '/api': { + target: 'http://localhost:5000', + changeOrigin: true, + }, + }, + }, + build: { + outDir: 'dist', + sourcemap: true, + }, +}); +``` + +--- + +## 5. State Management (Redux Toolkit) + +### Redux Store Configuration + +#### `src/redux/store.ts` + +```typescript +import { configureStore } from '@reduxjs/toolkit'; +import authReducer from './slices/authSlice'; +import workflowReducer from './slices/workflowSlice'; +import approvalReducer from './slices/approvalSlice'; +import notificationReducer from './slices/notificationSlice'; +import documentReducer from './slices/documentSlice'; +import workNoteReducer from './slices/workNoteSlice'; +import participantReducer from './slices/participantSlice'; +import uiReducer from './slices/uiSlice'; + +export const store = configureStore({ + reducer: { + auth: authReducer, + workflow: workflowReducer, + approval: approvalReducer, + notification: notificationReducer, + document: documentReducer, + workNote: workNoteReducer, + participant: participantReducer, + ui: uiReducer, + }, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ + serializableCheck: { + ignoredActions: ['persist/PERSIST'], + }, + }), +}); + +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; +``` + +### Typed Redux Hooks + +#### `src/redux/hooks.ts` + +```typescript +import { useDispatch, useSelector } from 'react-redux'; +import type { TypedUseSelectorHook } from 'react-redux'; +import type { RootState, AppDispatch } from './store'; + +// Use throughout your app instead of plain `useDispatch` and `useSelector` +export const useAppDispatch: () => AppDispatch = useDispatch; +export const useAppSelector: TypedUseSelectorHook = useSelector; +``` + +### Type Definitions + +#### `src/types/common.types.ts` + +```typescript +export enum Priority { + STANDARD = 'STANDARD', + EXPRESS = 'EXPRESS' +} + +export enum WorkflowStatus { + DRAFT = 'DRAFT', + PENDING = 'PENDING', + IN_PROGRESS = 'IN_PROGRESS', + APPROVED = 'APPROVED', + REJECTED = 'REJECTED', + CLOSED = 'CLOSED' +} + +export interface ApiResponse { + success: boolean; + message: string; + data?: T; + error?: string; + timestamp: string; +} + +export interface PaginatedResponse { + data: T[]; + pagination: { + page: number; + limit: number; + total: number; + totalPages: number; + }; +} +``` + +--- + +## 6. Component Structure + +### Example: Button Component + +#### `src/components/common/Button/Button.tsx` + +```typescript +import React from 'react'; +import styles from './Button.module.css'; + +interface ButtonProps { + label: string; + onClick: () => void; + variant?: 'primary' | 'secondary' | 'danger'; + disabled?: boolean; + fullWidth?: boolean; +} + +const Button: React.FC = ({ + label, + onClick, + variant = 'primary', + disabled = false, + fullWidth = false, +}) => { + return ( + + ); +}; + +export default Button; +``` + +#### `src/components/common/Button/Button.types.ts` + +```typescript +export interface ButtonProps { + label: string; + onClick: () => void; + variant?: 'primary' | 'secondary' | 'danger'; + disabled?: boolean; + fullWidth?: boolean; +} +``` + +--- + +## 7. Configuration Management + +### Environment Variables + +#### `.env.example` + +```bash +# frontend/.env.example + +# Application +REACT_APP_ENV=development +REACT_APP_NAME=RE Workflow Management +REACT_APP_VERSION=1.0.0 + +# API Configuration +REACT_APP_API_BASE_URL=http://localhost:5000/api/v1 +REACT_APP_API_TIMEOUT=30000 + +# SSO Configuration +REACT_APP_SSO_LOGIN_URL=http://localhost:5000/api/v1/auth/login +REACT_APP_SSO_LOGOUT_URL=http://localhost:5000/api/v1/auth/logout + +# Feature Flags +REACT_APP_ENABLE_NOTIFICATIONS=true +REACT_APP_ENABLE_EMAIL_NOTIFICATIONS=false +REACT_APP_ENABLE_ANALYTICS=true + +# File Upload +REACT_APP_MAX_FILE_SIZE_MB=10 +REACT_APP_ALLOWED_FILE_TYPES=pdf,doc,docx,xls,xlsx,ppt,pptx,jpg,jpeg,png,gif + +# Google Integration +REACT_APP_GOOGLE_API_KEY=your_google_api_key + +# UI Configuration +REACT_APP_THEME_PRIMARY_COLOR=#1976d2 +REACT_APP_ITEMS_PER_PAGE=20 +``` + +### API Configuration + +#### `src/config/api.config.ts` + +```typescript +export const API_CONFIG = { + baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000/api/v1', + timeout: parseInt(import.meta.env.VITE_API_TIMEOUT || '30000'), + headers: { + 'Content-Type': 'application/json', + }, +}; + +export const API_ENDPOINTS = { + auth: { + login: '/auth/login', + logout: '/auth/logout', + me: '/auth/me', + }, + workflows: { + getAll: '/workflows', + create: '/workflows', + getById: (id: string) => `/workflows/${id}`, + update: (id: string) => `/workflows/${id}`, + submit: (id: string) => `/workflows/${id}/submit`, + }, + // ... more endpoints +}; +``` + +--- + +## 8. Deployment Architecture + +### Dockerfile + +#### `Dockerfile` + +```dockerfile +# re-workflow-frontend/Dockerfile + +FROM node:22-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci + +# Copy source code +COPY . . + +# Build application with Vite +RUN npm run build + +# ===================================== +# Production Image with Nginx +# ===================================== +FROM nginx:alpine + +# Copy custom nginx config +COPY nginx/default.conf /etc/nginx/conf.d/default.conf + +# Copy built files from builder (Vite outputs to 'dist' by default) +COPY --from=builder /app/dist /usr/share/nginx/html + +# Expose port +EXPOSE 80 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s \ + CMD wget --quiet --tries=1 --spider http://localhost/ || exit 1 + +CMD ["nginx", "-g", "daemon off;"] +``` + +### Nginx Configuration + +#### `nginx/default.conf` + +```nginx +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Gzip compression + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + + # React Router support + location / { + try_files $uri $uri/ /index.html; + } + + # Cache static assets + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Health check endpoint + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } +} +``` + +### CI/CD Pipeline (GitHub Actions) + +#### `.github/workflows/frontend-deploy.yml` + +```yaml +# .github/workflows/frontend-deploy.yml + +name: Frontend CI/CD + +on: + push: + branches: [main, develop] + pull_request: + branches: [main] + +jobs: + test-and-build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install dependencies + run: npm ci + + - name: Run linter + run: npm run lint + + - name: Run tests + run: npm test + + - name: Build application + run: npm run build + + - name: Build Docker image + run: docker build -t gcr.io/re-project/re-workflow-frontend:${{ github.sha }} . + + - name: Push to GCR + run: docker push gcr.io/re-project/re-workflow-frontend:${{ github.sha }} +``` + +--- + +## 9. Development Setup Instructions + +### 9.1 Prerequisites + +- **Node.js:** v22.x LTS +- **npm:** v10.x or higher +- **Git:** Latest version +- **TypeScript:** v5.7.x (installed as dev dependency) +- **Backend API:** Running on `http://localhost:5000` (see backend documentation) + +### 9.2 Local Development Setup + +#### Step 1: Clone Frontend Repository + +```bash +git clone https://github.com/royalenfield/re-workflow-frontend.git +cd re-workflow-frontend +``` + +#### Step 2: Configure Frontend Environment + +```bash +# Copy environment file +cp .env.example .env + +# Edit .env with backend API URL +nano .env # or use your preferred editor + +# Set REACT_APP_API_BASE_URL=http://localhost:5000/api/v1 +``` + +#### Step 3: Install Dependencies & Run Frontend + +```bash +# Install dependencies +npm install + +# Run TypeScript type checking +npm run type-check + +# Start development server (Vite with hot reload) +npm run dev + +# Frontend will run on: http://localhost:3000 +``` + +#### Step 4: Access Application + +- **Frontend:** http://localhost:3000 +- **Backend API:** http://localhost:5000 +- **API Documentation:** http://localhost:5000/api-docs +- **Health Check:** http://localhost:5000/health + +### 9.3 Docker Setup + +```bash +# From frontend repository root +cd re-workflow-frontend + +# Copy environment file +cp .env.example .env + +# Build Docker image +docker build -t re-workflow-frontend . + +# Run frontend container +docker run -d -p 3000:80 --name frontend re-workflow-frontend + +# Access frontend: http://localhost:3000 + +# Stop container +docker stop frontend +docker rm frontend +``` + +### 9.4 Running Tests + +```bash +# From frontend repository +cd re-workflow-frontend + +npm test # Run all tests (Vitest) +npm run test:ui # Interactive UI mode (Vitest) +npm run test:coverage # With coverage report + +# Coverage report will be in: coverage/ +``` + +### 9.5 Code Quality Checks + +```bash +# From frontend repository +cd re-workflow-frontend + +npm run lint # ESLint check (React + TypeScript rules) +npm run lint:fix # Auto-fix issues +npm run format # Prettier formatting +npm run type-check # TypeScript type checking only (no build) + +# Run all quality checks together +npm run lint && npm run type-check && npm test +``` + +### 9.6 Git Workflow + +```bash +# Feature branch workflow +git checkout -b feature/your-feature-name +git add . +git commit -m "feat: add your feature description" +git push origin feature/your-feature-name + +# Create Pull Request on GitHub/GitLab +# After approval and merge: +git checkout main +git pull origin main +``` + +**Branch Strategy:** +- `main` - Production-ready code +- `develop` - Integration branch for features +- `feature/*` - New features +- `bugfix/*` - Bug fixes +- `hotfix/*` - Production hotfixes + +**Commit Message Convention (Conventional Commits):** +- `feat:` - New feature +- `fix:` - Bug fix +- `docs:` - Documentation changes +- `style:` - Code style changes (formatting) +- `refactor:` - Code refactoring +- `test:` - Test additions or changes +- `chore:` - Build process or tool changes + +--- + +## 10. Package.json + +```json +{ + "name": "re-workflow-frontend", + "version": "1.0.0", + "description": "Royal Enfield Workflow Management System - Frontend (TypeScript)", + "private": true, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1", + "@reduxjs/toolkit": "^2.5.0", + "react-redux": "^9.2.0", + "@mui/material": "^6.3.0", + "@mui/icons-material": "^6.3.0", + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.0", + "axios": "^1.7.9", + "formik": "^2.4.6", + "yup": "^1.6.3", + "zod": "^3.24.1", + "react-dropzone": "^14.3.5", + "date-fns": "^4.1.0", + "recharts": "^2.15.0", + "react-toastify": "^11.0.2" + }, + "devDependencies": { + "@types/react": "^19.0.6", + "@types/react-dom": "^19.0.2", + "@types/node": "^22.10.5", + "typescript": "^5.7.2", + "vite": "^6.0.7", + "@vitejs/plugin-react": "^4.3.4", + "@testing-library/react": "^16.1.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/user-event": "^14.5.2", + "eslint": "^9.17.0", + "@typescript-eslint/eslint-plugin": "^8.19.1", + "@typescript-eslint/parser": "^8.19.1", + "prettier": "^3.4.2", + "vitest": "^2.1.8" + }, + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "test": "vitest", + "test:ui": "vitest --ui", + "test:coverage": "vitest --coverage", + "lint": "eslint src/**/*.{ts,tsx}", + "lint:fix": "eslint src/**/*.{ts,tsx} --fix", + "format": "prettier --write \"src/**/*.{ts,tsx,css}\"", + "type-check": "tsc --noEmit" + }, + "eslintConfig": { + "extends": ["react-app", "react-app/jest"] + }, + "browserslist": { + "production": [">0.2%", "not dead", "not op_mini all"], + "development": ["last 1 chrome version", "last 1 firefox version", "last 1 safari version"] + }, + "engines": { + "node": ">=22.0.0", + "npm": ">=10.0.0" + } +} +``` + +--- + +## Summary + +This frontend documentation provides: + +✅ **Complete Frontend Architecture** - React SPA with Vite, Redux Toolkit, TypeScript +✅ **Technology Stack** - React 19 + TypeScript 5.7 + Vite 6.0 + Material-UI 6.3 +✅ **Folder Structure** - Detailed organization with TypeScript conventions +✅ **Type Definitions** - Comprehensive type safety with interfaces and enums +✅ **Redux Toolkit Setup** - Fully typed state management +✅ **Component Structure** - Reusable, testable components +✅ **Configuration Management** - Environment variables and settings +✅ **Deployment Architecture** - Docker, Nginx, CI/CD pipelines +✅ **Development Setup** - Step-by-step installation and configuration +✅ **Testing Strategy** - Vitest and React Testing Library +✅ **Code Quality** - ESLint, Prettier, TypeScript best practices + +**Technology Stack:** React 19 + TypeScript 5.7 + Vite 6.0 + Redux Toolkit 2.5 + Material-UI 6.3 +**Repository:** `re-workflow-frontend` (Independent Repository) +**Status:** ✅ Ready for Implementation + diff --git a/src/App.tsx b/src/App.tsx index 9cba27e..2b1094e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,8 @@ import { WorkNoteChat } from '@/components/workNote/WorkNoteChat'; import { CreateRequest } from '@/pages/CreateRequest'; import { ClaimManagementWizard } from '@/components/workflow/ClaimManagementWizard'; import { MyRequests } from '@/pages/MyRequests'; +import { Profile } from '@/pages/Profile'; +import { Settings } from '@/pages/Settings'; import { ApprovalActionModal } from '@/components/modals/ApprovalActionModal'; import { Toaster } from '@/components/ui/sonner'; import { toast } from 'sonner'; @@ -55,6 +57,15 @@ function AppRoutes({ onLogout }: AppProps) { }, [dynamicRequests]); const handleNavigate = (page: string) => { + // Handle special routes + if (page === 'profile') { + navigate('/profile'); + return; + } + if (page === 'settings') { + navigate('/settings'); + return; + } navigate(`/${page}`); }; @@ -556,6 +567,26 @@ function AppRoutes({ onLogout }: AppProps) { /> } /> + + {/* Profile */} + + + + } + /> + + {/* Settings */} + + + + } + /> void; +} + +export default function App(props?: MainAppProps) { + const { onLogout } = props || {}; + console.log('🟢 Main App component rendered'); + console.log('🟢 onLogout prop received:', !!onLogout); + return ( - + ); } diff --git a/src/components/layout/PageLayout/PageLayout.tsx b/src/components/layout/PageLayout/PageLayout.tsx index 02cb0f7..efcecd3 100644 --- a/src/components/layout/PageLayout/PageLayout.tsx +++ b/src/components/layout/PageLayout/PageLayout.tsx @@ -5,6 +5,17 @@ import { Input } from '@/components/ui/input'; import { Badge } from '@/components/ui/badge'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { useAuth } from '@/contexts/AuthContext'; import royalEnfieldLogo from '@/assets/images/royal_enfield_logo.png'; interface PageLayoutProps { @@ -17,6 +28,23 @@ interface PageLayoutProps { export function PageLayout({ children, currentPage = 'dashboard', onNavigate, onNewRequest, onLogout }: PageLayoutProps) { const [sidebarOpen, setSidebarOpen] = useState(false); + const [showLogoutDialog, setShowLogoutDialog] = useState(false); + const { user } = useAuth(); + + // Get user initials for avatar + const getUserInitials = () => { + if (user?.displayName) { + const names = user.displayName.split(' ').filter(Boolean); + if (names.length >= 2) { + return `${names[0]?.[0] || ''}${names[names.length - 1]?.[0] || ''}`.toUpperCase(); + } + return user.displayName.substring(0, 2).toUpperCase(); + } + if (user?.email) { + return user.email.substring(0, 2).toUpperCase(); + } + return 'U'; + }; const menuItems = [ { id: 'dashboard', label: 'Dashboard', icon: Home }, @@ -188,20 +216,32 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on - - JD + + + {getUserInitials()} + - + onNavigate?.('profile')}> Profile - + { + e.preventDefault(); + // Settings page not ready yet - do nothing for now + }} + className="opacity-50 cursor-not-allowed" + > Settings + Coming soon - + setShowLogoutDialog(true)} + className="text-red-600 focus:text-red-600" + > Logout @@ -215,6 +255,48 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on {children} + + {/* Logout Confirmation Dialog */} + + + + + + Confirm Logout + + + Are you sure you want to logout? You will need to sign in again to access your account. + + + + setShowLogoutDialog(false)}> + Cancel + + { + console.log('🔴 Logout button clicked in PageLayout'); + console.log('🔴 onLogout function exists?', !!onLogout); + setShowLogoutDialog(false); + if (onLogout) { + console.log('🔴 Calling onLogout function...'); + try { + await onLogout(); + console.log('🔴 onLogout completed'); + } catch (error) { + console.error('🔴 Error calling onLogout:', error); + } + } else { + console.error('🔴 ERROR: onLogout is undefined!'); + } + }} + className="bg-red-600 hover:bg-red-700 text-white focus:ring-red-600" + > + + Logout + + + + ); } \ No newline at end of file diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx index 875b8df..7560dc0 100644 --- a/src/components/ui/alert-dialog.tsx +++ b/src/components/ui/alert-dialog.tsx @@ -28,40 +28,40 @@ function AlertDialogPortal({ ); } -function AlertDialogOverlay({ - className, - ...props -}: React.ComponentProps) { - return ( - , + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + - ); -} - -function AlertDialogContent({ - className, - ...props -}: React.ComponentProps) { - return ( - - - - - ); -} + +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; function AlertDialogHeader({ className, diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..2c2857d --- /dev/null +++ b/src/contexts/AuthContext.tsx @@ -0,0 +1,618 @@ +/** + * Authentication Context + * Provides unified authentication interface that works with both: + * - Backend token exchange (localhost/development) + * - Auth0 Provider (production) + */ + +import { createContext, useContext, useEffect, useState, ReactNode, useRef } from 'react'; +import { Auth0Provider, useAuth0 as useAuth0Hook } from '@auth0/auth0-react'; +import { TokenManager, isTokenExpired } from '../utils/tokenManager'; +import { exchangeCodeForTokens, refreshAccessToken, getCurrentUser, logout as logoutApi } from '../services/authApi'; + +interface User { + userId?: string; + employeeId?: string; + email?: string; + firstName?: string; + lastName?: string; + displayName?: string; + department?: string; + designation?: string; + isAdmin?: boolean; + sub?: string; + name?: string; + picture?: string; + nickname?: string; +} + +interface AuthContextType { + isAuthenticated: boolean; + isLoading: boolean; + user: User | null; + error: Error | null; + login: () => Promise; + logout: () => Promise; + getAccessTokenSilently: () => Promise; + refreshTokenSilently: () => Promise; +} + +const AuthContext = createContext(undefined); + +interface AuthProviderProps { + children: ReactNode; +} + +/** + * Check if running on localhost + */ +const isLocalhost = (): boolean => { + return ( + window.location.hostname === 'localhost' || + window.location.hostname === '127.0.0.1' || + window.location.hostname === '' + ); +}; + +/** + * Backend-based Auth Provider (for localhost) + */ +function BackendAuthProvider({ children }: { children: ReactNode }) { + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [user, setUser] = useState(null); + const [error, setError] = useState(null); + const [isLoggingOut, setIsLoggingOut] = useState(false); + + // Check authentication status on mount + // Only check if we're not in the middle of a logout process + useEffect(() => { + // PRIORITY 1: Check for logout flags in sessionStorage (survives page reload during logout) + const logoutFlag = sessionStorage.getItem('__logout_in_progress__'); + const forceLogout = sessionStorage.getItem('__force_logout__'); + + if (logoutFlag === 'true' || forceLogout === 'true') { + console.log('🔴 Logout flag detected - PREVENTING auto-authentication'); + console.log('🔴 Clearing ALL authentication data and showing login screen'); + + // Remove flags + sessionStorage.removeItem('__logout_in_progress__'); + sessionStorage.removeItem('__force_logout__'); + + // Clear all tokens one more time (aggressive) + TokenManager.clearAll(); + + // Also manually clear everything + try { + localStorage.clear(); + sessionStorage.clear(); + } catch (e) { + console.error('Error clearing storage:', e); + } + + // Set unauthenticated state + setIsAuthenticated(false); + setUser(null); + setIsLoading(false); + setError(null); + + console.log('🔴 Logout complete - user should see login screen'); + return; + } + + // PRIORITY 2: Check if URL has logout parameter (from redirect) + const urlParams = new URLSearchParams(window.location.search); + if (urlParams.has('logout')) { + console.log('🔴 Logout parameter in URL - clearing everything'); + TokenManager.clearAll(); + localStorage.clear(); + sessionStorage.clear(); + setIsAuthenticated(false); + setUser(null); + setIsLoading(false); + // Clean URL + window.history.replaceState({}, document.title, '/'); + return; + } + + // PRIORITY 3: Check if this is a logout redirect by checking if all tokens are cleared + const token = TokenManager.getAccessToken(); + const refreshToken = TokenManager.getRefreshToken(); + const userData = TokenManager.getUserData(); + const hasAuthData = token || refreshToken || userData; + + // If no auth data exists, we're likely after a logout - set unauthenticated state immediately + if (!hasAuthData) { + console.log('🔴 No auth data found - setting unauthenticated state'); + setIsAuthenticated(false); + setUser(null); + setIsLoading(false); + return; + } + + // PRIORITY 4: Only check auth status if we have some auth data AND we're not logging out + if (!isLoggingOut) { + checkAuthStatus(); + } else { + console.log('🔴 Skipping checkAuthStatus - logout in progress'); + setIsLoading(false); + } + }, [isLoggingOut]); + + // Silent refresh interval + useEffect(() => { + if (!isAuthenticated) return; + + const checkAndRefresh = async () => { + const token = TokenManager.getAccessToken(); + if (token && isTokenExpired(token, 5)) { + // Token expires in less than 5 minutes, refresh it + try { + await refreshTokenSilently(); + } catch (error) { + console.error('Silent refresh failed:', error); + } + } + }; + + // Check every 5 minutes + const interval = setInterval(checkAndRefresh, 5 * 60 * 1000); + return () => clearInterval(interval); + }, [isAuthenticated]); + + // Handle callback from OAuth redirect + // Use ref to prevent duplicate calls (React StrictMode runs effects twice in dev) + const callbackProcessedRef = useRef(false); + + useEffect(() => { + // Skip if already processed or not on callback page + if (callbackProcessedRef.current || window.location.pathname !== '/login/callback') { + return; + } + + const handleCallback = async () => { + // Mark as processed immediately to prevent duplicate calls + callbackProcessedRef.current = true; + + const urlParams = new URLSearchParams(window.location.search); + const code = urlParams.get('code'); + const errorParam = urlParams.get('error'); + + // Clean URL immediately to prevent re-running on re-renders + window.history.replaceState({}, document.title, '/login/callback'); + + if (errorParam) { + setError(new Error(`Authentication error: ${errorParam}`)); + setIsLoading(false); + return; + } + + if (!code) { + setIsLoading(false); + return; + } + + try { + setIsLoading(true); + setIsAuthenticated(false); + setError(null); + + // IMPORTANT: redirectUri must match the one used in initial Okta authorization request + // This is the frontend callback URL, NOT the backend URL + // Backend will use this same URI when exchanging code with Okta + const redirectUri = `${window.location.origin}/login/callback`; + console.log('📥 Authorization Code Received:', { + code: code.substring(0, 10) + '...', + redirectUri, + fullUrl: window.location.href, + note: 'redirectUri is frontend URL (not backend) - must match Okta registration', + }); + + const result = await exchangeCodeForTokens(code, redirectUri); + + setUser(result.user); + setIsAuthenticated(true); + setError(null); + + // Clean URL after success + window.history.replaceState({}, document.title, '/'); + } catch (err: any) { + console.error('❌ Token exchange error in AuthContext:', err); + setError(err); + setIsAuthenticated(false); + setUser(null); + // Reset ref on error so user can retry if needed + callbackProcessedRef.current = false; + } finally { + setIsLoading(false); + } + }; + + handleCallback(); + }, []); // Empty deps - only run once on mount + + const checkAuthStatus = async () => { + // Don't check auth status if we're in the middle of logging out + if (isLoggingOut) { + console.log('🔴 Skipping checkAuthStatus - logout in progress'); + setIsLoading(false); + return; + } + + try { + setIsLoading(true); + + const token = TokenManager.getAccessToken(); + const storedUser = TokenManager.getUserData(); + + console.log('🔍 Checking auth status:', { hasToken: !!token, hasUser: !!storedUser, isLoggingOut }); + + // If no token at all, user is not authenticated + if (!token) { + console.log('🔍 No token found - setting unauthenticated'); + setIsAuthenticated(false); + setUser(null); + setIsLoading(false); + return; + } + + // Check if token is expired + if (isTokenExpired(token)) { + // Token expired, try to refresh + try { + await refreshTokenSilently(); + // Refresh succeeded, check again + const newToken = TokenManager.getAccessToken(); + if (newToken && !isTokenExpired(newToken)) { + const refreshedUser = TokenManager.getUserData(); + if (refreshedUser) { + setUser(refreshedUser); + setIsAuthenticated(true); + } else { + // Fetch user data + try { + const userData = await getCurrentUser(); + setUser(userData); + TokenManager.setUserData(userData); + setIsAuthenticated(true); + } catch { + // Token might be invalid, clear everything + TokenManager.clearAll(); + setIsAuthenticated(false); + setUser(null); + } + } + } else { + // Refresh failed, user is logged out + TokenManager.clearAll(); + setIsAuthenticated(false); + setUser(null); + } + } catch { + // Refresh failed, clear everything + TokenManager.clearAll(); + setIsAuthenticated(false); + setUser(null); + } + } else { + // Token is valid + if (storedUser) { + setUser(storedUser); + setIsAuthenticated(true); + } else { + // Fetch user data to ensure token is still valid + try { + const userData = await getCurrentUser(); + setUser(userData); + TokenManager.setUserData(userData); + setIsAuthenticated(true); + } catch { + // Token might be invalid, clear everything + TokenManager.clearAll(); + setIsAuthenticated(false); + setUser(null); + } + } + } + } catch (err: any) { + console.error('Error checking auth status:', err); + setError(err); + TokenManager.clearAll(); + setIsAuthenticated(false); + setUser(null); + } finally { + setIsLoading(false); + } + }; + + const login = async () => { + try { + setError(null); + // Redirect to Okta login + const oktaDomain = 'https://dev-830839.oktapreview.com'; + const clientId = '0oa2j8slwj5S4bG5k0h8'; + const redirectUri = `${window.location.origin}/login/callback`; + const responseType = 'code'; + const scope = 'openid profile email'; + const state = Math.random().toString(36).substring(7); + + const authUrl = `${oktaDomain}/oauth2/default/v1/authorize?` + + `client_id=${clientId}&` + + `redirect_uri=${encodeURIComponent(redirectUri)}&` + + `response_type=${responseType}&` + + `scope=${encodeURIComponent(scope)}&` + + `state=${state}`; + + window.location.href = authUrl; + } catch (err: any) { + setError(err); + throw err; + } + }; + + const logout = async () => { + console.log('🚪 LOGOUT FUNCTION CALLED - Starting logout process'); + console.log('🚪 Current auth state:', { isAuthenticated, hasUser: !!user, isLoading }); + + try { + // Set logout flag to prevent auto-authentication after redirect + sessionStorage.setItem('__logout_in_progress__', 'true'); + setIsLoggingOut(true); + + console.log('🚪 Step 1: Resetting auth state...'); + // Reset auth state FIRST to prevent any re-authentication + setIsAuthenticated(false); + setUser(null); + setError(null); + setIsLoading(true); // Set loading to prevent checkAuthStatus from running + console.log('🚪 Step 1: Auth state reset complete'); + + // Call backend logout API to clear server-side session and httpOnly cookies + // IMPORTANT: This MUST be called before clearing local storage to clear httpOnly cookies + try { + console.log('🚪 Step 2: Calling backend logout API to clear httpOnly cookies...'); + await logoutApi(); + console.log('🚪 Step 2: Backend logout API completed - httpOnly cookies should be cleared'); + } catch (err) { + console.error('🚪 Logout API error:', err); + console.warn('🚪 Backend logout failed - httpOnly cookies may not be cleared'); + // Continue with logout even if API call fails + } + + // Clear all authentication data + console.log('========================================'); + console.log('LOGOUT - Clearing all authentication data'); + console.log('========================================'); + + // Use TokenManager.clearAll() which does comprehensive cleanup + TokenManager.clearAll(); + console.log('tryng to clrear all(localStorage and sessionStorage)') + // Double-check: Clear everything one more time as backup + try { + // Get all localStorage keys and remove them + const localStorageKeys: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key) localStorageKeys.push(key); + } + localStorageKeys.forEach(key => { + try { + localStorage.removeItem(key); + } catch (e) { + console.warn(`Failed to remove localStorage key ${key}:`, e); + } + }); + localStorage.clear(); + + // Get all sessionStorage keys and remove them + const sessionStorageKeys: string[] = []; + for (let i = 0; i < sessionStorage.length; i++) { + const key = sessionStorage.key(i); + if (key) sessionStorageKeys.push(key); + } + sessionStorageKeys.forEach(key => { + try { + sessionStorage.removeItem(key); + } catch (e) { + console.warn(`Failed to remove sessionStorage key ${key}:`, e); + } + }); + sessionStorage.clear(); + + console.log('Final verification - Storage cleared:'); + console.log(`localStorage.length: ${localStorage.length}`); + console.log(`sessionStorage.length: ${sessionStorage.length}`); + + if (localStorage.length > 0) { + console.error('ERROR: localStorage still has items:', Object.keys(localStorage)); + } + if (sessionStorage.length > 0) { + console.error('ERROR: sessionStorage still has items:', Object.keys(sessionStorage)); + } + } catch (e) { + console.error('Error in final cleanup:', e); + } + + // Final verification BEFORE redirect + console.log('All authentication data cleared. Final verification:'); + console.log(`localStorage.length: ${localStorage.length}`); + console.log(`sessionStorage.length: ${sessionStorage.length}`); + + if (localStorage.length > 0) { + console.error('CRITICAL: localStorage still has items before redirect!', Object.keys(localStorage)); + // Force clear one more time + const remainingKeys = Object.keys(localStorage); + remainingKeys.forEach(key => localStorage.removeItem(key)); + localStorage.clear(); + } + if (sessionStorage.length > 0) { + console.error('CRITICAL: sessionStorage still has items before redirect!', Object.keys(sessionStorage)); + const remainingKeys = Object.keys(sessionStorage); + remainingKeys.forEach(key => sessionStorage.removeItem(key)); + sessionStorage.clear(); + } + + console.log('Redirecting to login...'); + + // Ensure logout flag is set before redirect + sessionStorage.setItem('__logout_in_progress__', 'true'); + + // Small delay to ensure sessionStorage is written before redirect + await new Promise(resolve => setTimeout(resolve, 50)); + + // Use replace instead of href to prevent browser history issues + // DON'T use setTimeout - redirect immediately after clearing + if (isLocalhost()) { + // Direct redirect to login page for localhost - force full page reload + // Add timestamp to force fresh page load + window.location.replace('/?logout=' + Date.now()); + } else { + // For production, redirect to Okta logout then to login + const oktaDomain = 'https://dev-830839.oktapreview.com'; + const loginUrl = `${window.location.origin}/?logout=${Date.now()}`; + const logoutUrl = `${oktaDomain}/oauth2/default/v1/logout?post_logout_redirect_uri=${encodeURIComponent(loginUrl)}`; + window.location.replace(logoutUrl); + } + } catch (error) { + console.error('Logout error:', error); + // Force redirect even on error + localStorage.clear(); + sessionStorage.clear(); + window.location.replace('/'); + } + }; + + const getAccessTokenSilently = async (): Promise => { + const token = TokenManager.getAccessToken(); + if (token && !isTokenExpired(token)) { + return token; + } + + // Try to refresh + try { + await refreshTokenSilently(); + return TokenManager.getAccessToken(); + } catch { + return null; + } + }; + + const refreshTokenSilently = async (): Promise => { + try { + const newToken = await refreshAccessToken(); + if (newToken) { + // Token refreshed successfully + return; + } + throw new Error('Failed to refresh token'); + } catch (err) { + TokenManager.clearAll(); + setIsAuthenticated(false); + setUser(null); + throw err; + } + }; + + const value: AuthContextType = { + isAuthenticated, + isLoading, + user, + error, + login, + logout, + getAccessTokenSilently, + refreshTokenSilently, + }; + + return {children}; +} + +/** + * Auth0-based Auth Provider (for production) + */ +function Auth0AuthProvider({ children }: { children: ReactNode }) { + return ( + { + console.log('Auth0 Redirect Callback:', { + appState, + returnTo: appState?.returnTo || window.location.pathname, + }); + }} + > + {children} + + ); +} + +/** + * Wrapper to convert Auth0 hook to our context format + */ +function Auth0ContextWrapper({ children }: { children: ReactNode }) { + const { + isAuthenticated: auth0IsAuthenticated, + isLoading: auth0IsLoading, + user: auth0User, + error: auth0Error, + loginWithRedirect, + logout: logoutAuth0, + getAccessTokenSilently: getAuth0Token, + } = useAuth0Hook(); + + const getAccessTokenSilently = async (): Promise => { + try { + return await getAuth0Token(); + } catch { + return null; + } + }; + + const refreshTokenSilently = async (): Promise => { + // Auth0 handles refresh automatically + try { + await getAuth0Token(); + } catch { + // Silently fail, Auth0 will handle it + } + }; + + const value: AuthContextType = { + isAuthenticated: auth0IsAuthenticated, + isLoading: auth0IsLoading, + user: auth0User as User | null, + error: auth0Error as Error | null, + login: loginWithRedirect, + logout: logoutAuth0, + getAccessTokenSilently, + refreshTokenSilently, + }; + + return {children}; +} + +/** + * Main Auth Provider - conditionally uses backend or Auth0 + */ +export function AuthProvider({ children }: AuthProviderProps) { + if (isLocalhost()) { + return {children}; + } + return {children}; +} + +/** + * Hook to use auth context + */ +export function useAuth(): AuthContextType { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +} + diff --git a/src/main.tsx b/src/main.tsx index cd5a3a3..1577405 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,34 +1,21 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; -import { Auth0Provider } from '@auth0/auth0-react'; +import { AuthProvider } from './contexts/AuthContext'; import { AuthenticatedApp } from './pages/Auth'; import './styles/globals.css'; console.log('Application Starting...'); -console.log('Auth0 Configuration:', { - domain: 'dev-dq1hiere8khcbdra.us.auth0.com', - clientId: '5gnC2Go5yRg3RXLuaFLr8KRc1fJ9PDJW', - origin: window.location.origin +console.log('Environment:', { + hostname: window.location.hostname, + origin: window.location.origin, + isLocalhost: window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1', }); ReactDOM.createRoot(document.getElementById('root')!).render( - { - console.log('Auth0 Redirect Callback:', { - appState, - returnTo: appState?.returnTo || window.location.pathname, - timestamp: new Date().toISOString() - }); - }} - > + - + ); diff --git a/src/pages/Auth/Auth.tsx b/src/pages/Auth/Auth.tsx index b972bcf..07ddfb1 100644 --- a/src/pages/Auth/Auth.tsx +++ b/src/pages/Auth/Auth.tsx @@ -1,10 +1,10 @@ -import { useAuth0 } from '@auth0/auth0-react'; +import { useAuth } from '@/contexts/AuthContext'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { LogIn, Shield } from 'lucide-react'; export function Auth() { - const { loginWithRedirect, isLoading, error } = useAuth0(); + const { login, isLoading, error } = useAuth(); console.log('Auth Component Render - Auth0 State:', { isLoading, @@ -25,26 +25,8 @@ export function Auth() { sessionStorage.clear(); console.log('Storage cleared'); - console.log('Calling loginWithRedirect with params:', { - authorizationParams: { - screen_hint: 'login', - prompt: 'login', - mode: 'login' - } - }); - try { - await loginWithRedirect({ - authorizationParams: { - screen_hint: 'login', - prompt: 'login', - mode: 'login' - }, - appState: { - returnTo: window.location.pathname - } - }); - + await login(); console.log('Login redirect initiated successfully'); } catch (loginError) { console.error('========================================'); diff --git a/src/pages/Auth/AuthCallback.tsx b/src/pages/Auth/AuthCallback.tsx index b171fb8..8882bb9 100644 --- a/src/pages/Auth/AuthCallback.tsx +++ b/src/pages/Auth/AuthCallback.tsx @@ -1,13 +1,15 @@ import React, { useEffect } from 'react'; -import { useAuth0 } from '@auth0/auth0-react'; +import { useAuth } from '@/contexts/AuthContext'; import { Loader } from '@/components/common/Loader'; +import { useNavigate } from 'react-router-dom'; export function AuthCallback() { - const { isAuthenticated, isLoading, error, user } = useAuth0(); + const { isAuthenticated, isLoading, error, user } = useAuth(); + const navigate = useNavigate(); useEffect(() => { console.log('AuthCallback Component Mounted'); - console.log('Auth0 State during callback:', { + console.log('Auth State during callback:', { isAuthenticated, isLoading, error: error?.message, @@ -17,19 +19,20 @@ export function AuthCallback() { if (user) { console.log('User authenticated successfully:', { - id: user.sub, + userId: user.userId, + employeeId: user.employeeId, email: user.email, - name: user.name, - picture: user.picture, - nickname: user.nickname, + displayName: user.displayName, userData: user }); + // Redirect to home after successful authentication + navigate('/'); } if (error) { console.error('Error during authentication callback:', error); } - }, [isAuthenticated, isLoading, error, user]); + }, [isAuthenticated, isLoading, error, user, navigate]); if (error) { console.error('AuthCallback Error:', error); diff --git a/src/pages/Auth/AuthenticatedApp.tsx b/src/pages/Auth/AuthenticatedApp.tsx index 98e6db3..1f8f3e8 100644 --- a/src/pages/Auth/AuthenticatedApp.tsx +++ b/src/pages/Auth/AuthenticatedApp.tsx @@ -1,37 +1,42 @@ import React, { useEffect, useState } from 'react'; -import { useAuth0 } from '@auth0/auth0-react'; +import { useAuth } from '@/contexts/AuthContext'; import { Auth } from './Auth'; import { AuthCallback } from './AuthCallback'; import { AuthDebugInfo } from '@/components/Auth/AuthDebugInfo'; import App from '../../App'; export function AuthenticatedApp() { - const { isAuthenticated, isLoading, error, user, logout: logoutAuth0 } = useAuth0(); + const { isAuthenticated, isLoading, error, user, logout } = useAuth(); const [showDebugInfo, setShowDebugInfo] = useState(false); const handleLogout = async () => { - console.log('========================================'); - console.log('LOGOUT - Initiated'); - console.log('Timestamp:', new Date().toISOString()); - console.log('========================================'); - - // Clear all storage - console.log('Clearing all storage...'); - localStorage.clear(); - sessionStorage.clear(); - console.log('Storage cleared'); + console.log('🔵 ========================================'); + console.log('🔵 AuthenticatedApp.handleLogout - CALLED'); + console.log('🔵 Timestamp:', new Date().toISOString()); + console.log('🔵 logout function exists?', !!logout); + console.log('🔵 ========================================'); try { - await logoutAuth0({ - logoutParams: { - returnTo: window.location.origin - }, - localOnly: false // Clear Auth0 session as well - }); + if (!logout) { + console.error('🔵 ERROR: logout function is undefined!'); + return; + } - console.log('Logout successful'); + console.log('🔵 Calling logout from auth context...'); + // Call logout from auth context (handles all cleanup and redirect) + await logout(); + console.log('🔵 Logout successful - redirecting to login...'); } catch (logoutError) { - console.error('Logout error:', logoutError); + console.error('🔵 Logout error in handleLogout:', logoutError); + // Even if logout fails, clear local data and redirect + try { + console.log('🔵 Attempting emergency cleanup...'); + localStorage.clear(); + sessionStorage.clear(); + window.location.href = '/'; + } catch (e) { + console.error('🔵 Error during emergency cleanup:', e); + } } }; @@ -48,15 +53,18 @@ export function AuthenticatedApp() { console.log('========================================'); console.log('USER AUTHENTICATED - Full Details'); console.log('========================================'); - console.log('User ID (sub):', user.sub); + console.log('User ID:', user.userId || user.sub); + console.log('Employee ID:', user.employeeId); console.log('Email:', user.email); - console.log('Name:', user.name); - console.log('Nickname:', user.nickname); - console.log('Picture:', user.picture); - console.log('Email Verified:', user.email_verified); - console.log('Updated At:', user.updated_at); + console.log('Name:', user.displayName || user.name); + console.log('Display Name:', user.displayName); + console.log('First Name:', user.firstName); + console.log('Last Name:', user.lastName); + console.log('Department:', user.department); + console.log('Designation:', user.designation); + console.log('Is Admin:', user.isAdmin); console.log('========================================'); - console.log('ALL USER CLAIMS:'); + console.log('ALL USER DATA:'); console.log(JSON.stringify(user, null, 2)); console.log('========================================'); } diff --git a/src/pages/Profile/Profile.tsx b/src/pages/Profile/Profile.tsx new file mode 100644 index 0000000..109296e --- /dev/null +++ b/src/pages/Profile/Profile.tsx @@ -0,0 +1,281 @@ +import { useAuth } from '@/contexts/AuthContext'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + User, + Mail, + Briefcase, + Building2, + Phone, + Shield, + Calendar, + Edit, + CheckCircle +} from 'lucide-react'; + +export function Profile() { + const { user } = useAuth(); + + // Helper to get user initials for avatar + const getUserInitials = () => { + if (user?.displayName) { + const names = user.displayName.split(' ').filter(Boolean); + if (names.length >= 2) { + return `${names[0]?.[0] || ''}${names[names.length - 1]?.[0] || ''}`.toUpperCase(); + } + return user.displayName.substring(0, 2).toUpperCase(); + } + if (user?.email) { + return user.email.substring(0, 2).toUpperCase(); + } + return 'U'; + }; + + return ( +
+ {/* Header Card */} + +
+ + +
+
+ + + + {getUserInitials()} + + + {user?.isAdmin && ( +
+ +
+ )} +
+ +
+
+
+

+ {user?.displayName || user?.name || 'User Profile'} +

+

+ {user?.email || 'No email provided'} +

+
+ {user?.isAdmin && ( + + + Administrator + + )} + {user?.employeeId && ( + + ID: {user.employeeId} + + )} +
+
+ +
+
+
+
+
+ + {/* Profile Information Cards */} +
+ {/* Personal Information */} + + +
+
+ +
+
+ Personal Information + Your personal details +
+
+
+ +
+
+ +
+

Email

+

+ {user?.email || 'Not provided'} +

+
+
+ + {user?.firstName && ( +
+ +
+

First Name

+

{user.firstName}

+
+
+ )} + + {user?.lastName && ( +
+ +
+

Last Name

+

{user.lastName}

+
+
+ )} + + {user?.displayName && ( +
+ +
+

Display Name

+

{user.displayName}

+
+
+ )} + + {(user as any)?.phone && ( +
+ +
+

Phone

+

{(user as any).phone}

+
+
+ )} +
+
+
+ + {/* Professional Information */} + + +
+
+ +
+
+ Professional Information + Work-related details +
+
+
+ +
+ {user?.employeeId && ( +
+ +
+

Employee ID

+

{user.employeeId}

+
+
+ )} + + {user?.department && ( +
+ +
+

Department

+

{user.department}

+
+
+ )} + + {user?.designation && ( +
+ +
+

Designation

+

{user.designation}

+
+
+ )} + +
+ +
+

Role

+
+ + {user?.isAdmin ? 'Administrator' : 'User'} + + {user?.isAdmin && ( + + )} +
+
+
+
+
+
+
+ + {/* Account Status Card */} + + +
+
+ +
+
+ Account Status + Account information and activity +
+
+
+ +
+
+
+
+ +
+
+

Account Status

+

Active and verified

+
+
+ + Active + +
+ + {user?.userId && ( +
+
+

User ID

+

{user.userId}

+
+ {user.employeeId && ( +
+

Employee ID

+

{user.employeeId}

+
+ )} +
+ )} +
+
+
+
+ ); +} + diff --git a/src/pages/Profile/index.ts b/src/pages/Profile/index.ts new file mode 100644 index 0000000..1f6be06 --- /dev/null +++ b/src/pages/Profile/index.ts @@ -0,0 +1,2 @@ +export { Profile } from './Profile'; + diff --git a/src/pages/Settings/Settings.tsx b/src/pages/Settings/Settings.tsx new file mode 100644 index 0000000..89515cc --- /dev/null +++ b/src/pages/Settings/Settings.tsx @@ -0,0 +1,146 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + Settings as SettingsIcon, + Bell, + Shield, + Palette, + Globe, + Lock, + Database, + Mail, + CheckCircle +} from 'lucide-react'; + +export function Settings() { + return ( +
+ {/* Header Card */} + +
+ + +
+
+
+
+ +
+
+

Settings

+

Manage your account settings and preferences

+
+
+
+
+
+
+ + {/* Settings Sections */} +
+ {/* Notification Settings */} + + +
+
+ +
+
+ Notifications + Manage notification preferences +
+
+
+ +
+
+

Notification settings will be available soon

+
+
+
+
+ + {/* Security Settings */} + + +
+
+ +
+
+ Security + Password and security settings +
+
+
+ +
+
+

Security settings will be available soon

+
+
+
+
+ + {/* Appearance Settings */} + + +
+
+ +
+
+ Appearance + Theme and display preferences +
+
+
+ +
+
+

Appearance settings will be available soon

+
+
+
+
+ + {/* Preferences */} + + +
+
+ +
+
+ Preferences + Application preferences +
+
+
+ +
+
+

User preferences will be available soon

+
+
+
+
+
+ + {/* Coming Soon Notice */} + + +
+ +
+

Settings page is under development

+

Settings and preferences management will be available in a future update.

+
+
+
+
+
+ ); +} + diff --git a/src/pages/Settings/index.ts b/src/pages/Settings/index.ts new file mode 100644 index 0000000..1de5a72 --- /dev/null +++ b/src/pages/Settings/index.ts @@ -0,0 +1,2 @@ +export { Settings } from './Settings'; + diff --git a/src/services/authApi.ts b/src/services/authApi.ts new file mode 100644 index 0000000..ca9ebc0 --- /dev/null +++ b/src/services/authApi.ts @@ -0,0 +1,236 @@ +/** + * Authentication API Service + * Handles communication with backend auth endpoints + */ + +import axios, { AxiosInstance } from 'axios'; +import { TokenManager } from '../utils/tokenManager'; + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000/api/v1'; + +// Create axios instance with default config +const apiClient: AxiosInstance = axios.create({ + baseURL: API_BASE_URL, + headers: { + 'Content-Type': 'application/json', + }, + withCredentials: true, // Important for cookie-based auth in localhost +}); + +// Request interceptor to add access token +apiClient.interceptors.request.use( + (config) => { + const token = TokenManager.getAccessToken(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => { + return Promise.reject(error); + } +); + +// Response interceptor to handle token refresh +apiClient.interceptors.response.use( + (response) => response, + async (error) => { + const originalRequest = error.config; + + // If error is 401 and we haven't retried yet + if (error.response?.status === 401 && !originalRequest._retry) { + originalRequest._retry = true; + + try { + // Attempt to refresh token + const refreshToken = TokenManager.getRefreshToken(); + if (!refreshToken) { + throw new Error('No refresh token available'); + } + + const response = await axios.post( + `${API_BASE_URL}/auth/refresh`, + { refreshToken }, + { withCredentials: true } + ); + + const { accessToken } = response.data.data || response.data; + if (accessToken) { + TokenManager.setAccessToken(accessToken); + originalRequest.headers.Authorization = `Bearer ${accessToken}`; + return apiClient(originalRequest); + } + } catch (refreshError) { + // Refresh failed, clear tokens and redirect to login + TokenManager.clearAll(); + window.location.href = '/'; + return Promise.reject(refreshError); + } + } + + return Promise.reject(error); + } +); + +export interface TokenExchangeResponse { + user: { + userId: string; + employeeId: string; + email: string; + firstName: string; + lastName: string; + displayName: string; + department?: string; + designation?: string; + isAdmin: boolean; + }; + accessToken: string; + refreshToken: string; +} + +export interface RefreshTokenResponse { + accessToken: string; +} + +/** + * Exchange authorization code for tokens (localhost only) + */ +export async function exchangeCodeForTokens( + code: string, + redirectUri: string +): Promise { + console.log('🔄 Exchange Code for Tokens:', { + code: code ? `${code.substring(0, 10)}...` : 'MISSING', + redirectUri, + endpoint: `${API_BASE_URL}/auth/token-exchange`, + }); + + try { + const response = await apiClient.post( + '/auth/token-exchange', + { + code, + redirectUri, + }, + { + responseType: 'json', // Explicitly set response type to JSON + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + } + ); + + console.log('✅ Token exchange successful', { + status: response.status, + statusText: response.statusText, + headers: response.headers, + contentType: response.headers['content-type'], + hasData: !!response.data, + dataType: typeof response.data, + dataIsArray: Array.isArray(response.data), + dataPreview: Array.isArray(response.data) + ? `Array[${response.data.length}]` + : typeof response.data === 'object' + ? JSON.stringify(response.data).substring(0, 100) + : String(response.data).substring(0, 100), + }); + + // Check if response is an array (buffer issue) + if (Array.isArray(response.data)) { + console.error('❌ Response is an array (buffer issue):', { + arrayLength: response.data.length, + firstFew: response.data.slice(0, 10), + rawResponse: response, + }); + throw new Error('Invalid response format: received array instead of JSON. Check Content-Type header.'); + } + + const data = response.data as any; + const result = data.data || data; + + // Tokens are set as httpOnly cookies by backend, but we also store them here for client access + if (result.accessToken && result.refreshToken) { + TokenManager.setAccessToken(result.accessToken); + TokenManager.setRefreshToken(result.refreshToken); + TokenManager.setUserData(result.user); + console.log('✅ Tokens stored successfully'); + } else { + console.warn('⚠️ Tokens missing in response', { result }); + } + + return result; + } catch (error: any) { + console.error('❌ Token exchange failed:', { + message: error.message, + response: error.response?.data, + status: error.response?.status, + code: code ? `${code.substring(0, 10)}...` : 'MISSING', + redirectUri, + }); + throw error; + } +} + +/** + * Refresh access token using refresh token + */ +export async function refreshAccessToken(): Promise { + const refreshToken = TokenManager.getRefreshToken(); + if (!refreshToken) { + throw new Error('No refresh token available'); + } + + const response = await apiClient.post('/auth/refresh', { + refreshToken, + }); + + const data = response.data as any; + const accessToken = data.data?.accessToken || data.accessToken; + + if (accessToken) { + TokenManager.setAccessToken(accessToken); + return accessToken; + } + + throw new Error('Failed to refresh token'); +} + +/** + * Get current user profile + */ +export async function getCurrentUser() { + const response = await apiClient.get('/auth/me'); + const data = response.data as any; + return data.data || data; +} + +/** + * Logout user + * CRITICAL: This endpoint MUST clear httpOnly cookies set by backend + * Note: TokenManager.clearAll() is called in AuthContext.logout() + * We don't call it here to avoid double clearing + */ +export async function logout(): Promise { + try { + console.log('📡 Calling backend logout endpoint to clear httpOnly cookies...'); + // Use withCredentials to ensure cookies are sent + const response = await apiClient.post('/auth/logout', {}, { + withCredentials: true, // Ensure cookies are sent with request + }); + console.log('📡 Backend logout response:', response.status, response.statusText); + console.log('📡 Response headers (check Set-Cookie):', response.headers); + } catch (error: any) { + console.error('📡 Logout API error:', error); + console.error('📡 Error details:', { + message: error.message, + status: error.response?.status, + data: error.response?.data, + }); + // Even if API call fails, cookies might still be cleared + // Don't throw - let the caller handle cleanup + } +} + +export default apiClient; + diff --git a/src/utils/tokenManager.ts b/src/utils/tokenManager.ts new file mode 100644 index 0000000..4d5d11d --- /dev/null +++ b/src/utils/tokenManager.ts @@ -0,0 +1,400 @@ +/** + * Token Management Utilities + * Handles token storage, retrieval, and refresh operations + */ + +const ACCESS_TOKEN_KEY = 'accessToken'; +const REFRESH_TOKEN_KEY = 'refreshToken'; +const USER_DATA_KEY = 'userData'; + +/** + * Cookie utility functions + */ +export const cookieUtils = { + /** + * Set a cookie with the given name, value, and options + */ + set(name: string, value: string, days: number = 7): void { + const expires = new Date(); + expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000); + const cookieValue = `${name}=${encodeURIComponent(value)}; expires=${expires.toUTCString()}; path=/; SameSite=Lax`; + document.cookie = cookieValue; + }, + + /** + * Get a cookie by name + */ + get(name: string): string | null { + const nameEQ = name + '='; + const cookies = document.cookie.split(';'); + for (let i = 0; i < cookies.length; i++) { + let cookie = cookies[i]; + while (cookie.charAt(0) === ' ') cookie = cookie.substring(1, cookie.length); + if (cookie.indexOf(nameEQ) === 0) { + return decodeURIComponent(cookie.substring(nameEQ.length, cookie.length)); + } + } + return null; + }, + + /** + * Remove a cookie + */ + remove(name: string): void { + document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`; + }, + + /** + * Clear all authentication cookies + * Attempts to clear cookies with different paths and domains + */ + clearAll(): void { + const cookieNames = [ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY, USER_DATA_KEY]; + + cookieNames.forEach(name => { + // Remove with default path + this.remove(name); + + // Remove with root path explicitly + document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`; + + // Remove with domain (if applicable) + const hostname = window.location.hostname; + if (hostname !== 'localhost' && hostname !== '127.0.0.1') { + document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=${hostname};`; + document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=.${hostname};`; + } + }); + }, +}; + +/** + * Token Manager - Handles token storage and retrieval + */ +export class TokenManager { + /** + * Store access token + */ + static setAccessToken(token: string): void { + if (this.isLocalhost()) { + // Store in cookie for localhost (backend sets httpOnly cookie, but we also store here for client-side access) + localStorage.setItem(ACCESS_TOKEN_KEY, token); + cookieUtils.set(ACCESS_TOKEN_KEY, token, 1); // 1 day + } else { + localStorage.setItem(ACCESS_TOKEN_KEY, token); + } + } + + /** + * Get access token + */ + static getAccessToken(): string | null { + if (this.isLocalhost()) { + // Try cookie first (set by backend), then localStorage + return cookieUtils.get(ACCESS_TOKEN_KEY) || localStorage.getItem(ACCESS_TOKEN_KEY); + } + return localStorage.getItem(ACCESS_TOKEN_KEY); + } + + /** + * Store refresh token + */ + static setRefreshToken(token: string): void { + if (this.isLocalhost()) { + localStorage.setItem(REFRESH_TOKEN_KEY, token); + cookieUtils.set(REFRESH_TOKEN_KEY, token, 7); // 7 days + } else { + localStorage.setItem(REFRESH_TOKEN_KEY, token); + } + } + + /** + * Get refresh token + */ + static getRefreshToken(): string | null { + if (this.isLocalhost()) { + // Try cookie first (set by backend), then localStorage + return cookieUtils.get(REFRESH_TOKEN_KEY) || localStorage.getItem(REFRESH_TOKEN_KEY); + } + return localStorage.getItem(REFRESH_TOKEN_KEY); + } + + /** + * Store user data + */ + static setUserData(user: any): void { + localStorage.setItem(USER_DATA_KEY, JSON.stringify(user)); + if (this.isLocalhost()) { + cookieUtils.set(USER_DATA_KEY, JSON.stringify(user), 7); + } + } + + /** + * Get user data + */ + static getUserData(): any | null { + const data = localStorage.getItem(USER_DATA_KEY); + if (!data) return null; + try { + return JSON.parse(data); + } catch { + return null; + } + } + + /** + * Clear all tokens and user data + * This includes localStorage, sessionStorage, cookies, and any auth-related data + * Uses aggressive clearing to ensure ALL data is removed + * IMPORTANT: This also sets a flag to prevent auto-authentication + */ + static clearAll(): void { + console.log('TokenManager.clearAll() - Starting cleanup...'); + + // CRITICAL: Set logout flag in sessionStorage FIRST (before clearing) + // This flag survives the redirect and prevents auto-authentication + try { + sessionStorage.setItem('__logout_in_progress__', 'true'); + sessionStorage.setItem('__force_logout__', 'true'); + } catch (e) { + console.warn('Could not set logout flags:', e); + } + + // Step 1: Clear specific auth-related localStorage keys + const authKeys = [ + ACCESS_TOKEN_KEY, + REFRESH_TOKEN_KEY, + USER_DATA_KEY, + 'oktaToken', + 'authToken', + 'user', + 'authState', + 'access_token', + 'refresh_token', + 'id_token', + 'token', + 'accessToken', + 'refreshToken', + 'userData', + 'auth', + 'authentication', + 'persist:root', + 'persist:auth', + 'redux-persist', + ]; + + authKeys.forEach(key => { + try { + localStorage.removeItem(key); + sessionStorage.removeItem(key); + console.log(`Removed ${key} from storage`); + } catch (e) { + console.warn(`Error removing ${key}:`, e); + } + }); + + // Step 2: Clear ALL localStorage items by iterating and removing + try { + const allLocalStorageKeys: string[] = []; + // Get all keys + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key) { + allLocalStorageKeys.push(key); + } + } + + // Remove each key explicitly + allLocalStorageKeys.forEach(key => { + try { + localStorage.removeItem(key); + console.log(`Removed localStorage key: ${key}`); + } catch (e) { + console.warn(`Error removing localStorage key ${key}:`, e); + } + }); + + // Final clear as backup + localStorage.clear(); + console.log('localStorage.clear() called'); + } catch (e) { + console.error('Error clearing localStorage:', e); + } + + // Step 3: Clear ALL sessionStorage items + try { + const allSessionStorageKeys: string[] = []; + // Get all keys + for (let i = 0; i < sessionStorage.length; i++) { + const key = sessionStorage.key(i); + if (key) { + allSessionStorageKeys.push(key); + } + } + + // Remove each key explicitly + allSessionStorageKeys.forEach(key => { + try { + sessionStorage.removeItem(key); + console.log(`Removed sessionStorage key: ${key}`); + } catch (e) { + console.warn(`Error removing sessionStorage key ${key}:`, e); + } + }); + + // Final clear as backup + sessionStorage.clear(); + console.log('sessionStorage.clear() called'); + } catch (e) { + console.error('Error clearing sessionStorage:', e); + } + + // Step 4: Clear cookies (both client-side and attempt to clear httpOnly cookies) + cookieUtils.clearAll(); + + // Step 5: Aggressively clear ALL cookies including httpOnly ones + // Note: httpOnly cookies can only be cleared by backend, but we try everything + const cookieNames = [ + 'accessToken', 'refreshToken', 'userData', 'oktaToken', 'authToken', + 'id_token', 'token', 'access_token', 'refresh_token' + ]; + + const hostname = window.location.hostname; + const paths = ['/', '/login', '/login/callback', '/api', '/api/v1']; + const domains = ['', hostname, `.${hostname}`]; + + // Try every combination of path, domain, and secure flags + cookieNames.forEach(name => { + paths.forEach(path => { + domains.forEach(domain => { + // Try without secure flag + try { + let cookieString = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${path}; SameSite=Lax;`; + if (domain && domain !== 'localhost' && domain !== '127.0.0.1' && !domain.startsWith('.')) { + cookieString += ` domain=${domain};`; + } + document.cookie = cookieString; + } catch (e) { + // Ignore errors + } + + // Try with secure flag + try { + let cookieString = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${path}; SameSite=None; Secure;`; + if (domain && domain !== 'localhost' && domain !== '127.0.0.1') { + cookieString += ` domain=${domain};`; + } + document.cookie = cookieString; + } catch (e) { + // Ignore errors + } + + // Try with SameSite=Strict + try { + let cookieString = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${path}; SameSite=Strict;`; + if (domain && domain !== 'localhost' && domain !== '127.0.0.1' && !domain.startsWith('.')) { + cookieString += ` domain=${domain};`; + } + document.cookie = cookieString; + } catch (e) { + // Ignore errors + } + }); + }); + }); + + // Log remaining cookies (httpOnly cookies will still show here as they can't be cleared from JS) + console.log('⚠️ Remaining cookies (httpOnly cookies cannot be cleared from JavaScript):', document.cookie); + console.log('⚠️ httpOnly cookies can only be cleared by backend - backend logout endpoint should handle this'); + + // Step 6: Verify cleanup + console.log('TokenManager.clearAll() - Verification:'); + console.log(`localStorage length: ${localStorage.length}`); + console.log(`sessionStorage length: ${sessionStorage.length}`); + console.log(`Cookies: ${document.cookie}`); + + if (localStorage.length > 0 || sessionStorage.length > 0) { + console.warn('WARNING: Storage not fully cleared!'); + console.log('Remaining localStorage keys:', Object.keys(localStorage)); + console.log('Remaining sessionStorage keys:', Object.keys(sessionStorage)); + } + + console.log('TokenManager.clearAll() - Cleanup complete'); + } + + /** + * Check if access token exists + */ + static hasAccessToken(): boolean { + return !!this.getAccessToken(); + } + + /** + * Check if refresh token exists + */ + static hasRefreshToken(): boolean { + return !!this.getRefreshToken(); + } + + /** + * Check if we're running on localhost + */ + static isLocalhost(): boolean { + return ( + window.location.hostname === 'localhost' || + window.location.hostname === '127.0.0.1' || + window.location.hostname === '' + ); + } +} + +/** + * Cookie utility for browser-side cookie management + * Note: Backend sets httpOnly cookies, these utilities are for client-side access if needed + */ +export const CookieManager = { + set(name: string, value: string, days: number = 7): void { + cookieUtils.set(name, value, days); + }, + get(name: string): string | null { + return cookieUtils.get(name); + }, + remove(name: string): void { + cookieUtils.remove(name); + }, + clearAll(): void { + cookieUtils.clearAll(); + }, +}; + +/** + * Check if token is expired (with buffer time) + */ +export function isTokenExpired(token: string | null, bufferMinutes: number = 5): boolean { + if (!token) return true; + + try { + const payload = JSON.parse(atob(token.split('.')[1])); + const exp = payload.exp * 1000; // Convert to milliseconds + const now = Date.now(); + const buffer = bufferMinutes * 60 * 1000; + return exp - now < buffer; + } catch { + return true; + } +} + +/** + * Get token expiration time + */ +export function getTokenExpiration(token: string | null): Date | null { + if (!token) return null; + + try { + const payload = JSON.parse(atob(token.split('.')[1])); + return new Date(payload.exp * 1000); + } catch { + return null; + } +} +