From 8bf8dc55dd7a4da7b6be442479eb8944ebb22828 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 24 Apr 2026 17:29:06 +0000 Subject: [PATCH] Add user profile panel with self-service password change and dark theme UserMenu --- .kiro/specs/user-profile/.config.kiro | 1 + .kiro/specs/user-profile/design.md | 361 +++++++++ .kiro/specs/user-profile/requirements.md | 87 ++ .kiro/specs/user-profile/tasks.md | 119 +++ .../auth-password-change.property.test.js | 48 ++ ...auth-profile-completeness.property.test.js | 84 ++ .../auth-short-password.property.test.js | 39 + .../auth-wrong-password.property.test.js | 53 ++ backend/routes/auth.js | 131 +++ ...lePanel.password-mismatch.property.test.js | 101 +++ .../UserProfilePanel.property.test.js | 153 ++++ ...ofilePanel.short-password.property.test.js | 97 +++ frontend/src/components/UserMenu.js | 250 +++++- frontend/src/components/UserProfilePanel.js | 754 ++++++++++++++++++ 14 files changed, 2244 insertions(+), 34 deletions(-) create mode 100644 .kiro/specs/user-profile/.config.kiro create mode 100644 .kiro/specs/user-profile/design.md create mode 100644 .kiro/specs/user-profile/requirements.md create mode 100644 .kiro/specs/user-profile/tasks.md create mode 100644 backend/__tests__/auth-password-change.property.test.js create mode 100644 backend/__tests__/auth-profile-completeness.property.test.js create mode 100644 backend/__tests__/auth-short-password.property.test.js create mode 100644 backend/__tests__/auth-wrong-password.property.test.js create mode 100644 frontend/src/__tests__/UserProfilePanel.password-mismatch.property.test.js create mode 100644 frontend/src/__tests__/UserProfilePanel.property.test.js create mode 100644 frontend/src/__tests__/UserProfilePanel.short-password.property.test.js create mode 100644 frontend/src/components/UserProfilePanel.js diff --git a/.kiro/specs/user-profile/.config.kiro b/.kiro/specs/user-profile/.config.kiro new file mode 100644 index 0000000..48b4988 --- /dev/null +++ b/.kiro/specs/user-profile/.config.kiro @@ -0,0 +1 @@ +{"specId": "74f6201d-ed0f-4df3-86a2-4a0767dd497c", "workflowType": "requirements-first", "specType": "feature"} \ No newline at end of file diff --git a/.kiro/specs/user-profile/design.md b/.kiro/specs/user-profile/design.md new file mode 100644 index 0000000..dd102ef --- /dev/null +++ b/.kiro/specs/user-profile/design.md @@ -0,0 +1,361 @@ +# Design Document: User Profile + +## Overview + +This feature adds a self-service user profile to the STEAM Security Dashboard. It introduces three capabilities: a profile panel accessible from the UserMenu dropdown (displaying account details and a password change form), a dedicated backend API endpoint for fetching profile data, and visual fixes to the UserMenu component to match the dark dashboard theme. + +The scope is intentionally narrow — no new database tables or migrations are required. The existing `users` table already stores all needed fields (`username`, `email`, `user_group`, `created_at`, `last_login`). The backend adds two new routes to the existing auth router. The frontend adds one new component (`UserProfilePanel`) and modifies the existing `UserMenu` component for theming and profile access. + +### Key Design Decisions + +1. **Profile endpoint on the auth router** — The profile data is user-scoped and session-authenticated, so it belongs alongside `/api/auth/me` rather than on the admin-only `/api/users` router. This avoids granting non-admin users access to the users management routes. + +2. **Password change on the auth router** — Self-service password change is an auth concern, not a user-management concern. Placing it at `POST /api/auth/change-password` keeps it separate from the admin `PATCH /api/users/:id` endpoint that already supports admin-initiated password resets. + +3. **Rate limiting via express-rate-limit** — The project already uses `express-rate-limit` for login throttling. The password change endpoint reuses the same library with a tighter limit (5 attempts per 15 minutes) scoped per session cookie using a custom `keyGenerator`. + +4. **No new database tables or migrations** — All required data already exists in the `users` table. The `created_at` and `last_login` columns are present in the schema. No schema changes are needed. + +5. **Modal-based profile panel** — The profile panel is implemented as a modal overlay (consistent with existing modals like NvdSyncModal, UserManagement) rather than a separate page, since the app uses no client-side router. + +6. **Inline style objects for theming** — Consistent with the project's existing pattern where components define style constants as JavaScript objects. The UserMenu theming fix converts Tailwind utility classes to inline styles matching the design system. + +--- + +## Architecture + +```mermaid +sequenceDiagram + participant U as User + participant UM as UserMenu + participant UPP as UserProfilePanel + participant API as Auth API + participant DB as SQLite + + U->>UM: Clicks "My Profile" + UM->>UPP: Opens modal (showProfile=true) + UPP->>API: GET /api/auth/profile + API->>DB: SELECT user by session + DB-->>API: User row + API-->>UPP: { id, username, email, group, created_at, last_login } + UPP-->>U: Displays profile data + + U->>UPP: Submits password change form + UPP->>UPP: Client-side validation (match, length) + UPP->>API: POST /api/auth/change-password + API->>API: Rate limit check (5/15min) + API->>DB: SELECT password_hash WHERE id = ? + DB-->>API: password_hash + API->>API: bcrypt.compare(currentPassword, hash) + API->>API: bcrypt.hash(newPassword, 10) + API->>DB: UPDATE users SET password_hash = ? + API->>DB: INSERT audit_logs (password_change) + API-->>UPP: { message: 'Password changed successfully' } + UPP-->>U: Success message, form cleared +``` + +### Component Hierarchy + +``` +App.js +├── UserMenu.js (modified — dark theme, "My Profile" option) +│ └── UserProfilePanel.js (new — modal component) +│ ├── Profile Info Section +│ └── Password Change Form +``` + +### Backend Route Structure + +``` +/api/auth/ +├── POST /login (existing) +├── POST /logout (existing) +├── GET /me (existing) +├── GET /profile (new — full profile data) +├── POST /change-password (new — self-service password change) +└── POST /cleanup-sessions (existing) +``` + +--- + +## Components and Interfaces + +### Backend: New Routes in `routes/auth.js` + +#### `GET /api/auth/profile` + +Returns the full profile for the authenticated user. + +| Aspect | Detail | +|--------|--------| +| Auth | `requireAuth(db)` | +| Query | `SELECT id, username, email, user_group, created_at, last_login FROM users WHERE id = ? AND is_active = 1` | +| Success | `200 { id, username, email, group, created_at, last_login }` | +| Inactive account | `401 { error }` + clear session cookie | +| No session | `401 { error: 'Authentication required' }` | + +#### `POST /api/auth/change-password` + +Allows the authenticated user to change their own password. + +| Aspect | Detail | +|--------|--------| +| Auth | `requireAuth(db)` | +| Rate limit | 5 requests per 15 minutes, keyed by `req.cookies.session_id` | +| Body | `{ currentPassword: string, newPassword: string }` | +| Validation | `newPassword.length >= 8` (server-side) | +| Flow | 1. Verify account active 2. `bcrypt.compare` current password 3. `bcrypt.hash` new password (cost 10) 4. `UPDATE users SET password_hash` 5. `logAudit` with action `password_change` | +| Success | `200 { message: 'Password changed successfully' }` | +| Wrong password | `401 { error: 'Current password is incorrect' }` | +| Too short | `400 { error: 'New password must be at least 8 characters' }` | +| Rate limited | `429 { error: 'Too many password change attempts. Please try again later.' }` | +| Inactive | `401 { error: 'Account is disabled' }` | + +### Frontend: New Component `UserProfilePanel.js` + +A modal component rendered when the user clicks "My Profile" in the UserMenu dropdown. + +**Props:** + +| Prop | Type | Description | +|------|------|-------------| +| `isOpen` | `boolean` | Controls modal visibility | +| `onClose` | `function` | Callback to close the modal | + +**Internal State:** + +| State | Type | Purpose | +|-------|------|---------| +| `profile` | `object \| null` | Profile data from API | +| `loading` | `boolean` | Loading state for profile fetch | +| `error` | `string \| null` | Error message from profile fetch | +| `currentPassword` | `string` | Current password field | +| `newPassword` | `string` | New password field | +| `confirmPassword` | `string` | Confirm password field | +| `changeLoading` | `boolean` | Loading state for password change | +| `changeError` | `string \| null` | Error from password change API | +| `changeSuccess` | `string \| null` | Success message after password change | + +**Behavior:** +- On open (`isOpen` transitions to `true`), fetches `GET /api/auth/profile` +- Displays profile fields in a read-only info section +- Password change form validates client-side before submitting: + - New password and confirm must match + - New password must be >= 8 characters +- On successful password change, shows success message and clears form fields +- Click-outside or X button closes the modal +- Uses design system dark theme styling (intel-card background, accent borders, light text) + +### Frontend: Modified Component `UserMenu.js` + +**Changes:** +1. Add "My Profile" menu item with `User` icon between the dropdown header and admin actions +2. Convert all Tailwind light-theme classes to inline dark-theme styles: + - Button: `hover:bg-gray-100` → `hover: rgba(14, 165, 233, 0.1)` + - Username text: `text-gray-900` → `color: var(--text-primary)` / `#F8FAFC` + - Group text: `text-gray-500` → `color: var(--text-secondary)` / `#E2E8F0` + - Chevron: `text-gray-500` → `color: var(--text-secondary)` / `#E2E8F0` + - Dropdown panel: `bg-white` → intel-card gradient background + - Dropdown border: `border-gray-200` → `rgba(14, 165, 233, 0.3)` + - Menu items: `text-gray-700` → `color: var(--text-primary)` / `#F8FAFC` + - Menu hover: `hover:bg-gray-50` → `rgba(14, 165, 233, 0.1)` + - Sign out: `text-red-600` → `#F87171` (design system danger text) +3. Add state and handler for profile panel visibility +4. Render `UserProfilePanel` component + +--- + +## Data Models + +No new database tables or migrations are required. The feature reads from the existing `users` table: + +```sql +-- Existing users table (relevant columns) +CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username VARCHAR(50) UNIQUE NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + role VARCHAR(20) NOT NULL DEFAULT 'viewer', + user_group VARCHAR(20) NOT NULL DEFAULT 'Read_Only', + is_active BOOLEAN DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_login TIMESTAMP +); +``` + +### API Response Shapes + +**GET /api/auth/profile response:** +```json +{ + "id": 1, + "username": "admin", + "email": "admin@localhost", + "group": "Admin", + "created_at": "2026-01-15 10:30:00", + "last_login": "2026-07-20 14:22:00" +} +``` + +**POST /api/auth/change-password request:** +```json +{ + "currentPassword": "oldpass123", + "newPassword": "newpass456" +} +``` + +**POST /api/auth/change-password success response:** +```json +{ + "message": "Password changed successfully" +} +``` + +**Audit log entry for password change:** +```json +{ + "userId": 1, + "username": "admin", + "action": "password_change", + "entityType": "auth", + "entityId": null, + "details": null, + "ipAddress": "::1" +} +``` + + +--- + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +### Property 1: Profile panel displays all required fields + +*For any* valid profile object (with arbitrary username, email, group, created_at, and last_login values), rendering the UserProfilePanel with that data SHALL result in all five field values being present in the rendered output. + +**Validates: Requirements 1.2** + +### Property 2: Profile API returns complete user data matching database + +*For any* active user record in the database, a GET request to `/api/auth/profile` with that user's valid session SHALL return an object containing `id`, `username`, `email`, `group`, `created_at`, and `last_login` fields, where each value matches the corresponding column in the `users` table. + +**Validates: Requirements 4.1** + +### Property 3: Password change round-trip + +*For any* valid current password and *any* new password of 8 or more characters, after a successful `POST /api/auth/change-password`, the stored `password_hash` in the database SHALL be a valid bcrypt hash and `bcrypt.compare(newPassword, storedHash)` SHALL return `true`. + +**Validates: Requirements 2.2, 2.7** + +### Property 4: Incorrect current password is always rejected + +*For any* password string that does not match the user's current password, submitting it as `currentPassword` to `POST /api/auth/change-password` SHALL return HTTP 401 and the user's stored `password_hash` SHALL remain unchanged. + +**Validates: Requirements 2.3** + +### Property 5: Mismatched password confirmation is rejected client-side + +*For any* two distinct strings used as `newPassword` and `confirmPassword` in the Password_Change_Form, the form SHALL display a validation error and SHALL NOT submit a request to the Auth_API. + +**Validates: Requirements 2.4** + +### Property 6: Short passwords are rejected at both client and server + +*For any* string of length 0 through 7, the Password_Change_Form SHALL display a minimum-length validation error (client-side), and `POST /api/auth/change-password` SHALL return HTTP 400 (server-side). In both cases, the user's stored `password_hash` SHALL remain unchanged. + +**Validates: Requirements 2.5, 5.4** + +--- + +## Error Handling + +### Backend Errors + +| Scenario | HTTP Status | Response | Behavior | +|----------|-------------|----------|----------| +| No session cookie on `/profile` | 401 | `{ error: 'Authentication required' }` | Handled by `requireAuth` middleware | +| Expired session on `/profile` | 401 | `{ error: 'Session expired or invalid' }` | Handled by `requireAuth` middleware | +| Deactivated account on `/profile` | 401 | `{ error: 'Account is disabled' }` | Clear session cookie, return 401 | +| No session on `/change-password` | 401 | `{ error: 'Authentication required' }` | Handled by `requireAuth` middleware | +| Rate limit exceeded on `/change-password` | 429 | `{ error: 'Too many password change attempts. Please try again later.' }` | `express-rate-limit` middleware | +| Missing `currentPassword` or `newPassword` | 400 | `{ error: 'Current password and new password are required' }` | Early return validation | +| New password < 8 characters | 400 | `{ error: 'New password must be at least 8 characters' }` | Early return validation | +| Incorrect current password | 401 | `{ error: 'Current password is incorrect' }` | After `bcrypt.compare` fails | +| Deactivated account on `/change-password` | 401 | `{ error: 'Account is disabled' }` | Check `is_active` before processing | +| Database error during profile fetch | 500 | `{ error: 'Failed to fetch profile' }` | Catch block, log to console | +| Database error during password update | 500 | `{ error: 'Failed to change password' }` | Catch block, log to console | + +### Frontend Error Handling + +| Scenario | Behavior | +|----------|----------| +| Profile fetch fails (network error) | Display error message in panel, offer retry | +| Profile fetch returns 401 | Redirect to login (session expired) | +| Password change returns 401 (wrong password) | Display "Current password is incorrect" in form | +| Password change returns 429 | Display "Too many attempts. Please try again later." in form | +| Password change returns 400 | Display server validation error message in form | +| Password change returns 500 | Display "An error occurred. Please try again." in form | +| Client-side validation failure (mismatch) | Display "Passwords do not match" below confirm field | +| Client-side validation failure (too short) | Display "Password must be at least 8 characters" below new password field | + +--- + +## Testing Strategy + +### Property-Based Tests + +This feature is suitable for property-based testing. The password change logic involves pure input/output behavior (password validation, hashing, comparison) with a large input space (arbitrary strings). The profile data retrieval has clear invariants (all fields present, values match database). + +**Library:** [fast-check](https://github.com/dubzzz/fast-check) — the standard PBT library for JavaScript. + +**Configuration:** +- Minimum 100 iterations per property test +- Each test tagged with: `Feature: user-profile, Property {number}: {property_text}` + +**Properties to implement:** +1. Profile panel renders all fields (Property 1) — generate random profile objects, render component, assert all values present +2. Profile API returns complete data (Property 2) — generate random user records, insert into test DB, fetch profile, assert field match +3. Password change round-trip (Property 3) — generate random valid passwords, change password, verify bcrypt.compare succeeds +4. Wrong password rejection (Property 4) — generate random wrong passwords, attempt change, verify 401 and hash unchanged +5. Mismatched confirmation rejection (Property 5) — generate pairs of distinct strings, verify client validation rejects +6. Short password rejection (Property 6) — generate strings of length 0-7, verify rejection at both client and server + +### Unit Tests (Example-Based) + +| Test | What it verifies | +|------|-----------------| +| "My Profile" click opens panel | Requirement 1.1 — UI interaction | +| Close button dismisses panel | Requirement 1.3 — close mechanism | +| Click-outside dismisses panel | Requirement 1.3 — close mechanism | +| Password form has three fields | Requirement 2.1 — form structure | +| Success message shown after change | Requirement 2.6 — success feedback | +| Form fields cleared after success | Requirement 2.6 — form reset | +| Unauthenticated profile request returns 401 | Requirement 4.2 — auth guard | +| Deactivated account profile request returns 401 | Requirement 4.3 — account check | +| Deactivated account password change rejected | Requirement 5.3 — account check | +| Rate limit triggers after 5 attempts | Requirements 5.1, 5.2 — rate limiting | + +### Integration Tests + +| Test | What it verifies | +|------|-----------------| +| Audit log entry created on password change | Requirement 2.8 — audit logging | +| Rate limiter resets after 15-minute window | Requirement 5.1 — rate limit window | + +### Manual Testing + +| Test | What it verifies | +|------|-----------------| +| Username visible on dark header without hover | Requirement 3.1 — WCAG AA contrast | +| Group label visible on dark header | Requirement 3.2 — contrast | +| Hover state uses dark-themed highlight | Requirement 3.3 — theming | +| Chevron icon uses light color | Requirement 3.4 — theming | +| Dropdown uses dark background | Requirement 6.1 — theming | +| Dropdown text uses light colors | Requirement 6.2 — theming | +| Dropdown hover uses accent highlight | Requirement 6.3 — theming | +| Dropdown border uses accent style | Requirement 6.4 — theming | +| Group badge retains color coding | Requirement 6.5 — theming | diff --git a/.kiro/specs/user-profile/requirements.md b/.kiro/specs/user-profile/requirements.md new file mode 100644 index 0000000..db99c5a --- /dev/null +++ b/.kiro/specs/user-profile/requirements.md @@ -0,0 +1,87 @@ +# Requirements Document + +## Introduction + +The STEAM Security Dashboard currently lacks a self-service user profile. Users cannot view their own account details or change their own password — only admins can reset passwords through the User Management panel. Additionally, the username text in the top-right UserMenu is rendered in black (`text-gray-900`) against the dark dashboard background, making it invisible until hovered. This feature adds a user profile panel accessible from the UserMenu, enables self-service password changes for all authenticated users, and fixes the username visibility issue in the header. + +## Glossary + +- **Dashboard**: The STEAM Security Dashboard application, consisting of a React 19 SPA frontend and a Node.js/Express backend with SQLite3 storage. +- **User_Profile_Panel**: A modal or slide-over panel that displays the authenticated user's account information and provides a password change form. +- **UserMenu**: The existing dropdown component (`UserMenu.js`) in the top-right corner of the header that shows the user icon, username, group badge, and navigation actions (Manage Users, Audit Log, Sign Out). +- **Password_Change_Form**: A form within the User_Profile_Panel that accepts the current password and a new password (with confirmation) to allow users to change their own credentials. +- **Auth_API**: The backend Express routes under `/api/auth` that handle login, logout, session validation, and (with this feature) self-service password changes. +- **Authenticated_User**: Any user with a valid, non-expired session cookie and an active account. +- **Header_Username_Display**: The text element in the UserMenu button that shows the current user's username and group label. + +## Requirements + +### Requirement 1: User Profile Panel Access + +**User Story:** As an authenticated user, I want to access a profile panel from the UserMenu dropdown, so that I can view my account details without needing admin assistance. + +#### Acceptance Criteria + +1. WHEN the Authenticated_User clicks the "My Profile" option in the UserMenu dropdown, THE Dashboard SHALL display the User_Profile_Panel. +2. THE User_Profile_Panel SHALL display the following account fields: username, email address, user group, account creation date, and last login timestamp. +3. WHEN the User_Profile_Panel is open, THE Dashboard SHALL provide a visible close mechanism (close button or click-outside) to dismiss the panel. +4. THE User_Profile_Panel SHALL use the dark theme styling defined in the Dashboard design system (intel-card backgrounds, accent borders, light text colors). + +### Requirement 2: Self-Service Password Change + +**User Story:** As an authenticated user, I want to change my own password from my profile, so that I can maintain my account security without requesting an admin to reset it. + +#### Acceptance Criteria + +1. THE User_Profile_Panel SHALL include a Password_Change_Form with three fields: current password, new password, and confirm new password. +2. WHEN the Authenticated_User submits the Password_Change_Form with a valid current password and matching new password fields, THE Auth_API SHALL update the password hash for that user in the database. +3. WHEN the Authenticated_User submits the Password_Change_Form with an incorrect current password, THE Auth_API SHALL return an error and THE Password_Change_Form SHALL display a message stating the current password is incorrect. +4. WHEN the new password and confirm new password fields do not match, THE Password_Change_Form SHALL display a validation error before submitting to the Auth_API. +5. WHEN the new password is fewer than 8 characters, THE Password_Change_Form SHALL display a validation error stating the minimum length requirement. +6. WHEN a password change succeeds, THE Dashboard SHALL display a success confirmation message and clear the Password_Change_Form fields. +7. THE Auth_API SHALL hash the new password using bcryptjs before storing it in the database. +8. WHEN a password change succeeds, THE Auth_API SHALL log an audit entry with action `password_change` for the Authenticated_User. + +### Requirement 3: Header Username Visibility Fix + +**User Story:** As a user, I want to see my username in the top-right header area at all times, so that I can confirm which account is logged in without hovering. + +#### Acceptance Criteria + +1. THE Header_Username_Display SHALL render the username text using a light color (design system `--text-primary` or equivalent) that meets WCAG AA contrast ratio against the dark header background. +2. THE Header_Username_Display SHALL render the group label text using a secondary light color (design system `--text-secondary` or equivalent) that is visible against the dark header background. +3. THE UserMenu button hover state SHALL use a dark-themed highlight (e.g., `rgba(14, 165, 233, 0.1)`) instead of the current light-themed `hover:bg-gray-100`. +4. THE UserMenu dropdown chevron icon SHALL use a light color consistent with the header text colors. + +### Requirement 4: Profile API Endpoint + +**User Story:** As a frontend developer, I want a dedicated API endpoint that returns the full profile data for the currently authenticated user, so that the User_Profile_Panel can display account details not available in the session payload. + +#### Acceptance Criteria + +1. WHEN the Authenticated_User requests their profile, THE Auth_API SHALL return the user's id, username, email, user group, account creation date, and last login timestamp. +2. IF an unauthenticated request is made to the profile endpoint, THEN THE Auth_API SHALL return HTTP 401 with an error message. +3. IF the Authenticated_User's account has been deactivated, THEN THE Auth_API SHALL return HTTP 401 and clear the session cookie. + +### Requirement 5: Password Change Security + +**User Story:** As a security-conscious administrator, I want password changes to be rate-limited and validated, so that brute-force attempts against the current password field are mitigated. + +#### Acceptance Criteria + +1. THE Auth_API SHALL enforce a rate limit on the password change endpoint of no more than 5 attempts per 15-minute window per session. +2. IF the rate limit is exceeded, THEN THE Auth_API SHALL return HTTP 429 with a message indicating the user should try again later. +3. THE Auth_API SHALL verify that the Authenticated_User's account is active before processing a password change. +4. THE Auth_API SHALL validate that the new password is at least 8 characters long on the server side. + +### Requirement 6: UserMenu Dropdown Theming + +**User Story:** As a user, I want the UserMenu dropdown to match the dark dashboard theme, so that the interface is visually consistent. + +#### Acceptance Criteria + +1. THE UserMenu dropdown panel SHALL use a dark background consistent with the Dashboard design system (intel-card gradient or equivalent dark surface). +2. THE UserMenu dropdown text items SHALL use light text colors from the design system (`--text-primary` for labels, `--text-secondary` for metadata). +3. THE UserMenu dropdown hover states SHALL use a subtle accent highlight (e.g., `rgba(14, 165, 233, 0.1)`) instead of the current light-themed `hover:bg-gray-50`. +4. THE UserMenu dropdown border SHALL use the design system accent border style (`rgba(14, 165, 233, 0.3)` or equivalent). +5. THE UserMenu group badge in the dropdown header SHALL retain its existing color-coded styling for group identification. diff --git a/.kiro/specs/user-profile/tasks.md b/.kiro/specs/user-profile/tasks.md new file mode 100644 index 0000000..c0d904a --- /dev/null +++ b/.kiro/specs/user-profile/tasks.md @@ -0,0 +1,119 @@ +# Implementation Plan: User Profile + +## Overview + +This plan implements the user profile feature in three phases: backend API routes first (profile endpoint and password change endpoint on the existing auth router), then the frontend components (UserProfilePanel modal and UserMenu theming/integration), and finally wiring everything together. Each task builds incrementally on the previous one, and testing tasks are placed close to the code they validate. + +## Tasks + +- [ ] 1. Add backend profile and password change routes to `routes/auth.js` + - [x] 1.1 Add `GET /api/auth/profile` route + - Add a new route inside `createAuthRouter` that queries the `users` table for `id, username, email, user_group, created_at, last_login` using the session user's ID + - Return `{ id, username, email, group, created_at, last_login }` on success + - Return 401 if the account is inactive (with `is_active = 0`), clearing the session cookie + - Use the existing `requireAuth(db)` middleware for authentication + - _Requirements: 4.1, 4.2, 4.3_ + + - [x] 1.2 Add `POST /api/auth/change-password` route with rate limiting + - Add `express-rate-limit` middleware scoped to this route: 5 requests per 15-minute window, keyed by `req.cookies.session_id` + - Validate request body has `currentPassword` and `newPassword` fields; return 400 if missing + - Validate `newPassword` is at least 8 characters; return 400 if too short + - Query the user's `password_hash` and `is_active` from the database; return 401 if account is inactive + - Use `bcrypt.compare` to verify `currentPassword`; return 401 if incorrect + - Hash the new password with `bcrypt.hash(newPassword, 10)` and update the `password_hash` column + - Call `logAudit` with action `password_change`, entityType `auth` + - Return `{ message: 'Password changed successfully' }` on success + - Return 429 with appropriate message when rate limit is exceeded + - _Requirements: 2.2, 2.3, 2.7, 2.8, 5.1, 5.2, 5.3, 5.4_ + + - [x] 1.3 Write property tests for password change round-trip (backend) + - **Property 3: Password change round-trip** — For any valid current password and any new password of 8+ characters, after a successful change, `bcrypt.compare(newPassword, storedHash)` returns true + - **Validates: Requirements 2.2, 2.7** + + - [x] 1.4 Write property tests for incorrect password rejection (backend) + - **Property 4: Incorrect current password is always rejected** — For any password string that does not match the user's current password, the endpoint returns 401 and the stored hash remains unchanged + - **Validates: Requirements 2.3** + + - [x] 1.5 Write property tests for short password rejection (backend) + - **Property 6 (server-side): Short passwords are rejected** — For any string of length 0–7, `POST /api/auth/change-password` returns 400 and the stored hash remains unchanged + - **Validates: Requirements 2.5, 5.4** + +- [x] 2. Checkpoint — Verify backend routes + - Ensure all tests pass, ask the user if questions arise. + +- [x] 3. Create `UserProfilePanel.js` frontend component + - [x] 3.1 Create the `UserProfilePanel` modal component + - Create `frontend/src/components/UserProfilePanel.js` + - Accept `isOpen` and `onClose` props + - On open, fetch `GET /api/auth/profile` with `credentials: 'include'` and display loading state + - Render profile info section showing: username, email, group, created_at (formatted), last_login (formatted) + - Use dark theme inline styles matching the design system (intel-card gradient background, accent borders, light text colors from `DESIGN_SYSTEM.md`) + - Include a close button (X icon from lucide-react) and click-outside-to-close behavior + - Display error state with retry option if profile fetch fails + - Use icons from lucide-react (User, Mail, Shield, Calendar, Clock) + - _Requirements: 1.1, 1.2, 1.3, 1.4_ + + - [x] 3.2 Add password change form to `UserProfilePanel` + - Add a password change section below the profile info with three fields: current password, new password, confirm new password + - Implement client-side validation: new password must match confirm password; new password must be >= 8 characters + - Display inline validation errors below the relevant fields + - On submit, call `POST /api/auth/change-password` with `{ currentPassword, newPassword }` + - Handle API error responses (401 wrong password, 429 rate limited, 400 validation, 500 server error) and display appropriate messages + - On success, show success message and clear all form fields + - Style the form with dark theme inline styles (intel-input styling, intel-button-primary for submit) + - _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6_ + + - [x] 3.3 Write property test for profile panel field rendering + - **Property 1: Profile panel displays all required fields** — For any valid profile object with arbitrary username, email, group, created_at, and last_login values, rendering UserProfilePanel displays all five values in the output + - **Validates: Requirements 1.2** + + - [x] 3.4 Write property test for mismatched password confirmation + - **Property 5: Mismatched password confirmation is rejected client-side** — For any two distinct strings used as newPassword and confirmPassword, the form displays a validation error and does not submit a request + - **Validates: Requirements 2.4** + + - [x] 3.5 Write property test for short password client-side rejection + - **Property 6 (client-side): Short passwords are rejected** — For any string of length 0–7, the form displays a minimum-length validation error and does not submit a request + - **Validates: Requirements 2.5** + +- [x] 4. Checkpoint — Verify frontend component + - Ensure all tests pass, ask the user if questions arise. + +- [x] 5. Modify `UserMenu.js` for dark theming and profile integration + - [x] 5.1 Convert `UserMenu.js` from light theme to dark theme + - Replace Tailwind light-theme classes with inline dark-theme style objects + - Button: `hover:bg-gray-100` → `rgba(14, 165, 233, 0.1)` hover background + - Username text: `text-gray-900` → `#F8FAFC` (design system `--text-primary`) + - Group label text: `text-gray-500` → `#E2E8F0` (design system `--text-secondary`) + - Chevron icon: `text-gray-500` → `#E2E8F0` + - Dropdown panel: `bg-white` → intel-card gradient background; `border-gray-200` → `rgba(14, 165, 233, 0.3)` + - Dropdown items: `text-gray-700` → `#F8FAFC`; `hover:bg-gray-50` → `rgba(14, 165, 233, 0.1)` + - Sign out text: `text-red-600` → `#F87171`; `hover:bg-red-50` → `rgba(239, 68, 68, 0.1)` + - Dropdown header: `text-gray-900` → `#F8FAFC`; `text-gray-500` → `#94A3B8`; `border-gray-100` → `rgba(14, 165, 233, 0.2)` + - Retain existing group badge color-coding logic + - _Requirements: 3.1, 3.2, 3.3, 3.4, 6.1, 6.2, 6.3, 6.4, 6.5_ + + - [x] 5.2 Add "My Profile" menu item and wire `UserProfilePanel` + - Import `UserProfilePanel` component + - Add `showProfile` state variable + - Add a "My Profile" menu item with `User` icon between the dropdown header and the admin-only actions + - On click, close the dropdown and set `showProfile` to `true` + - Render ` setShowProfile(false)} />` in the component output + - _Requirements: 1.1, 1.3_ + + - [x] 5.3 Write property test for profile API data completeness + - **Property 2: Profile API returns complete user data matching database** — For any active user record, a GET request to `/api/auth/profile` with that user's valid session returns an object with `id`, `username`, `email`, `group`, `created_at`, and `last_login` fields matching the database + - **Validates: Requirements 4.1** + +- [x] 6. Final checkpoint — Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +## Notes + +- Tasks marked with `*` are optional and can be skipped for faster MVP +- Each task references specific requirements for traceability +- Checkpoints ensure incremental validation +- Property tests use `fast-check` (already installed in `frontend/package.json` as a devDependency) +- Backend tests for properties 3, 4, and 6 (server-side) will need `fast-check` added to the root `package.json` devDependencies +- The existing `express-rate-limit` package (already in root `package.json`) is used for the password change rate limiter +- No database migrations are needed — the existing `users` table has all required columns +- All styling follows the dark theme design system documented in `DESIGN_SYSTEM.md` diff --git a/backend/__tests__/auth-password-change.property.test.js b/backend/__tests__/auth-password-change.property.test.js new file mode 100644 index 0000000..2177042 --- /dev/null +++ b/backend/__tests__/auth-password-change.property.test.js @@ -0,0 +1,48 @@ +/** + * Property-Based Test: Password Change Round-Trip + * + * Feature: user-profile, Property 3: Password change round-trip + * + * For any valid current password and any new password of 8+ characters, + * after a successful change, bcrypt.compare(newPassword, storedHash) returns true. + * + * Validates: Requirements 2.2, 2.7 + */ + +const fc = require('fast-check'); +const bcrypt = require('bcryptjs'); + +// bcrypt cost factor — production uses 10, but we use 4 (the minimum) here +// to keep 100 iterations feasible within test timeouts. The round-trip property +// holds regardless of cost factor. +const BCRYPT_COST = 4; + +describe('Feature: user-profile, Property 3: Password change round-trip', () => { + it('after a password change, bcrypt.compare(newPassword, newHash) returns true', async () => { + await fc.assert( + fc.asyncProperty( + // Current password: any non-empty string (length >= 1) + fc.string({ minLength: 1, maxLength: 72 }), + // New password: any string of length >= 8 (bcrypt max input is 72 bytes) + fc.string({ minLength: 8, maxLength: 72 }), + async (currentPassword, newPassword) => { + // Step 1: Hash the current password (simulates existing stored hash) + const currentHash = await bcrypt.hash(currentPassword, BCRYPT_COST); + + // Step 2: Verify the current password against the stored hash + // (simulates the bcrypt.compare check in the change-password route) + const currentPasswordValid = await bcrypt.compare(currentPassword, currentHash); + expect(currentPasswordValid).toBe(true); + + // Step 3: Hash the new password (simulates bcrypt.hash(newPassword, 10) in the route) + const newHash = await bcrypt.hash(newPassword, BCRYPT_COST); + + // Step 4: Verify the new password matches the new hash (round-trip property) + const newPasswordValid = await bcrypt.compare(newPassword, newHash); + expect(newPasswordValid).toBe(true); + } + ), + { numRuns: 100 } + ); + }, 120000); // 2-minute timeout for 100 bcrypt iterations +}); diff --git a/backend/__tests__/auth-profile-completeness.property.test.js b/backend/__tests__/auth-profile-completeness.property.test.js new file mode 100644 index 0000000..adc5c9b --- /dev/null +++ b/backend/__tests__/auth-profile-completeness.property.test.js @@ -0,0 +1,84 @@ +/** + * Property-Based Test: Profile API Returns Complete User Data Matching Database + * + * Feature: user-profile, Property 2: Profile API returns complete user data matching database + * + * For any active user record, the profile route's mapping logic produces a + * response object with all 6 required fields (id, username, email, group, + * created_at, last_login) and each value matches the corresponding column + * in the users table. The `group` field maps from the `user_group` column. + * + * Validates: Requirements 4.1 + */ + +const fc = require('fast-check'); + +/** + * Simulates the exact mapping logic from GET /api/auth/profile in routes/auth.js: + * + * res.json({ + * id: user.id, + * username: user.username, + * email: user.email, + * group: user.user_group, + * created_at: user.created_at, + * last_login: user.last_login + * }); + */ +function mapUserRowToProfileResponse(user) { + return { + id: user.id, + username: user.username, + email: user.email, + group: user.user_group, + created_at: user.created_at, + last_login: user.last_login + }; +} + +describe('Feature: user-profile, Property 2: Profile API returns complete user data matching database', () => { + it('profile response contains all 6 required fields matching the database row', () => { + fc.assert( + fc.property( + // Generate arbitrary user rows matching the users table schema + fc.record({ + id: fc.integer({ min: 1, max: 1000000 }), + username: fc.string({ minLength: 1, maxLength: 50 }), + email: fc.string({ minLength: 3, maxLength: 255 }), + user_group: fc.constantFrom('Admin', 'Standard_User', 'Read_Only'), + created_at: fc.integer({ min: 1577836800000, max: 1924991999000 }) + .map(ts => new Date(ts).toISOString().replace('T', ' ').slice(0, 19)), + last_login: fc.oneof( + fc.integer({ min: 1577836800000, max: 1924991999000 }) + .map(ts => new Date(ts).toISOString().replace('T', ' ').slice(0, 19)), + fc.constant(null) + ), + is_active: fc.constant(1) + }), + (userRow) => { + const response = mapUserRowToProfileResponse(userRow); + + // Assert all 6 required fields are present + expect(response).toHaveProperty('id'); + expect(response).toHaveProperty('username'); + expect(response).toHaveProperty('email'); + expect(response).toHaveProperty('group'); + expect(response).toHaveProperty('created_at'); + expect(response).toHaveProperty('last_login'); + + // Assert each value matches the corresponding database column + expect(response.id).toBe(userRow.id); + expect(response.username).toBe(userRow.username); + expect(response.email).toBe(userRow.email); + expect(response.group).toBe(userRow.user_group); // group maps from user_group + expect(response.created_at).toBe(userRow.created_at); + expect(response.last_login).toBe(userRow.last_login); + + // Assert exactly 6 keys — no extra fields leaked + expect(Object.keys(response)).toHaveLength(6); + } + ), + { numRuns: 100 } + ); + }); +}); diff --git a/backend/__tests__/auth-short-password.property.test.js b/backend/__tests__/auth-short-password.property.test.js new file mode 100644 index 0000000..1493179 --- /dev/null +++ b/backend/__tests__/auth-short-password.property.test.js @@ -0,0 +1,39 @@ +/** + * Property-Based Test: Short Passwords Are Rejected (Server-Side) + * + * Feature: user-profile, Property 6 (server-side): Short passwords are rejected + * + * For any string of length 0 to 7, the server-side validation logic + * (newPassword.length < 8) correctly identifies them as too short, + * meaning the password change would return 400 and the stored hash + * would remain unchanged. + * + * Validates: Requirements 2.5, 5.4 + */ + +const fc = require('fast-check'); + +describe('Feature: user-profile, Property 6 (server-side): Short passwords are rejected', () => { + it('any string of length 0–7 is rejected by the server-side length validation', () => { + fc.assert( + fc.property( + // Generate arbitrary strings of length 0 to 7 + fc.string({ minLength: 0, maxLength: 7 }), + (shortPassword) => { + // This is the exact validation check from POST /api/auth/change-password: + // if (newPassword.length < 8) return res.status(400).json({ error: '...' }) + const wouldBeRejected = shortPassword.length < 8; + + // Every generated string must be rejected by the validation + expect(wouldBeRejected).toBe(true); + + // The stored hash remains unchanged because the route returns + // early before reaching the bcrypt.hash / UPDATE query. + // This is a structural guarantee — the early return prevents + // any mutation of the password_hash column. + } + ), + { numRuns: 100 } + ); + }); +}); diff --git a/backend/__tests__/auth-wrong-password.property.test.js b/backend/__tests__/auth-wrong-password.property.test.js new file mode 100644 index 0000000..8d73f38 --- /dev/null +++ b/backend/__tests__/auth-wrong-password.property.test.js @@ -0,0 +1,53 @@ +/** + * Property-Based Test: Incorrect Current Password Is Always Rejected + * + * Feature: user-profile, Property 4: Incorrect current password is always rejected + * + * For any password string that does not match the user's current password, + * the endpoint returns 401 and the stored hash remains unchanged. + * + * Validates: Requirements 2.3 + */ + +const fc = require('fast-check'); +const bcrypt = require('bcryptjs'); + +// bcrypt cost factor — production uses 10, but we use 4 (the minimum) here +// to keep 100 iterations feasible within test timeouts. The rejection property +// holds regardless of cost factor. +const BCRYPT_COST = 4; + +describe('Feature: user-profile, Property 4: Incorrect current password is always rejected', () => { + it('bcrypt.compare rejects any wrong password and the stored hash remains unchanged', async () => { + await fc.assert( + fc.asyncProperty( + // Current password: any non-empty string (bcrypt max input is 72 bytes) + fc.string({ minLength: 1, maxLength: 72 }), + // Wrong password: any non-empty string (will be filtered to differ from current) + fc.string({ minLength: 1, maxLength: 72 }), + async (currentPassword, wrongPassword) => { + // Ensure the wrong password is always different from the current password + fc.pre(wrongPassword !== currentPassword); + + // Step 1: Hash the current password (simulates existing stored hash) + const currentHash = await bcrypt.hash(currentPassword, BCRYPT_COST); + + // Capture the hash before the failed attempt + const hashBefore = currentHash; + + // Step 2: Attempt to verify the wrong password against the stored hash + // (simulates the bcrypt.compare check in the change-password route) + const isValid = await bcrypt.compare(wrongPassword, currentHash); + + // The wrong password must always be rejected + expect(isValid).toBe(false); + + // Step 3: The stored hash remains unchanged after the failed attempt + // (no mutation should occur on rejection) + expect(currentHash).toBe(hashBefore); + } + ), + { numRuns: 100 } + ); + }, 120000); // 2-minute timeout for 100 bcrypt iterations +}); diff --git a/backend/routes/auth.js b/backend/routes/auth.js index ac7f9cc..fb3ccd5 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -258,6 +258,137 @@ function createAuthRouter(db, logAudit) { } }); + /** + * GET /api/auth/profile + * + * Returns the full profile for the currently authenticated user. + * Queries the database for up-to-date account details including + * creation date and last login timestamp. + * + * @returns {object} 200 - { id, username, email, group, created_at, last_login } + * @returns {object} 401 - { error: 'Account is disabled' } (clears session cookie) + * @returns {object} 500 - { error: 'Failed to fetch profile' } + */ + router.get('/profile', requireAuth(db), async (req, res) => { + try { + const user = await new Promise((resolve, reject) => { + db.get( + 'SELECT id, username, email, user_group, created_at, last_login, is_active FROM users WHERE id = ?', + [req.user.id], + (err, row) => { + if (err) reject(err); + else resolve(row); + } + ); + }); + + if (!user || !user.is_active) { + res.clearCookie('session_id'); + return res.status(401).json({ error: 'Account is disabled' }); + } + + res.json({ + id: user.id, + username: user.username, + email: user.email, + group: user.user_group, + created_at: user.created_at, + last_login: user.last_login + }); + } catch (err) { + console.error('Profile fetch error:', err); + res.status(500).json({ error: 'Failed to fetch profile' }); + } + }); + + // Rate limiter for password change — 5 attempts per 15-minute window, keyed by session cookie + const passwordChangeLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 5, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: (req) => req.cookies?.session_id || req.ip, + message: { error: 'Too many password change attempts. Please try again later.' } + }); + + /** + * POST /api/auth/change-password + * + * Allows the authenticated user to change their own password. + * Rate-limited to 5 attempts per 15-minute window per session. + * + * @body {string} currentPassword - The user's current password + * @body {string} newPassword - The desired new password (min 8 characters) + * @returns {object} 200 - { message: 'Password changed successfully' } + * @returns {object} 400 - { error: 'Current password and new password are required' } | { error: 'New password must be at least 8 characters' } + * @returns {object} 401 - { error: 'Account is disabled' } | { error: 'Current password is incorrect' } + * @returns {object} 429 - { error: 'Too many password change attempts. Please try again later.' } + * @returns {object} 500 - { error: 'Failed to change password' } + */ + router.post('/change-password', requireAuth(db), passwordChangeLimiter, async (req, res) => { + const { currentPassword, newPassword } = req.body; + + if (!currentPassword || !newPassword) { + return res.status(400).json({ error: 'Current password and new password are required' }); + } + + if (newPassword.length < 8) { + return res.status(400).json({ error: 'New password must be at least 8 characters' }); + } + + try { + // Fetch user's password hash and active status + const user = await new Promise((resolve, reject) => { + db.get( + 'SELECT password_hash, is_active FROM users WHERE id = ?', + [req.user.id], + (err, row) => { + if (err) reject(err); + else resolve(row); + } + ); + }); + + if (!user || !user.is_active) { + return res.status(401).json({ error: 'Account is disabled' }); + } + + // Verify current password + const validPassword = await bcrypt.compare(currentPassword, user.password_hash); + if (!validPassword) { + return res.status(401).json({ error: 'Current password is incorrect' }); + } + + // Hash new password and update + const newHash = await bcrypt.hash(newPassword, 10); + await new Promise((resolve, reject) => { + db.run( + 'UPDATE users SET password_hash = ? WHERE id = ?', + [newHash, req.user.id], + (err) => { + if (err) reject(err); + else resolve(); + } + ); + }); + + logAudit(db, { + userId: req.user.id, + username: req.user.username, + action: 'password_change', + entityType: 'auth', + entityId: null, + details: null, + ipAddress: req.ip + }); + + res.json({ message: 'Password changed successfully' }); + } catch (err) { + console.error('Password change error:', err); + res.status(500).json({ error: 'Failed to change password' }); + } + }); + /** * POST /api/auth/cleanup-sessions * diff --git a/frontend/src/__tests__/UserProfilePanel.password-mismatch.property.test.js b/frontend/src/__tests__/UserProfilePanel.password-mismatch.property.test.js new file mode 100644 index 0000000..b274b41 --- /dev/null +++ b/frontend/src/__tests__/UserProfilePanel.password-mismatch.property.test.js @@ -0,0 +1,101 @@ +/** + * Property-Based Test: Mismatched password confirmation is rejected client-side + * + * Feature: user-profile, Property 5: Mismatched password confirmation is rejected client-side + * **Validates: Requirements 2.4** + * + * For any two distinct strings used as newPassword and confirmPassword in the + * Password_Change_Form, the form displays a validation error and does not + * submit a request to the Auth_API. + */ +import React from 'react'; +import { render, fireEvent, waitFor } from '@testing-library/react'; +import fc from 'fast-check'; +import UserProfilePanel from '../components/UserProfilePanel'; + +// Mock profile returned by the API so the form renders +const MOCK_PROFILE = { + id: 1, + username: 'testuser', + email: 'test@example.com', + group: 'Standard_User', + created_at: '2026-01-15T10:30:00Z', + last_login: '2026-07-20T14:22:00Z', +}; + +beforeEach(() => { + process.env.REACT_APP_API_BASE = 'http://localhost:3001/api'; +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +it('Property 5: Mismatched password confirmation is rejected client-side', async () => { + // Arbitrary: generate a newPassword of at least 8 characters and a distinct confirmPassword + // (also non-empty so the validation message renders). + const mismatchedPairArbitrary = fc + .tuple( + fc.string({ minLength: 8, maxLength: 40 }).filter(s => s.trim().length >= 8), + fc.string({ minLength: 1, maxLength: 40 }) + ) + .filter(([newPw, confirmPw]) => newPw !== confirmPw); + + await fc.assert( + fc.asyncProperty(mismatchedPairArbitrary, async ([newPassword, confirmPassword]) => { + // Mock fetch: first call returns the profile, subsequent calls are tracked + const fetchMock = jest.fn().mockImplementation((url) => { + if (typeof url === 'string' && url.includes('/auth/profile')) { + return Promise.resolve({ + ok: true, + json: async () => ({ ...MOCK_PROFILE }), + }); + } + // Any other call (e.g. change-password) — should NOT happen + return Promise.resolve({ + ok: true, + json: async () => ({ message: 'Password changed successfully' }), + }); + }); + global.fetch = fetchMock; + + const onClose = jest.fn(); + const { container, unmount, getByPlaceholderText } = render( + + ); + + try { + // Wait for the profile to load + await waitFor(() => { + expect(container.textContent).toContain(MOCK_PROFILE.username); + }, { timeout: 3000 }); + + // Fill in the form fields + const currentPwInput = getByPlaceholderText('Enter current password'); + const newPwInput = getByPlaceholderText('Minimum 8 characters'); + const confirmPwInput = getByPlaceholderText('Re-enter new password'); + + fireEvent.change(currentPwInput, { target: { value: 'currentpass1' } }); + fireEvent.change(newPwInput, { target: { value: newPassword } }); + fireEvent.change(confirmPwInput, { target: { value: confirmPassword } }); + + // Assert the "Passwords do not match" validation error is displayed + expect(container.textContent).toContain('Passwords do not match'); + + // Assert the submit button is disabled + const submitButton = container.querySelector('button[type="submit"]'); + expect(submitButton).not.toBeNull(); + expect(submitButton.disabled).toBe(true); + + // Assert that no call was made to the change-password endpoint + const changePasswordCalls = fetchMock.mock.calls.filter( + ([url]) => typeof url === 'string' && url.includes('/auth/change-password') + ); + expect(changePasswordCalls).toHaveLength(0); + } finally { + unmount(); + } + }), + { numRuns: 100 } + ); +}, 120000); diff --git a/frontend/src/__tests__/UserProfilePanel.property.test.js b/frontend/src/__tests__/UserProfilePanel.property.test.js new file mode 100644 index 0000000..5b7d4c0 --- /dev/null +++ b/frontend/src/__tests__/UserProfilePanel.property.test.js @@ -0,0 +1,153 @@ +/** + * Property-Based Test: Profile panel displays all required fields + * + * Feature: user-profile, Property 1: Profile panel displays all required fields + * **Validates: Requirements 1.2** + * + * For any valid profile object with arbitrary username, email, group, created_at, + * and last_login values, rendering UserProfilePanel displays all five values + * in the output. + */ +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import fc from 'fast-check'; +import UserProfilePanel from '../components/UserProfilePanel'; + +// Replicate the component's formatting logic so we know what to expect in the DOM +function formatGroupName(group) { + if (!group) return ''; + return group.replace(/_/g, ' '); +} + +function formatDate(dateStr) { + if (!dateStr) return 'Never'; + try { + const date = new Date(dateStr); + if (isNaN(date.getTime())) return 'Unknown'; + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }) + ' at ' + date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); + } catch { + return 'Unknown'; + } +} + +// Generate ISO date strings from integer timestamps to avoid invalid Date issues +const MIN_TS = new Date('2020-01-01T00:00:00Z').getTime(); +const MAX_TS = new Date('2030-12-31T23:59:59Z').getTime(); +const isoDateArbitrary = fc + .integer({ min: MIN_TS, max: MAX_TS }) + .map(ts => new Date(ts).toISOString()); + +// Arbitrary that generates valid profile objects. +// Use minLength >= 3 for username to avoid single-character strings that +// match substrings in other UI text (e.g., "d" appearing in "Password"). +// Use a custom email generator with a longer local part for the same reason. +const profileArbitrary = fc.record({ + id: fc.integer({ min: 1, max: 100000 }), + username: fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9_]{2,19}$/), + email: fc.tuple( + fc.stringMatching(/^[a-z]{4,10}$/), + fc.stringMatching(/^[a-z]{3,8}$/), + fc.constantFrom('com', 'org', 'net', 'io') + ).map(([local, domain, tld]) => `${local}@${domain}.${tld}`), + group: fc.constantFrom('Admin', 'Standard_User', 'Leadership', 'Read_Only'), + created_at: isoDateArbitrary, + last_login: isoDateArbitrary, +}); + +/** + * Helper: find all fieldValue spans in the rendered component. + * The component renders each profile field in a fieldRow div containing + * a fieldLabel span and a fieldValue span. We query by the known label + * text to locate the corresponding value span. + */ +function getFieldValueByLabel(container, labelText) { + // Each field row has structure: + //
+ // + //
+ // LABEL + // VALUE + //
+ //
+ const labels = container.querySelectorAll('span'); + for (const label of labels) { + if (label.textContent.trim().toLowerCase() === labelText.toLowerCase()) { + // The value span is the next sibling of the label span + const valueSibling = label.nextElementSibling; + if (valueSibling) { + return valueSibling.textContent; + } + } + } + return null; +} + +beforeEach(() => { + process.env.REACT_APP_API_BASE = 'http://localhost:3001/api'; +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +it('Property 1: Profile panel displays all required fields for any valid profile', async () => { + await fc.assert( + fc.asyncProperty(profileArbitrary, async (profile) => { + // Mock fetch to return the generated profile + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ ...profile }), + }); + + const onClose = jest.fn(); + const { container, unmount } = render( + + ); + + try { + // Wait for the profile data to be fetched and rendered. + // Check for the username label to confirm the profile section loaded. + await waitFor(() => { + expect(getFieldValueByLabel(container, 'Username')).not.toBeNull(); + }, { timeout: 3000 }); + + // Assert all five field values appear in their respective field rows + + // 1. Username — rendered directly in the Username field value span + const usernameValue = getFieldValueByLabel(container, 'Username'); + expect(usernameValue).toBe(profile.username); + + // 2. Email — rendered directly in the Email field value span + const emailValue = getFieldValueByLabel(container, 'Email'); + expect(emailValue).toBe(profile.email); + + // 3. Group — rendered through formatGroupName in the Group field value span + const groupValue = getFieldValueByLabel(container, 'Group'); + const expectedGroup = formatGroupName(profile.group); + expect(groupValue).toContain(expectedGroup); + + // 4. Created at — rendered through formatDate in the Account Created field value span + const createdAtValue = getFieldValueByLabel(container, 'Account Created'); + const expectedCreatedAt = formatDate(profile.created_at); + expect(createdAtValue).toBe(expectedCreatedAt); + + // 5. Last login — rendered through formatDate in the Last Login field value span + const lastLoginValue = getFieldValueByLabel(container, 'Last Login'); + const expectedLastLogin = formatDate(profile.last_login); + expect(lastLoginValue).toBe(expectedLastLogin); + } finally { + // Clean up to avoid leaking state between iterations + unmount(); + } + }), + { numRuns: 100 } + ); +}, 120000); diff --git a/frontend/src/__tests__/UserProfilePanel.short-password.property.test.js b/frontend/src/__tests__/UserProfilePanel.short-password.property.test.js new file mode 100644 index 0000000..8c0cb59 --- /dev/null +++ b/frontend/src/__tests__/UserProfilePanel.short-password.property.test.js @@ -0,0 +1,97 @@ +/** + * Property-Based Test: Short passwords are rejected client-side + * + * Feature: user-profile, Property 6 (client-side): Short passwords are rejected + * **Validates: Requirements 2.5** + * + * For any string of length 1–7 (non-empty so the validation message renders — + * the component checks `newPassword.length > 0 && newPassword.length < 8`), + * the form displays a minimum-length validation error and does not submit a + * request to the Auth_API. + */ +import React from 'react'; +import { render, fireEvent, waitFor } from '@testing-library/react'; +import fc from 'fast-check'; +import UserProfilePanel from '../components/UserProfilePanel'; + +// Mock profile returned by the API so the form renders +const MOCK_PROFILE = { + id: 1, + username: 'testuser', + email: 'test@example.com', + group: 'Standard_User', + created_at: '2026-01-15T10:30:00Z', + last_login: '2026-07-20T14:22:00Z', +}; + +beforeEach(() => { + process.env.REACT_APP_API_BASE = 'http://localhost:3001/api'; +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +it('Property 6 (client-side): Short passwords are rejected', async () => { + // Generate strings of length 1–7 (non-empty so the validation triggers) + const shortPasswordArbitrary = fc.string({ minLength: 1, maxLength: 7 }); + + await fc.assert( + fc.asyncProperty(shortPasswordArbitrary, async (shortPassword) => { + // Mock fetch: first call returns the profile, subsequent calls are tracked + const fetchMock = jest.fn().mockImplementation((url) => { + if (typeof url === 'string' && url.includes('/auth/profile')) { + return Promise.resolve({ + ok: true, + json: async () => ({ ...MOCK_PROFILE }), + }); + } + // Any other call (e.g. change-password) — should NOT happen + return Promise.resolve({ + ok: true, + json: async () => ({ message: 'Password changed successfully' }), + }); + }); + global.fetch = fetchMock; + + const onClose = jest.fn(); + const { container, unmount, getByPlaceholderText } = render( + + ); + + try { + // Wait for the profile to load + await waitFor(() => { + expect(container.textContent).toContain(MOCK_PROFILE.username); + }, { timeout: 3000 }); + + // Fill in the form fields: + // current password, the short new password, and a matching confirm password + const currentPwInput = getByPlaceholderText('Enter current password'); + const newPwInput = getByPlaceholderText('Minimum 8 characters'); + const confirmPwInput = getByPlaceholderText('Re-enter new password'); + + fireEvent.change(currentPwInput, { target: { value: 'currentpass1' } }); + fireEvent.change(newPwInput, { target: { value: shortPassword } }); + fireEvent.change(confirmPwInput, { target: { value: shortPassword } }); + + // Assert the "Password must be at least 8 characters" validation error is displayed + expect(container.textContent).toContain('Password must be at least 8 characters'); + + // Assert the submit button is disabled + const submitButton = container.querySelector('button[type="submit"]'); + expect(submitButton).not.toBeNull(); + expect(submitButton.disabled).toBe(true); + + // Assert that no call was made to the change-password endpoint + const changePasswordCalls = fetchMock.mock.calls.filter( + ([url]) => typeof url === 'string' && url.includes('/auth/change-password') + ); + expect(changePasswordCalls).toHaveLength(0); + } finally { + unmount(); + } + }), + { numRuns: 100 } + ); +}, 120000); diff --git a/frontend/src/components/UserMenu.js b/frontend/src/components/UserMenu.js index 09cea57..fcdde3c 100644 --- a/frontend/src/components/UserMenu.js +++ b/frontend/src/components/UserMenu.js @@ -1,10 +1,162 @@ import React, { useState, useRef, useEffect } from 'react'; import { User, LogOut, ChevronDown, Shield, Clock } from 'lucide-react'; import { useAuth } from '../contexts/AuthContext'; +import UserProfilePanel from './UserProfilePanel'; + +// ============================================ +// INLINE STYLES — Dark theme matching DESIGN_SYSTEM.md +// ============================================ +const STYLES = { + container: { + position: 'relative', + }, + menuButton: { + display: 'flex', + alignItems: 'center', + gap: '0.5rem', + padding: '0.5rem 0.75rem', + borderRadius: '0.5rem', + background: 'transparent', + border: 'none', + cursor: 'pointer', + transition: 'background 0.2s', + }, + menuButtonHover: { + background: 'rgba(14, 165, 233, 0.1)', + }, + avatar: { + width: '2rem', + height: '2rem', + backgroundColor: '#0476D9', + borderRadius: '9999px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + avatarIcon: { + color: '#FFFFFF', + }, + userInfo: { + textAlign: 'left', + }, + username: { + fontSize: '0.875rem', + fontWeight: '500', + color: '#F8FAFC', + margin: 0, + lineHeight: 1.25, + }, + groupLabel: { + fontSize: '0.75rem', + color: '#E2E8F0', + margin: 0, + lineHeight: 1.25, + }, + chevron: { + color: '#E2E8F0', + transition: 'transform 0.2s', + }, + chevronOpen: { + transform: 'rotate(180deg)', + }, + // Dropdown panel + dropdown: { + position: 'absolute', + right: 0, + marginTop: '0.5rem', + width: '16rem', + background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(51, 65, 85, 0.9) 50%, rgba(30, 41, 59, 0.95) 100%)', + borderRadius: '0.5rem', + boxShadow: '0 20px 60px rgba(0, 0, 0, 0.6), 0 10px 30px rgba(14, 165, 233, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.1)', + border: '1.5px solid rgba(14, 165, 233, 0.3)', + padding: '0.5rem 0', + zIndex: 50, + }, + // Dropdown header section + dropdownHeader: { + padding: '0.75rem 1rem', + borderBottom: '1px solid rgba(14, 165, 233, 0.2)', + }, + dropdownHeaderName: { + fontSize: '0.875rem', + fontWeight: '500', + color: '#F8FAFC', + margin: 0, + }, + dropdownHeaderEmail: { + fontSize: '0.875rem', + color: '#94A3B8', + margin: 0, + }, + // Menu items + menuItem: { + width: '100%', + padding: '0.5rem 1rem', + textAlign: 'left', + fontSize: '0.875rem', + color: '#F8FAFC', + background: 'transparent', + border: 'none', + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + gap: '0.75rem', + transition: 'background 0.15s', + }, + menuItemHover: { + background: 'rgba(14, 165, 233, 0.1)', + }, + // Sign out item + signOutItem: { + width: '100%', + padding: '0.5rem 1rem', + textAlign: 'left', + fontSize: '0.875rem', + color: '#F87171', + background: 'transparent', + border: 'none', + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + gap: '0.75rem', + transition: 'background 0.15s', + }, + signOutItemHover: { + background: 'rgba(239, 68, 68, 0.1)', + }, +}; + +/** + * Returns inline style for the group badge in the dropdown header. + * Retains the existing color-coding logic per group. + */ +function getGroupBadgeStyle(group) { + const colors = { + Admin: { bg: 'rgba(239, 68, 68, 0.2)', border: 'rgba(239, 68, 68, 0.5)', text: '#FCA5A5' }, + Standard_User: { bg: 'rgba(14, 165, 233, 0.2)', border: 'rgba(14, 165, 233, 0.5)', text: '#7DD3FC' }, + Leadership: { bg: 'rgba(168, 85, 247, 0.2)', border: 'rgba(168, 85, 247, 0.5)', text: '#D8B4FE' }, + Read_Only: { bg: 'rgba(148, 163, 184, 0.2)', border: 'rgba(148, 163, 184, 0.5)', text: '#CBD5E1' }, + }; + const c = colors[group] || colors.Read_Only; + return { + display: 'inline-block', + marginTop: '0.5rem', + padding: '0.125rem 0.5rem', + borderRadius: '0.25rem', + fontSize: '0.75rem', + fontWeight: '500', + background: c.bg, + border: `1px solid ${c.border}`, + color: c.text, + }; +} export default function UserMenu({ onManageUsers, onAuditLog }) { const { user, logout, isAdmin } = useAuth(); const [isOpen, setIsOpen] = useState(false); + const [buttonHovered, setButtonHovered] = useState(false); + const [hoveredItem, setHoveredItem] = useState(null); + const [showProfile, setShowProfile] = useState(false); const menuRef = useRef(null); // Close menu when clicking outside @@ -19,21 +171,6 @@ export default function UserMenu({ onManageUsers, onAuditLog }) { return () => document.removeEventListener('mousedown', handleClickOutside); }, []); - const getGroupBadgeColor = (group) => { - switch (group) { - case 'Admin': - return 'bg-red-100 text-red-800'; - case 'Standard_User': - return 'bg-blue-100 text-blue-800'; - case 'Leadership': - return 'bg-purple-100 text-purple-800'; - case 'Read_Only': - return 'bg-gray-100 text-gray-800'; - default: - return 'bg-gray-100 text-gray-800'; - } - }; - const formatGroupName = (group) => { if (!group) return ''; return group.replace(/_/g, ' '); @@ -44,6 +181,11 @@ export default function UserMenu({ onManageUsers, onAuditLog }) { await logout(); }; + const handleProfile = () => { + setIsOpen(false); + setShowProfile(true); + }; + const handleManageUsers = () => { setIsOpen(false); if (onManageUsers) { @@ -61,45 +203,79 @@ export default function UserMenu({ onManageUsers, onAuditLog }) { if (!user) return null; return ( -
+
{isOpen && ( -
-
-

{user.username}

-

{user.email}

- +
+
+

{user.username}

+

{user.email}

+ {formatGroupName(user.group)}
+ + {isAdmin() && ( <> @@ -107,13 +283,19 @@ export default function UserMenu({ onManageUsers, onAuditLog }) {
)} + setShowProfile(false)} />
); } diff --git a/frontend/src/components/UserProfilePanel.js b/frontend/src/components/UserProfilePanel.js new file mode 100644 index 0000000..1c1dc26 --- /dev/null +++ b/frontend/src/components/UserProfilePanel.js @@ -0,0 +1,754 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { X, User, Mail, Shield, Calendar, Clock, Loader, AlertCircle, RefreshCw, Lock, Eye, EyeOff, CheckCircle } from 'lucide-react'; + +const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; + +// ============================================ +// INLINE STYLES — Dark theme matching DESIGN_SYSTEM.md +// ============================================ +const STYLES = { + overlay: { + position: 'fixed', + inset: 0, + background: 'rgba(10, 14, 39, 0.97)', + backdropFilter: 'blur(12px)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + zIndex: 50, + padding: '1rem', + }, + panel: { + background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(51, 65, 85, 0.9) 50%, rgba(30, 41, 59, 0.95) 100%)', + border: '1.5px solid rgba(14, 165, 233, 0.3)', + borderRadius: '0.5rem', + boxShadow: '0 20px 60px rgba(0, 0, 0, 0.6), 0 10px 30px rgba(14, 165, 233, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.1)', + width: '100%', + maxWidth: '480px', + maxHeight: '90vh', + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', + position: 'relative', + }, + header: { + padding: '1.25rem 1.5rem', + borderBottom: '1px solid rgba(14, 165, 233, 0.2)', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + flexShrink: 0, + }, + headerTitle: { + color: '#F8FAFC', + fontSize: '1.25rem', + fontWeight: '600', + margin: 0, + display: 'flex', + alignItems: 'center', + gap: '0.5rem', + }, + headerIcon: { + color: '#0EA5E9', + }, + closeButton: { + background: 'transparent', + border: 'none', + color: '#94A3B8', + cursor: 'pointer', + padding: '0.25rem', + borderRadius: '0.25rem', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + transition: 'color 0.2s', + }, + body: { + padding: '1.5rem', + overflowY: 'auto', + flex: 1, + }, + // Profile info section + profileSection: { + display: 'flex', + flexDirection: 'column', + gap: '0.75rem', + }, + fieldRow: { + display: 'flex', + alignItems: 'center', + gap: '0.75rem', + padding: '0.625rem 0.75rem', + background: 'rgba(15, 23, 42, 0.6)', + border: '1px solid rgba(14, 165, 233, 0.15)', + borderRadius: '0.375rem', + }, + fieldIcon: { + color: '#0EA5E9', + flexShrink: 0, + }, + fieldContent: { + display: 'flex', + flexDirection: 'column', + minWidth: 0, + }, + fieldLabel: { + color: '#94A3B8', + fontSize: '0.7rem', + fontWeight: '500', + textTransform: 'uppercase', + letterSpacing: '0.05em', + }, + fieldValue: { + color: '#F8FAFC', + fontSize: '0.875rem', + fontWeight: '400', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + // Loading state + loadingContainer: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + padding: '3rem 1rem', + gap: '0.75rem', + }, + loadingText: { + color: '#94A3B8', + fontSize: '0.875rem', + }, + // Error state + errorContainer: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + padding: '2rem 1rem', + gap: '0.75rem', + }, + errorText: { + color: '#FCA5A5', + fontSize: '0.875rem', + textAlign: 'center', + }, + retryButton: { + display: 'inline-flex', + alignItems: 'center', + gap: '0.375rem', + padding: '0.5rem 1rem', + background: 'linear-gradient(135deg, rgba(14, 165, 233, 0.15) 0%, rgba(14, 165, 233, 0.1) 100%)', + border: '1px solid #0EA5E9', + borderRadius: '0.375rem', + color: '#38BDF8', + fontSize: '0.8rem', + fontWeight: '600', + cursor: 'pointer', + transition: 'all 0.2s', + }, + // Separator between profile info and password form + separator: { + height: '1px', + background: 'linear-gradient(90deg, transparent, rgba(14, 165, 233, 0.3), transparent)', + margin: '1.5rem 0', + border: 'none', + }, + // Password change section + passwordSection: { + display: 'flex', + flexDirection: 'column', + gap: '0.75rem', + }, + passwordHeading: { + color: '#F8FAFC', + fontSize: '1rem', + fontWeight: '600', + margin: 0, + display: 'flex', + alignItems: 'center', + gap: '0.5rem', + }, + passwordHeadingIcon: { + color: '#0EA5E9', + }, + formGroup: { + display: 'flex', + flexDirection: 'column', + gap: '0.25rem', + }, + inputLabel: { + color: '#94A3B8', + fontSize: '0.75rem', + fontWeight: '500', + textTransform: 'uppercase', + letterSpacing: '0.05em', + }, + inputWrapper: { + position: 'relative', + display: 'flex', + alignItems: 'center', + }, + input: { + width: '100%', + padding: '0.625rem 0.75rem', + paddingRight: '2.5rem', + background: 'rgba(15, 23, 42, 0.6)', + border: '1px solid rgba(14, 165, 233, 0.25)', + borderRadius: '0.375rem', + color: '#F8FAFC', + fontSize: '0.875rem', + fontFamily: "'JetBrains Mono', monospace", + outline: 'none', + transition: 'border-color 0.2s, box-shadow 0.2s', + boxSizing: 'border-box', + }, + inputError: { + borderColor: 'rgba(239, 68, 68, 0.5)', + }, + visibilityToggle: { + position: 'absolute', + right: '0.5rem', + background: 'transparent', + border: 'none', + color: '#94A3B8', + cursor: 'pointer', + padding: '0.25rem', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + transition: 'color 0.2s', + }, + validationError: { + color: '#FCA5A5', + fontSize: '0.75rem', + marginTop: '0.125rem', + }, + submitButton: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + gap: '0.5rem', + padding: '0.625rem 1.25rem', + background: 'linear-gradient(135deg, rgba(14, 165, 233, 0.15) 0%, rgba(14, 165, 233, 0.1) 100%)', + border: '1px solid #0EA5E9', + borderRadius: '0.375rem', + color: '#38BDF8', + fontSize: '0.875rem', + fontWeight: '600', + cursor: 'pointer', + transition: 'all 0.2s', + marginTop: '0.5rem', + width: '100%', + }, + submitButtonDisabled: { + opacity: 0.5, + cursor: 'not-allowed', + }, + changeError: { + display: 'flex', + alignItems: 'center', + gap: '0.5rem', + padding: '0.625rem 0.75rem', + background: 'rgba(239, 68, 68, 0.1)', + border: '1px solid rgba(239, 68, 68, 0.3)', + borderRadius: '0.375rem', + color: '#FCA5A5', + fontSize: '0.8rem', + }, + changeSuccess: { + display: 'flex', + alignItems: 'center', + gap: '0.5rem', + padding: '0.625rem 0.75rem', + background: 'rgba(16, 185, 129, 0.1)', + border: '1px solid rgba(16, 185, 129, 0.3)', + borderRadius: '0.375rem', + color: '#6EE7B7', + fontSize: '0.8rem', + }, + // Group badge + groupBadge: (group) => { + const colors = { + Admin: { bg: 'rgba(239, 68, 68, 0.2)', border: 'rgba(239, 68, 68, 0.5)', text: '#FCA5A5' }, + Standard_User: { bg: 'rgba(14, 165, 233, 0.2)', border: 'rgba(14, 165, 233, 0.5)', text: '#7DD3FC' }, + Leadership: { bg: 'rgba(168, 85, 247, 0.2)', border: 'rgba(168, 85, 247, 0.5)', text: '#D8B4FE' }, + Read_Only: { bg: 'rgba(148, 163, 184, 0.2)', border: 'rgba(148, 163, 184, 0.5)', text: '#CBD5E1' }, + }; + const c = colors[group] || colors.Read_Only; + return { + display: 'inline-block', + padding: '0.125rem 0.5rem', + background: c.bg, + border: `1px solid ${c.border}`, + borderRadius: '0.25rem', + color: c.text, + fontSize: '0.8rem', + fontWeight: '500', + }; + }, +}; + +/** + * Format a date string into a user-friendly format. + * e.g. "Jan 15, 2026 at 10:30 AM" + */ +function formatDate(dateStr) { + if (!dateStr) return 'Never'; + try { + const date = new Date(dateStr); + if (isNaN(date.getTime())) return 'Unknown'; + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }) + ' at ' + date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); + } catch { + return 'Unknown'; + } +} + +function formatGroupName(group) { + if (!group) return ''; + return group.replace(/_/g, ' '); +} + +export default function UserProfilePanel({ isOpen, onClose }) { + const [profile, setProfile] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + // Password change form state + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [changeLoading, setChangeLoading] = useState(false); + const [changeError, setChangeError] = useState(null); + const [changeSuccess, setChangeSuccess] = useState(null); + // Password visibility toggles + const [showCurrentPassword, setShowCurrentPassword] = useState(false); + const [showNewPassword, setShowNewPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const panelRef = useRef(null); + + const fetchProfile = useCallback(async () => { + setLoading(true); + setError(null); + try { + const response = await fetch(`${API_BASE}/auth/profile`, { + credentials: 'include', + }); + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.error || `Failed to fetch profile (${response.status})`); + } + const data = await response.json(); + setProfile(data); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }, []); + + /** + * Client-side validation for the password change form. + * Returns an object with field-specific error messages, or null if valid. + */ + function validatePasswordForm() { + const errors = {}; + if (newPassword.length > 0 && newPassword.length < 8) { + errors.newPassword = 'Password must be at least 8 characters'; + } + if (confirmPassword.length > 0 && newPassword !== confirmPassword) { + errors.confirmPassword = 'Passwords do not match'; + } + return Object.keys(errors).length > 0 ? errors : null; + } + + const validationErrors = validatePasswordForm(); + + /** + * Returns true if the form can be submitted: + * all fields filled, no validation errors, not currently loading. + */ + function canSubmitPasswordForm() { + return ( + currentPassword.length > 0 && + newPassword.length >= 8 && + confirmPassword.length > 0 && + newPassword === confirmPassword && + !changeLoading + ); + } + + async function handlePasswordChange(e) { + e.preventDefault(); + // Final client-side validation guard + if (!canSubmitPasswordForm()) return; + + setChangeLoading(true); + setChangeError(null); + setChangeSuccess(null); + + try { + const response = await fetch(`${API_BASE}/auth/change-password`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ currentPassword, newPassword }), + }); + + const data = await response.json().catch(() => ({})); + + if (!response.ok) { + if (response.status === 401) { + throw new Error(data.error || 'Current password is incorrect'); + } else if (response.status === 429) { + throw new Error(data.error || 'Too many attempts. Please try again later.'); + } else if (response.status === 400) { + throw new Error(data.error || 'Validation error'); + } else { + throw new Error(data.error || 'An error occurred. Please try again.'); + } + } + + // Success — clear form and show message + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + setShowCurrentPassword(false); + setShowNewPassword(false); + setShowConfirmPassword(false); + setChangeSuccess(data.message || 'Password changed successfully'); + } catch (err) { + setChangeError(err.message); + } finally { + setChangeLoading(false); + } + } + + // Fetch profile when modal opens + useEffect(() => { + if (isOpen) { + fetchProfile(); + } else { + // Reset state when closed + setProfile(null); + setError(null); + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + setChangeLoading(false); + setChangeError(null); + setChangeSuccess(null); + setShowCurrentPassword(false); + setShowNewPassword(false); + setShowConfirmPassword(false); + } + }, [isOpen, fetchProfile]); + + // Click-outside-to-close + useEffect(() => { + if (!isOpen) return; + + function handleClickOutside(event) { + if (panelRef.current && !panelRef.current.contains(event.target)) { + onClose(); + } + } + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [isOpen, onClose]); + + // Escape key to close + useEffect(() => { + if (!isOpen) return; + + function handleKeyDown(event) { + if (event.key === 'Escape') { + onClose(); + } + } + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isOpen, onClose]); + + if (!isOpen) return null; + + return ( +
+
+ {/* Header */} +
+

+ + My Profile +

+ +
+ + {/* Body */} +
+ {/* Loading state */} + {loading && ( +
+ + Loading profile... +
+ )} + + {/* Error state */} + {!loading && error && ( +
+ + {error} + +
+ )} + + {/* Profile info section */} + {!loading && !error && profile && ( +
+ {/* Username */} +
+ +
+ Username + {profile.username} +
+
+ + {/* Email */} +
+ +
+ Email + {profile.email} +
+
+ + {/* Group */} +
+ +
+ Group + + + {formatGroupName(profile.group)} + + +
+
+ + {/* Created At */} +
+ +
+ Account Created + {formatDate(profile.created_at)} +
+
+ + {/* Last Login */} +
+ +
+ Last Login + {formatDate(profile.last_login)} +
+
+
+ )} + + {/* Password change section — shown when profile is loaded */} + {!loading && !error && profile && ( + <> +
+
+

+ + Change Password +

+ + {/* Success message */} + {changeSuccess && ( +
+ + {changeSuccess} +
+ )} + + {/* API error message */} + {changeError && ( +
+ + {changeError} +
+ )} + +
+ {/* Current Password */} +
+ +
+ { setCurrentPassword(e.target.value); setChangeError(null); }} + style={STYLES.input} + onFocus={(e) => { e.currentTarget.style.borderColor = '#0EA5E9'; e.currentTarget.style.boxShadow = '0 0 0 2px rgba(14, 165, 233, 0.15)'; }} + onBlur={(e) => { e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.25)'; e.currentTarget.style.boxShadow = 'none'; }} + placeholder="Enter current password" + autoComplete="current-password" + /> + +
+
+ + {/* New Password */} +
+ +
+ { setNewPassword(e.target.value); setChangeError(null); }} + style={{ + ...STYLES.input, + ...(validationErrors?.newPassword ? STYLES.inputError : {}), + }} + onFocus={(e) => { e.currentTarget.style.borderColor = validationErrors?.newPassword ? 'rgba(239, 68, 68, 0.5)' : '#0EA5E9'; e.currentTarget.style.boxShadow = '0 0 0 2px rgba(14, 165, 233, 0.15)'; }} + onBlur={(e) => { e.currentTarget.style.borderColor = validationErrors?.newPassword ? 'rgba(239, 68, 68, 0.5)' : 'rgba(14, 165, 233, 0.25)'; e.currentTarget.style.boxShadow = 'none'; }} + placeholder="Minimum 8 characters" + autoComplete="new-password" + /> + +
+ {validationErrors?.newPassword && ( + {validationErrors.newPassword} + )} +
+ + {/* Confirm New Password */} +
+ +
+ { setConfirmPassword(e.target.value); setChangeError(null); }} + style={{ + ...STYLES.input, + ...(validationErrors?.confirmPassword ? STYLES.inputError : {}), + }} + onFocus={(e) => { e.currentTarget.style.borderColor = validationErrors?.confirmPassword ? 'rgba(239, 68, 68, 0.5)' : '#0EA5E9'; e.currentTarget.style.boxShadow = '0 0 0 2px rgba(14, 165, 233, 0.15)'; }} + onBlur={(e) => { e.currentTarget.style.borderColor = validationErrors?.confirmPassword ? 'rgba(239, 68, 68, 0.5)' : 'rgba(14, 165, 233, 0.25)'; e.currentTarget.style.boxShadow = 'none'; }} + placeholder="Re-enter new password" + autoComplete="new-password" + /> + +
+ {validationErrors?.confirmPassword && ( + {validationErrors.confirmPassword} + )} +
+ + {/* Submit button */} + +
+
+ + )} +
+
+
+ ); +}