diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..6534b61a0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,22 @@ +node_modules +npm-debug.log +.git +.gitignore +README.md +.env +.env.local +.env.development +.env.test +.env.production +.nyc_output +coverage +.coverage +.cache +dist +.vscode +.DS_Store +.storybook +storybook-static +e2e +playwright-report +test-results \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..e8e5c3a5d --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# Environment Configuration +# +# All environment variables use VITE_ prefix for both development and Docker deployment +# For Docker: docker run -e VITE_API_URL=https://api.example.com -e VITE_OAUTH_CLIENT_ID=your-id your-app + +VITE_API_URL=http://localhost:3001/api +VITE_APP_NAME=React App +VITE_ENV=development +VITE_VERSION=1.0.0 +VITE_OAUTH_TOKEN_URL=http://localhost:3001/oauth/token/ +VITE_OAUTH_CLIENT_ID=your-client-id \ No newline at end of file diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 71fd813b6..000000000 --- a/.eslintignore +++ /dev/null @@ -1 +0,0 @@ -jsconfig.json \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index caea51e5c..000000000 --- a/.eslintrc.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "env": { - "browser": true, - "commonjs": true, - "es2021": true, - "mocha": true, - "jest": true - }, - "extends": [ - "airbnb", - "airbnb/hooks" - ], - "parserOptions": { - "ecmaFeatures": { - "jsx": true - }, - "ecmaVersion": 12, - "sourceType": "module" - }, - "plugins": [ - "react", - "import" - ], - "rules": { - "camelcase": "off", - "curly": "error", - "no-restricted-globals": "off", - "no-unused-vars": "off", - "no-undef": "error", - "no-use-before-define": "off", - "import/prefer-default-export": "off", - "react/forbid-prop-types": "off", - "react/no-array-index-key": "off", - "react/no-unescaped-entities": "off", - "react/prop-types": "off", - "react/jsx-props-no-spreading": "off", - "react/jsx-filename-extension": "off", - "react-hooks/exhaustive-deps": "off", - "import/no-unresolved": "off", - "import/no-named-as-default-member": "off", - "import/no-named-as-default": "off", - "import/extensions": "off", - "import/order": "off", - "import/no-duplicates": "off", - "import/no-cycle": "off", - "import/no-self-import": "off", - "import/no-extraneous-dependencies": "off", - "import/named": "off", - "import/no-useless-path-segments": "off", - "linebreak-style": 0, - "max-len": "off" - - }, - "settings": { - "import/resolver": "webpack" - } -} \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 0b05c1020..edc2ae9dc 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,8 +1,50 @@ -## Purpose -_Describe the problem or feature_ +# Pull Request -## Further info -_Other important information related to the ticket_ +## Summary + -## Ticket number -_PLAT-999_ +## Type of Change +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Documentation update +- [ ] Refactor (no functional changes, no api changes) +- [ ] Performance improvement +- [ ] Test coverage improvement + +## Changes Made + +- +- +- + +## Testing +- [ ] Tests pass locally (`npm run test`) +- [ ] E2E tests pass (`npm run test:e2e`) +- [ ] Manual testing completed +- [ ] No console errors or warnings + +## Code Quality +- [ ] Code follows established patterns in `CODING_STYLE.md` +- [ ] TypeScript compilation passes (`npm run build`) +- [ ] Linting passes (`npm run lint`) +- [ ] No debug statements (console.log, etc.) +- [ ] JSDoc documentation added for new components/functions + +## Documentation +- [ ] `README.md` updated if needed +- [ ] `CLAUDE.md` updated with implementation details +- [ ] `CODING_STYLE.md` updated if new patterns introduced +- [ ] Component stories added/updated if UI changes + +## Screenshots/GIFs + + +## Checklist +- [ ] PR title clearly describes the change +- [ ] Branch is up to date with base branch +- [ ] All merge conflicts resolved +- [ ] Ready for review + +## Additional Notes + diff --git a/.gitignore b/.gitignore old mode 100755 new mode 100644 index 6c7ced957..d1451b811 --- a/.gitignore +++ b/.gitignore @@ -1,25 +1,43 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules -/src/clients/ -package-lock.json -yarn.lock +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* -# production -/dist +node_modules +dist +dist-ssr -# misc +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea .DS_Store -.env.local -.env.development.local -.env.test.local -.env.production.local +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? -npm-debug.log* +# Environment files (all env files) +.env* +!.env.example + +# Lock files (use npm ci for consistent installs) +package-lock.json +yarn.lock +pnpm-lock.yaml -.npmrc +# Testing +coverage +.coverage +.nyc_output +test-results/ +playwright-report/ -#IDE -.idea/ -.vscode/ +# Storybook +*storybook.log +storybook-static diff --git a/.storybook/main.ts b/.storybook/main.ts new file mode 100644 index 000000000..bbbda12e8 --- /dev/null +++ b/.storybook/main.ts @@ -0,0 +1,20 @@ +import type { StorybookConfig } from '@storybook/react-vite'; + +const config: StorybookConfig = { + "stories": [ + "../src/**/*.mdx", + "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)" + ], + "addons": [ + "@chromatic-com/storybook", + "@storybook/addon-docs", + "@storybook/addon-onboarding", + "@storybook/addon-a11y", + "@storybook/addon-vitest" + ], + "framework": { + "name": "@storybook/react-vite", + "options": {} + } +}; +export default config; \ No newline at end of file diff --git a/.storybook/preview.ts b/.storybook/preview.ts new file mode 100644 index 000000000..073582ec0 --- /dev/null +++ b/.storybook/preview.ts @@ -0,0 +1,21 @@ +import type { Preview } from '@storybook/react-vite' + +const preview: Preview = { + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + + a11y: { + // 'todo' - show a11y violations in the test UI only + // 'error' - fail CI on a11y violations + // 'off' - skip a11y checks entirely + test: 'todo' + } + }, +}; + +export default preview; \ No newline at end of file diff --git a/.storybook/vitest.setup.ts b/.storybook/vitest.setup.ts new file mode 100644 index 000000000..44922d55e --- /dev/null +++ b/.storybook/vitest.setup.ts @@ -0,0 +1,7 @@ +import * as a11yAddonAnnotations from "@storybook/addon-a11y/preview"; +import { setProjectAnnotations } from '@storybook/react-vite'; +import * as projectAnnotations from './preview'; + +// This is an important step to apply the right configuration when testing your stories. +// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations +setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]); \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..7e275a51c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,751 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +# ๐Ÿšจ MANDATORY CODING STANDARDS AND WORKFLOW + +## CRITICAL: Read CODING_STYLE.md First +Before making ANY changes to this codebase, you MUST: +1. **Read and understand** `CODING_STYLE.md` completely +2. **Follow ALL patterns and conventions** documented in that file +3. **Complete the pre-commit checklist** before finishing any task + +## ๐Ÿ”„ MANDATORY DEVELOPMENT WORKFLOW + +### For EVERY code change, you MUST: + +1. **Plan Implementation** + - Follow established patterns in `CODING_STYLE.md` + - Use existing component structures and TypeScript conventions + - Maintain consistency with state management and API patterns + +2. **Write Clean Code** + - No console.log or console.error statements + - Proper TypeScript typing for all code + - JSDoc documentation for components + - Follow naming conventions exactly + +3. **Test Functionality** + - Ensure all features work correctly + - Verify error handling and user feedback + - Check loading states and notifications + +4. **Update Documentation (MANDATORY)** + - Update `README.md` if adding new features/components + - Update `CLAUDE.md` with implementation details + - Keep documentation accurate and current + - Update `CODING_STYLE.md` if introducing new patterns + +5. **Pre-Commit Checklist** + - Code follows all established patterns + - No debug statements or console logs + - All TypeScript errors resolved + - Documentation is updated + - Code is production-ready + +**FAILURE TO FOLLOW THIS WORKFLOW IS UNACCEPTABLE** + +--- + +## Project Overview + +A modern React 19 template built with TypeScript, Vite, and comprehensive tooling. This is a production-ready template featuring OAuth authentication, light/dark/system theme support, dual environment variable support for both local development and Docker containerization, with integrated testing, linting, and component development workflows. + +## Development Commands + +### Setup and Installation +```bash +npm install # Install dependencies +cp .env.example .env.development # Set up environment variables +``` + +### Development Server +```bash +npm run dev # Start dev server on port 3000 +npm run preview # Preview production build locally +``` + +**Important**: Always stop the dev server before making configuration changes to avoid cached issues. + +### Building and Deployment +```bash +npm run build # TypeScript compilation + Vite build +npm run docker:build # Build Docker image +npm run docker:run # Run Docker container on port 80 +``` + +### Testing and Quality +```bash +# Unit Testing (Vitest + React Testing Library) +npm run test # Run in watch mode +npm run test:coverage # Run with coverage report +vitest run src/path/to/test.test.tsx # Run single test file + +# End-to-End Testing (Playwright) +npm run test:e2e # Run all E2E tests +npm run test:e2e:ui # Run with Playwright UI +npx playwright test --debug # Debug E2E tests + +# Code Quality +npm run lint # Run ESLint +npm run lint:fix # Auto-fix ESLint issues + +# Component Development +npm run storybook # Start Storybook on port 6006 +npm run build-storybook # Build static Storybook +``` + +## Architecture and Key Patterns + +### Environment Variable System +The app uses a dual environment system supporting both local development and Docker runtime injection: + +- **Local Development**: Vite loads `.env.development.local` โ†’ `.env.development` โ†’ `.env.local` โ†’ `.env` +- **Docker Runtime**: Variables injected via `docker-entrypoint.sh` into `window._env_` +- **Environment Utility**: `src/utils/env.ts` provides unified access with fallback hierarchy + +All environment variables must be prefixed with `VITE_` and defined in the `EnvConfig` interface in `src/utils/env.ts`. + +### Authentication System +Complete OAuth-based authentication system with full user lifecycle support: + +#### **Core Authentication Features**: +- **OAuth Login**: Multipart/form-data POST to token endpoint with client credentials +- **User Registration**: Account creation with email verification requirement +- **Password Reset**: Secure password reset via email links +- **Email Verification**: Token-based email verification from registration emails +- **Session Management**: Persistent login state with automatic cleanup + +#### **State Management**: +- **Zustand Store** (`src/stores/authStore.ts`) with localStorage persistence +- **Token Management**: Automatic expiration handling and cleanup +- **Authentication Guards**: Prevents access to auth pages when logged in + +#### **API Integration**: +- **TanStack Query Mutations**: All auth operations use React Query for state management +- **Error Handling**: Comprehensive error handling with user-friendly messages +- **Loading States**: Integrated loader system for all authentication operations + +#### **User Experience**: +- **Global Notifications**: Success/error notifications that persist across page navigation +- **Automatic Redirects**: Smart routing based on authentication state +- **Form Validation**: Client-side validation for all authentication forms +- **Immediate Feedback**: No artificial delays, immediate responses to user actions + +### Theme System +Comprehensive theme system with light/dark/system modes: + +- **Theme Modes**: Light, Dark, and System (follows OS preference) +- **State Management**: Zustand store (`src/stores/themeStore.ts`) with localStorage persistence +- **CSS Custom Properties**: Dynamic theme switching via CSS variables +- **System Integration**: Automatic detection and response to OS theme changes +- **Default Mode**: System preference with automatic switching + +### Global UI System +Unified system for user feedback and loading states: + +#### **Global Loader** (`src/components/GlobalLoader/`): +- **Overlay System**: Full-screen loading overlay during API operations +- **Contextual Messages**: Custom loading messages for different operations +- **State Management**: Zustand-based global loader state +- **Non-blocking**: Prevents user interaction during critical operations + +#### **Global Notifications** (`src/components/GlobalNotification/`): +- **Toast System**: Non-intrusive notifications for user feedback +- **Multiple Types**: Success, error, warning, and info notifications +- **Persistent Options**: Notifications can persist across page navigation +- **Auto-dismiss**: Configurable auto-dismiss timing with manual close option +- **Animation**: Smooth slide-in/out animations with progress indicators + +#### **Integration Pattern**: +```typescript +// Global loader usage +const { showLoader, hideLoader } = useLoader() +showLoader(LOADER_MESSAGES.SIGNING_IN) + +// Global notifications usage +const { showSuccess, showError } = useNotification() +showSuccess(NOTIFICATION_MESSAGES.LOGIN_SUCCESS) +showError('Custom error message', { persistent: true }) +``` + +### Testing Architecture +**Comprehensive Testing Setup** with 430+ test scenarios covering all possible use cases: +- **Unit Tests**: Vitest project targeting `src/**/*.test.{ts,tsx}` with jsdom environment +- **Component Tests**: React Testing Library with full user interaction simulation +- **Integration Tests**: Complete user flow testing from authentication to user management +- **API Tests**: Comprehensive mocking and testing of all API endpoints with error scenarios +- **Store Tests**: Zustand state management with persistence, cleanup, and synchronization testing +- **Hook Tests**: Custom hooks testing with complex usage scenarios and edge cases +- **Utility Tests**: Environment configuration, user roles, and constants validation +- **Storybook Tests**: Separate Vitest project using Playwright browser for component story testing +- **E2E Tests**: Playwright targeting `e2e/` directory with automatic dev server startup + +#### Comprehensive Test Coverage (430+ scenarios) +**API Layer Tests**: 99 test scenarios +- Authentication API (47 tests): Login, registration, password reset, email verification, error handling +- User Management API (52 tests): CRUD operations, role management, invitations, sequential API calls + +**State Management Tests**: 80 test scenarios +- Auth Store (36 tests): Token management, expiration, cleanup, persistence +- Theme Store (44 tests): Light/dark/system modes, DOM integration, event handling + +**Component Tests**: 73 test scenarios +- Theme Toggle (31 tests): Rendering, interactions, accessibility, store integration +- User Menu (42 tests): Menu behavior, user management access, logout flow, keyboard navigation + +**Utility Function Tests**: 87 test scenarios +- Environment Config (28 tests): Docker runtime, Vite build-time, fallbacks, edge cases +- User Roles (29 tests): Role detection, permissions, access control logic +- Constants (30 tests): Immutability, consistency, message validation + +**Custom Hook Tests**: 73 test scenarios +- Loader Hook (32 tests): State management, function behavior, complex usage scenarios +- Notification Hook (41 tests): Auto-removal, manual removal, state persistence, edge cases + +**Integration Tests**: 19 comprehensive scenarios +- Complete authentication flows (login, logout, registration, password reset) +- Theme system integration with DOM manipulation +- Global UI system (loader, notifications) integration +- Navigation and route protection +- User management access control +- Error handling and recovery +- State persistence across sessions +- Performance and accessibility testing + +#### Test Implementation Features +- **Production-Ready**: All tests follow established coding standards and patterns +- **Comprehensive Mocking**: API calls, timers, DOM methods, localStorage, external dependencies +- **Real-world Scenarios**: Tests mirror actual user interactions and business logic +- **Error Boundary Testing**: Graceful failure handling and recovery patterns +- **Security Testing**: Permission-based access control and role validation +- **Performance Testing**: Rapid interactions, concurrent operations, memory management +- **Accessibility Testing**: Screen reader support, keyboard navigation, ARIA compliance +- **Cross-browser Compatibility**: Tests work across different environments and configurations + +### Component Structure +Components follow co-location pattern: each component has its own directory with: +- `ComponentName.tsx` - Main component +- `ComponentName.test.tsx` - Unit tests +- `ComponentName.stories.tsx` - Storybook stories +- `ComponentName.css` - Component-specific styles (if needed) + +### Docker Multi-Stage Build +- **Build Stage**: Node.js container compiles TypeScript and builds with Vite +- **Runtime Stage**: Nginx Alpine serves static files +- **Environment Injection**: `docker-entrypoint.sh` creates runtime environment configuration + +### Build and Bundle Configuration +- **Vite**: Development server on port 3000, optimized production builds +- **TypeScript**: Strict mode enabled, ES2022 target, React JSX transform +- **ESLint**: TypeScript + React rules with Storybook integration +- **Vitest**: Two-project setup (unit + storybook) with jsdom and Playwright browser environments + +## Important Implementation Details + +### Dynamic Document Title +The app sets `document.title` dynamically using `useEffect` in the main App component, pulling from environment variables rather than static HTML. + +### Environment Variable Access Pattern +```typescript +import { env } from '@/utils/env' + +// Available environment variables: +env.API_URL // VITE_API_URL +env.APP_NAME // VITE_APP_NAME +env.OAUTH_TOKEN_URL // VITE_OAUTH_TOKEN_URL +env.OAUTH_CLIENT_ID // VITE_OAUTH_CLIENT_ID +env.IS_DEVELOPMENT // boolean derived from VITE_ENV +env.VERSION // VITE_VERSION +``` + +### Authentication Implementation Pattern +```typescript +import { useAuthStore } from '@/stores/authStore' +import { useLoginMutation } from '@/api/auth' + +// Authentication state access +const { isAuthenticated, user, logout, checkAuth } = useAuthStore() + +// Login mutation +const loginMutation = useLoginMutation() +await loginMutation.mutateAsync({ username, password }) +``` + +### Theme Implementation Pattern +```typescript +import { useThemeStore } from '@/stores/themeStore' + +// Theme state and actions +const { mode, resolvedTheme, setTheme } = useThemeStore() + +// Theme initialization (call in App.tsx) +const { initializeTheme } = useThemeStore() +useEffect(() => { + const cleanup = initializeTheme() + return cleanup +}, []) +``` + +### Routing Architecture +- **Root Route (`/`)**: Authentication check and redirect logic +- **Authentication Routes** (accessible to unauthenticated users): + - `/login` - OAuth login form with username/password + - `/register` - User registration with email verification + - `/forgot-password` - Password reset request form + - `/reset-password-confirm/:uid/:token` - Password reset confirmation from email + - `/verify-email` - Email verification from registration emails +- **Protected Routes** (requires authentication): + - `/app` - Main dashboard page + - `/app/user-management` - User management interface +- **Smart Redirects**: Automatic routing based on authentication state and user context + +### Docker Environment Override +When running in Docker, environment variables are injected at container startup and override any build-time values, enabling the same image to run in different environments. + +### Testing Setup Requirements +Unit tests require `src/test/setup.ts` which provides Jest DOM matchers and mocks the global `window._env_` object with test values. + +## Key Dependencies and Versions + +### Core Dependencies +- **React**: v19.1.1 with new JSX transform +- **TypeScript**: v5.8.3 with strict mode +- **Vite**: v7.1.2 for build tooling and dev server +- **React Router**: v7.8.2 for client-side routing +- **Zustand**: v5.0.8 for state management +- **TanStack Query**: v5.85.5 for server state management + +### Development Tools +- **Vitest**: v3.2.4 for unit testing with jsdom +- **Playwright**: v1.55.0 for end-to-end testing +- **Storybook**: v9.1.3 for component development +- **ESLint**: v9.34.0 with TypeScript and React rules + +## File Structure Overview + +``` +src/ +โ”œโ”€โ”€ api/ # API layer (TanStack Query) +โ”‚ โ”œโ”€โ”€ auth.ts # Complete authentication API (login, register, reset, verify) +โ”‚ โ””โ”€โ”€ users.ts # User management API (CRUD, invites, roles, organizations) +โ”œโ”€โ”€ assets/ # Static assets (logos, images) +โ”œโ”€โ”€ components/ # Reusable UI components +โ”‚ โ”œโ”€โ”€ Button/ # Button component with stories/tests +โ”‚ โ”œโ”€โ”€ TopBar/ # Navigation with gradient background and user menu +โ”‚ โ”œโ”€โ”€ ThemeToggle/ # Theme switching component +โ”‚ โ”œโ”€โ”€ UserMenu/ # User dropdown menu with profile and logout +โ”‚ โ”œโ”€โ”€ GlobalLoader/ # Global loading overlay +โ”‚ โ”œโ”€โ”€ GlobalNotification/ # Global notification system +โ”‚ โ”œโ”€โ”€ UserManagement/ # User management components +โ”‚ โ”‚ โ”œโ”€โ”€ UsersTab.tsx # Users table with editing capabilities +โ”‚ โ”‚ โ””โ”€โ”€ UserRolesTab.tsx # User roles table (read-only, no edit/delete) +โ”‚ โ”œโ”€โ”€ EditUserModal/ # User editing modal with organization and role assignment +โ”‚ โ”œโ”€โ”€ InviteUsersModal/ # Multi-user invitation modal +โ”‚ โ””โ”€โ”€ ui/ # Base UI components +โ”œโ”€โ”€ hooks/ # Custom React hooks +โ”‚ โ”œโ”€โ”€ useLoader.ts # Global loader state management +โ”‚ โ””โ”€โ”€ useNotification.ts # Global notification system +โ”œโ”€โ”€ pages/ # Route-level components +โ”‚ โ”œโ”€โ”€ Dashboard/ # Protected dashboard page +โ”‚ โ”œโ”€โ”€ Login/ # OAuth login page +โ”‚ โ”œโ”€โ”€ Register/ # User registration page +โ”‚ โ”œโ”€โ”€ ForgotPassword/ # Password reset request page +โ”‚ โ”œโ”€โ”€ ResetPasswordConfirm/ # Password reset confirmation page +โ”‚ โ”œโ”€โ”€ VerifyEmail/ # Email verification page +โ”‚ โ””โ”€โ”€ UserManagement/ # Complete user management system with tabbed interface +โ”œโ”€โ”€ stores/ # Zustand state stores +โ”‚ โ”œโ”€โ”€ authStore.ts # Authentication state +โ”‚ โ””โ”€โ”€ themeStore.ts # Theme state +โ”œโ”€โ”€ styles/ # Global styles and theme definitions +โ”œโ”€โ”€ test/ # Test setup and utilities +โ”œโ”€โ”€ utils/ # Utility functions +โ”‚ โ”œโ”€โ”€ env.ts # Environment variable handling +โ”‚ โ”œโ”€โ”€ constants.ts # Application constants and messages +โ”‚ โ””โ”€โ”€ userRoles.ts # User role utilities and helpers +โ””โ”€โ”€ App.tsx # Main application component +``` + +## Important Notes + +### Server Management +Always stop the development server (`npm run dev`) before making configuration changes to avoid cached issues. + +### Theme Colors +- **Primary**: #1B5FA3 (Buildly blue) +- **Secondary**: #F9943B (Buildly orange) +- **System Default**: Follows OS preference with automatic switching + +### Authentication Flow +**Login Flow**: +1. User visits `/` โ†’ checks authentication status +2. If authenticated โ†’ redirects to `/app` (Dashboard) +3. If not authenticated โ†’ redirects to `/login` +4. After successful login โ†’ automatic redirect to `/app` +5. Logout โ†’ clears state and redirects to `/login` + +**Registration Flow**: +1. User visits `/register` โ†’ fills registration form +2. After successful registration โ†’ shows success notification and redirects to `/login` +3. User receives verification email โ†’ clicks link to `/verify-email?token=...` +4. Email verification โ†’ automatic redirect to `/login` with success/error notification + +**Password Reset Flow**: +1. User visits `/forgot-password` โ†’ enters email +2. After request โ†’ shows confirmation and redirects to `/login` +3. User receives reset email โ†’ clicks link to `/reset-password-confirm/:uid/:token` +4. Password reset โ†’ automatic redirect to `/login` with success notification + +**Protected Route Access**: +- All `/app/*` routes require authentication +- Unauthenticated users automatically redirected to `/login` +- Authenticated users cannot access auth pages (redirected to `/app`) + +### Docker Deployment +The application uses multi-stage Docker builds with runtime environment injection, allowing the same image to be deployed across different environments without rebuilding. + +## Current Implementation Status + +### โœ… Completed Features + +#### **Authentication System (Complete)**: +- **OAuth Login** with username/password authentication +- **User Registration** with email verification requirement +- **Password Reset** via secure email links +- **Email Verification** from registration emails +- **Session Management** with persistent login state +- **Protected Routes** with automatic redirects +- **Form Validation** and error handling + +#### **User Management System (Complete)**: +- **Complete CRUD Operations** for user management +- **Tabbed Interface** with Users and User Roles tabs +- **User Invitations** with bulk email invite functionality +- **User Editing** with modal interface for status, organization, and role changes +- **Conditional API Updates** with sequential organization and user field updates +- **Role-based Permissions** display with read-only user roles table +- **Real-time Data Refresh** after all operations +- **Advanced Filtering** by status, organization, and role +- **Clean Console** - removed all debug logging for production readiness + +#### **Global UI System (Complete)**: +- **Global Loader** with contextual messages +- **Global Notifications** with multiple types and persistence +- **Theme System** with light/dark/system modes +- **Responsive Design** across all components + +#### **State Management (Complete)**: +- **Authentication State** via Zustand with localStorage +- **Theme State** via Zustand with localStorage +- **Loader State** via Zustand for global loading +- **Notification State** via Zustand for global notifications + +#### **API Layer (Complete)**: +- **TanStack Query integration** for all authentication and user management operations +- **Error handling** with user-friendly messages +- **Loading states** integrated with global loader +- **Promise-based mutations** for reliable state management +- **Intelligent API routing** based on data changes (organization vs user field updates) + +## User Management API Implementation + +### Conditional API Update System +The user update functionality implements intelligent API routing based on the fields being modified: + +#### **API Endpoints**: +- **Organization Updates**: `PATCH /coreuser/update_org/{userId}/` - Handles organization_name changes +- **User Field Updates**: `PATCH /coreuser/{userId}/` - Handles is_active and core_groups changes + +#### **Update Logic Flow**: +```typescript +// Sequential API calls based on data changes +1. If organization_name is being updated: + โ†’ Call updateUserOrganization() first + โ†’ If this fails, stop and show error notification + +2. If is_active or core_groups are being updated: + โ†’ Call updateUserFields() second + โ†’ Return result from this call as the final user object + +3. If only organization was updated: + โ†’ Fetch updated user data via GET /coreuser/{userId}/ +``` + +#### **UserUpdateData Interface**: +```typescript +interface UserUpdateData { + is_active?: boolean + organization_name?: string + core_groups?: number[] +} +``` + +#### **Error Handling**: +- **Sequential Processing**: Organization update must succeed before user fields update +- **Fail-Fast Approach**: If first API call fails, operation stops immediately +- **User Notifications**: Clear error messages displayed via global notification system +- **No Console Logging**: All debug console statements removed for production readiness + +### Data Management +- **TanStack Query Integration**: Efficient caching and automatic refetching +- **Real-time Updates**: Data automatically refreshes after successful operations +- **Optimistic Updates**: UI updates immediately with rollback on API failure +- **Type Safety**: Full TypeScript interfaces for all API data structures + +## Current Application Architecture + +### User Interface Components + +#### TopBar Component +- **Location**: `src/components/TopBar/TopBar.tsx` +- **Features**: + - Gradient background matching login page design (`linear-gradient(135deg, var(--color-primary) 0%, var(--color-secondary) 100%)`) + - Theme-aware logo switching (light logo for dark theme, dark logo for light theme) + - Sticky positioning for persistent navigation + - Responsive design with mobile breakpoints +- **Contains**: ThemeToggle and UserMenu components + +#### ThemeToggle Component +- **Location**: `src/components/ThemeToggle/ThemeToggle.tsx` +- **Features**: + - Pill-shaped container with three theme options + - Icon-only design with hover tooltips + - Smooth animations and transitions + - Visual feedback with active state highlighting +- **Theme Options**: Light, Dark, System (follows OS preference) + +#### UserMenu Component +- **Location**: `src/components/UserMenu/UserMenu.tsx` +- **Features**: + - Circular profile icon trigger + - Dropdown menu with glass morphism effects + - User profile information display + - Two menu options: User Management and Logout + - Click-outside and escape key handling + +#### Authentication Pages +- **Login**: `/login` - OAuth authentication with gradient background +- **Register**: `/register` - User registration with grouped form fields (first/last name, username/email, password/confirm password on same lines) +- **Forgot Password**: `/forgot-password` - Password reset interface + +#### Protected Pages +- **Dashboard**: `/app` - Default authenticated landing page +- **User Management**: `/app/user-management` - Clean interface ready for user management features (all previous cards/sections removed) + +### Recent Changes and Current State + +#### TopBar Styling +- Uses gradient background matching login page design +- All text and icons use theme-appropriate colors (standard CSS custom properties) +- Removed custom glass morphism effects in favor of standard theme system + +#### User Management Page +- **Cleaned State**: All user cards, roles/permissions, activity logs, security settings, and current user information sections have been removed +- **Current State**: Clean layout with header and empty content area ready for new functionality +- **Purpose**: Provides a clean slate for implementing specific user management features + +#### Theme System Integration +- All components use CSS custom properties for consistent theming +- Automatic light/dark theme support through media queries +- Theme toggle provides visual feedback and smooth transitions + +### Code Quality and Documentation + +#### Comments and Documentation +- **Added comprehensive comments** to key components for better understanding: + - `App.tsx`: Main application setup and routing + - `TopBar.tsx`: Navigation component features and structure + - `ThemeToggle.tsx`: Theme switching functionality and UI patterns + - `UserManagement.tsx`: Current clean state and future-ready structure + +#### File Structure Standards +- Components follow co-location pattern with `.tsx`, `.css`, `.test.tsx`, and `.stories.tsx` files +- Clear separation between pages, components, stores, and utilities +- Consistent import organization and commenting + +### Important Implementation Notes + +#### TopBar Background +- **Current**: Uses login page gradient (`linear-gradient(135deg, var(--color-primary) 0%, var(--color-secondary) 100%)`) +- **Colors**: Buildly blue (#1B5FA3) to Buildly orange (#F9943B) +- **Theme Integration**: Works with standard CSS custom property theme system + +#### User Management System (Complete Implementation) +- **Full-Featured Interface**: Complete user management with tabbed navigation +- **Users Tab**: + - User table with comprehensive user information display + - Edit functionality via modal interface + - User filtering and search capabilities + - User invitation system with bulk email support +- **User Roles Tab**: + - Read-only permissions matrix showing CRUD permissions per role + - Organization-specific and global roles display + - Clean table layout without edit/delete functionality (removed per requirements) +- **Modal System**: + - EditUserModal for user status, organization, and role management + - InviteUsersModal for sending bulk email invitations +- **API Integration**: + - Conditional API calls based on data changes (organization vs user fields) + - Sequential processing for organization updates followed by user field updates + - Comprehensive error handling with user notifications + +#### Component Patterns +- All UI components use standard CSS custom properties for theming +- Hover states and animations follow consistent patterns +- Responsive design implemented across all components +- Accessibility considerations with proper ARIA labels and keyboard navigation + +## ๐Ÿงช Comprehensive Test Implementation + +### Test Files Created (430+ Test Scenarios) + +The codebase now includes comprehensive test coverage with the following test files: + +#### API Layer Tests +- **`src/api/auth.test.ts`** - Authentication API testing (47 scenarios) + - Login flow with token and user data retrieval + - Registration with email verification requirement + - Password reset request and confirmation + - Email verification from registration emails + - Error handling for all authentication scenarios + - Network error and edge case testing + +- **`src/api/users.test.ts`** - User Management API testing (52 scenarios) + - Users, core groups, and organizations queries + - User invitation system with bulk email support + - Complex user update logic with conditional API calls + - Sequential API processing (organization updates โ†’ user field updates) + - Comprehensive error handling and edge cases + - Query caching and stale time validation + +#### State Management Tests +- **`src/stores/authStore.test.ts`** - Authentication state testing (36 scenarios) + - Token management with expiration handling + - User data persistence and cleanup + - Authentication state synchronization + - localStorage integration and rehydration + - Token expiration cleanup and validation + - Edge cases and error scenarios + +- **`src/stores/themeStore.test.ts`** - Theme system testing (44 scenarios) + - Light/dark/system theme modes + - OS preference detection and integration + - DOM manipulation and CSS class application + - Event listener management and cleanup + - Theme persistence across sessions + - System theme change responsiveness + +#### Component Tests +- **`src/components/ThemeToggle/ThemeToggle.test.tsx`** - Theme toggle testing (31 scenarios) + - Theme selection UI interactions + - Active state management and visual feedback + - Accessibility and keyboard navigation + - Store integration and state synchronization + - Rapid interaction handling + - Edge cases and error scenarios + +- **`src/components/UserMenu/UserMenu.test.tsx`** - User menu testing (42 scenarios) + - Menu open/close behavior with click-outside handling + - User information display and formatting + - Logout functionality with navigation + - User management access control + - Keyboard navigation and escape key handling + - Event listener cleanup and memory management + +#### Utility Function Tests +- **`src/utils/env.test.ts`** - Environment configuration testing (28 scenarios) + - Docker runtime vs Vite build-time variable handling + - Fallback hierarchy and default values + - SSR compatibility and edge cases + - Environment variable validation + - Type safety and configuration consistency + - Real-world deployment scenarios + +- **`src/utils/userRoles.test.ts`** - User roles and permissions testing (29 scenarios) + - Role detection logic (global admin, admin, user) + - Permission-based access control + - User management access validation + - Complex user group scenarios + - Edge cases and malformed data handling + - Integration with authentication system + +- **`src/utils/constants.test.ts`** - Application constants testing (30 scenarios) + - Constants structure and type validation + - Message consistency and formatting + - Immutability and configuration integrity + - Cross-constant consistency validation + - User-friendly message validation + - Naming convention compliance + +#### Custom Hook Tests +- **`src/hooks/useLoader.test.ts`** - Global loader hook testing (32 scenarios) + - Loading state management and message handling + - State persistence across hook instances + - Function identity and performance optimization + - Complex usage scenarios and edge cases + - Store integration and cleanup + - Rapid interaction handling + +- **`src/hooks/useNotification.test.ts`** - Notification system testing (41 scenarios) + - Notification creation with auto-removal timers + - Manual removal and state management + - Persistent vs temporary notification handling + - Multiple notification types (success, error, warning, info) + - State synchronization across hook instances + - Message validation and edge case handling + +#### Integration Tests +- **`src/test/integration.test.tsx`** - Complete user flow testing (19 scenarios) + - Full authentication flows (login, logout, registration, password reset) + - Theme system integration with DOM manipulation + - Global UI system integration (loader + notifications) + - Navigation and route protection validation + - User management access control integration + - Error handling and recovery patterns + - State persistence across sessions + - Performance and accessibility validation + +### Test Implementation Standards + +All tests follow the established coding standards and include: +- **Comprehensive Mocking**: API calls, timers, DOM methods, localStorage +- **TypeScript Integration**: Full type safety with proper interfaces +- **Error Boundary Testing**: Graceful failure handling patterns +- **Real-world Scenarios**: Tests mirror actual user interactions +- **Performance Testing**: Rapid interactions and concurrent operations +- **Security Validation**: Permission-based access and role verification +- **Accessibility Testing**: Keyboard navigation and screen reader support +- **Cross-environment Compatibility**: Works across development and production setups + +### Running Specific Test Suites + +```bash +# API Layer Tests +npm run test src/api/auth.test.ts +npm run test src/api/users.test.ts + +# State Management Tests +npm run test src/stores/authStore.test.ts +npm run test src/stores/themeStore.test.ts + +# Component Tests +npm run test src/components/ThemeToggle/ThemeToggle.test.tsx +npm run test src/components/UserMenu/UserMenu.test.tsx + +# Utility Tests +npm run test src/utils/env.test.ts +npm run test src/utils/userRoles.test.ts +npm run test src/utils/constants.test.ts + +# Hook Tests +npm run test src/hooks/useLoader.test.ts +npm run test src/hooks/useNotification.test.ts + +# Integration Tests +npm run test src/test/integration.test.tsx + +# Run all tests +npm run test +``` + +This comprehensive test suite ensures production-ready code quality, covers all possible user scenarios, and provides confidence for ongoing development and maintenance. \ No newline at end of file diff --git a/CODING_STYLE.md b/CODING_STYLE.md new file mode 100644 index 000000000..5c065c5cb --- /dev/null +++ b/CODING_STYLE.md @@ -0,0 +1,534 @@ +# Buildly React Template - Coding Style Guide + +This document defines the coding standards and conventions used throughout this React TypeScript application. **ALL changes to the codebase MUST follow these guidelines**. + +## ๐Ÿšจ MANDATORY DEVELOPMENT WORKFLOW + +### Before Making ANY Changes: +1. **Read and understand** these coding style guidelines +2. **Plan your implementation** following the established patterns +3. **Write code** adhering to all conventions below +4. **Test your changes** to ensure they work correctly +5. **Update documentation** (README.md and CLAUDE.md) to reflect changes +6. **Commit changes** with descriptive commit messages + +--- + +## ๐Ÿ“‹ React Component Patterns + +### Component Structure (MANDATORY) +```typescript +/** + * Component description with features and purpose + * Features: + * - Feature 1 description + * - Feature 2 description + */ +export const ComponentName = () => { + // Component implementation +} +``` + +**Requirements:** +- โœ… **Named exports only**: `export const ComponentName` +- โœ… **Functional components only**: No class components +- โœ… **JSDoc comments**: Document component purpose and features +- โœ… **Co-location pattern**: Component, styles, tests, stories in same directory + +### Props and TypeScript (MANDATORY) +```typescript +interface ComponentProps extends React.HTMLAttributes { + required: string + optional?: boolean + variant?: 'small' | 'medium' | 'large' +} + +export const Component = ({ + required, + optional = false, + variant = 'medium', + className, + ...props +}: ComponentProps) => { + // Implementation +} +``` + +**Requirements:** +- โœ… **Interface-based props**: Always define props via TypeScript interfaces +- โœ… **Extend HTML attributes**: When applicable, extend appropriate HTML element interfaces +- โœ… **Default parameters**: Use destructuring defaults `optional = false` +- โœ… **Spread remaining props**: Always use `{...props}` for HTML attributes + +--- + +## ๐Ÿ”ง TypeScript Usage (MANDATORY) + +### Type Definitions +```typescript +// โœ… CORRECT - Use interfaces for object shapes +interface User { + id: number + name: string + email?: string +} + +// โœ… CORRECT - Use const assertions for immutable data +export const CONSTANTS = { + MAX_ITEMS: 10, + TIMEOUT: 5000, +} as const + +// โœ… CORRECT - Union types for limited values +type Theme = 'light' | 'dark' | 'system' +``` + +**Requirements:** +- โœ… **Strict typing**: All functions, variables, components must have explicit types +- โœ… **Interface over type**: Use `interface` for object shapes +- โœ… **Const assertions**: Use `as const` for immutable objects +- โœ… **Union types**: For limited value sets +- โœ… **Optional properties**: Use `?:` for optional fields + +### Type Organization +```typescript +// โœ… CORRECT - Define interfaces inline or in API files +interface ComponentProps { + data: User[] + onUpdate: (user: User) => void +} + +// โœ… CORRECT - Import shared types from API files +import type { User, Organization } from '../../api/users' +``` + +**Requirements:** +- โœ… **Inline interfaces**: Define simple interfaces directly in component files +- โœ… **Shared types**: Define complex types in API files and import with `type` keyword +- โœ… **Generic types**: Use generics for reusable components and hooks + +--- + +## ๐Ÿช State Management Patterns (MANDATORY) + +### Zustand Store Structure +```typescript +interface StoreState { + data: DataType[] + isLoading: boolean + addItem: (item: DataType) => void + removeItem: (id: string) => void +} + +export const useStore = create()((set, get) => ({ + data: [], + isLoading: false, + + addItem: (item) => { + set((state) => ({ + data: [...state.data, item] + })) + }, + + removeItem: (id) => { + set((state) => ({ + data: state.data.filter(item => item.id !== id) + })) + }, +})) + +// โœ… CORRECT - Custom hook wrapper +export const useCustomHook = () => { + const { data, addItem } = useStore() + return { + items: data, + addItem, + // Expose specific methods only + } +} +``` + +**Requirements:** +- โœ… **Zustand pattern**: Use `create()((set, get) => ({}))` +- โœ… **Persist middleware**: Use for localStorage integration when needed +- โœ… **Custom hook wrappers**: Create specific hooks that expose only needed methods +- โœ… **Immutable updates**: Always use spread operators for state updates +- โœ… **Flat state structure**: Minimize nesting in state objects + +--- + +## ๐ŸŒ API Integration Patterns (MANDATORY) + +### TanStack Query Usage +```typescript +// โœ… CORRECT - API function +const fetchUsers = async (token: string): Promise => { + const response = await fetch('/api/users', { + headers: { 'Authorization': `Bearer ${token}` } + }) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + return response.json() +} + +// โœ… CORRECT - Query hook +export const useUsersQuery = (token: string | null) => { + return useQuery({ + queryKey: ['users'], + queryFn: () => fetchUsers(token!), + enabled: !!token, + staleTime: 5 * 60 * 1000, + }) +} + +// โœ… CORRECT - Mutation hook (NO error handling in hook) +export const useUpdateUserMutation = () => { + return useMutation({ + mutationFn: ({ token, userId, data }: UpdateUserParams) => + updateUser(token, userId, data) + // No onError here - handle at component level + }) +} +``` + +**Requirements:** +- โœ… **Async/await syntax**: All API functions use async/await +- โœ… **Error throwing**: API functions throw errors, let TanStack Query handle them +- โœ… **Response validation**: Always check `response.ok` before parsing +- โœ… **Type safety**: Explicit return types for all API functions +- โœ… **No redundant error handling**: Handle errors at component level only +- โœ… **Custom hooks**: Wrap TanStack Query hooks for specific use cases + +--- + +## ๐ŸŽจ Styling Conventions (MANDATORY) + +### CSS Custom Properties +```css +/* โœ… CORRECT - Use CSS custom properties */ +.component { + background-color: var(--color-surface); + color: var(--color-text-primary); + border-radius: var(--radius-md); + box-shadow: var(--shadow-sm); + transition: all 0.2s ease; +} + +.component:hover { + background-color: var(--color-surface-hover); + transform: translateY(-1px); + box-shadow: var(--shadow-md); +} + +/* โœ… CORRECT - BEM-like modifiers */ +.component--primary { + background-color: var(--color-primary); + color: var(--color-text-inverse); +} + +.component--large { + padding: 12px 24px; + font-size: 16px; +} +``` + +**Requirements:** +- โœ… **CSS Custom Properties**: Use `var(--property-name)` for all theme values +- โœ… **Semantic naming**: `--color-text-primary`, not `--blue-500` +- โœ… **Component-scoped CSS**: Each component has its own CSS file +- โœ… **BEM-like naming**: `.component--modifier` for variants +- โœ… **Hover states**: Consistent hover animations with `transform` and `box-shadow` +- โœ… **Transitions**: Use `transition: all 0.2s ease` for smooth interactions + +### Style Organization +``` +src/ +โ”œโ”€โ”€ styles/ +โ”‚ โ”œโ”€โ”€ theme.css # Global theme variables +โ”‚ โ”œโ”€โ”€ forms.css # Shared form styles +โ”‚ โ””โ”€โ”€ auth-pages.css # Shared authentication styles +โ”œโ”€โ”€ components/ +โ”‚ โ””โ”€โ”€ Button/ +โ”‚ โ”œโ”€โ”€ Button.tsx +โ”‚ โ”œโ”€โ”€ Button.css # Component-specific styles +โ”‚ โ”œโ”€โ”€ Button.test.tsx +โ”‚ โ””โ”€โ”€ Button.stories.tsx +``` + +**Requirements:** +- โœ… **Co-located CSS**: Component styles in same directory +- โœ… **Global themes**: Theme definitions in `src/styles/theme.css` +- โœ… **Shared styles**: Common styles in separate files when used by multiple components +- โœ… **No CSS-in-JS**: Use pure CSS files with CSS custom properties + +--- + +## ๐Ÿ“ File Organization (MANDATORY) + +### Directory Structure +``` +src/ +โ”œโ”€โ”€ api/ # API functions and types +โ”œโ”€โ”€ components/ # Reusable UI components +โ”‚ โ””โ”€โ”€ ComponentName/ # Each component in own directory +โ”œโ”€โ”€ hooks/ # Custom React hooks +โ”œโ”€โ”€ pages/ # Route-level components +โ”œโ”€โ”€ stores/ # Zustand state stores +โ”œโ”€โ”€ styles/ # Global styles and themes +โ”œโ”€โ”€ utils/ # Utility functions +โ””โ”€โ”€ App.tsx # Main application component +``` + +**Requirements:** +- โœ… **Feature grouping**: Related components grouped by domain +- โœ… **Co-location**: All related files in same directory +- โœ… **Flat structure**: Minimal nesting within feature directories +- โœ… **Clear separation**: API, components, hooks, pages, stores clearly separated + +### Naming Conventions +```typescript +// โœ… CORRECT - File and component naming +// File: src/components/UserMenu/UserMenu.tsx +export const UserMenu = () => {} + +// โœ… CORRECT - Interface naming +interface UserMenuProps {} + +// โœ… CORRECT - Variable and function naming +const handleSubmit = () => {} +const isLoading = true + +// โœ… CORRECT - Constant naming +export const API_ENDPOINTS = { + USERS: '/api/users', +} as const +``` + +**Requirements:** +- โœ… **PascalCase**: Components, interfaces, types +- โœ… **camelCase**: Variables, functions, object properties +- โœ… **UPPER_SNAKE_CASE**: Constants and enum-like objects +- โœ… **kebab-case**: CSS classes and file names for styles +- โœ… **Descriptive names**: Clear, self-documenting names + +--- + +## ๐Ÿ“ฅ Import/Export Patterns (MANDATORY) + +### Import Organization +```typescript +// โœ… CORRECT - Import order and grouping +// 1. React and React-related imports +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' + +// 2. Third-party library imports +import { useMutation } from '@tanstack/react-query' + +// 3. Local component imports +import { Button } from '../../components/Button/Button' +import { useAuthStore } from '../../stores/authStore' + +// 4. Utility and constant imports +import { env } from '../../utils/env' +import { LOADER_MESSAGES } from '../../utils/constants' + +// 5. Asset imports +import darkLogo from '../../assets/dark-logo.png' + +// 6. Style imports (last) +import './ComponentName.css' +``` + +**Requirements:** +- โœ… **Grouped imports**: React, third-party, local components, utilities, assets, styles +- โœ… **Named imports**: Use `{ useState }` instead of default imports when possible +- โœ… **Relative paths**: Use `../../` with explicit file extensions for local imports +- โœ… **Type imports**: Use `import type { }` for type-only imports + +### Export Patterns +```typescript +// โœ… CORRECT - Named exports preferred +export const ComponentName = () => {} +export interface ComponentProps {} +export type ComponentVariant = 'small' | 'large' + +// โŒ AVOID - Default exports (except for special cases) +// export default ComponentName +``` + +**Requirements:** +- โœ… **Named exports**: Prefer named exports over default exports +- โœ… **No re-exports**: Import directly from source files +- โœ… **Explicit exports**: Export interfaces and types that might be reused + +--- + +## ๐Ÿšซ Error Handling & UX (MANDATORY) + +### Error Handling Patterns +```typescript +// โœ… CORRECT - Component-level error handling +const handleSubmit = async () => { + try { + showLoader('Processing...') + await mutation.mutateAsync(data) + showSuccess('Operation completed successfully!') + } catch (error) { + showError('Operation failed. Please try again.') + // No console.log or console.error + } finally { + hideLoader() + } +} + +// โœ… CORRECT - API function error handling +const apiFunction = async (): Promise => { + const response = await fetch('/api/endpoint') + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + return response.json() +} +``` + +**Requirements:** +- โœ… **Try/catch at component level**: Wrap API calls in try/catch +- โœ… **User-friendly messages**: No technical error details to users +- โœ… **Global notifications**: Use notification system for user feedback +- โœ… **Loading states**: Use global loader for API operations +- โœ… **No console statements**: Remove all console.log, console.error for production + +### User Experience Standards +```typescript +// โœ… CORRECT - Loading and feedback patterns +const { showLoader, hideLoader } = useLoader() +const { showSuccess, showError } = useNotification() + +// Always show loading states +showLoader('Updating user...') + +// Always provide user feedback +showSuccess('User updated successfully!') +showError('Failed to update user. Please try again.') +``` + +**Requirements:** +- โœ… **Loading indicators**: Always show loading states for async operations +- โœ… **User feedback**: Provide success/error notifications for all operations +- โœ… **Descriptive loading messages**: Use specific messages like "Updating user..." +- โœ… **Consistent patterns**: Use same notification patterns throughout app + +--- + +## ๐Ÿ“ Documentation Standards (MANDATORY) + +### Component Documentation +```typescript +/** + * UserMenu component for authenticated user actions + * Features: + * - User profile information display + * - Dropdown menu with navigation options + * - Logout functionality with confirmation + * - Click-outside and escape key handling + * - Responsive design with mobile support + */ +export const UserMenu = () => { + // Implementation +} +``` + +### Code Comments +```typescript +// โœ… CORRECT - Explain complex logic +const processedGroups = useMemo(() => { + return coreGroups.map(group => { + // Find organization name from organizations API if group has organization reference + let displayName = group.name + + if (group.is_global) { + displayName = group.name // Keep as is for global roles like "Global Admin" + } else if (group.organization) { + // Handle both object and ID references for organization + const org = organizations.find(org => + org.id === group.organization || + org.organization_uuid === group.organization + ) + if (org) { + displayName = `${group.name} - ${org.name}` + } + } + + return { id: group.id, displayName, permissions: group.permissions } + }) +}, [coreGroups, organizations]) +``` + +**Requirements:** +- โœ… **JSDoc for components**: Document purpose and key features +- โœ… **Inline comments**: Explain complex logic and business rules +- โœ… **API documentation**: Document endpoint usage and data transformations +- โœ… **Update README/CLAUDE**: Always update documentation when making changes + +--- + +## ๐Ÿ”„ MANDATORY PRE-COMMIT CHECKLIST + +### Before Every Commit: + +1. **โœ… Code Quality Check** + - [ ] All code follows established patterns above + - [ ] No console.log or console.error statements + - [ ] All TypeScript errors resolved + - [ ] All components have proper JSDoc documentation + +2. **โœ… Testing & Functionality** + - [ ] All functionality works as expected + - [ ] Error handling is implemented properly + - [ ] Loading states and user feedback work correctly + - [ ] Responsive design is maintained + +3. **โœ… Documentation Update (MANDATORY)** + - [ ] Update `README.md` if new features or components added + - [ ] Update `CLAUDE.md` with implementation details + - [ ] Update this `CODING_STYLE.md` if new patterns introduced + - [ ] Verify all documentation is accurate and current + +4. **โœ… Final Review** + - [ ] Code is clean and production-ready + - [ ] All imports are organized correctly + - [ ] CSS follows naming conventions + - [ ] File structure is maintained + +### Commit Message Format: +``` +feat: implement user role management system + +- Add UserRolesTab component with read-only permissions display +- Implement conditional API updates for user organization changes +- Add comprehensive error handling with user notifications +- Update documentation to reflect new functionality + +๐Ÿค– Generated with [Claude Code](https://claude.ai/code) + +Co-Authored-By: Claude +``` + +--- + +## ๐ŸŽฏ Key Principles + +1. **Consistency First**: Follow established patterns exactly +2. **Type Safety**: Everything must be properly typed +3. **User Experience**: Always provide loading states and feedback +4. **Clean Code**: No debug logging, clear naming, proper documentation +5. **Documentation**: Always update docs before committing +6. **Production Ready**: Code should be ready for production deployment + +**Remember: These guidelines are MANDATORY. All code changes must follow these patterns to maintain code quality and consistency.** \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 0574b5b3a..9babfd2de 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,36 @@ -FROM --platform=linux/amd64 nginx:latest +# Multi-stage build for React app +FROM node:18-alpine as build -COPY . /app -COPY ./nginx.conf /etc/nginx/conf.d/default.conf WORKDIR /app -EXPOSE 9000 +# Copy package files +COPY package*.json ./ -CMD ["sh", "-c", "./initialize_container.sh && nginx -g 'daemon off;'"] +# Install dependencies +RUN npm ci --only=production + +# Copy source code +COPY . . + +# Build the app +RUN npm run build + +# Production stage +FROM nginx:alpine + +# Copy custom nginx config +COPY nginx.conf /etc/nginx/nginx.conf + +# Copy built app from build stage +COPY --from=build /app/dist /usr/share/nginx/html + +# Copy environment template script +COPY docker-entrypoint.sh /docker-entrypoint.sh +RUN chmod +x /docker-entrypoint.sh + +# Expose port +EXPOSE 80 + +# Use custom entrypoint to inject environment variables +ENTRYPOINT ["/docker-entrypoint.sh"] +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/README.md b/README.md index 13d39a9c0..f1e258898 100644 --- a/README.md +++ b/README.md @@ -1,125 +1,515 @@ # Buildly React Template -[![Build Status](https://travis-ci.org/buildlyio/buildly-react-template.svg?branch=master)](https://travis-ci.org/buildlyio/buildly-react-template) [![Documentation Status](https://readthedocs.org/projects/buildly-react-template/badge/?version=latest)](https://buildly-react-template.readthedocs.io/en/latest/?badge=latest) [![Gitter](https://badges.gitter.im/Buildlyio/community.svg)](https://gitter.im/Buildlyio/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) -Buildly React Template is a [React](https://reactjs.org/) web application that implements the core features of the UI core, pre-configure to connect to [Buildly Core](https://github.com/buildlyio/buildly-core). - -## Getting Started - -These instructions will get you a copy of the project up and running on your local machine for development and testing purposes. See deployment for notes on how to deploy the project on a live system. +A modern React web application template built with the latest technologies and best practices. This production-ready template features complete OAuth authentication system with user registration, email verification, password reset, comprehensive theme system, global notification system, and Docker support with flexible environment variable handling. + +## ๐Ÿš€ Features + +### Core Technologies +- **React 19** with TypeScript and modern JSX transform +- **Vite** for lightning-fast development and optimized builds +- **TanStack Query v5** for server state management and caching +- **React Router v6** for client-side routing +- **Zustand** for lightweight global state management + +### Authentication & Security +- **Complete OAuth 2.0** authentication system with token management +- **User Registration** with email verification requirement +- **Password Reset** via secure email links with token-based confirmation +- **Email Verification** for new user accounts +- **Protected routes** with automatic authentication-based redirects +- **Persistent login state** with automatic token cleanup and expiration handling +- **Form validation** with client-side validation and error handling +- **Session management** with secure token storage and automatic cleanup + +### UI & User Experience +- **Light/Dark/System themes** with automatic OS preference detection +- **Global notification system** with toast notifications for user feedback +- **Global loading overlay** with contextual loading messages +- **Responsive design** across all screen sizes and devices +- **CSS custom properties** for dynamic theme switching +- **Persistent preferences** with localStorage integration +- **Smooth animations** and transitions throughout the interface + +### Development Tools +- **ESLint** with TypeScript and React rules +- **Storybook** for component development and documentation +- **Vitest** for unit testing with React Testing Library +- **Playwright** for end-to-end testing +- **Docker** support with multi-stage builds and runtime environment injection +- **Hot Module Replacement (HMR)** for fast development + +## ๐Ÿ“ฆ Quick Start ### Prerequisites -The web application was tested and built with the following versions: - -- node v16.14.2 -- yarn v1.17.3 - -- You need to create .env.development.local file with the env variables (something as below). -buildly-react-template -|--.env.development.local - +- Node.js 18+ and npm +- Docker (optional, for containerization) + +### Local Development + +1. **Clone and install dependencies:** + ```bash + git clone + cd react-app-template + npm install + ``` + +2. **Set up environment variables:** + ```bash + # Copy base development environment + cp .env.example .env.development + + # Optionally create local overrides (not committed to git) + # Edit .env.development.local with your local values if needed + ``` + +3. **Start the development server:** + ```bash + npm run dev + ``` + Open [http://localhost:3000](http://localhost:3000) to view the app. + +## ๐Ÿ› ๏ธ Available Scripts + +### Development +- `npm run dev` - Start development server +- `npm run build` - Build for production +- `npm run preview` - Preview production build locally + +### Code Quality +- `npm run lint` - Run ESLint +- `npm run lint:fix` - Fix ESLint issues automatically + +### Testing +- `npm run test` - Run unit tests with Vitest +- `npm run test:ui` - Run unit tests with UI +- `npm run test:coverage` - Run tests with coverage report +- `npm run test:e2e` - Run end-to-end tests with Playwright +- `npm run test:e2e:ui` - Run E2E tests with UI + +### Storybook +- `npm run storybook` - Start Storybook development server +- `npm run build-storybook` - Build Storybook for production + +### Docker +- `npm run docker:build` - Build Docker image +- `npm run docker:run` - Run Docker container + +## ๐ŸŒ Environment Variables + +The application supports environment variables through multiple methods with automatic loading priority: + +### Local Development (.env files) +Vite automatically loads environment files in this priority order: +1. `.env.development.local` - Local overrides (not committed to git) +2. `.env.development` - Development environment defaults +3. `.env.local` - Local overrides for all environments (not committed) +4. `.env` - Global defaults + +**Base development config (`.env.development`):** +```env +VITE_API_URL=http://dev-api.example.com/ +VITE_APP_NAME=React App +VITE_ENV=development +VITE_VERSION=1.0.0 +VITE_OAUTH_TOKEN_URL=http://dev-api.example.com/token/ +VITE_OAUTH_CLIENT_ID=your-client-id ``` -window.env={ - API_URL: "https://dev.example.com/", - OAUTH_TOKEN_URL: "https://dev.example.com/oauth/token/", - OAUTH_CLIENT_ID: "sjkghwty982092u1tjfwjit0348y82092utwgio", - PRODUCTION: "false", -} -``` - -- In case you want to run https we app version on your local, you need to have a certificate created for your localhost already. Modify package.json file to indicate the path to you certififcate and key in the scripts --> https:local - -### Installing - -First of all, you need to have a Buildly Core instance up and running locally. -Further detail about how to deploy Buildly Core locally, check its [documentation](https://buildly-core.readthedocs.io/en/latest/). - -To install the application you need to download and install its dependencies, so you have to navigate to the project folder and run the following command: +**Local overrides (`.env.development.local`) - Optional:** +```env +# Create this file locally if you need to override any values +# This file is not committed to git +VITE_API_URL=http://localhost:8000/api +VITE_OAUTH_TOKEN_URL=http://localhost:8000/oauth/token/ +VITE_OAUTH_CLIENT_ID=your-local-client-id +VITE_APP_NAME=My Local React App ``` -$ yarn install -``` - -Now, initialize and build the project -``` -$ yarn run build:local +### Docker Runtime (Injected via environment) +When running in Docker, environment variables are injected at runtime using VITE_ prefix: +```bash +docker run -e VITE_API_URL=https://api.example.com \ + -e VITE_APP_NAME="Production App" \ + -e VITE_OAUTH_TOKEN_URL=https://api.example.com/oauth/token/ \ + -e VITE_OAUTH_CLIENT_ID=prod-client-id \ + -p 80:80 react-app ``` -To run the web app: +The environment utility (`src/utils/env.ts`) automatically handles both scenarios. -``` -$ yarn run start:local -``` +## ๐Ÿณ Docker Usage -To run the https web app: +### Build and run with Docker: -``` -$ yarn run https:local +```bash +# Build the image +docker build -t react-app . + +# Run with default environment +docker run -p 80:80 react-app + +# Run with custom environment variables +docker run -p 80:80 \ + -e VITE_API_URL=https://api.example.com \ + -e VITE_APP_NAME="My Production App" \ + -e VITE_ENV=production \ + -e VITE_OAUTH_CLIENT_ID=your-prod-client-id \ + react-app ``` -Your Buildly React Template will be running locally and listening to the port 3000, so you can access it via your browser typing this address: 127.0.0.1:3000 - -## Running the tests +### Docker Compose example: + +```yaml +version: '3.8' +services: + app: + build: . + ports: + - "80:80" + environment: + - VITE_API_URL=https://api.example.com + - VITE_APP_NAME=Production App + - VITE_ENV=production + - VITE_VERSION=1.0.0 + - VITE_OAUTH_TOKEN_URL=https://api.example.com/oauth/token/ + - VITE_OAUTH_CLIENT_ID=your-production-client-id +``` -To **run tests** using [Jest](https://jestjs.io/): +## ๐Ÿ“ Project Structure ``` -$ yarn run test +โ”œโ”€โ”€ src/ +โ”‚ โ”œโ”€โ”€ api/ # API layer with TanStack Query +โ”‚ โ”‚ โ”œโ”€โ”€ auth.ts # Complete authentication API (login, register, reset, verify) +โ”‚ โ”‚ โ””โ”€โ”€ users.ts # User management API (CRUD operations, roles, organizations) +โ”‚ โ”œโ”€โ”€ assets/ # Static assets (logos, images) +โ”‚ โ”‚ โ”œโ”€โ”€ light-logo.png # Logo for light theme +โ”‚ โ”‚ โ””โ”€โ”€ dark-logo.png # Logo for dark theme +โ”‚ โ”œโ”€โ”€ components/ # Reusable UI components +โ”‚ โ”‚ โ”œโ”€โ”€ Button/ # Button component with tests & stories +โ”‚ โ”‚ โ”œโ”€โ”€ TopBar/ # Navigation bar with gradient background +โ”‚ โ”‚ โ”œโ”€โ”€ ThemeToggle/ # Theme switcher component +โ”‚ โ”‚ โ”œโ”€โ”€ UserMenu/ # User dropdown menu +โ”‚ โ”‚ โ”œโ”€โ”€ ProtectedRoute/ # Route protection wrapper +โ”‚ โ”‚ โ”œโ”€โ”€ GlobalLoader/ # Global loading overlay system +โ”‚ โ”‚ โ”œโ”€โ”€ GlobalNotification/ # Global toast notification system +โ”‚ โ”‚ โ”œโ”€โ”€ UserManagement/ # User management components +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ UsersTab.tsx # Users table with editing capabilities +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ UserRolesTab.tsx # User roles and permissions table (read-only) +โ”‚ โ”‚ โ”œโ”€โ”€ EditUserModal/ # User editing modal with organization and role assignment +โ”‚ โ”‚ โ”œโ”€โ”€ InviteUsersModal/# Multi-user invitation modal +โ”‚ โ”‚ โ””โ”€โ”€ ui/ # Base UI components +โ”‚ โ”œโ”€โ”€ hooks/ # Custom React hooks +โ”‚ โ”‚ โ”œโ”€โ”€ useLoader.ts # Global loader state management +โ”‚ โ”‚ โ””โ”€โ”€ useNotification.ts # Global notification system +โ”‚ โ”œโ”€โ”€ pages/ # Route-level page components +โ”‚ โ”‚ โ”œโ”€โ”€ Login/ # OAuth login page +โ”‚ โ”‚ โ”œโ”€โ”€ Register/ # User registration with email verification +โ”‚ โ”‚ โ”œโ”€โ”€ ForgotPassword/ # Password reset request page +โ”‚ โ”‚ โ”œโ”€โ”€ ResetPasswordConfirm/ # Password reset confirmation page +โ”‚ โ”‚ โ”œโ”€โ”€ VerifyEmail/ # Email verification page +โ”‚ โ”‚ โ”œโ”€โ”€ Dashboard/ # Protected dashboard +โ”‚ โ”‚ โ””โ”€โ”€ UserManagement/ # Complete user management system with tabbed interface +โ”‚ โ”œโ”€โ”€ stores/ # Zustand state stores +โ”‚ โ”‚ โ”œโ”€โ”€ authStore.ts # Authentication state management +โ”‚ โ”‚ โ””โ”€โ”€ themeStore.ts # Theme state management +โ”‚ โ”œโ”€โ”€ styles/ # Global styles and theme definitions +โ”‚ โ”‚ โ”œโ”€โ”€ theme.css # CSS custom properties for themes +โ”‚ โ”‚ โ””โ”€โ”€ auth-pages.css # Shared authentication page styles +โ”‚ โ”œโ”€โ”€ test/ # Test setup and utilities +โ”‚ โ”œโ”€โ”€ utils/ # Utility functions +โ”‚ โ”‚ โ”œโ”€โ”€ env.ts # Environment variable handling +โ”‚ โ”‚ โ”œโ”€โ”€ constants.ts # Application constants and messages +โ”‚ โ”‚ โ””โ”€โ”€ userRoles.ts # User role utilities and helpers +โ”‚ โ””โ”€โ”€ App.tsx # Main app component with routing +โ”œโ”€โ”€ e2e/ # End-to-end tests +โ”œโ”€โ”€ .storybook/ # Storybook configuration +โ”œโ”€โ”€ public/ # Static assets +โ”œโ”€โ”€ Dockerfile # Docker configuration +โ”œโ”€โ”€ docker-entrypoint.sh # Docker environment injection script +โ”œโ”€โ”€ nginx.conf # Nginx configuration for Docker +โ”œโ”€โ”€ playwright.config.ts # E2E test configuration +โ”œโ”€โ”€ CLAUDE.md # Claude Code assistant instructions +โ””โ”€โ”€ vite.config.ts # Vite configuration with dual test setup ``` -## Deployment - -To deploy Buildly React Template on live, you can either use our [Buildly React Template Docker image](https://hub.docker.com/r/buildly/buildly-react-template) from Docker Hub or build your own image and host it somewhere, so it can be used with your deployment platform and/or tool. - -### Build Docker image - -First you need to have the web app dependencies installed and the app initialized locally. -And then you need to build it as a production application executing the following command: - +## ๐Ÿ” Complete Authentication System + +### Authentication Features +The application provides a complete authentication system with full user lifecycle support: + +- **OAuth 2.0 Login** - Secure username/password authentication with token management +- **User Registration** - Account creation with comprehensive form validation +- **Email Verification** - Token-based email verification for new accounts +- **Password Reset** - Secure password reset via email links +- **Session Management** - Persistent login state with automatic token cleanup +- **Protected Routes** - Authentication-based access control with automatic redirects + +### Authentication Flows + +#### **Login Flow**: +1. User visits root `/` โ†’ authentication check +2. Unauthenticated users redirected to `/login` +3. Login form submits credentials to OAuth endpoint +4. Successful authentication stores tokens and redirects to `/app` +5. Token expiration triggers automatic cleanup and re-authentication + +#### **Registration Flow**: +1. User visits `/register` โ†’ fills out registration form +2. Form validation ensures all required fields and password requirements +3. Successful registration shows success notification and redirects to `/login` +4. User receives verification email with token link +5. Clicking email link goes to `/verify-email?token=...` โ†’ automatic verification and redirect + +#### **Password Reset Flow**: +1. User visits `/forgot-password` โ†’ enters email address +2. Password reset request sent, user redirected to `/login` with confirmation +3. User receives email with reset link to `/reset-password-confirm/:uid/:token` +4. User enters new password โ†’ successful reset redirects to `/login` + +### Route Architecture ``` -$ yarn run build:prod +/ (root) โ†’ RootRedirect (authentication check) +โ”œโ”€โ”€ Authentication Routes (public access) +โ”‚ โ”œโ”€โ”€ /login โ†’ OAuth login form +โ”‚ โ”œโ”€โ”€ /register โ†’ User registration with validation +โ”‚ โ”œโ”€โ”€ /forgot-password โ†’ Password reset request +โ”‚ โ”œโ”€โ”€ /reset-password-confirm/:uid/:token โ†’ Password reset confirmation +โ”‚ โ””โ”€โ”€ /verify-email โ†’ Email verification from registration +โ””โ”€โ”€ Protected Routes (requires authentication) + โ”œโ”€โ”€ /app โ†’ Main dashboard + โ””โ”€โ”€ /app/user-management โ†’ User management interface ``` -Now, you just need to build a Docker image and host it somewhere. Further info about how to build images, check Docker's [documentation](https://docs.docker.com/). - -### Configuration - -The following table lists the configurable parameters of Buildly React Template and their default values. They can be updated in the -Docker container via flags as below or configured as environment variables in Travis. - -| Parameter | Description | Default | -|-------------------------------------|------------------------------------|-------------------------------------------| -| `API_URL` | Buildly Core URL | `` | -| `OAUTH_CLIENT_ID` | The client identifier issued to the client during Buildly Core deployment | `` | -| `OAUTH_TOKEN_URL` | Buildly Core URL used to authenticate users | `` | - -Specify each parameter using `-e`, `--env`, and `--env-file` flags to set simple (non-array) environment variables to `docker run`. For example, - +### Global UI Systems + +#### **Notification System** +- **Toast Notifications** - Non-intrusive user feedback +- **Multiple Types** - Success, error, warning, and info notifications +- **Persistent Options** - Notifications can survive page navigation +- **Auto-dismiss** - Configurable timing with manual close options + +#### **Loading System** +- **Global Overlay** - Full-screen loading during API operations +- **Contextual Messages** - Custom loading messages for different operations +- **User Protection** - Prevents interaction during critical operations + +#### **Theme System** +- **Light Theme** - Clean, bright interface +- **Dark Theme** - Dark, high-contrast interface +- **System Theme** - Automatically follows OS preference +- **Persistent State** - Theme preferences stored in localStorage + +## ๐Ÿ‘ฅ User Management System + +### Complete User Administration +The application provides a comprehensive user management system with full CRUD operations and role-based permissions: + +- **User List Management** - View, edit, and manage all system users +- **User Invitations** - Send email invitations to new users with bulk invite support +- **User Editing** - Modify user status, organization assignment, and role permissions +- **Role Management** - View user roles and permissions (read-only display) +- **Organization Management** - Assign users to different organizations +- **Real-time Updates** - Automatic data refresh after operations + +### User Management Features + +#### **Users Tab** +- **User Table** - Displays all users with key information: + - Full name, username, email + - Organization assignment + - User role (derived from core groups) + - Active/inactive status + - Join date +- **Search & Filter** - Filter users by status, organization, or role +- **Edit Functionality** - In-line editing with modal interface +- **Bulk Actions** - Mass user operations and invitations + +#### **User Roles Tab** +- **Permissions Matrix** - Visual display of role permissions: + - Create, Read, Update, Delete permissions per role + - Organization-specific and global roles + - Role hierarchy and inheritance +- **Read-Only Display** - Permissions viewing without editing capabilities + +#### **User Invitations** +- **Email Invitations** - Send invites to multiple email addresses +- **Bulk Invite Support** - Add multiple emails simultaneously +- **Invitation Tracking** - Monitor invitation status and responses +- **Automatic User Creation** - Users created upon invitation acceptance + +#### **User Editing** +- **Modal Interface** - Clean, focused editing experience +- **Organization Assignment** - Move users between organizations +- **Role Management** - Assign appropriate roles and permissions +- **Status Management** - Activate/deactivate user accounts +- **Real-time Validation** - Client-side form validation with error handling + +### API Integration + +#### **Conditional Update Logic** +The user update system uses intelligent API calls based on data changes: + +- **Organization Updates** - Uses `/coreuser/update_org/{id}/` endpoint for organization changes +- **User Field Updates** - Uses `/coreuser/{id}/` endpoint for status and role changes +- **Sequential Processing** - Organization updates processed first, then user fields +- **Error Handling** - Comprehensive error handling with user-friendly notifications +- **Optimistic Updates** - UI updates immediately with rollback on failure + +#### **Data Management** +- **TanStack Query Integration** - Efficient data fetching with caching +- **Automatic Refresh** - Data automatically refreshes after operations +- **Loading States** - Global loading indicators during operations +- **Error Recovery** - Graceful error handling with retry mechanisms + +### User Interface Design + +#### **Tabbed Interface** +- **Clean Navigation** - Easy switching between users and roles +- **Visual Indicators** - Tab badges show counts for users and roles +- **Responsive Design** - Mobile-friendly interface across all screen sizes + +#### **Modern Table Design** +- **Sortable Columns** - Click to sort by any column +- **Responsive Layout** - Tables adapt to different screen sizes +- **Action Buttons** - Intuitive edit and action controls +- **Status Indicators** - Visual status indicators for user states + +#### **Modal System** +- **Focused Editing** - Distraction-free editing environment +- **Form Validation** - Real-time validation with helpful error messages +- **Save/Cancel Actions** - Clear action buttons with confirmation + +## ๐Ÿงช Testing + +The application includes **comprehensive test coverage** with **430+ test scenarios** covering all possible use cases, edge cases, and user flows. + +### Test Architecture +- **Unit Tests**: Vitest with React Testing Library and jsdom environment +- **Component Tests**: Isolated component testing with full user interaction simulation +- **Integration Tests**: Complete user flow testing from authentication to user management +- **API Tests**: Comprehensive mocking and testing of all API endpoints +- **Store Tests**: Zustand state management with persistence and cleanup testing +- **Utility Tests**: Environment configuration, user roles, and constants validation + +### Test Coverage Overview +**Total Test Files**: 11 comprehensive test suites +**Total Test Scenarios**: 430+ individual test cases +**Coverage Areas**: All major application functionality + +#### API Layer Tests (99 scenarios) +- **Authentication API** (47 tests): Login, registration, password reset, email verification, error handling +- **User Management API** (52 tests): CRUD operations, role management, invitations, sequential API calls + +#### State Management Tests (80 scenarios) +- **Auth Store** (36 tests): Token management, expiration, cleanup, persistence +- **Theme Store** (44 tests): Light/dark/system modes, DOM integration, event handling + +#### Component Tests (73 scenarios) +- **Theme Toggle** (31 tests): Rendering, interactions, accessibility, store integration +- **User Menu** (42 tests): Menu behavior, user management access, logout flow, keyboard navigation + +#### Utility Function Tests (87 scenarios) +- **Environment Config** (28 tests): Docker runtime, Vite build-time, fallbacks, edge cases +- **User Roles** (29 tests): Role detection, permissions, access control logic +- **Constants** (30 tests): Immutability, consistency, message validation + +#### Custom Hook Tests (73 scenarios) +- **Loader Hook** (32 tests): State management, function behavior, complex usage scenarios +- **Notification Hook** (41 tests): Auto-removal, manual removal, state persistence, edge cases + +#### Integration Tests (19 scenarios) +- Complete authentication flows (login, logout, registration, password reset) +- Theme system integration with DOM manipulation +- Global UI system (loader, notifications) integration +- Navigation and route protection +- User management access control +- Error handling and recovery +- State persistence across sessions +- Performance and accessibility testing + +### Running Tests ```bash -$ docker run -e MYVAR1 --env MYVAR2=foo \ - --env-file ./env.list \ - buildly/buildly-react-template +# Unit and Integration Tests +npm run test # Run in watch mode +npm run test:coverage # Run with coverage report +npm run test -- --run # Run once and exit + +# Component Stories +npm run storybook # Start Storybook for component testing + +# End-to-End Tests +npm run test:e2e # Run E2E tests +npm run test:e2e:ui # Run with Playwright UI +npx playwright test --debug # Debug E2E tests + +# Run specific test files +npm run test src/api/auth.test.ts +npm run test src/stores/authStore.test.ts +npm run test src/test/integration.test.tsx ``` -## Built With - -* [Travis CI](https://travis-ci.org/) - Recommended CI/CD - -## Contributing - -Please read [CONTRIBUTING.md](https://github.com/buildlyio/docs/blob/master/CONTRIBUTING.md) for details on our code of conduct, and the process for submitting pull requests to us. - -## Versioning - -We use [SemVer](http://semver.org/) for versioning. For the versions available, see the [tags on this repository](https://github.com/buildlyio/buildly-react-template/tags). - -## Authors - -* **Buildly** - *Initial work* - -See also the list of [contributors](https://github.com/buildlyio/buildly-react-template/graphs/contributors) who participated in this project. - -## License - -This project is licensed under the GPL v3 License - see the [LICENSE](LICENSE) file for details +### Test Features +- **Comprehensive Mocking**: All external dependencies, API calls, timers, DOM methods +- **Real-world Scenarios**: Tests mirror actual user interactions and edge cases +- **Error Boundary Testing**: Graceful failure handling and recovery +- **Cross-browser Compatibility**: Tests work across different environments +- **Performance Testing**: Rapid interaction handling and concurrent operations +- **Accessibility Testing**: Screen reader support and keyboard navigation +- **Security Testing**: Permission-based access control and role validation + +## ๐Ÿ”ง Configuration + +### TypeScript +- Strict type checking enabled +- Modern ES2022 target +- React JSX transform +- Path aliases support (can be configured in `tsconfig.json`) + +### ESLint +- TypeScript and React rules +- Automatic formatting +- Storybook integration +- Modern ES2022 syntax support + +### Vite +- Fast HMR for development +- Optimized production builds +- Automatic code splitting +- Environment variable handling + +## ๐Ÿš€ Deployment + +### Static Hosting (Netlify, Vercel, etc.) +1. Build the project: `npm run build` +2. Deploy the `dist/` folder +3. Configure environment variables in your hosting platform + +### Docker Deployment +1. Build image: `docker build -t react-app .` +2. Run with environment variables: `docker run -p 80:80 -e VITE_API_URL=... react-app` +3. Deploy to your container platform (AWS ECS, Google Cloud Run, etc.) + +## ๐Ÿ“š Learn More + +- [React Documentation](https://react.dev/) +- [TypeScript Documentation](https://www.typescriptlang.org/) +- [Vite Documentation](https://vite.dev/) +- [Storybook Documentation](https://storybook.js.org/) +- [Playwright Documentation](https://playwright.dev/) +- [Vitest Documentation](https://vitest.dev/) + +## ๐Ÿค Contributing + +1. Fork the repository +2. Create a feature branch: `git checkout -b feature/my-feature` +3. Make your changes and add tests +4. Run linting and tests: `npm run lint && npm run test` +5. Commit your changes: `git commit -m 'Add my feature'` +6. Push to the branch: `git push origin feature/my-feature` +7. Open a Pull Request + +## ๐Ÿ“„ License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/__mocks__/fileMock.js b/__mocks__/fileMock.js deleted file mode 100644 index 86059f362..000000000 --- a/__mocks__/fileMock.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = 'test-file-stub'; diff --git a/__mocks__/styleMock.js b/__mocks__/styleMock.js deleted file mode 100644 index a09954537..000000000 --- a/__mocks__/styleMock.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = {}; \ No newline at end of file diff --git a/babel.config.js b/babel.config.js deleted file mode 100644 index 52cfffc0b..000000000 --- a/babel.config.js +++ /dev/null @@ -1,12 +0,0 @@ -module.exports = (api) => { - api.cache(true); - - const presets = ['@babel/env', '@babel/preset-react']; - - const plugins = ['@babel/plugin-transform-runtime']; - - return { - presets, - plugins, - }; -}; diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 000000000..e765188e2 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,17 @@ +#!/bin/sh + +# Create environment configuration from environment variables +# All variables use VITE_ prefix +cat > /usr/share/nginx/html/env-config.js << EOF +window._env_ = { + VITE_API_URL: "${VITE_API_URL:-http://localhost:3001/api}", + VITE_APP_NAME: "${VITE_APP_NAME:-React App}", + VITE_ENV: "${VITE_ENV:-production}", + VITE_VERSION: "${VITE_VERSION:-1.0.0}", + VITE_OAUTH_TOKEN_URL: "${VITE_OAUTH_TOKEN_URL:-http://localhost:3001/oauth/token/}", + VITE_OAUTH_CLIENT_ID: "${VITE_OAUTH_CLIENT_ID:-your-client-id}" +}; +EOF + +# Execute the main command +exec "$@" \ No newline at end of file diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index d4bb2cbb9..000000000 --- a/docs/Makefile +++ /dev/null @@ -1,20 +0,0 @@ -# Minimal makefile for Sphinx documentation -# - -# You can set these variables from the command line, and also -# from the environment for the first two. -SPHINXOPTS ?= -SPHINXBUILD ?= sphinx-build -SOURCEDIR = . -BUILDDIR = _build - -# Put it first so that "make" without argument is like "make help". -help: - @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) - -.PHONY: help Makefile - -# Catch-all target: route all unknown targets to Sphinx using the new -# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). -%: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/_static/images/current_users.png b/docs/_static/images/current_users.png deleted file mode 100644 index b62dbc8af..000000000 Binary files a/docs/_static/images/current_users.png and /dev/null differ diff --git a/docs/_static/images/login.png b/docs/_static/images/login.png deleted file mode 100644 index ce2d514b8..000000000 Binary files a/docs/_static/images/login.png and /dev/null differ diff --git a/docs/_static/images/register.png b/docs/_static/images/register.png deleted file mode 100644 index 946ec19b1..000000000 Binary files a/docs/_static/images/register.png and /dev/null differ diff --git a/docs/_static/images/settings.png b/docs/_static/images/settings.png deleted file mode 100644 index 797007f14..000000000 Binary files a/docs/_static/images/settings.png and /dev/null differ diff --git a/docs/_static/images/user_group.png b/docs/_static/images/user_group.png deleted file mode 100644 index a103a718e..000000000 Binary files a/docs/_static/images/user_group.png and /dev/null differ diff --git a/docs/conf.py b/docs/conf.py deleted file mode 100644 index 92e0eb9d8..000000000 --- a/docs/conf.py +++ /dev/null @@ -1,140 +0,0 @@ -# Configuration file for the Sphinx documentation builder. -# -# This file only contains a selection of the most common options. For a full -# list see the documentation: -# https://www.sphinx-doc.org/en/master/usage/configuration.html - -# -- Path setup -------------------------------------------------------------- - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -# -# import os -# import sys -# sys.path.insert(0, os.path.abspath('.')) - - -# -- Project information ----------------------------------------------------- - -project = 'Buildly React Template' -copyright = '2019, Buildly Inc.' -author = 'Buildly Inc.' - -# The short X.Y version -version = u'' -# The full version, including alpha/beta/rc tags -release = u'' - -# -- General configuration --------------------------------------------------- - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. -extensions = [ -] - -# Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This pattern also affects html_static_path and html_extra_path. -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] - -# The suffix(es) of source filenames. -# You can specify multiple suffix as a list of string: -# -# source_suffix = ['.rst', '.md'] -source_suffix = '.rst' - -# The master toctree document. -master_doc = 'index' - -# -- Options for HTML output ------------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# -html_theme = 'alabaster' - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] - - -# -- Options for HTMLHelp output --------------------------------------------- - -# Output file base name for HTML help builder. -htmlhelp_basename = 'BuildlyReactTemplatedoc' - - -# -- Options for LaTeX output ------------------------------------------------ - -latex_elements = { - # The paper size ('letterpaper' or 'a4paper'). - # - # 'papersize': 'letterpaper', - - # The font size ('10pt', '11pt' or '12pt'). - # - # 'pointsize': '10pt', - - # Additional stuff for the LaTeX preamble. - # - # 'preamble': '', - - # Latex figure (float) alignment - # - # 'figure_align': 'htbp', -} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, -# author, documentclass [howto, manual, or own class]). -latex_documents = [ - (master_doc, 'BuildlyReactTemplate.tex', u'Buildly React Template Documentation', - u'Buildly.io', 'manual'), -] - - -# -- Options for manual page output ------------------------------------------ - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [ - (master_doc, 'buildlycore', u'Buildly Core Documentation', - [author], 1) -] - - -# -- Options for Texinfo output ---------------------------------------------- - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - (master_doc, 'BuildlyCore', u'Buildly Core Documentation', - author, 'BuildlyCore', 'One line description of project.', - 'Miscellaneous'), -] - - -# -- Options for Epub output ------------------------------------------------- - -# Bibliographic Dublin Core info. -epub_title = project - -# The unique identifier of the text. This can be a ISBN number -# or the project homepage. -# -# epub_identifier = '' - -# A unique identification for the text. -# -# epub_uid = '' - -# A list of files that should not be packed into the epub file. -epub_exclude_files = ['search.html'] - diff --git a/docs/contributing.rst b/docs/contributing.rst deleted file mode 100644 index 53e54e9b6..000000000 --- a/docs/contributing.rst +++ /dev/null @@ -1,6 +0,0 @@ -Contribuiting -============= - -First off, thanks for taking the time to contribute! - -To know more about how you can contribute to Buildly React Template, click `here `_! \ No newline at end of file diff --git a/docs/features/index.rst b/docs/features/index.rst deleted file mode 100644 index 689e1108a..000000000 --- a/docs/features/index.rst +++ /dev/null @@ -1,16 +0,0 @@ -.. _features: - -Features -======== - -.. toctree:: - :caption: Contents: - :maxdepth: 1 - - login - registration - user_management - profile_settings - - -Buildly React Template provides different features and user interfaces corresponding to each feature. diff --git a/docs/features/login.rst b/docs/features/login.rst deleted file mode 100644 index 581a63ca7..000000000 --- a/docs/features/login.rst +++ /dev/null @@ -1,11 +0,0 @@ -.. _login: - -Login -===== - -Uses the OAuth module for the OAuth password-flow -authentication process with Buildly Core. - -.. image:: ../_static/images/login.png - :align: center - :alt: Login Screen diff --git a/docs/features/profile_settings.rst b/docs/features/profile_settings.rst deleted file mode 100644 index 3ae16138d..000000000 --- a/docs/features/profile_settings.rst +++ /dev/null @@ -1,10 +0,0 @@ -.. _profile_settings: - -Profile Settings -================ - -A screen where administrators can update their own profile. - -.. image:: ../_static/images/settings.png - :align: center - :alt: Profile Settings Screen diff --git a/docs/features/registration.rst b/docs/features/registration.rst deleted file mode 100644 index e527d0128..000000000 --- a/docs/features/registration.rst +++ /dev/null @@ -1,12 +0,0 @@ -.. _registration: - -Registration -============ - -A form where the user can register an account with the app. -They will also be redirected to this screen after accepting -an invitation from a super user. - -.. image:: ../_static/images/register.png - :align: center - :alt: Register Screen diff --git a/docs/features/user_management.rst b/docs/features/user_management.rst deleted file mode 100644 index 69c2d9802..000000000 --- a/docs/features/user_management.rst +++ /dev/null @@ -1,16 +0,0 @@ -.. _user_management: - -User Management -=============== - -A screen where an administrator can manage users, create/update/delete application -permission groups and assign them to users. - -.. image:: ../_static/images/current_users.png - :align: center - :alt: Current Users Screen - - -.. image:: ../_static/images/user_group.png - :align: center - :alt: User Group Screen diff --git a/docs/index.rst b/docs/index.rst deleted file mode 100644 index 6369375ec..000000000 --- a/docs/index.rst +++ /dev/null @@ -1,44 +0,0 @@ -.. rst-class:: hide-header - -.. rst-class:: hide-header - -.. _buildly-react-template: - -.. image:: _static/images/buildly-logo.png - :alt: Buildly React Template: The Buildly Core Administrator - :align: center - :target: https://buildly.io/buildly-react-template - -Welcome to Buildly React Template's documentation! Get started with the :ref:`quickstart`. -Features are described and explained in more details in the :ref:`features` section. -The rest of the docs describe each module of Buildly React Template in detail. - -Buildly React Template depends on the NodeJS and Yarn. The documentation for this service can be found at: - -- `NodeJS documentation `_ -- `Yarn documentation `_ - - -User's Guide ------------- - -This technical communication document, is intended to give assistance to developers using a Buildly React Template. - -.. toctree:: - :maxdepth: 2 - - quickstart - features/index - modules/index - - -Additional Notes ----------------- - -Design notes, legal information and changelog are here. - -.. toctree:: - :maxdepth: 2 - - license - contributing \ No newline at end of file diff --git a/docs/license.rst b/docs/license.rst deleted file mode 100644 index 1d3efc948..000000000 --- a/docs/license.rst +++ /dev/null @@ -1,7 +0,0 @@ -License -======= - -The license applies to all files in the Buildly React Template repository and source distribution. This -includes Buildly React Template's source code, the examples, and tests, as well as the documentation. - -To read the whole Buildly React Template's license, click `here `_! \ No newline at end of file diff --git a/docs/make.bat b/docs/make.bat deleted file mode 100644 index 2119f5109..000000000 --- a/docs/make.bat +++ /dev/null @@ -1,35 +0,0 @@ -@ECHO OFF - -pushd %~dp0 - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -) -set SOURCEDIR=. -set BUILDDIR=_build - -if "%1" == "" goto help - -%SPHINXBUILD% >NUL 2>NUL -if errorlevel 9009 ( - echo. - echo.The 'sphinx-build' command was not found. Make sure you have Sphinx - echo.installed, then set the SPHINXBUILD environment variable to point - echo.to the full path of the 'sphinx-build' executable. Alternatively you - echo.may add the Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.http://sphinx-doc.org/ - exit /b 1 -) - -%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% -goto end - -:help -%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% - -:end -popd diff --git a/docs/modules/connect_buildly_core.rst b/docs/modules/connect_buildly_core.rst deleted file mode 100644 index fdfbd0124..000000000 --- a/docs/modules/connect_buildly_core.rst +++ /dev/null @@ -1,22 +0,0 @@ -.. _connect_buildly_core: - -Connection to Buildly Core -========================== - -Buildly React Template uses the Redux-Saga middleware library to handle interactions with -Buildly Core. A store configuration is defined in the `/src/redux` directory and -connected at the root level `index.js` file. - -The `/src/redux` directory contains sub-directories for each entity. These contains the related -`.actions.js`, `.reducer.js` and `.sagas.js` files. - -Actions are dispatched directly by components. Each client has a separate `.actions.js` -file which contains actions to create, read, update and delete. - -Generator functions defined in the client's `.saga.js` file watch for actions to be -dispatched. When this happens, they call another function that uses the services -provided in `/src/modules`. These services are responsible for commnunicating -with the API. All sagas must be imported into the `index.js` file in this directory. - -The `.reducer.js` files update the state with the responses from the server. All -reducers should be imported into the `index.js` file within this directory. diff --git a/docs/modules/http.rst b/docs/modules/http.rst deleted file mode 100644 index 5e962b00b..000000000 --- a/docs/modules/http.rst +++ /dev/null @@ -1,6 +0,0 @@ -.. _http: - -HTTP Module -=========== - -The Request implementation in HTTP module handles requests to the server. diff --git a/docs/modules/index.rst b/docs/modules/index.rst deleted file mode 100644 index e25953a27..000000000 --- a/docs/modules/index.rst +++ /dev/null @@ -1,16 +0,0 @@ -.. _modules: - -Modules -======= - -.. toctree:: - :caption: Contents: - :maxdepth: 1 - - connect_buildly_core - http - oauth - -In the attempt of removing the redundancy and repeatability when implementing -the application, Buildly React Template has some Modules built into it. They are data -structure and logic created for re-usability to facilitate developers life. diff --git a/docs/modules/oauth.rst b/docs/modules/oauth.rst deleted file mode 100644 index 939451e9e..000000000 --- a/docs/modules/oauth.rst +++ /dev/null @@ -1,11 +0,0 @@ -.. _oauth: - -OAuth Module -============ - -The OAuth module provides functionalities related to authentication, -e.g., logging in using the password flow, -logging out, retrieving and saving the access token. - -Buildly React Template uses the actions defined in `/src/redux/authuser/authuser.actions.js` to access -these functionalities within the components. diff --git a/docs/quickstart.rst b/docs/quickstart.rst deleted file mode 100644 index 8394f7703..000000000 --- a/docs/quickstart.rst +++ /dev/null @@ -1,44 +0,0 @@ -.. _quickstart: - -Quickstart -========== - -Excited to get start? This page gives a decent prologue to Buildly React Template. It assumes -you as of now have a Buildly Core instance up and running and also all the project's -prerequisites installed. - -- node v16.14.2 -- yarn v1.17.3 - -Installing ----------- - -Download and install web application dependencies running the following command: - -.. code-block:: bash - - yarn install - -Now, initialize and build the project - -.. code-block:: bash - - yarn run build - -To run the web app: - -.. code-block:: bash - - yarn run start - -your Buildy UI will be running locally and listening to the port 4200, so you can access -it via your browser typing this address: 127.0.0.1:4200 - -Running the tests ------------------ - -To run tests using `Jest `_: - -.. code-block:: bash - - yarn run test diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts new file mode 100644 index 000000000..191b80876 --- /dev/null +++ b/e2e/app.spec.ts @@ -0,0 +1,63 @@ +import { test, expect } from '@playwright/test'; + +test.describe('React App', () => { + test('should load the homepage', async ({ page }) => { + await page.goto('/'); + + // Check if the page loads correctly + await expect(page).toHaveTitle(/React App/); + + // Check for main heading + await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); + + // Check for Vite and React logos + await expect(page.getByAltText('Vite logo')).toBeVisible(); + await expect(page.getByAltText('React logo')).toBeVisible(); + }); + + test('should increment counter when button is clicked', async ({ page }) => { + await page.goto('/'); + + // Find the counter button + const counterButton = page.getByRole('button', { name: /count is \d+/i }); + await expect(counterButton).toBeVisible(); + + // Initial count should be 0 + await expect(counterButton).toHaveText('Count is 0'); + + // Click the button to increment + await counterButton.click(); + await expect(counterButton).toHaveText('Count is 1'); + + // Click again to verify it increments + await counterButton.click(); + await expect(counterButton).toHaveText('Count is 2'); + }); + + test('should display environment information', async ({ page }) => { + await page.goto('/'); + + // Check that environment info is displayed + await expect(page.getByText(/Environment:/)).toBeVisible(); + await expect(page.getByText(/Version:/)).toBeVisible(); + await expect(page.getByText(/API URL:/)).toBeVisible(); + }); + + test('should have proper responsive layout', async ({ page }) => { + await page.goto('/'); + + // Test desktop viewport + await page.setViewportSize({ width: 1200, height: 800 }); + const logo = page.getByAltText('Vite logo'); + await expect(logo).toBeVisible(); + + // Test mobile viewport + await page.setViewportSize({ width: 375, height: 667 }); + await expect(logo).toBeVisible(); + + // Counter should still work on mobile + const counterButton = page.getByRole('button', { name: /count is \d+/i }); + await counterButton.click(); + await expect(counterButton).toHaveText('Count is 1'); + }); +}); \ No newline at end of file diff --git a/e2e/navigation.spec.ts b/e2e/navigation.spec.ts new file mode 100644 index 000000000..17e853ad7 --- /dev/null +++ b/e2e/navigation.spec.ts @@ -0,0 +1,31 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Navigation', () => { + test('should navigate to external links', async ({ page, context }) => { + await page.goto('/'); + + // Test Vite link opens in new tab + const viteLink = page.getByRole('link').filter({ has: page.getByAltText('Vite logo') }); + await expect(viteLink).toHaveAttribute('href', 'https://vite.dev'); + await expect(viteLink).toHaveAttribute('target', '_blank'); + + // Test React link opens in new tab + const reactLink = page.getByRole('link').filter({ has: page.getByAltText('React logo') }); + await expect(reactLink).toHaveAttribute('href', 'https://react.dev'); + await expect(reactLink).toHaveAttribute('target', '_blank'); + }); + + test('should handle page refresh correctly', async ({ page }) => { + await page.goto('/'); + + // Click counter a few times + const counterButton = page.getByRole('button', { name: /count is \d+/i }); + await counterButton.click(); + await counterButton.click(); + await expect(counterButton).toHaveText('Count is 2'); + + // Refresh page - counter should reset + await page.reload(); + await expect(counterButton).toHaveText('Count is 0'); + }); +}); \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 000000000..bc656236f --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,26 @@ +// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format +import storybook from "eslint-plugin-storybook"; + +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { globalIgnores } from 'eslint/config' + +export default tseslint.config([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs['recommended-latest'], + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +], storybook.configs["flat/recommended"]); diff --git a/index.html b/index.html new file mode 100644 index 000000000..1b865fbd4 --- /dev/null +++ b/index.html @@ -0,0 +1,19 @@ + + + + + + + React App + + + +
+ + + diff --git a/initialize_container.sh b/initialize_container.sh deleted file mode 100755 index abb8a5d1e..000000000 --- a/initialize_container.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/bash - -#Export the current commitID, branch and remote that the build was made from -export GIT_FETCH_HEAD=`cat .git/FETCH_HEAD` - -#Read all env variables as output by printenv and put them into an object stored in window.env -Read all env variables as output by printenv and put them into an object stored in window.env -RESULT='window.env = {' -RESULT+='API_URL: "'$API_URL -RESULT+='", OAUTH_CLIENT_ID: "'$OAUTH_CLIENT_ID -RESULT+='", OAUTH_TOKEN_URL: "'$OAUTH_TOKEN_URL -RESULT+='", production: '$PRODUCTION -RESULT+='}' - -PATH=`ls dist/` -OUTPUTPATH="dist/environment.js" - -echo $RESULT > $OUTPUTPATH diff --git a/jsconfig.json b/jsconfig.json deleted file mode 100644 index e1d70bddf..000000000 --- a/jsconfig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "extends": "./jsconfig.paths.json", - "compilerOptions": { - "outDir": "./dist/", - "module": "commonjs", - "target": "es6", - "moduleResolution": "node", - "jsx": "react", - "checkJs": false, - "allowSyntheticDefaultImports": true, - }, - "include": [ - "./src/**/*" - ], - "exclude": [ - "node_modules" - ], -} \ No newline at end of file diff --git a/jsconfig.paths.json b/jsconfig.paths.json deleted file mode 100644 index fca1aa98f..000000000 --- a/jsconfig.paths.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "compilerOptions": { - "baseUrl": "./src", - "paths": { - "@assets/*": [ - "./assets/*" - ], - "@components/*": [ - "./components/*" - ], - "@context/*": [ - "./context/*" - ], - "@hooks/*": [ - "./hooks/*" - ], - "@layout/*": [ - "./layout/*" - ], - "@modules/*": [ - "./modules/*" - ], - "@pages/*": [ - "./pages/*" - ], - "@routes/*": [ - "./routes/*" - ], - "@styles/*": [ - "./styles/*" - ], - "@utils/*": [ - "./utils/*" - ], - }, - }, -} \ No newline at end of file diff --git a/nginx.conf b/nginx.conf index f8b6fad76..b40ac22e2 100644 --- a/nginx.conf +++ b/nginx.conf @@ -1,10 +1,46 @@ -server { - listen 9000; - server_name buildly-react; - root /app/dist/; - index index.html; - # Force all paths to load either itself (js files) or go through index.html. - location / { - try_files $uri /index.html; - } +events { + worker_connections 1024; } + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + sendfile on; + keepalive_timeout 65; + + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types + text/plain + text/css + text/xml + text/javascript + application/javascript + application/xml+rss + application/json; + + server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Handle client-side routing + location / { + try_files $uri $uri/ /index.html; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # 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; + } +} \ No newline at end of file diff --git a/package.json b/package.json index 250255c2d..69b7b8699 100644 --- a/package.json +++ b/package.json @@ -1,152 +1,63 @@ { - "name": "buildly-react-template", - "version": "1.0.0", - "description": "Frontend Template from Buildly built using the React framework", - "main": "src/index.js", + "name": "react-app-template", "private": true, - "workspaces": [ - "src/clients/*" - ], - "dependencies": { - "@babel/plugin-proposal-class-properties": "^7.18.6", - "@babel/plugin-proposal-private-methods": "^7.16.11", - "@babel/plugin-transform-runtime": "^7.4.4", - "@emotion/react": "^11.8.1", - "@emotion/styled": "^11.8.1", - "@mui/icons-material": "^5.5.0", - "@mui/lab": "^5.0.0-alpha.122", - "@mui/material": "^5.5.0", - "@mui/styles": "^5.5.0", - "@types/react": "^17.0.39", - "@types/react-dom": "^17.0.13", - "i18next": "^15.1.3", - "i18next-browser-languagedetector": "^3.0.1", - "mui-datatables": "^4.3.0", - "npm": "^6.9.0", - "path": "^0.12.7", - "polished": "^3.2.0", - "prop-types": "^15.7.2", - "react": "^17.0.0", - "react-dom": "^17.0.0", - "react-hot-loader": "^4.6.3", - "react-i18next": "^10.11.0", - "react-query": "^3.39.3", - "react-router-dom": "5.3.4", - "serve": "^11.0.0", - "workbox-core": "^6.5.1", - "workbox-precaching": "^6.5.1", - "workbox-window": "^6.5.1", - "zustand": "^4.4.7" - }, - "devDependencies": { - "@babel/cli": "^7.2.3", - "@babel/core": "^7.2.2", - "@babel/plugin-proposal-class-properties": "^7.18.6", - "@babel/preset-env": "^7.3.1", - "@babel/preset-react": "^7.0.0", - "@types/jest": "^24.0.13", - "awesome-typescript-loader": "^5.2.1", - "axios": "^1.8.3", - "axios-cancel": "^0.2.2", - "babel-core": "^7.0.0-bridge.0", - "babel-jest": "^23.6.0", - "babel-loader": "^8.0.5", - "babel-polyfill": "^6.26.0", - "babel-preset-es2015": "^6.24.1", - "babel-preset-react": "^6.24.1", - "copy-webpack-plugin": "^5.0.2", - "css-loader": "^2.1.1", - "enzyme": "^3.8.0", - "enzyme-adapter-react-16": "^1.13.1", - "enzyme-to-json": "^3.3.5", - "eslint": "^7.2.0", - "eslint-config-airbnb": "18.2.1", - "eslint-import-resolver-webpack": "^0.13.0", - "eslint-plugin-import": "^2.22.1", - "eslint-plugin-jsx-a11y": "^6.4.1", - "eslint-plugin-react": "^7.21.5", - "eslint-plugin-react-hooks": "^1.7.0", - "file-loader": "^3.0.1", - "file-replace-loader": "^1.3.2", - "html-loader": "^0.5.5", - "html-webpack-plugin": "^3.2.0", - "image-webpack-loader": "^4.6.0", - "jest": "^23.6.0", - "pngquant": "^4.2.0", - "react-docgen": "^4.1.1", - "react-docgen-typescript": "^1.12.4", - "rxjs": "^7.8.2", - "sass": "^1.83.0", - "sass-loader": "^10.2.0", - "source-map-loader": "^0.2.4", - "style-loader": "^0.23.1", - "ts-jest": "^24.0.2", - "typedoc": "^0.14.2", - "typescript": "^3.4.5", - "webpack": "^4.29.0", - "webpack-cli": "^3.2.1", - "webpack-dev-server": "^3.1.14", - "workbox-webpack-plugin": "^6.5.1" - }, - "resolutions": { - "@babel/core": "7.19.6", - "@babel/generator": "7.19.6", - "@babel/compat-data": "7.19.4", - "@babel/helper-compilation-targets": "7.19.3", - "@babel/helper-create-class-features-plugin": "7.19.0", - "@babel/helper-module-transforms": "7.19.6", - "babel-loader": "8.2.5" - }, + "version": "1.0.0", + "type": "module", "scripts": { - "build": "webpack --mode development", - "start:local": "webpack-dev-server --mode development --env.build=local", - "https:local": "webpack-dev-server --https --cert ~/.localhost-ssl/localhost.crt --key ~/.localhost-ssl/localhost.key --mode development --env.build=local", - "build:dev": "webpack --mode development --env.build=dev", - "start:dev": "webpack-dev-server --mode development --env.build=dev", - "build:prod": "webpack --mode production --env.build=prod", - "start:prod": "webpack-dev-server --mode development --env.build=prod", - "test": "jest --watch", - "test:prod": "jest", - "test-coverage": "jest --updateSnapshot --coverage", - "serve": "./node_modules/.bin/serve -s dist", - "lint": "eslint src/**/*.js" + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "preview": "vite preview", + "test": "vitest", + "test:ui": "vitest --ui", + "test:coverage": "vitest run --coverage", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build", + "docker:build": "docker build -t react-app .", + "docker:run": "docker run -p 80:80 react-app" }, - "jest": { - "moduleNameMapper": { - "\\.(css|less|scss)$": "/__mocks__/styleMock.js", - "\\.(gif|ttf|eot|svg|png|jpg)$": "/__mocks__/fileMock.js" - }, - "moduleDirectories": [ - "node_modules", - "/src", - "src", - "/src/styles", - "" - ], - "moduleFileExtensions": [ - "js", - "jsx", - "ts", - "tsx" - ], - "transform": { - "^.+\\.tsx?$": "ts-jest", - "\\.(js|jsx)?$": "./test-transform.js" - }, - "setupFiles": [ - "/test-setup.js" - ], - "collectCoverageFrom": [ - "src/**/*.{js,jsx,tsx}" - ], - "snapshotSerializers": [ - "enzyme-to-json/serializer" - ] + "dependencies": { + "@tanstack/react-query": "^5.85.5", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-router-dom": "^7.8.2", + "zustand": "^5.0.8" }, - "keywords": [ - "Buildly", - "React" - ], - "author": "Buildly", - "license": "ISC" + "devDependencies": { + "@chromatic-com/storybook": "^4.1.1", + "@eslint/js": "^9.34.0", + "@playwright/test": "^1.55.0", + "@storybook/addon-a11y": "^9.1.3", + "@storybook/addon-docs": "^9.1.3", + "@storybook/addon-onboarding": "^9.1.3", + "@storybook/addon-vitest": "^9.1.3", + "@storybook/react-vite": "^9.1.3", + "@testing-library/jest-dom": "^6.8.0", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", + "@types/react": "^19.1.10", + "@types/react-dom": "^19.1.7", + "@types/react-router-dom": "^5.3.3", + "@typescript-eslint/eslint-plugin": "^8.41.0", + "@typescript-eslint/parser": "^8.41.0", + "@vitejs/plugin-react": "^5.0.0", + "@vitest/browser": "^3.2.4", + "@vitest/coverage-v8": "^3.2.4", + "eslint": "^9.34.0", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "eslint-plugin-storybook": "^9.1.3", + "globals": "^16.3.0", + "jsdom": "^26.1.0", + "playwright": "^1.55.0", + "storybook": "^9.1.3", + "typescript": "~5.8.3", + "typescript-eslint": "^8.39.1", + "vite": "^7.1.2", + "vitest": "^3.2.4" + } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 000000000..471835095 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,61 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * @see https://playwright.dev/docs/test-configuration + */ +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + baseURL: 'http://127.0.0.1:3000', + trace: 'on-first-retry', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: 'npm run dev', + url: 'http://127.0.0.1:3000', + reuseExistingServer: !process.env.CI, + }, +}); \ No newline at end of file diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 000000000..69ba194ee Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/index.html b/public/index.html deleted file mode 100644 index fe49cbf10..000000000 --- a/public/index.html +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - Buildly - - - -
- - - - - \ No newline at end of file diff --git a/public/vite.svg b/public/vite.svg new file mode 100644 index 000000000..e7b8dfb1b --- /dev/null +++ b/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/scripts/deploy-aws.sh b/scripts/deploy-aws.sh deleted file mode 100644 index 5413fd921..000000000 --- a/scripts/deploy-aws.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash -e - -# Push image to ECR -################### -pip install --user awscli -aws ecr get-login-password --region us-west-2 | docker login --username AWS --password-stdin 684870619712.dkr.ecr.us-west-2.amazonaws.com - -# update latest version -docker tag transparent-path/react-ui:latest 684870619712.dkr.ecr.us-west-2.amazonaws.com/transparent-path/react-ui:latest -docker push 684870619712.dkr.ecr.us-west-2.amazonaws.com/transparent-path/react-ui:latest diff --git a/scripts/deploy.sh b/scripts/deploy.sh deleted file mode 100644 index 132577352..000000000 --- a/scripts/deploy.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash - -# Tag image and push to Docker Hub -if [ -z "$TRAVIS_TAG" ]; then - TRAVIS_TAG="latest" -fi - -echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin -docker tag "$DOCKER_REPO" "$DOCKER_REPO:$TRAVIS_TAG" -docker push "$DOCKER_REPO:$TRAVIS_TAG" diff --git a/src/App.css b/src/App.css new file mode 100644 index 000000000..559bd5dc9 --- /dev/null +++ b/src/App.css @@ -0,0 +1,42 @@ +#root { + width: 100vw; + min-height: 100vh; + margin: 0; + padding: 0; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/src/App.js b/src/App.js deleted file mode 100644 index e611679f6..000000000 --- a/src/App.js +++ /dev/null @@ -1,73 +0,0 @@ -import * as React from 'react'; -import { hot } from 'react-hot-loader'; -import { - BrowserRouter as Router, - Route, - Redirect, -} from 'react-router-dom'; -import { - CssBaseline, - StyledEngineProvider, -} from '@mui/material'; -import Alert from '@components/Alert/Alert'; -import { app, AppContext } from '@context/App.context'; -import ContainerDashboard from '@layout/Container/Container'; -import { oauthService } from '@modules/oauth/oauth.service'; -import Login from '@pages/Login/Login'; -import Register from '@pages/Register/Register'; -import EmailForm from '@pages/ResetPassword/EmailForm'; -import Verification from '@pages/ResetPassword/Verification'; -import NewPasswordForm from '@pages/ResetPassword/NewPasswordForm'; -import { PrivateRoute } from '@routes/Private.route'; -import { routes } from '@routes/routesConstants'; -import theme from '@styles/theme'; -import { - Experimental_CssVarsProvider as CssVarsProvider, -} from '@mui/material/styles'; - -const App = () => ( - - - - -
- - ( - oauthService.hasValidAccessToken() - ? - : - )} - /> - - - - - - -
- -
-
-
-
-); - -export default hot(module)(App); diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 000000000..1a60257c9 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,110 @@ +// React and routing imports +import { useEffect } from 'react' +import { BrowserRouter as Router, Routes, Route } from 'react-router-dom' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' + +// Store imports for global state management +import { useAuthStore } from './stores/authStore' +import { useThemeStore } from './stores/themeStore' + +// Component imports +import { RootRedirect } from './components/RootRedirect/RootRedirect' +import { ProtectedRoute } from './components/ProtectedRoute/ProtectedRoute' +import { GlobalLoader } from './components/GlobalLoader/GlobalLoader' +import { GlobalNotification } from './components/GlobalNotification/GlobalNotification' + +// Page imports +import { Login } from './pages/Login/Login' +import { Register } from './pages/Register/Register' +import { ForgotPassword } from './pages/ForgotPassword/ForgotPassword' +import { ResetPasswordConfirm } from './pages/ResetPasswordConfirm/ResetPasswordConfirm' +import { VerifyEmail } from './pages/VerifyEmail/VerifyEmail' +import { Dashboard } from './pages/Dashboard/Dashboard' +import { UserManagement } from './pages/UserManagement/UserManagement' + +// Global styles +import './styles/theme.css' +import './App.css' + +// Configure TanStack Query client with optimized defaults +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, // Only retry failed requests once + refetchOnWindowFocus: false, // Don't refetch when window regains focus + }, + }, +}) + +/** + * Main App component that sets up routing, global providers, and initialization logic + * Features: + * - React Router for client-side routing + * - TanStack Query for server state management + * - Authentication state management with auto-cleanup + * - Theme system with light/dark/system modes + * - Protected routes with automatic redirects + */ +function App() { + const { cleanupExpiredAuth } = useAuthStore() + const { initializeTheme } = useThemeStore() + + // Initialize theme and auth on app start + useEffect(() => { + // Clean up any expired authentication tokens on app startup + cleanupExpiredAuth() + + // Initialize theme system and get cleanup function + const cleanupTheme = initializeTheme() + + // Return cleanup function for theme system + return cleanupTheme + }, [cleanupExpiredAuth, initializeTheme]) + + return ( + + + + {/* Root route - redirects based on auth status */} + } /> + + {/* Authentication routes - accessible to unauthenticated users */} + } /> + } /> + } /> + } /> + } /> + + {/* Protected app routes - requires authentication */} + + + + } + /> + + + + } + /> + + {/* Catch all route - redirect to root for handling */} + } /> + + + {/* Global loader for API calls */} + + + {/* Global notifications */} + + + + ) +} + +export default App diff --git a/src/api/auth.test.ts b/src/api/auth.test.ts new file mode 100644 index 000000000..acf83fcf8 --- /dev/null +++ b/src/api/auth.test.ts @@ -0,0 +1,521 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import { + useLoginMutation, + useResetPasswordMutation, + useResetPasswordConfirmMutation, + useRegisterMutation, + useVerifyEmailMutation, +} from './auth' +import { env } from '../utils/env' + +// Mock environment variables +vi.mock('../utils/env', () => ({ + env: { + API_URL: 'https://api.test.com', + OAUTH_TOKEN_URL: 'https://oauth.test.com/token', + OAUTH_CLIENT_ID: 'test-client-id', + }, +})) + +// Mock fetch globally +const mockFetch = vi.fn() +global.fetch = mockFetch + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + return ({ children }: { children: React.ReactNode }) => ( + {children} + ) +} + +describe('Authentication API', () => { + beforeEach(() => { + mockFetch.mockClear() + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe('useLoginMutation', () => { + it('should successfully login with valid credentials', async () => { + const mockTokenResponse = { + access: 'mock-access-token', + refresh: 'mock-refresh-token', + token_type: 'Bearer', + expires_in: 3600, + } + + const mockUserData = { + id: '123', + username: 'testuser', + email: 'test@example.com', + first_name: 'Test', + last_name: 'User', + } + + // Mock token endpoint response + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockTokenResponse), + }) + // Mock user data endpoint response + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockUserData), + }) + + const { result } = renderHook(() => useLoginMutation(), { + wrapper: createWrapper(), + }) + + const loginData = { username: 'testuser', password: 'password123' } + + await waitFor(() => { + result.current.mutate(loginData) + }) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + expect(mockFetch).toHaveBeenCalledTimes(2) + + // Verify token endpoint call + expect(mockFetch).toHaveBeenNthCalledWith(1, env.OAUTH_TOKEN_URL, { + method: 'POST', + headers: { + 'User-Agent': 'buildly-react-template/1.0.0', + }, + body: expect.any(FormData), + }) + + // Verify user data endpoint call + expect(mockFetch).toHaveBeenNthCalledWith(2, `${env.API_URL}/coreuser/me/`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${mockTokenResponse.access}`, + 'User-Agent': 'buildly-react-template/1.0.0', + }, + }) + + expect(result.current.data).toEqual({ + ...mockTokenResponse, + user: mockUserData, + }) + }) + + it('should handle login failure with invalid credentials', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + text: () => Promise.resolve('Invalid credentials'), + }) + + const { result } = renderHook(() => useLoginMutation(), { + wrapper: createWrapper(), + }) + + const loginData = { username: 'invalid', password: 'invalid' } + + await waitFor(() => { + result.current.mutate(loginData) + }) + + await waitFor(() => { + expect(result.current.isError).toBe(true) + }) + + expect(result.current.error?.message).toBe('Invalid credentials') + }) + + it('should handle user data fetch failure after successful token', async () => { + const mockTokenResponse = { + access: 'mock-access-token', + refresh: 'mock-refresh-token', + } + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockTokenResponse), + }) + .mockResolvedValueOnce({ + ok: false, + status: 403, + text: () => Promise.resolve('Access denied'), + }) + + const { result } = renderHook(() => useLoginMutation(), { + wrapper: createWrapper(), + }) + + const loginData = { username: 'testuser', password: 'password123' } + + await waitFor(() => { + result.current.mutate(loginData) + }) + + await waitFor(() => { + expect(result.current.isError).toBe(true) + }) + + expect(result.current.error?.message).toBe('Access denied') + }) + + it('should send correct FormData in login request', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ access: 'token', refresh: 'refresh' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({}), + }) + + const { result } = renderHook(() => useLoginMutation(), { + wrapper: createWrapper(), + }) + + const loginData = { username: 'testuser', password: 'password123' } + + await waitFor(() => { + result.current.mutate(loginData) + }) + + const formDataCall = mockFetch.mock.calls[0][1].body + expect(formDataCall).toBeInstanceOf(FormData) + }) + }) + + describe('useResetPasswordMutation', () => { + it('should successfully send password reset request', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(''), + }) + + const { result } = renderHook(() => useResetPasswordMutation(), { + wrapper: createWrapper(), + }) + + const resetData = { email: 'test@example.com' } + + await waitFor(() => { + result.current.mutate(resetData) + }) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + expect(mockFetch).toHaveBeenCalledWith(`${env.API_URL}/coreuser/reset-password/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'buildly-react-template/1.0.0', + }, + body: JSON.stringify(resetData), + }) + }) + + it('should handle password reset failure', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + text: () => Promise.resolve('Email not found'), + }) + + const { result } = renderHook(() => useResetPasswordMutation(), { + wrapper: createWrapper(), + }) + + const resetData = { email: 'notfound@example.com' } + + await waitFor(() => { + result.current.mutate(resetData) + }) + + await waitFor(() => { + expect(result.current.isError).toBe(true) + }) + + expect(result.current.error?.message).toBe('Email not found') + }) + + it('should handle API URL with trailing slash correctly', async () => { + vi.mocked(env).API_URL = 'https://api.test.com/' + + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(''), + }) + + const { result } = renderHook(() => useResetPasswordMutation(), { + wrapper: createWrapper(), + }) + + const resetData = { email: 'test@example.com' } + + await waitFor(() => { + result.current.mutate(resetData) + }) + + expect(mockFetch).toHaveBeenCalledWith('https://api.test.com/coreuser/reset-password/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'buildly-react-template/1.0.0', + }, + body: JSON.stringify(resetData), + }) + }) + }) + + describe('useResetPasswordConfirmMutation', () => { + it('should successfully confirm password reset', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(''), + }) + + const { result } = renderHook(() => useResetPasswordConfirmMutation(), { + wrapper: createWrapper(), + }) + + const confirmData = { + new_password1: 'newpassword123', + new_password2: 'newpassword123', + uid: 'user-uid', + token: 'reset-token', + } + + await waitFor(() => { + result.current.mutate(confirmData) + }) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + expect(mockFetch).toHaveBeenCalledWith(`${env.API_URL}/coreuser/reset-password-confirm/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'buildly-react-template/1.0.0', + }, + body: JSON.stringify(confirmData), + }) + }) + + it('should handle password reset confirmation failure', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + text: () => Promise.resolve('Invalid token or passwords do not match'), + }) + + const { result } = renderHook(() => useResetPasswordConfirmMutation(), { + wrapper: createWrapper(), + }) + + const confirmData = { + new_password1: 'newpassword123', + new_password2: 'differentpassword', + uid: 'user-uid', + token: 'invalid-token', + } + + await waitFor(() => { + result.current.mutate(confirmData) + }) + + await waitFor(() => { + expect(result.current.isError).toBe(true) + }) + + expect(result.current.error?.message).toBe('Invalid token or passwords do not match') + }) + }) + + describe('useRegisterMutation', () => { + it('should successfully register new user', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(''), + }) + + const { result } = renderHook(() => useRegisterMutation(), { + wrapper: createWrapper(), + }) + + const registerData = { + username: 'newuser', + email: 'newuser@example.com', + password: 'password123', + organization_name: 'Test Organization', + first_name: 'New', + last_name: 'User', + } + + await waitFor(() => { + result.current.mutate(registerData) + }) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + expect(mockFetch).toHaveBeenCalledWith(`${env.API_URL}/coreuser/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'buildly-react-template/1.0.0', + }, + body: JSON.stringify(registerData), + }) + }) + + it('should handle registration failure with validation errors', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + text: () => Promise.resolve('Username already exists'), + }) + + const { result } = renderHook(() => useRegisterMutation(), { + wrapper: createWrapper(), + }) + + const registerData = { + username: 'existinguser', + email: 'existing@example.com', + password: 'password123', + organization_name: 'Test Organization', + first_name: 'Existing', + last_name: 'User', + } + + await waitFor(() => { + result.current.mutate(registerData) + }) + + await waitFor(() => { + expect(result.current.isError).toBe(true) + }) + + expect(result.current.error?.message).toBe('Username already exists') + }) + }) + + describe('useVerifyEmailMutation', () => { + it('should successfully verify email with valid token', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(''), + }) + + const { result } = renderHook(() => useVerifyEmailMutation(), { + wrapper: createWrapper(), + }) + + const verifyData = { token: 'valid-verification-token' } + + await waitFor(() => { + result.current.mutate(verifyData) + }) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + expect(mockFetch).toHaveBeenCalledWith(`${env.API_URL}/coreuser/verify_email/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'buildly-react-template/1.0.0', + }, + body: JSON.stringify(verifyData), + }) + }) + + it('should handle email verification failure with invalid token', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + text: () => Promise.resolve('Invalid or expired verification token'), + }) + + const { result } = renderHook(() => useVerifyEmailMutation(), { + wrapper: createWrapper(), + }) + + const verifyData = { token: 'invalid-token' } + + await waitFor(() => { + result.current.mutate(verifyData) + }) + + await waitFor(() => { + expect(result.current.isError).toBe(true) + }) + + expect(result.current.error?.message).toBe('Invalid or expired verification token') + }) + }) + + describe('Error handling edge cases', () => { + it('should handle network errors gracefully', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')) + + const { result } = renderHook(() => useLoginMutation(), { + wrapper: createWrapper(), + }) + + const loginData = { username: 'testuser', password: 'password123' } + + await waitFor(() => { + result.current.mutate(loginData) + }) + + await waitFor(() => { + expect(result.current.isError).toBe(true) + }) + + expect(result.current.error?.message).toBe('Network error') + }) + + it('should handle empty error responses', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + text: () => Promise.resolve(''), + }) + + const { result } = renderHook(() => useLoginMutation(), { + wrapper: createWrapper(), + }) + + const loginData = { username: 'testuser', password: 'password123' } + + await waitFor(() => { + result.current.mutate(loginData) + }) + + await waitFor(() => { + expect(result.current.isError).toBe(true) + }) + + expect(result.current.error?.message).toBe('HTTP error! status: 500') + }) + }) +}) \ No newline at end of file diff --git a/src/api/auth.ts b/src/api/auth.ts new file mode 100644 index 000000000..ab6515490 --- /dev/null +++ b/src/api/auth.ts @@ -0,0 +1,183 @@ +import { useMutation } from '@tanstack/react-query' +import { env } from '../utils/env' + +interface LoginCredentials { + username: string + password: string +} + +interface ResetPasswordRequest { + email: string +} + +interface ResetPasswordConfirmRequest { + new_password1: string + new_password2: string + uid: string + token: string +} + +interface RegisterUserRequest { + username: string + email: string + password: string + organization_name: string + first_name: string + last_name: string +} + +interface VerifyEmailRequest { + token: string +} + +interface TokenResponse { + access: string + refresh: string + token_type?: string + expires_in?: number + user?: { + id: string + username: string + email: string + first_name: string + last_name: string + } +} + +const loginUser = async (credentials: LoginCredentials): Promise => { + const formData = new FormData() + formData.append('username', credentials.username) + formData.append('password', credentials.password) + formData.append('client_id', env.OAUTH_CLIENT_ID) + + const response = await fetch(env.OAUTH_TOKEN_URL, { + method: 'POST', + headers: { + 'User-Agent': 'buildly-react-template/1.0.0', + }, + body: formData, + }) + + if (!response.ok) { + const errorData = await response.text() + throw new Error(errorData || `HTTP error! status: ${response.status}`) + } + + const user = await response.json() + + const baseUrl = env.API_URL.endsWith('/') ? env.API_URL.slice(0, -1) : env.API_URL + const userResponse = await fetch(baseUrl + '/coreuser/me/', { + method: 'GET', + headers: { + 'Authorization': `Bearer ${user.access}`, + 'User-Agent': 'buildly-react-template/1.0.0', + }, + }) + + if (!userResponse.ok) { + const errorData = await userResponse.text() + throw new Error(errorData || `HTTP error! status: ${userResponse.status}`) + } + + const userData = await userResponse.json() + + return { ...user, user: userData } +} + +const resetPassword = async (request: ResetPasswordRequest): Promise => { + const baseUrl = env.API_URL.endsWith('/') ? env.API_URL.slice(0, -1) : env.API_URL + const response = await fetch(baseUrl + '/coreuser/reset-password/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'buildly-react-template/1.0.0', + }, + body: JSON.stringify(request), + }) + + if (!response.ok) { + const errorData = await response.text() + throw new Error(errorData || `HTTP error! status: ${response.status}`) + } +} + +const resetPasswordConfirm = async (request: ResetPasswordConfirmRequest): Promise => { + const baseUrl = env.API_URL.endsWith('/') ? env.API_URL.slice(0, -1) : env.API_URL + const response = await fetch(baseUrl + '/coreuser/reset-password-confirm/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'buildly-react-template/1.0.0', + }, + body: JSON.stringify(request), + }) + + if (!response.ok) { + const errorData = await response.text() + throw new Error(errorData || `HTTP error! status: ${response.status}`) + } +} + +const registerUser = async (request: RegisterUserRequest): Promise => { + const baseUrl = env.API_URL.endsWith('/') ? env.API_URL.slice(0, -1) : env.API_URL + const response = await fetch(baseUrl + '/coreuser/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'buildly-react-template/1.0.0', + }, + body: JSON.stringify(request), + }) + + if (!response.ok) { + const errorData = await response.text() + throw new Error(errorData || `HTTP error! status: ${response.status}`) + } +} + +const verifyEmail = async (request: VerifyEmailRequest): Promise => { + const baseUrl = env.API_URL.endsWith('/') ? env.API_URL.slice(0, -1) : env.API_URL + const response = await fetch(baseUrl + '/coreuser/verify_email/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'buildly-react-template/1.0.0', + }, + body: JSON.stringify(request), + }) + + if (!response.ok) { + const errorData = await response.text() + throw new Error(errorData || `HTTP error! status: ${response.status}`) + } +} + +export const useLoginMutation = () => { + return useMutation({ + mutationFn: loginUser, + }) +} + +export const useResetPasswordMutation = () => { + return useMutation({ + mutationFn: resetPassword, + }) +} + +export const useResetPasswordConfirmMutation = () => { + return useMutation({ + mutationFn: resetPasswordConfirm, + }) +} + +export const useRegisterMutation = () => { + return useMutation({ + mutationFn: registerUser, + }) +} + +export const useVerifyEmailMutation = () => { + return useMutation({ + mutationFn: verifyEmail, + }) +} \ No newline at end of file diff --git a/src/api/users.test.ts b/src/api/users.test.ts new file mode 100644 index 000000000..a54414409 --- /dev/null +++ b/src/api/users.test.ts @@ -0,0 +1,816 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import { + useUsersQuery, + useCoreGroupsQuery, + useOrganizationsQuery, + useInviteUsersMutation, + useUpdateUserMutation, + type User, + type CoreGroup, + type Organization, + type UserUpdateData, +} from './users' +import { env } from '../utils/env' + +// Mock environment variables +vi.mock('../utils/env', () => ({ + env: { + API_URL: 'https://api.test.com', + }, +})) + +// Mock fetch globally +const mockFetch = vi.fn() +global.fetch = mockFetch + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + return ({ children }: { children: React.ReactNode }) => ( + {children} + ) +} + +const mockAccessToken = 'mock-access-token' + +const mockUser: User = { + id: 1, + core_user_uuid: 'user-uuid-123', + username: 'testuser', + email: 'test@example.com', + first_name: 'Test', + last_name: 'User', + is_active: true, + title: 'Developer', + contact_info: '+1234567890', + privacy_disclaimer_accepted: true, + tos_disclaimer_accepted: true, + organization: { + organization_uuid: 'org-uuid-123', + id: 'org-1', + name: 'Test Organization', + description: 'Test Description', + organization_url: 'https://test.com', + create_date: '2023-01-01T00:00:00Z', + edit_date: '2023-01-01T00:00:00Z', + oauth_domains: null, + date_format: 'YYYY-MM-DD', + phone: null, + allow_import_export: true, + radius: 0, + stripe_subscription_details: null, + unlimited_free_plan: false, + coupon: null, + industries: [], + subscriptions: [], + subscription_active: true, + referral_link: null, + organization_type: null, + }, + core_groups: [ + { + id: 1, + uuid: 'group-uuid-123', + name: 'Admin', + is_global: false, + is_org_level: true, + permissions: { + create: true, + read: true, + update: true, + delete: true, + }, + organization: null, + }, + ], + user_type: 'standard', + survey_status: false, + subscription_active: true, + social_profiles: {}, + primary_social_platform: null, + primary_social_username: null, + primary_social_avatar_url: null, + github_username: null, + has_github_profile: false, +} + +const mockCoreGroup: CoreGroup = { + id: 1, + uuid: 'group-uuid-123', + name: 'Admin', + is_global: false, + is_org_level: true, + permissions: { + create: true, + read: true, + update: true, + delete: true, + }, + organization: null, +} + +const mockOrganization: Organization = { + id: 'org-1', + organization_uuid: 'org-uuid-123', + name: 'Test Organization', + description: 'Test Description', + organization_url: 'https://test.com', + create_date: '2023-01-01T00:00:00Z', + edit_date: '2023-01-01T00:00:00Z', + oauth_domains: null, + date_format: 'YYYY-MM-DD', + phone: null, + allow_import_export: true, + radius: 0, + stripe_subscription_details: null, + unlimited_free_plan: false, + coupon: null, + industries: [], + subscriptions: [], + subscription_active: true, + referral_link: null, + organization_type: null, +} + +describe('Users API', () => { + beforeEach(() => { + mockFetch.mockClear() + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe('useUsersQuery', () => { + it('should fetch users successfully with valid token', async () => { + const mockUsers = [mockUser] + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockUsers), + }) + + const { result } = renderHook(() => useUsersQuery(mockAccessToken), { + wrapper: createWrapper(), + }) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + expect(mockFetch).toHaveBeenCalledWith(`${env.API_URL}/coreuser/`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${mockAccessToken}`, + 'User-Agent': 'buildly-react-template/1.0.0', + }, + }) + + expect(result.current.data).toEqual(mockUsers) + }) + + it('should not fetch when access token is null', async () => { + const { result } = renderHook(() => useUsersQuery(null), { + wrapper: createWrapper(), + }) + + expect(result.current.isPending).toBe(true) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('should handle fetch users error', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + text: () => Promise.resolve('Unauthorized'), + }) + + const { result } = renderHook(() => useUsersQuery(mockAccessToken), { + wrapper: createWrapper(), + }) + + await waitFor(() => { + expect(result.current.isError).toBe(true) + }) + + expect(result.current.error?.message).toBe('Unauthorized') + }) + + it('should handle API URL with trailing slash', async () => { + vi.mocked(env).API_URL = 'https://api.test.com/' + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve([mockUser]), + }) + + const { result } = renderHook(() => useUsersQuery(mockAccessToken), { + wrapper: createWrapper(), + }) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + expect(mockFetch).toHaveBeenCalledWith('https://api.test.com/coreuser/', { + method: 'GET', + headers: { + 'Authorization': `Bearer ${mockAccessToken}`, + 'User-Agent': 'buildly-react-template/1.0.0', + }, + }) + }) + }) + + describe('useCoreGroupsQuery', () => { + it('should fetch core groups successfully', async () => { + const mockGroups = [mockCoreGroup] + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockGroups), + }) + + const { result } = renderHook(() => useCoreGroupsQuery(mockAccessToken), { + wrapper: createWrapper(), + }) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + expect(mockFetch).toHaveBeenCalledWith(`${env.API_URL}/coregroups/`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${mockAccessToken}`, + 'User-Agent': 'buildly-react-template/1.0.0', + }, + }) + + expect(result.current.data).toEqual(mockGroups) + }) + + it('should not fetch when access token is null', async () => { + const { result } = renderHook(() => useCoreGroupsQuery(null), { + wrapper: createWrapper(), + }) + + expect(result.current.isPending).toBe(true) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('should handle fetch core groups error', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 403, + text: () => Promise.resolve('Forbidden'), + }) + + const { result } = renderHook(() => useCoreGroupsQuery(mockAccessToken), { + wrapper: createWrapper(), + }) + + await waitFor(() => { + expect(result.current.isError).toBe(true) + }) + + expect(result.current.error?.message).toBe('Forbidden') + }) + }) + + describe('useOrganizationsQuery', () => { + it('should fetch organizations successfully', async () => { + const mockOrganizations = [mockOrganization] + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockOrganizations), + }) + + const { result } = renderHook(() => useOrganizationsQuery(mockAccessToken), { + wrapper: createWrapper(), + }) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + expect(mockFetch).toHaveBeenCalledWith(`${env.API_URL}/organization/`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${mockAccessToken}`, + 'User-Agent': 'buildly-react-template/1.0.0', + }, + }) + + expect(result.current.data).toEqual(mockOrganizations) + }) + + it('should handle fetch organizations error', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + text: () => Promise.resolve('Internal Server Error'), + }) + + const { result } = renderHook(() => useOrganizationsQuery(mockAccessToken), { + wrapper: createWrapper(), + }) + + await waitFor(() => { + expect(result.current.isError).toBe(true) + }) + + expect(result.current.error?.message).toBe('Internal Server Error') + }) + }) + + describe('useInviteUsersMutation', () => { + it('should invite users successfully', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(''), + }) + + const { result } = renderHook(() => useInviteUsersMutation(), { + wrapper: createWrapper(), + }) + + const inviteData = { + accessToken: mockAccessToken, + emails: ['user1@example.com', 'user2@example.com'], + } + + await waitFor(() => { + result.current.mutate(inviteData) + }) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + expect(mockFetch).toHaveBeenCalledWith(`${env.API_URL}/coreuser/invite/`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${mockAccessToken}`, + 'Content-Type': 'application/json', + 'User-Agent': 'buildly-react-template/1.0.0', + }, + body: JSON.stringify({ emails: inviteData.emails }), + }) + }) + + it('should handle invite users failure', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + text: () => Promise.resolve('Invalid email addresses'), + }) + + const { result } = renderHook(() => useInviteUsersMutation(), { + wrapper: createWrapper(), + }) + + const inviteData = { + accessToken: mockAccessToken, + emails: ['invalid-email'], + } + + await waitFor(() => { + result.current.mutate(inviteData) + }) + + await waitFor(() => { + expect(result.current.isError).toBe(true) + }) + + expect(result.current.error?.message).toBe('Invalid email addresses') + }) + + it('should handle empty emails array', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(''), + }) + + const { result } = renderHook(() => useInviteUsersMutation(), { + wrapper: createWrapper(), + }) + + const inviteData = { + accessToken: mockAccessToken, + emails: [], + } + + await waitFor(() => { + result.current.mutate(inviteData) + }) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + expect(mockFetch).toHaveBeenCalledWith(`${env.API_URL}/coreuser/invite/`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${mockAccessToken}`, + 'Content-Type': 'application/json', + 'User-Agent': 'buildly-react-template/1.0.0', + }, + body: JSON.stringify({ emails: [] }), + }) + }) + }) + + describe('useUpdateUserMutation', () => { + it('should update user with only organization change', async () => { + const updatedUser = { ...mockUser, organization: { ...mockUser.organization, name: 'New Organization' } } + + // Mock organization update call + mockFetch + .mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(''), + }) + // Mock fetch updated user call + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(updatedUser), + }) + + const { result } = renderHook(() => useUpdateUserMutation(), { + wrapper: createWrapper(), + }) + + const updateData = { + accessToken: mockAccessToken, + userId: 1, + userData: { organization_name: 'New Organization' } as UserUpdateData, + } + + await waitFor(() => { + result.current.mutate(updateData) + }) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + expect(mockFetch).toHaveBeenCalledTimes(2) + + // Verify organization update call + expect(mockFetch).toHaveBeenNthCalledWith(1, `${env.API_URL}/coreuser/update_org/1/`, { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${mockAccessToken}`, + 'Content-Type': 'application/json', + 'User-Agent': 'buildly-react-template/1.0.0', + }, + body: JSON.stringify({ organization_name: 'New Organization' }), + }) + + // Verify user fetch call + expect(mockFetch).toHaveBeenNthCalledWith(2, `${env.API_URL}/coreuser/1/`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${mockAccessToken}`, + 'User-Agent': 'buildly-react-template/1.0.0', + }, + }) + + expect(result.current.data).toEqual(updatedUser) + }) + + it('should update user with only user fields', async () => { + const updatedUser = { ...mockUser, is_active: false } + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(updatedUser), + }) + + const { result } = renderHook(() => useUpdateUserMutation(), { + wrapper: createWrapper(), + }) + + const updateData = { + accessToken: mockAccessToken, + userId: 1, + userData: { is_active: false } as UserUpdateData, + } + + await waitFor(() => { + result.current.mutate(updateData) + }) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + expect(mockFetch).toHaveBeenCalledTimes(1) + expect(mockFetch).toHaveBeenCalledWith(`${env.API_URL}/coreuser/1/`, { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${mockAccessToken}`, + 'Content-Type': 'application/json', + 'User-Agent': 'buildly-react-template/1.0.0', + }, + body: JSON.stringify({ is_active: false }), + }) + + expect(result.current.data).toEqual(updatedUser) + }) + + it('should update user with both organization and user fields', async () => { + const updatedUser = { + ...mockUser, + is_active: false, + organization: { ...mockUser.organization, name: 'New Organization' } + } + + // Mock organization update call + mockFetch + .mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(''), + }) + // Mock user fields update call + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(updatedUser), + }) + + const { result } = renderHook(() => useUpdateUserMutation(), { + wrapper: createWrapper(), + }) + + const updateData = { + accessToken: mockAccessToken, + userId: 1, + userData: { + organization_name: 'New Organization', + is_active: false, + core_groups: [2, 3] + } as UserUpdateData, + } + + await waitFor(() => { + result.current.mutate(updateData) + }) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + expect(mockFetch).toHaveBeenCalledTimes(2) + + // Verify organization update call (first) + expect(mockFetch).toHaveBeenNthCalledWith(1, `${env.API_URL}/coreuser/update_org/1/`, { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${mockAccessToken}`, + 'Content-Type': 'application/json', + 'User-Agent': 'buildly-react-template/1.0.0', + }, + body: JSON.stringify({ organization_name: 'New Organization' }), + }) + + // Verify user fields update call (second) + expect(mockFetch).toHaveBeenNthCalledWith(2, `${env.API_URL}/coreuser/1/`, { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${mockAccessToken}`, + 'Content-Type': 'application/json', + 'User-Agent': 'buildly-react-template/1.0.0', + }, + body: JSON.stringify({ is_active: false, core_groups: [2, 3] }), + }) + + expect(result.current.data).toEqual(updatedUser) + }) + + it('should fail fast if organization update fails', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + text: () => Promise.resolve('Organization update failed'), + }) + + const { result } = renderHook(() => useUpdateUserMutation(), { + wrapper: createWrapper(), + }) + + const updateData = { + accessToken: mockAccessToken, + userId: 1, + userData: { + organization_name: 'Invalid Organization', + is_active: false + } as UserUpdateData, + } + + await waitFor(() => { + result.current.mutate(updateData) + }) + + await waitFor(() => { + expect(result.current.isError).toBe(true) + }) + + // Should only call organization update, not user fields update + expect(mockFetch).toHaveBeenCalledTimes(1) + expect(result.current.error?.message).toBe('Organization update failed') + }) + + it('should handle user fields update failure', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + text: () => Promise.resolve('Invalid user data'), + }) + + const { result } = renderHook(() => useUpdateUserMutation(), { + wrapper: createWrapper(), + }) + + const updateData = { + accessToken: mockAccessToken, + userId: 1, + userData: { is_active: false } as UserUpdateData, + } + + await waitFor(() => { + result.current.mutate(updateData) + }) + + await waitFor(() => { + expect(result.current.isError).toBe(true) + }) + + expect(result.current.error?.message).toBe('Invalid user data') + }) + + it('should handle user fetch failure after organization update', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(''), + }) + .mockResolvedValueOnce({ + ok: false, + status: 404, + text: () => Promise.resolve('User not found'), + }) + + const { result } = renderHook(() => useUpdateUserMutation(), { + wrapper: createWrapper(), + }) + + const updateData = { + accessToken: mockAccessToken, + userId: 1, + userData: { organization_name: 'New Organization' } as UserUpdateData, + } + + await waitFor(() => { + result.current.mutate(updateData) + }) + + await waitFor(() => { + expect(result.current.isError).toBe(true) + }) + + expect(mockFetch).toHaveBeenCalledTimes(2) + expect(result.current.error?.message).toBe('User not found') + }) + + it('should handle core_groups update correctly', async () => { + const updatedUser = { ...mockUser, core_groups: [{ ...mockUser.core_groups[0], id: 2 }] } + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(updatedUser), + }) + + const { result } = renderHook(() => useUpdateUserMutation(), { + wrapper: createWrapper(), + }) + + const updateData = { + accessToken: mockAccessToken, + userId: 1, + userData: { core_groups: [2] } as UserUpdateData, + } + + await waitFor(() => { + result.current.mutate(updateData) + }) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + expect(mockFetch).toHaveBeenCalledWith(`${env.API_URL}/coreuser/1/`, { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${mockAccessToken}`, + 'Content-Type': 'application/json', + 'User-Agent': 'buildly-react-template/1.0.0', + }, + body: JSON.stringify({ core_groups: [2] }), + }) + + expect(result.current.data).toEqual(updatedUser) + }) + }) + + describe('Error handling edge cases', () => { + it('should handle network errors gracefully', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')) + + const { result } = renderHook(() => useUsersQuery(mockAccessToken), { + wrapper: createWrapper(), + }) + + await waitFor(() => { + expect(result.current.isError).toBe(true) + }) + + expect(result.current.error?.message).toBe('Network error') + }) + + it('should handle empty error responses', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + text: () => Promise.resolve(''), + }) + + const { result } = renderHook(() => useUsersQuery(mockAccessToken), { + wrapper: createWrapper(), + }) + + await waitFor(() => { + expect(result.current.isError).toBe(true) + }) + + expect(result.current.error?.message).toBe('HTTP error! status: 500') + }) + + it('should handle malformed JSON responses', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.reject(new Error('Invalid JSON')), + }) + + const { result } = renderHook(() => useUsersQuery(mockAccessToken), { + wrapper: createWrapper(), + }) + + await waitFor(() => { + expect(result.current.isError).toBe(true) + }) + + expect(result.current.error?.message).toBe('Invalid JSON') + }) + }) + + describe('Query options and caching', () => { + it('should have correct staleTime configured', () => { + const { result: usersResult } = renderHook(() => useUsersQuery(mockAccessToken), { + wrapper: createWrapper(), + }) + const { result: groupsResult } = renderHook(() => useCoreGroupsQuery(mockAccessToken), { + wrapper: createWrapper(), + }) + const { result: orgsResult } = renderHook(() => useOrganizationsQuery(mockAccessToken), { + wrapper: createWrapper(), + }) + + // All queries should have 5 minute stale time + expect(usersResult.current.dataUpdatedAt).toBeDefined() + expect(groupsResult.current.dataUpdatedAt).toBeDefined() + expect(orgsResult.current.dataUpdatedAt).toBeDefined() + }) + + it('should use correct query keys', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve([]), + }) + + const wrapper = createWrapper() + + renderHook(() => useUsersQuery(mockAccessToken), { wrapper }) + renderHook(() => useCoreGroupsQuery(mockAccessToken), { wrapper }) + renderHook(() => useOrganizationsQuery(mockAccessToken), { wrapper }) + + // Query keys are used internally by TanStack Query for caching + // We can't directly test them but we can verify the hooks work correctly + expect(mockFetch).toHaveBeenCalledTimes(3) + }) + }) +}) \ No newline at end of file diff --git a/src/api/users.ts b/src/api/users.ts new file mode 100644 index 000000000..17b7d90e2 --- /dev/null +++ b/src/api/users.ts @@ -0,0 +1,295 @@ +import { useQuery, useMutation } from '@tanstack/react-query' +import { env } from '../utils/env' + +export interface CoreGroup { + id: number + uuid: string + name: string + is_global: boolean + is_org_level: boolean + permissions: { + create: boolean + read: boolean + update: boolean + delete: boolean + } + organization: any | null +} + +export interface Organization { + id: string + organization_uuid: string + name: string + description: string | null + organization_url: string | null + create_date: string + edit_date: string + oauth_domains: any | null + date_format: string + phone: string | null + allow_import_export: boolean + radius: number + stripe_subscription_details: any | null + unlimited_free_plan: boolean + coupon: any | null + industries: any[] + subscriptions: any[] + subscription_active: boolean + referral_link: string | null + organization_type: string | null +} + +export interface User { + id: number + core_user_uuid: string + username: string + email: string + first_name: string + last_name: string + is_active: boolean + title: string | null + contact_info: string | null + privacy_disclaimer_accepted: boolean + tos_disclaimer_accepted: boolean + organization: { + organization_uuid: string + id: string + name: string + description: string | null + organization_url: string | null + create_date: string + edit_date: string + oauth_domains: any | null + date_format: string + phone: string | null + allow_import_export: boolean + radius: number + stripe_subscription_details: any | null + unlimited_free_plan: boolean + coupon: any | null + industries: any[] + subscriptions: any[] + subscription_active: boolean + referral_link: string | null + organization_type: string | null + } + core_groups: Array<{ + id: number + uuid: string + name: string + is_global: boolean + is_org_level: boolean + permissions: { + create: boolean + read: boolean + update: boolean + delete: boolean + } + organization: any | null + }> + user_type: string + survey_status: boolean + subscription_active: boolean + social_profiles: Record + primary_social_platform: string | null + primary_social_username: string | null + primary_social_avatar_url: string | null + github_username: string | null + has_github_profile: boolean +} + +const fetchUsers = async (accessToken: string): Promise => { + const baseUrl = env.API_URL.endsWith('/') ? env.API_URL.slice(0, -1) : env.API_URL + const response = await fetch(`${baseUrl}/coreuser/`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'User-Agent': 'buildly-react-template/1.0.0', + }, + }) + + if (!response.ok) { + const errorData = await response.text() + throw new Error(errorData || `HTTP error! status: ${response.status}`) + } + + return response.json() +} + +const fetchCoreGroups = async (accessToken: string): Promise => { + const baseUrl = env.API_URL.endsWith('/') ? env.API_URL.slice(0, -1) : env.API_URL + const response = await fetch(`${baseUrl}/coregroups/`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'User-Agent': 'buildly-react-template/1.0.0', + }, + }) + + if (!response.ok) { + const errorData = await response.text() + throw new Error(errorData || `HTTP error! status: ${response.status}`) + } + + return response.json() +} + +const fetchOrganizations = async (accessToken: string): Promise => { + const baseUrl = env.API_URL.endsWith('/') ? env.API_URL.slice(0, -1) : env.API_URL + const response = await fetch(`${baseUrl}/organization/`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'User-Agent': 'buildly-react-template/1.0.0', + }, + }) + + if (!response.ok) { + const errorData = await response.text() + throw new Error(errorData || `HTTP error! status: ${response.status}`) + } + + return response.json() +} + +export const useUsersQuery = (accessToken: string | null) => { + return useQuery({ + queryKey: ['users'], + queryFn: () => fetchUsers(accessToken!), + enabled: !!accessToken, + staleTime: 5 * 60 * 1000, // 5 minutes + }) +} + +export const useCoreGroupsQuery = (accessToken: string | null) => { + return useQuery({ + queryKey: ['coregroups'], + queryFn: () => fetchCoreGroups(accessToken!), + enabled: !!accessToken, + staleTime: 5 * 60 * 1000, // 5 minutes + }) +} + +const inviteUsers = async (accessToken: string, emails: string[]): Promise => { + const baseUrl = env.API_URL.endsWith('/') ? env.API_URL.slice(0, -1) : env.API_URL + const response = await fetch(`${baseUrl}/coreuser/invite/`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'User-Agent': 'buildly-react-template/1.0.0', + }, + body: JSON.stringify({ emails }), + }) + + if (!response.ok) { + const errorData = await response.text() + throw new Error(errorData || `HTTP error! status: ${response.status}`) + } +} + +export const useOrganizationsQuery = (accessToken: string | null) => { + return useQuery({ + queryKey: ['organizations'], + queryFn: () => fetchOrganizations(accessToken!), + enabled: !!accessToken, + staleTime: 5 * 60 * 1000, // 5 minutes + }) +} + +export const useInviteUsersMutation = () => { + return useMutation({ + mutationFn: ({ accessToken, emails }: { accessToken: string, emails: string[] }) => + inviteUsers(accessToken, emails), + }) +} + +export interface UserUpdateData { + is_active?: boolean + organization_name?: string + core_groups?: number[] +} + +const updateUserOrganization = async (accessToken: string, userId: number, orgData: { organization_name: string }): Promise => { + const baseUrl = env.API_URL.endsWith('/') ? env.API_URL.slice(0, -1) : env.API_URL + const response = await fetch(`${baseUrl}/coreuser/update_org/${userId}/`, { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'User-Agent': 'buildly-react-template/1.0.0', + }, + body: JSON.stringify(orgData), + }) + + if (!response.ok) { + const errorData = await response.text() + throw new Error(errorData || `HTTP error! status: ${response.status}`) + } +} + +const updateUserFields = async (accessToken: string, userId: number, userData: { is_active?: boolean, core_groups?: number[] }): Promise => { + const baseUrl = env.API_URL.endsWith('/') ? env.API_URL.slice(0, -1) : env.API_URL + const response = await fetch(`${baseUrl}/coreuser/${userId}/`, { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'User-Agent': 'buildly-react-template/1.0.0', + }, + body: JSON.stringify(userData), + }) + + if (!response.ok) { + const errorData = await response.text() + throw new Error(errorData || `HTTP error! status: ${response.status}`) + } + + return response.json() +} + +const updateUser = async (accessToken: string, userId: number, userData: UserUpdateData): Promise => { + // Step 1: Update organization if needed (must happen first) + if ('organization_name' in userData && userData.organization_name !== undefined) { + await updateUserOrganization(accessToken, userId, { + organization_name: userData.organization_name + }) + } + + // Step 2: Update user fields if needed + const userFields = { + ...(userData.is_active !== undefined && { is_active: userData.is_active }), + ...(userData.core_groups !== undefined && { core_groups: userData.core_groups }) + } + + if (Object.keys(userFields).length > 0) { + return await updateUserFields(accessToken, userId, userFields) + } + + // If only organization was updated, fetch the updated user + const baseUrl = env.API_URL.endsWith('/') ? env.API_URL.slice(0, -1) : env.API_URL + const response = await fetch(`${baseUrl}/coreuser/${userId}/`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'User-Agent': 'buildly-react-template/1.0.0', + }, + }) + + if (!response.ok) { + const errorData = await response.text() + throw new Error(errorData || `HTTP error! status: ${response.status}`) + } + + return response.json() +} + +export const useUpdateUserMutation = () => { + return useMutation({ + mutationFn: ({ accessToken, userId, userData }: { + accessToken: string, + userId: number, + userData: UserUpdateData + }) => updateUser(accessToken, userId, userData), + }) +} \ No newline at end of file diff --git a/src/assets/buildly-logo.png b/src/assets/buildly-logo.png deleted file mode 100644 index ca52f61ce..000000000 Binary files a/src/assets/buildly-logo.png and /dev/null differ diff --git a/docs/_static/images/buildly-logo.png b/src/assets/dark-logo.png similarity index 100% rename from docs/_static/images/buildly-logo.png rename to src/assets/dark-logo.png diff --git a/src/assets/topbar-logo.png b/src/assets/light-logo.png similarity index 100% rename from src/assets/topbar-logo.png rename to src/assets/light-logo.png diff --git a/src/components/Alert/Alert.js b/src/components/Alert/Alert.js deleted file mode 100644 index 74f040ba4..000000000 --- a/src/components/Alert/Alert.js +++ /dev/null @@ -1,52 +0,0 @@ -import React from 'react'; -import { Snackbar, Slide, IconButton } from '@mui/material'; -import CloseIcon from '@mui/icons-material/Close'; -import { useStore } from '@zustand/alert/alertStore'; -import './AlertStyles.css'; - -const Alert = () => { - const { data, hideAlert } = useStore(); - - const handleClose = (event, reason) => { - if (reason === 'clickaway') { - return; - } - hideAlert(); - if (data && data.onClose) { - data.onClose(data.id); - } - }; - - return ( -
- {data && ( - } - classes={{ - root: `${data.type}`, - }} - action={( - <> - - - - - )} - /> - )} -
- ); -}; - -export default Alert; diff --git a/src/components/Alert/AlertStyles.css b/src/components/Alert/AlertStyles.css deleted file mode 100644 index 1d45f3403..000000000 --- a/src/components/Alert/AlertStyles.css +++ /dev/null @@ -1,28 +0,0 @@ -.alertRoot { - width: 100%; -} - -.alertRoot > * + * { - margin-top: 16px; -} - -.alertRoot .MuiSnackbarContent-root { - background-color: transparent; - color: var(--color-palette-background-default); -} - -.success { - background-color: var(--color-palette-success-main); -} - -.info { - background-color: var(--color-palette-info-main); -} - -.warning { - background-color: var(--color-palette-warning-main); -} - -.error { - background-color: var(--color-palette-error-main); -} diff --git a/src/components/Button/Button.css b/src/components/Button/Button.css new file mode 100644 index 000000000..69be5ce64 --- /dev/null +++ b/src/components/Button/Button.css @@ -0,0 +1,65 @@ +.button { + font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + font-weight: 600; + border: 0; + border-radius: var(--radius-lg); + cursor: pointer; + display: inline-block; + line-height: 1; + transition: all 0.2s ease; +} + +.button--primary { + color: var(--color-text-inverse); + background-color: var(--color-primary); +} + +.button--primary:hover { + background-color: var(--color-primary-hover); +} + +.button--secondary { + color: var(--color-text-primary); + background-color: transparent; + border: 1px solid var(--color-surface-border); +} + +.button--secondary:hover { + background-color: var(--color-surface-hover); +} + +.button--small { + font-size: 12px; + padding: 10px 16px; +} + +.button--medium { + font-size: 14px; + padding: 11px 20px; +} + +.button--large { + font-size: 16px; + padding: 12px 24px; +} + +.button:hover { + transform: translateY(-1px); + box-shadow: var(--shadow-md); +} + +.button:active { + transform: translateY(0); + box-shadow: var(--shadow-sm); +} + +.button:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; +} + +.button:disabled:hover { + transform: none; + box-shadow: none; +} \ No newline at end of file diff --git a/src/components/Button/Button.stories.tsx b/src/components/Button/Button.stories.tsx new file mode 100644 index 000000000..304b8c179 --- /dev/null +++ b/src/components/Button/Button.stories.tsx @@ -0,0 +1,54 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; +import { Button } from './Button'; + +const meta = { + title: 'Example/Button', + component: Button, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + backgroundColor: { control: 'color' }, + }, + args: { onClick: fn() }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Primary: Story = { + args: { + primary: true, + label: 'Button', + }, +}; + +export const Secondary: Story = { + args: { + label: 'Button', + }, +}; + +export const Large: Story = { + args: { + size: 'large', + label: 'Button', + }, +}; + +export const Small: Story = { + args: { + size: 'small', + label: 'Button', + }, +}; + +export const CustomColor: Story = { + args: { + primary: true, + backgroundColor: '#ff6b6b', + label: 'Custom Button', + }, +}; \ No newline at end of file diff --git a/src/components/Button/Button.test.tsx b/src/components/Button/Button.test.tsx new file mode 100644 index 000000000..f8cf21ce3 --- /dev/null +++ b/src/components/Button/Button.test.tsx @@ -0,0 +1,47 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Button } from './Button'; + +describe('Button', () => { + it('renders with label', () => { + render( + ); +}; \ No newline at end of file diff --git a/src/components/Copyright/Copyright.css b/src/components/Copyright/Copyright.css new file mode 100644 index 000000000..57f363777 --- /dev/null +++ b/src/components/Copyright/Copyright.css @@ -0,0 +1,23 @@ +.copyright-footer { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background-color: var(--color-surface); + border-top: 1px solid var(--color-surface-border); + backdrop-filter: blur(8px); + z-index: 10; +} + +.copyright-content { + padding: 12px 20px; + text-align: center; +} + +.copyright-content p { + margin: 0; + color: var(--color-text-tertiary); + font-size: 12px; + font-weight: 400; + letter-spacing: 0.025em; +} \ No newline at end of file diff --git a/src/components/Copyright/Copyright.js b/src/components/Copyright/Copyright.js deleted file mode 100644 index 6d077b4d8..000000000 --- a/src/components/Copyright/Copyright.js +++ /dev/null @@ -1,21 +0,0 @@ -import React, { useContext } from 'react'; -import Link from '@mui/material/Link'; -import Typography from '@mui/material/Typography'; -import { AppContext } from '@context/App.context'; - -const Copyright = () => { - const { title } = useContext(AppContext); - return ( - - {'Copyright ยฉ '} - - {title} - - {' '} - {new Date().getFullYear()} - . - - ); -}; - -export default Copyright; diff --git a/src/components/Copyright/Copyright.tsx b/src/components/Copyright/Copyright.tsx new file mode 100644 index 000000000..6dcd2fab3 --- /dev/null +++ b/src/components/Copyright/Copyright.tsx @@ -0,0 +1,14 @@ +import { env } from '../../utils/env' +import './Copyright.css' + +export const Copyright = () => { + const currentYear = new Date().getFullYear() + + return ( +
+
+

ยฉ {currentYear} {env.APP_NAME}. All rights reserved.

+
+
+ ) +} \ No newline at end of file diff --git a/src/components/DataTableWrapper/DataTableWrapper.js b/src/components/DataTableWrapper/DataTableWrapper.js deleted file mode 100644 index f23511153..000000000 --- a/src/components/DataTableWrapper/DataTableWrapper.js +++ /dev/null @@ -1,205 +0,0 @@ -import React from 'react'; -import MUIDataTable from 'mui-datatables'; -import { - Grid, - Button, - IconButton, - Box, - Typography, -} from '@mui/material'; -import { - Add as AddIcon, - Edit as EditIcon, - Delete as DeleteIcon, -} from '@mui/icons-material'; -import Loader from '../Loader/Loader'; -import ConfirmModal from '../Modal/ConfirmModal'; -import { getUser } from '@context/User.context'; -import { hasAdminRights, hasGlobalAdminRights } from '@utils/permissions'; -import './DataTableWrapperStyles.css'; - -const DataTableWrapper = ({ - loading, - rows, - columns, - filename, - addButtonHeading, - onAddButtonClick, - children, - editAction, - deleteAction, - openDeleteModal, - setDeleteModal, - handleDeleteModal, - deleteModalTitle, - tableHeight, - tableHeader, - hideAddButton, - selectable, - selected, - customSort, - customTheme, - noSpace, - noOptionsIcon, - centerLabel, - extraOptions, -}) => { - const user = getUser(); - const isAdmin = hasAdminRights(user) || hasGlobalAdminRights(user); - - let finalColumns = []; - if (editAction && isAdmin) { - finalColumns = [ - ...finalColumns, - { - name: 'Edit', - options: { - filter: false, - sort: false, - empty: true, - setCellHeaderProps: () => ({ style: { textAlign: centerLabel ? 'center' : 'start' } }), - customBodyRenderLite: (dataIndex) => ( - editAction(rows[dataIndex])} - > - - - ), - }, - }, - ]; - } - if (deleteAction && isAdmin) { - finalColumns = [ - ...finalColumns, - { - name: 'Delete', - options: { - filter: false, - sort: false, - empty: true, - customBodyRenderLite: (dataIndex) => ( - deleteAction(rows[dataIndex])} - > - - - ), - }, - }, - ]; - } - finalColumns = [ - ...finalColumns, - ...columns, - ]; - - const options = { - download: !noOptionsIcon, - print: !noOptionsIcon, - search: !noOptionsIcon, - viewColumns: !noOptionsIcon, - filter: !noOptionsIcon, - filterType: 'multiselect', - responsive: 'standard', - pagination: true, - jumpToPage: true, - tableBodyHeight: tableHeight || '', - selectableRows: selectable && selectable.rows - ? selectable.rows - : 'none', - selectToolbarPlacement: 'none', - selectableRowsHeader: selectable && selectable.rowsHeader - ? selectable.rowsHeader - : true, - selectableRowsHideCheckboxes: selectable && selectable.rowsHideCheckboxes - ? selectable.rowsHideCheckboxes - : false, - rowsSelected: selected || [], - rowsPerPageOptions: [5, 10, 15], - downloadOptions: noOptionsIcon - ? { - filename: 'nothing.csv', - separator: ',', - filterOptions: { - useDisplayedColumnsOnly: true, - }, - } - : { - filename: `${filename}.csv`, - separator: ',', - filterOptions: { - useDisplayedColumnsOnly: true, - }, - }, - textLabels: { - body: { - noMatch: 'No data to display', - }, - pagination: { - jumpToPage: 'Go To Page', - }, - }, - setRowProps: (row, dataIndex, rowIndex) => !customTheme && ({ - className: 'dataTableBody', - }), - customSort, - ...extraOptions, - }; - - return ( - - {loading && } -
- {!hideAddButton && isAdmin && ( - - - - )} - {tableHeader && ( - - {tableHeader} - - )} - - - - - - {children} -
- {deleteAction && isAdmin && ( - - )} -
- ); -}; - -export default DataTableWrapper; diff --git a/src/components/DataTableWrapper/DataTableWrapperStyles.css b/src/components/DataTableWrapper/DataTableWrapperStyles.css deleted file mode 100644 index 047e6d187..000000000 --- a/src/components/DataTableWrapper/DataTableWrapperStyles.css +++ /dev/null @@ -1,33 +0,0 @@ -.dataTableDashboardHeading { - font-weight: bold; - margin-bottom: 0.5em; -} - -.dataTableIconButton { - padding: 0; - color: var(--color-palette-primary-dark); -} - -.dataTableIconButton:hover { - background-color: var(--color-palette-primary-light); -} - -.dataTableBody:hover { - background-color: none; -} - -.dataTable .MuiPaper-root > .MuiToolbar-root { - background-color: var(--color-palette-primary-dark); -} - -.dataTable .MuiPaper-root > .MuiToolbar-root .MuiSvgIcon-root { - fill: var(--color-palette-background-default); -} - -.dataTable tr > th { - background-color: var(--color-palette-primary-light); -} - -.dataTable .MuiTableFooter-root { - background-color: var(--color-palette-primary-light); -} diff --git a/src/components/EditUserModal/EditUserModal.css b/src/components/EditUserModal/EditUserModal.css new file mode 100644 index 000000000..1dddafb05 --- /dev/null +++ b/src/components/EditUserModal/EditUserModal.css @@ -0,0 +1,375 @@ +/* Modal Overlay */ +.edit-user-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(8px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 20px; + animation: fadeIn 0.3s ease; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +/* Modal Container */ +.edit-user-modal { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 20px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + width: 100%; + max-width: 500px; + max-height: 90vh; + overflow: hidden; + animation: slideUp 0.3s ease; +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(50px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Modal Header */ +.edit-user-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 24px 28px; + border-bottom: 1px solid var(--color-border); + background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-secondary) 100%); + color: white; +} + +.edit-user-modal-header h2 { + margin: 0; + font-size: 20px; + font-weight: 600; +} + +.edit-user-modal-close { + background: none; + border: none; + color: white; + cursor: pointer; + padding: 8px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; +} + +.edit-user-modal-close:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.1); + transform: scale(1.05); +} + +.edit-user-modal-close:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Modal Body */ +.edit-user-modal-body { + padding: 28px; + max-height: 60vh; + overflow-y: auto; +} + +/* User Info Section */ +.user-info-section { + margin-bottom: 24px; + padding-bottom: 20px; + border-bottom: 1px solid var(--color-border); + display: flex; + align-items: flex-start; + justify-content: space-between; +} + +.user-info h3 { + margin: 0 0 4px 0; + font-size: 18px; + font-weight: 600; + color: var(--color-text-primary); +} + +.user-email { + margin: 0 0 2px 0; + font-size: 14px; + color: var(--color-text-secondary); +} + +.user-username { + margin: 0; + font-size: 13px; + color: var(--color-text-secondary); + font-weight: 500; +} + +.user-status-toggle { + display: flex; + align-items: center; + margin-top: 4px; +} + +.user-status-toggle .toggle-container { + display: flex; + align-items: center; + gap: 8px; +} + +.user-status-toggle .toggle-label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + user-select: none; +} + +.user-status-toggle .toggle-text { + font-size: 13px; + font-weight: 600; + color: var(--color-text-primary); + min-width: 50px; +} + +/* Form Section */ +.form-section { + display: flex; + flex-direction: column; + gap: 20px; +} + +.form-field { + display: flex; + flex-direction: column; +} + +.form-field label { + font-size: 14px; + font-weight: 600; + color: var(--color-text-primary); + margin-bottom: 8px; +} + +.form-select { + width: 100%; + padding: 14px 16px; + border: 2px solid var(--color-border); + border-radius: 12px; + font-size: 15px; + font-weight: 500; + font-family: inherit; + background: var(--color-surface); + color: var(--color-text-primary); + transition: all 0.3s ease; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + box-sizing: border-box; + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e"); + background-position: right 12px center; + background-repeat: no-repeat; + background-size: 16px; + padding-right: 40px; +} + +.form-select:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px rgba(27, 95, 163, 0.1), 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.form-select:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.form-select option { + padding: 8px; + background: var(--color-surface); + color: var(--color-text-primary); +} + +/* Toggle Switch */ +.toggle-field { + display: flex; + flex-direction: column; + gap: 8px; +} + +.toggle-container { + display: flex; + align-items: center; + gap: 12px; +} + +.toggle-input { + display: none; +} + +.toggle-label { + display: flex; + align-items: center; + gap: 12px; + cursor: pointer; + user-select: none; +} + +.toggle-switch { + position: relative; + width: 50px; + height: 26px; + background: var(--color-border); + border-radius: 13px; + transition: all 0.3s ease; + display: flex; + align-items: center; +} + +.toggle-switch::before { + content: ''; + position: absolute; + width: 20px; + height: 20px; + border-radius: 50%; + background: white; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + transition: all 0.3s ease; + left: 3px; +} + +.toggle-input:checked + .toggle-label .toggle-switch { + background: var(--color-primary); +} + +.toggle-input:checked + .toggle-label .toggle-switch::before { + transform: translateX(24px); +} + +.toggle-input:disabled + .toggle-label { + opacity: 0.6; + cursor: not-allowed; +} + +.toggle-text { + font-size: 14px; + font-weight: 500; + color: var(--color-text-primary); + min-width: 60px; +} + +/* Modal Footer */ +.edit-user-modal-footer { + display: flex; + gap: 12px; + justify-content: flex-end; + padding: 20px 28px; + border-top: 1px solid var(--color-border); + background: var(--color-surface-secondary); +} + +.btn-secondary, +.btn-primary { + padding: 12px 24px; + border-radius: 10px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + border: none; + display: flex; + align-items: center; + justify-content: center; + min-width: 120px; +} + +.btn-secondary { + background: var(--color-surface); + color: var(--color-text-secondary); + border: 1px solid var(--color-border); +} + +.btn-secondary:hover:not(:disabled) { + background: var(--color-surface-secondary); + border-color: var(--color-primary); + color: var(--color-text-primary); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.btn-primary { + background: var(--color-primary); + color: white; + box-shadow: 0 4px 12px rgba(27, 95, 163, 0.3); +} + +.btn-primary:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(27, 95, 163, 0.4); +} + +.btn-primary:disabled, +.btn-secondary:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .edit-user-modal-overlay { + padding: 16px; + } + + .edit-user-modal { + max-width: 100%; + border-radius: 16px; + } + + .edit-user-modal-header, + .edit-user-modal-body, + .edit-user-modal-footer { + padding-left: 20px; + padding-right: 20px; + } + + .edit-user-modal-header { + padding-top: 20px; + padding-bottom: 20px; + } + + .edit-user-modal-body { + padding-top: 20px; + padding-bottom: 20px; + } + + .edit-user-modal-footer { + flex-direction: column; + gap: 8px; + } + + .btn-secondary, + .btn-primary { + width: 100%; + } +} \ No newline at end of file diff --git a/src/components/EditUserModal/EditUserModal.tsx b/src/components/EditUserModal/EditUserModal.tsx new file mode 100644 index 000000000..b3cd19bf2 --- /dev/null +++ b/src/components/EditUserModal/EditUserModal.tsx @@ -0,0 +1,269 @@ +import { useState, useEffect, useMemo } from 'react' +import type { User, Organization, CoreGroup } from '../../api/users' +import { useAuthStore } from '../../stores/authStore' +import { getUserRole } from '../../utils/userRoles' +import './EditUserModal.css' + +interface EditUserModalProps { + isOpen: boolean + onClose: () => void + user: User | null + organizations: Organization[] + coreGroups: CoreGroup[] + onUpdate: (userData: Partial) => void + isLoading?: boolean +} + +export const EditUserModal = ({ + isOpen, + onClose, + user, + organizations, + coreGroups, + onUpdate, + isLoading = false +}: EditUserModalProps) => { + const { user: currentUser } = useAuthStore() + const currentUserRole = currentUser ? getUserRole(currentUser) : 'user' + const isGlobalAdmin = currentUserRole === 'global-admin' + + const [formData, setFormData] = useState({ + organization: '', + is_active: true, + access_level: '' + }) + + const [hasChanges, setHasChanges] = useState(false) + + // Initialize form data when user changes + useEffect(() => { + if (user && isOpen) { + const initialData = { + organization: user.organization.organization_uuid || '', + is_active: user.is_active, + access_level: user.core_groups[0]?.uuid || '' + } + setFormData(initialData) + setHasChanges(false) + } + }, [user, isOpen]) + + // Check for changes + useEffect(() => { + if (!user) return + + const originalData = { + organization: user.organization.organization_uuid || '', + is_active: user.is_active, + access_level: user.core_groups[0]?.uuid || '' + } + + const hasChanged = + formData.organization !== originalData.organization || + formData.is_active !== originalData.is_active || + formData.access_level !== originalData.access_level + + setHasChanges(hasChanged) + }, [formData, user]) + + const handleInputChange = (field: string, value: any) => { + setFormData(prev => { + const newData = { + ...prev, + [field]: value + } + + // If organization changes, reset access level + if (field === 'organization') { + newData.access_level = '' + } + + return newData + }) + } + + // Filter core groups based on selected organization or current user's organization + const filteredCoreGroups = useMemo(() => { + let targetOrganization = formData.organization + + // If user is not Global Admin, use the current user's organization + if (!isGlobalAdmin && currentUser) { + targetOrganization = currentUser.organization.organization_uuid + } + + if (!targetOrganization) { + return coreGroups.filter(group => group.is_global) + } + + return coreGroups.filter(group => { + // Include global groups + if (group.is_global) return true + + // Include groups that match the target organization + if (group.organization) { + // If group.organization is an object with organization_uuid + if (group.organization.organization_uuid) { + return group.organization.organization_uuid === targetOrganization + } + // If group.organization is just an ID/UUID reference + return group.organization === targetOrganization || + group.organization.toString() === targetOrganization + } + + return false + }) + }, [coreGroups, formData.organization, isGlobalAdmin, currentUser]) + + const handleUpdate = () => { + if (!hasChanges || !user) return + + // Start with complete user data + const updateData: any = { + ...user, + // Override with any changes + } + + if (formData.organization !== user.organization.organization_uuid) { + // Find and include the entire organization object + const selectedOrg = organizations.find(org => org.organization_uuid === formData.organization) + updateData.organization = selectedOrg || user.organization + } + + if (formData.is_active !== user.is_active) { + updateData.is_active = formData.is_active + } + + if (formData.access_level !== (user.core_groups[0]?.uuid || '')) { + // Find the selected core group and replace core_groups array + const selectedCoreGroup = coreGroups.find(group => group.uuid === formData.access_level) + if (selectedCoreGroup) { + updateData.core_groups = [selectedCoreGroup] + } + } + + onUpdate(updateData) + } + + // Reset form when modal closes + useEffect(() => { + if (!isOpen) { + setFormData({ + organization: '', + is_active: true, + access_level: '' + }) + setHasChanges(false) + } + }, [isOpen]) + + if (!isOpen || !user) return null + + return ( +
+
e.stopPropagation()}> +
+

Edit User

+ +
+ +
+ {/* User Info Display */} +
+
+

{user.first_name} {user.last_name}

+

{user.email}

+

@{user.username}

+
+
+
+ handleInputChange('is_active', e.target.checked)} + disabled={isLoading} + className="toggle-input" + /> + +
+
+
+ + {/* Form Fields */} +
+ {/* Organization Select - Only visible to Global Admin */} + {isGlobalAdmin && ( +
+ + +
+ )} + + {/* Access Level Select */} +
+ + +
+
+
+ +
+ + +
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/GlobalLoader/GlobalLoader.tsx b/src/components/GlobalLoader/GlobalLoader.tsx new file mode 100644 index 000000000..8f91e4f88 --- /dev/null +++ b/src/components/GlobalLoader/GlobalLoader.tsx @@ -0,0 +1,15 @@ +import { useLoader } from '../../hooks/useLoader' +import { Loader } from '../Loader/Loader' + +export const GlobalLoader = () => { + const { isLoading, message } = useLoader() + + return ( + + ) +} \ No newline at end of file diff --git a/src/components/GlobalNotification/GlobalNotification.css b/src/components/GlobalNotification/GlobalNotification.css new file mode 100644 index 000000000..ed7427d36 --- /dev/null +++ b/src/components/GlobalNotification/GlobalNotification.css @@ -0,0 +1,61 @@ +/* Global notification container positioned at top-right */ + +.global-notification-container { + position: fixed; + top: 20px; + right: 20px; + z-index: 10000; + pointer-events: none; + max-height: calc(100vh - 40px); + overflow-y: auto; + display: flex; + flex-direction: column; +} + +.global-notification-container > * { + pointer-events: all; +} + +/* Mobile positioning */ +@media (max-width: 640px) { + .global-notification-container { + top: 10px; + right: 10px; + left: 10px; + max-height: calc(100vh - 20px); + } +} + +/* Ensure notifications appear above everything */ +.global-notification-container { + z-index: 10001; +} + +/* Scrollbar styling for notification container */ +.global-notification-container::-webkit-scrollbar { + width: 6px; +} + +.global-notification-container::-webkit-scrollbar-track { + background: transparent; +} + +.global-notification-container::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.2); + border-radius: 3px; +} + +.global-notification-container::-webkit-scrollbar-thumb:hover { + background: rgba(0, 0, 0, 0.3); +} + +/* Dark theme scrollbar */ +@media (prefers-color-scheme: dark) { + .global-notification-container::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + } + + .global-notification-container::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.3); + } +} \ No newline at end of file diff --git a/src/components/GlobalNotification/GlobalNotification.tsx b/src/components/GlobalNotification/GlobalNotification.tsx new file mode 100644 index 000000000..863ab9d40 --- /dev/null +++ b/src/components/GlobalNotification/GlobalNotification.tsx @@ -0,0 +1,28 @@ +import { useNotification } from '../../hooks/useNotification' +import { Notification } from '../Notification/Notification' +import './GlobalNotification.css' + +export const GlobalNotification = () => { + const { notifications, removeNotification } = useNotification() + + if (notifications.length === 0) return null + + return ( +
+ {notifications.map((notification) => ( + + ))} +
+ ) +} \ No newline at end of file diff --git a/src/components/InviteUsersModal/InviteUsersModal.css b/src/components/InviteUsersModal/InviteUsersModal.css new file mode 100644 index 000000000..7560eba13 --- /dev/null +++ b/src/components/InviteUsersModal/InviteUsersModal.css @@ -0,0 +1,336 @@ +/* Modal Overlay */ +.invite-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(8px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 20px; + animation: fadeIn 0.3s ease; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +/* Modal Container */ +.invite-modal { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 20px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + width: 100%; + max-width: 600px; + max-height: 90vh; + overflow: hidden; + animation: slideUp 0.3s ease; +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(50px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Modal Header */ +.invite-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 24px 28px; + border-bottom: 1px solid var(--color-border); + background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-secondary) 100%); + color: white; +} + +.invite-modal-header h2 { + margin: 0; + font-size: 20px; + font-weight: 600; +} + +.invite-modal-close { + background: none; + border: none; + color: white; + cursor: pointer; + padding: 8px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; +} + +.invite-modal-close:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.1); + transform: scale(1.05); +} + +.invite-modal-close:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Modal Body */ +.invite-modal-body { + padding: 28px; + max-height: 60vh; + overflow-y: auto; +} + +/* Email Input Section */ +.email-input-section { + margin-bottom: 24px; +} + +.email-input-section label { + display: block; + font-size: 14px; + font-weight: 600; + color: var(--color-text-primary); + margin-bottom: 8px; +} + +.email-input { + width: 100%; + min-height: 100px; + padding: 14px 16px; + border: 2px solid var(--color-border); + border-radius: 12px; + font-size: 14px; + font-family: inherit; + background: var(--color-surface); + color: var(--color-text-primary); + resize: vertical; + transition: all 0.3s ease; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + box-sizing: border-box; +} + +.email-input:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px rgba(27, 95, 163, 0.1), 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.email-input:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.email-input::placeholder { + color: var(--color-text-secondary); + line-height: 1.4; +} + +.input-hint { + margin: 8px 0 0 0; + font-size: 12px; + color: var(--color-text-secondary); +} + +/* Email Chips Section */ +.email-chips-section, +.email-errors-section { + margin-bottom: 20px; +} + +.email-chips-section label, +.email-errors-section label { + display: block; + font-size: 13px; + font-weight: 600; + color: var(--color-text-primary); + margin-bottom: 12px; +} + +.error-label { + color: var(--color-error) !important; +} + +.email-chips, +.email-errors { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.email-chip { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border-radius: 20px; + font-size: 13px; + font-weight: 500; + transition: all 0.2s ease; + animation: chipIn 0.3s ease; +} + +@keyframes chipIn { + from { + opacity: 0; + transform: scale(0.8); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.email-chip.valid { + background: rgba(34, 197, 94, 0.1); + color: var(--color-success); + border: 1px solid rgba(34, 197, 94, 0.2); +} + +.email-chip.invalid { + background: rgba(239, 68, 68, 0.1); + color: var(--color-error); + border: 1px solid rgba(239, 68, 68, 0.2); +} + +.remove-chip { + background: none; + border: none; + color: inherit; + cursor: pointer; + padding: 2px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; +} + +.remove-chip:hover:not(:disabled) { + background: rgba(0, 0, 0, 0.1); + transform: scale(1.1); +} + +.remove-chip:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Modal Footer */ +.invite-modal-footer { + display: flex; + gap: 12px; + justify-content: flex-end; + padding: 20px 28px; + border-top: 1px solid var(--color-border); + background: var(--color-surface-secondary); +} + +.btn-secondary, +.btn-primary { + padding: 12px 24px; + border-radius: 10px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + border: none; + display: flex; + align-items: center; + justify-content: center; + min-width: 120px; +} + +.btn-secondary { + background: var(--color-surface); + color: var(--color-text-secondary); + border: 1px solid var(--color-border); +} + +.btn-secondary:hover:not(:disabled) { + background: var(--color-surface-secondary); + border-color: var(--color-primary); + color: var(--color-text-primary); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.btn-primary { + background: var(--color-primary); + color: white; + box-shadow: 0 4px 12px rgba(27, 95, 163, 0.3); +} + +.btn-primary:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(27, 95, 163, 0.4); +} + +.btn-primary:disabled, +.btn-secondary:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .invite-modal-overlay { + padding: 16px; + } + + .invite-modal { + max-width: 100%; + border-radius: 16px; + } + + .invite-modal-header, + .invite-modal-body, + .invite-modal-footer { + padding-left: 20px; + padding-right: 20px; + } + + .invite-modal-header { + padding-top: 20px; + padding-bottom: 20px; + } + + .invite-modal-body { + padding-top: 20px; + padding-bottom: 20px; + } + + .invite-modal-footer { + flex-direction: column; + gap: 8px; + } + + .btn-secondary, + .btn-primary { + width: 100%; + } + + .email-chips, + .email-errors { + gap: 6px; + } + + .email-chip { + font-size: 12px; + } +} \ No newline at end of file diff --git a/src/components/InviteUsersModal/InviteUsersModal.tsx b/src/components/InviteUsersModal/InviteUsersModal.tsx new file mode 100644 index 000000000..7ab4058cd --- /dev/null +++ b/src/components/InviteUsersModal/InviteUsersModal.tsx @@ -0,0 +1,186 @@ +import { useState, useEffect } from 'react' +import './InviteUsersModal.css' + +interface InviteUsersModalProps { + isOpen: boolean + onClose: () => void + onInvite: (emails: string[]) => void + isLoading?: boolean +} + +export const InviteUsersModal = ({ isOpen, onClose, onInvite, isLoading = false }: InviteUsersModalProps) => { + const [emailInput, setEmailInput] = useState('') + const [validEmails, setValidEmails] = useState([]) + const [errors, setErrors] = useState([]) + + // Email validation function + const isValidEmail = (email: string): boolean => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + return emailRegex.test(email.trim()) + } + + // Process email input + const processEmailInput = (input: string) => { + const emails = input + .split(/[,\n]/) + .map(email => email.trim()) + .filter(email => email.length > 0) + + const valid: string[] = [] + const invalid: string[] = [] + + emails.forEach(email => { + if (isValidEmail(email)) { + if (!valid.includes(email)) { + valid.push(email) + } + } else if (email.length > 0) { + invalid.push(email) + } + }) + + setValidEmails(valid) + setErrors(invalid) + } + + // Handle input change + const handleInputChange = (e: React.ChangeEvent) => { + const value = e.target.value + setEmailInput(value) + processEmailInput(value) + } + + // Handle key press for adding emails + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + processEmailInput(emailInput) + } + } + + // Remove email chip + const removeEmail = (emailToRemove: string) => { + const updatedEmails = validEmails.filter(email => email !== emailToRemove) + setValidEmails(updatedEmails) + + // Update input to reflect removed email + const remainingEmails = emailInput + .split(/[,\n]/) + .map(email => email.trim()) + .filter(email => email !== emailToRemove && email.length > 0) + + setEmailInput(remainingEmails.join(', ')) + } + + // Handle invite + const handleInvite = () => { + if (validEmails.length > 0) { + onInvite(validEmails) + } + } + + // Reset modal state when closed + useEffect(() => { + if (!isOpen) { + setEmailInput('') + setValidEmails([]) + setErrors([]) + } + }, [isOpen]) + + if (!isOpen) return null + + return ( +
+
e.stopPropagation()}> +
+

Invite Users

+ +
+ +
+
+ +