Add user profile panel with self-service password change and dark theme UserMenu
This commit is contained in:
1
.kiro/specs/user-profile/.config.kiro
Normal file
1
.kiro/specs/user-profile/.config.kiro
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"specId": "74f6201d-ed0f-4df3-86a2-4a0767dd497c", "workflowType": "requirements-first", "specType": "feature"}
|
||||||
361
.kiro/specs/user-profile/design.md
Normal file
361
.kiro/specs/user-profile/design.md
Normal file
@@ -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 |
|
||||||
87
.kiro/specs/user-profile/requirements.md
Normal file
87
.kiro/specs/user-profile/requirements.md
Normal file
@@ -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.
|
||||||
119
.kiro/specs/user-profile/tasks.md
Normal file
119
.kiro/specs/user-profile/tasks.md
Normal file
@@ -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 `<UserProfilePanel isOpen={showProfile} onClose={() => 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`
|
||||||
48
backend/__tests__/auth-password-change.property.test.js
Normal file
48
backend/__tests__/auth-password-change.property.test.js
Normal file
@@ -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
|
||||||
|
});
|
||||||
84
backend/__tests__/auth-profile-completeness.property.test.js
Normal file
84
backend/__tests__/auth-profile-completeness.property.test.js
Normal file
@@ -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 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
39
backend/__tests__/auth-short-password.property.test.js
Normal file
39
backend/__tests__/auth-short-password.property.test.js
Normal file
@@ -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 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
53
backend/__tests__/auth-wrong-password.property.test.js
Normal file
53
backend/__tests__/auth-wrong-password.property.test.js
Normal file
@@ -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
|
||||||
|
});
|
||||||
@@ -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
|
* POST /api/auth/cleanup-sessions
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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(
|
||||||
|
<UserProfilePanel isOpen={true} onClose={onClose} />
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
153
frontend/src/__tests__/UserProfilePanel.property.test.js
Normal file
153
frontend/src/__tests__/UserProfilePanel.property.test.js
Normal file
@@ -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:
|
||||||
|
// <div style={fieldRow}>
|
||||||
|
// <svg ... />
|
||||||
|
// <div style={fieldContent}>
|
||||||
|
// <span style={fieldLabel}>LABEL</span>
|
||||||
|
// <span style={fieldValue}>VALUE</span>
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
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(
|
||||||
|
<UserProfilePanel isOpen={true} onClose={onClose} />
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
@@ -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(
|
||||||
|
<UserProfilePanel isOpen={true} onClose={onClose} />
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
@@ -1,10 +1,162 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { User, LogOut, ChevronDown, Shield, Clock } from 'lucide-react';
|
import { User, LogOut, ChevronDown, Shield, Clock } from 'lucide-react';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
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 }) {
|
export default function UserMenu({ onManageUsers, onAuditLog }) {
|
||||||
const { user, logout, isAdmin } = useAuth();
|
const { user, logout, isAdmin } = useAuth();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [buttonHovered, setButtonHovered] = useState(false);
|
||||||
|
const [hoveredItem, setHoveredItem] = useState(null);
|
||||||
|
const [showProfile, setShowProfile] = useState(false);
|
||||||
const menuRef = useRef(null);
|
const menuRef = useRef(null);
|
||||||
|
|
||||||
// Close menu when clicking outside
|
// Close menu when clicking outside
|
||||||
@@ -19,21 +171,6 @@ export default function UserMenu({ onManageUsers, onAuditLog }) {
|
|||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
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) => {
|
const formatGroupName = (group) => {
|
||||||
if (!group) return '';
|
if (!group) return '';
|
||||||
return group.replace(/_/g, ' ');
|
return group.replace(/_/g, ' ');
|
||||||
@@ -44,6 +181,11 @@ export default function UserMenu({ onManageUsers, onAuditLog }) {
|
|||||||
await logout();
|
await logout();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleProfile = () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setShowProfile(true);
|
||||||
|
};
|
||||||
|
|
||||||
const handleManageUsers = () => {
|
const handleManageUsers = () => {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
if (onManageUsers) {
|
if (onManageUsers) {
|
||||||
@@ -61,45 +203,79 @@ export default function UserMenu({ onManageUsers, onAuditLog }) {
|
|||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={menuRef}>
|
<div style={STYLES.container} ref={menuRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-gray-100 transition-colors"
|
onMouseEnter={() => setButtonHovered(true)}
|
||||||
|
onMouseLeave={() => setButtonHovered(false)}
|
||||||
|
style={{
|
||||||
|
...STYLES.menuButton,
|
||||||
|
...(buttonHovered ? STYLES.menuButtonHover : {}),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="w-8 h-8 bg-[#0476D9] rounded-full flex items-center justify-center">
|
<div style={STYLES.avatar}>
|
||||||
<User className="w-4 h-4 text-white" />
|
<User size={16} style={STYLES.avatarIcon} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left hidden sm:block">
|
<div style={STYLES.userInfo} className="hidden sm:block">
|
||||||
<p className="text-sm font-medium text-gray-900">{user.username}</p>
|
<p style={STYLES.username}>{user.username}</p>
|
||||||
<p className="text-xs text-gray-500">{formatGroupName(user.group)}</p>
|
<p style={STYLES.groupLabel}>{formatGroupName(user.group)}</p>
|
||||||
</div>
|
</div>
|
||||||
<ChevronDown className={`w-4 h-4 text-gray-500 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
<ChevronDown
|
||||||
|
size={16}
|
||||||
|
style={{
|
||||||
|
...STYLES.chevron,
|
||||||
|
...(isOpen ? STYLES.chevronOpen : {}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="absolute right-0 mt-2 w-64 bg-white rounded-lg shadow-lg border border-gray-200 py-2 z-50">
|
<div style={STYLES.dropdown}>
|
||||||
<div className="px-4 py-3 border-b border-gray-100">
|
<div style={STYLES.dropdownHeader}>
|
||||||
<p className="text-sm font-medium text-gray-900">{user.username}</p>
|
<p style={STYLES.dropdownHeaderName}>{user.username}</p>
|
||||||
<p className="text-sm text-gray-500">{user.email}</p>
|
<p style={STYLES.dropdownHeaderEmail}>{user.email}</p>
|
||||||
<span className={`inline-block mt-2 px-2 py-1 rounded text-xs font-medium ${getGroupBadgeColor(user.group)}`}>
|
<span style={getGroupBadgeStyle(user.group)}>
|
||||||
{formatGroupName(user.group)}
|
{formatGroupName(user.group)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleProfile}
|
||||||
|
onMouseEnter={() => setHoveredItem('profile')}
|
||||||
|
onMouseLeave={() => setHoveredItem(null)}
|
||||||
|
style={{
|
||||||
|
...STYLES.menuItem,
|
||||||
|
...(hoveredItem === 'profile' ? STYLES.menuItemHover : {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<User size={16} />
|
||||||
|
My Profile
|
||||||
|
</button>
|
||||||
|
|
||||||
{isAdmin() && (
|
{isAdmin() && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={handleManageUsers}
|
onClick={handleManageUsers}
|
||||||
className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-3"
|
onMouseEnter={() => setHoveredItem('manage')}
|
||||||
|
onMouseLeave={() => setHoveredItem(null)}
|
||||||
|
style={{
|
||||||
|
...STYLES.menuItem,
|
||||||
|
...(hoveredItem === 'manage' ? STYLES.menuItemHover : {}),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Shield className="w-4 h-4" />
|
<Shield size={16} />
|
||||||
Manage Users
|
Manage Users
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleAuditLog}
|
onClick={handleAuditLog}
|
||||||
className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-3"
|
onMouseEnter={() => setHoveredItem('audit')}
|
||||||
|
onMouseLeave={() => setHoveredItem(null)}
|
||||||
|
style={{
|
||||||
|
...STYLES.menuItem,
|
||||||
|
...(hoveredItem === 'audit' ? STYLES.menuItemHover : {}),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Clock className="w-4 h-4" />
|
<Clock size={16} />
|
||||||
Audit Log
|
Audit Log
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
@@ -107,13 +283,19 @@ export default function UserMenu({ onManageUsers, onAuditLog }) {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-3"
|
onMouseEnter={() => setHoveredItem('signout')}
|
||||||
|
onMouseLeave={() => setHoveredItem(null)}
|
||||||
|
style={{
|
||||||
|
...STYLES.signOutItem,
|
||||||
|
...(hoveredItem === 'signout' ? STYLES.signOutItemHover : {}),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<LogOut className="w-4 h-4" />
|
<LogOut size={16} />
|
||||||
Sign Out
|
Sign Out
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<UserProfilePanel isOpen={showProfile} onClose={() => setShowProfile(false)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
754
frontend/src/components/UserProfilePanel.js
Normal file
754
frontend/src/components/UserProfilePanel.js
Normal file
@@ -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 (
|
||||||
|
<div style={STYLES.overlay}>
|
||||||
|
<div ref={panelRef} style={STYLES.panel}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={STYLES.header}>
|
||||||
|
<h2 style={STYLES.headerTitle}>
|
||||||
|
<User style={STYLES.headerIcon} size={20} />
|
||||||
|
My Profile
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={STYLES.closeButton}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.color = '#F8FAFC'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.color = '#94A3B8'; }}
|
||||||
|
aria-label="Close profile panel"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div style={STYLES.body}>
|
||||||
|
{/* Loading state */}
|
||||||
|
{loading && (
|
||||||
|
<div style={STYLES.loadingContainer}>
|
||||||
|
<Loader size={28} color="#0EA5E9" style={{ animation: 'spin 1s linear infinite' }} />
|
||||||
|
<span style={STYLES.loadingText}>Loading profile...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error state */}
|
||||||
|
{!loading && error && (
|
||||||
|
<div style={STYLES.errorContainer}>
|
||||||
|
<AlertCircle size={32} color="#EF4444" />
|
||||||
|
<span style={STYLES.errorText}>{error}</span>
|
||||||
|
<button
|
||||||
|
onClick={fetchProfile}
|
||||||
|
style={STYLES.retryButton}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(14, 165, 233, 0.25) 0%, rgba(14, 165, 233, 0.2) 100%)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(14, 165, 233, 0.15) 0%, rgba(14, 165, 233, 0.1) 100%)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Profile info section */}
|
||||||
|
{!loading && !error && profile && (
|
||||||
|
<div style={STYLES.profileSection}>
|
||||||
|
{/* Username */}
|
||||||
|
<div style={STYLES.fieldRow}>
|
||||||
|
<User size={18} style={STYLES.fieldIcon} />
|
||||||
|
<div style={STYLES.fieldContent}>
|
||||||
|
<span style={STYLES.fieldLabel}>Username</span>
|
||||||
|
<span style={STYLES.fieldValue}>{profile.username}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<div style={STYLES.fieldRow}>
|
||||||
|
<Mail size={18} style={STYLES.fieldIcon} />
|
||||||
|
<div style={STYLES.fieldContent}>
|
||||||
|
<span style={STYLES.fieldLabel}>Email</span>
|
||||||
|
<span style={STYLES.fieldValue}>{profile.email}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Group */}
|
||||||
|
<div style={STYLES.fieldRow}>
|
||||||
|
<Shield size={18} style={STYLES.fieldIcon} />
|
||||||
|
<div style={STYLES.fieldContent}>
|
||||||
|
<span style={STYLES.fieldLabel}>Group</span>
|
||||||
|
<span style={{ ...STYLES.fieldValue, display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
<span style={STYLES.groupBadge(profile.group)}>
|
||||||
|
{formatGroupName(profile.group)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Created At */}
|
||||||
|
<div style={STYLES.fieldRow}>
|
||||||
|
<Calendar size={18} style={STYLES.fieldIcon} />
|
||||||
|
<div style={STYLES.fieldContent}>
|
||||||
|
<span style={STYLES.fieldLabel}>Account Created</span>
|
||||||
|
<span style={STYLES.fieldValue}>{formatDate(profile.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Last Login */}
|
||||||
|
<div style={STYLES.fieldRow}>
|
||||||
|
<Clock size={18} style={STYLES.fieldIcon} />
|
||||||
|
<div style={STYLES.fieldContent}>
|
||||||
|
<span style={STYLES.fieldLabel}>Last Login</span>
|
||||||
|
<span style={STYLES.fieldValue}>{formatDate(profile.last_login)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Password change section — shown when profile is loaded */}
|
||||||
|
{!loading && !error && profile && (
|
||||||
|
<>
|
||||||
|
<hr style={STYLES.separator} />
|
||||||
|
<div style={STYLES.passwordSection}>
|
||||||
|
<h3 style={STYLES.passwordHeading}>
|
||||||
|
<Lock size={18} style={STYLES.passwordHeadingIcon} />
|
||||||
|
Change Password
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Success message */}
|
||||||
|
{changeSuccess && (
|
||||||
|
<div style={STYLES.changeSuccess}>
|
||||||
|
<CheckCircle size={16} />
|
||||||
|
{changeSuccess}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* API error message */}
|
||||||
|
{changeError && (
|
||||||
|
<div style={STYLES.changeError}>
|
||||||
|
<AlertCircle size={16} />
|
||||||
|
{changeError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handlePasswordChange} autoComplete="off">
|
||||||
|
{/* Current Password */}
|
||||||
|
<div style={{ ...STYLES.formGroup, marginBottom: '0.75rem' }}>
|
||||||
|
<label style={STYLES.inputLabel}>Current Password</label>
|
||||||
|
<div style={STYLES.inputWrapper}>
|
||||||
|
<input
|
||||||
|
type={showCurrentPassword ? 'text' : 'password'}
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => { 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={STYLES.visibilityToggle}
|
||||||
|
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.color = '#F8FAFC'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.color = '#94A3B8'; }}
|
||||||
|
aria-label={showCurrentPassword ? 'Hide current password' : 'Show current password'}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showCurrentPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* New Password */}
|
||||||
|
<div style={{ ...STYLES.formGroup, marginBottom: '0.75rem' }}>
|
||||||
|
<label style={STYLES.inputLabel}>New Password</label>
|
||||||
|
<div style={STYLES.inputWrapper}>
|
||||||
|
<input
|
||||||
|
type={showNewPassword ? 'text' : 'password'}
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => { 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={STYLES.visibilityToggle}
|
||||||
|
onClick={() => setShowNewPassword(!showNewPassword)}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.color = '#F8FAFC'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.color = '#94A3B8'; }}
|
||||||
|
aria-label={showNewPassword ? 'Hide new password' : 'Show new password'}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showNewPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{validationErrors?.newPassword && (
|
||||||
|
<span style={STYLES.validationError}>{validationErrors.newPassword}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirm New Password */}
|
||||||
|
<div style={{ ...STYLES.formGroup, marginBottom: '0.75rem' }}>
|
||||||
|
<label style={STYLES.inputLabel}>Confirm New Password</label>
|
||||||
|
<div style={STYLES.inputWrapper}>
|
||||||
|
<input
|
||||||
|
type={showConfirmPassword ? 'text' : 'password'}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => { 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={STYLES.visibilityToggle}
|
||||||
|
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.color = '#F8FAFC'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.color = '#94A3B8'; }}
|
||||||
|
aria-label={showConfirmPassword ? 'Hide confirm password' : 'Show confirm password'}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showConfirmPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{validationErrors?.confirmPassword && (
|
||||||
|
<span style={STYLES.validationError}>{validationErrors.confirmPassword}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit button */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!canSubmitPasswordForm()}
|
||||||
|
style={{
|
||||||
|
...STYLES.submitButton,
|
||||||
|
...(!canSubmitPasswordForm() ? STYLES.submitButtonDisabled : {}),
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (canSubmitPasswordForm()) {
|
||||||
|
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(14, 165, 233, 0.25) 0%, rgba(14, 165, 233, 0.2) 100%)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 0 20px rgba(14, 165, 233, 0.25)';
|
||||||
|
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(14, 165, 233, 0.15) 0%, rgba(14, 165, 233, 0.1) 100%)';
|
||||||
|
e.currentTarget.style.boxShadow = 'none';
|
||||||
|
e.currentTarget.style.transform = 'none';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{changeLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader size={16} style={{ animation: 'spin 1s linear infinite' }} />
|
||||||
|
Changing Password...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Lock size={16} />
|
||||||
|
Change Password
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user