28 Commits

Author SHA1 Message Date
William Valentin ed8cbca1da build: streamline Docker setup and environment config
Build and Deploy / build (push) Has been cancelled
Build and Deploy / test (push) Has been cancelled
Build and Deploy / deploy (push) Has been cancelled
- Simplify Dockerfile to use official Bun image as base
- Add admin bootstrap environment variables to .env.example
- Include VITE_ADMIN_EMAIL and VITE_ADMIN_PASSWORD build args
- Fix newline at end of .env.example file
- Remove redundant system dependency installations
2025-10-16 13:16:52 -07:00
William Valentin f44ec57c62 build: enhance test configuration and TypeScript handling
- Update Jest config to use ts-jest for better TypeScript support
- Add TSX test file pattern support for React components
- Improve Babel config with proper TypeScript preset settings
- Enable better import.meta transformation for Jest compatibility
2025-10-16 13:16:37 -07:00
William Valentin bf36f14eab fix(config): resolve circular import in unified config
- Replace direct logger import with dynamic import pattern
- Add withLogger utility to handle async logger initialization
- Prevent module loading issues during configuration bootstrap
- Maintain logging functionality while avoiding circular dependencies
2025-10-16 13:16:25 -07:00
William Valentin 7f5cf7a9e5 test: add comprehensive test coverage structure
- Add accessibility tests for ResetPasswordPage component
- Add performance tests for schedule generation
- Add visual regression tests with snapshot baselines
- Establish testing patterns for UI accessibility compliance
- Include performance benchmarks for core utilities
2025-10-16 13:16:12 -07:00
William Valentin 6a6b48cbc5 test: update auth and database tests for password hashing
- Refactor AvatarDropdown tests to use helper function pattern
- Add ResetPasswordPage test coverage for form validation and submission
- Update auth integration tests to verify bcrypt password handling
- Fix database service tests to expect hashed passwords
- Add proper mock setup for password verification scenarios
2025-10-16 13:16:00 -07:00
William Valentin 7317616032 feat(ui): improve avatar dropdown keyboard accessibility
- Add keyboard event handler for Enter and Space keys
- Support proper dropdown toggle via keyboard navigation
- Prevent default behavior for key events
- Enhance accessibility for screen reader users
2025-10-16 13:15:37 -07:00
William Valentin 25e25d92bc feat(auth): add password reset page component
- Add ResetPasswordPage with token validation from URL params
- Implement password confirmation validation
- Display success/error states with proper feedback
- Include accessible form labels and navigation buttons
- Export ResetPasswordPage from auth components index
2025-10-16 13:15:25 -07:00
William Valentin a183aca4d8 feat(auth): implement secure password authentication
- Replace plaintext password comparison with bcrypt verification
- Hash passwords before database storage in registration
- Validate bcrypt hashes during login to reject legacy plaintext
- Update password change and reset flows with proper hashing
- Add legacy password detection for security enforcement
2025-10-16 13:15:08 -07:00
William Valentin 50a352fb27 feat(auth): add bcrypt password hashing service
- Add password hashing and verification utilities
- Implement bcrypt hash detection helper
- Support configurable salt rounds from unified config
- Replace plaintext password storage with secure hashing
2025-10-16 13:14:54 -07:00
William Valentin 35d6a48802 docs: add repository guidelines and development standards 2025-10-16 13:14:42 -07:00
William Valentin 10d1de91fe refactor(logging): replace console usage with logger 2025-09-23 12:19:15 -07:00
William Valentin 16bd4a8b20 chore(makefile): add parity targets and seeding 2025-09-23 11:39:30 -07:00
William Valentin dec8c7b42e docs: add docker compose quickstart 2025-09-23 11:32:48 -07:00
William Valentin eb43766b21 docs: clarify run profiles and env setup 2025-09-23 11:29:39 -07:00
William Valentin de237fd997 refactor: centralize base64 encoding 2025-09-23 11:27:32 -07:00
William Valentin e3a924c0c6 feat(db): retry on document conflicts 2025-09-23 11:25:28 -07:00
William Valentin f9ccb50222 feat(db): index users by email 2025-09-23 11:23:45 -07:00
William Valentin fcfe2a38e2 feat(admin): replace alerts with toasts 2025-09-23 11:20:37 -07:00
William Valentin e9a662d1e2 feat(admin): add user search and sorting 2025-09-23 10:58:01 -07:00
William Valentin 7c712ae84b test(reminders): cover schedule edge cases 2025-09-23 10:54:33 -07:00
William Valentin 9bed793997 feat(reminders): validate frequency and time range 2025-09-23 10:53:12 -07:00
William Valentin 35dcae07e5 feat(accessibility): improve dose and reminder cards 2025-09-23 10:47:48 -07:00
William Valentin 2cb56d5f5f feat(medication): improve snooze timer handling 2025-09-23 10:45:18 -07:00
William Valentin 9b4ee116e6 test(schedule): cover dst transition logic 2025-09-23 10:39:35 -07:00
William Valentin e7dbe30763 feat(mail): clarify mailgun configuration feedback 2025-09-23 10:30:12 -07:00
William Valentin 71c37f4b7b test(auth): add token service coverage 2025-09-23 10:24:44 -07:00
William Valentin c1c8e28f01 refactor(logging): replace console usage in auth flows 2025-09-23 10:15:57 -07:00
William Valentin 6b6a44acef feat(auth): centralize token storage 2025-09-23 10:11:14 -07:00
55 changed files with 3120 additions and 770 deletions
+7 -1
View File
@@ -20,6 +20,12 @@ VITE_COUCHDB_URL=http://localhost:5984
VITE_COUCHDB_USER=admin
VITE_COUCHDB_PASSWORD=change-this-secure-password
# Default Admin Bootstrap (used by frontend seeder at startup)
# Note: These are evaluated at build-time by Vite. If you change them,
# rebuild the frontend image (`docker compose build frontend`).
VITE_ADMIN_EMAIL=admin@localhost
VITE_ADMIN_PASSWORD=admin123!
# Application Configuration
# Base URL for your application (used in email links)
# Development: http://localhost:5173
@@ -85,4 +91,4 @@ GITEA_REPOSITORY=yourusername/rxminder
DEPLOYMENT_WEBHOOK_URL=
# Image cleanup settings
CLEANUP_OLD_IMAGES=true
CLEANUP_OLD_IMAGES=true
+25
View File
@@ -0,0 +1,25 @@
# Repository Guidelines
## Project Structure & Module Organization
Source boots from `index.tsx` into `App.tsx`. Feature UIs live in `components/`, shared context in `contexts/`, and reusable hooks under `hooks/`. Network clients stay in `services/`; supporting utilities and shared types sit in `utils/` and `types/`. Configuration belongs in `config/`, while CouchDB credentials and seeds are organized in `couchdb-config/` and `couchdb-data/`. Tests live alongside code with broader integration suites in `tests/`; docs and proposals belong in `docs/`.
## Build, Test, and Development Commands
Run `bun run dev` (or `make dev`) for the Vite development server. `bun run build` compiles a production bundle; verify it locally with `bun run preview`. Guard quality with `bun run lint`, `bun run format:check`, and `bun run type-check`. Execute the full Jest suite via `bun run test`, switching to `bun run test:watch` or `bun run test:coverage` when iterating. Make targets (`make build`, `make test`) wrap the same tasks for CI parity.
## Coding Style & Naming Conventions
All code is TypeScript; use `.tsx` for React components and `.ts` elsewhere. Prettier enforces 2-space indentation, trailing commas, and quote consistency—run `bun run format:check` before committing. Follow ESLint rules (hooks lifecycle, `no-unused-vars`, no `console` outside tests). Name components in PascalCase, hooks as `useThing`, and tests as `Feature.test.ts[x]`. Keep configuration constants in SCREAMING_SNAKE_CASE and load env-specific values through `config/` helpers.
## Testing Guidelines
Jest with React Testing Library drives component coverage. Prefer colocated tests to mirror features; integration flows live under `tests/integration/`. Mock CouchDB requests with the lightweight service helpers instead of hitting live endpoints. Aim for meaningful assertions over snapshots unless the UI is intentionally static. Keep coverage balanced across hooks and service contracts before merging.
## Commit & Pull Request Guidelines
Commits follow Conventional Commits (`type(scope): imperative summary`) and should contain a single logical change. Ensure lint, format, and tests pass locally; Husky will re-run the checks. Pull requests should describe motivation, summarize implementation decisions, and list manual test evidence. Link relevant tickets or CouchDB tasks, include screenshots or terminal output for UI/CLI changes, and flag migrations so reviewers can reproduce.
## Security & Configuration Tips
Never commit real secrets—copy `.env.example` when you need local overrides. Use `bun run check:secrets` before pushing whenever credentials might be touched. Prefer `config/` accessors over hard-coded URLs or keys, and store CouchDB credentials in the secure store rather than the repo.
+84 -28
View File
@@ -32,6 +32,7 @@ import {
AuthPage,
AvatarDropdown,
ChangePasswordModal,
ResetPasswordPage,
} from './components/auth';
import { AdminInterface } from './components/admin';
import {
@@ -62,6 +63,9 @@ import {
import { useUser } from './contexts/UserContext';
import { databaseService } from './services/database';
import { databaseSeeder } from './services/database.seeder';
import { logger } from './services/logging';
import { normalizeError } from './utils/error';
import { determineDoseStatus } from './utils/doseStatus';
const Header: React.FC<{
onAdd: () => void;
@@ -230,7 +234,9 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => {
useEffect(() => {
// Don't try to fetch data if user._id is not available
if (!user._id) {
console.warn('Skipping data fetch: user._id is not available');
logger.ui.action('Skipping data fetch because user id is missing', {
userId: user._id,
});
return;
}
@@ -239,7 +245,9 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => {
setIsLoading(true);
setError(null);
console.warn('Fetching data for user:', user._id);
logger.db.query('Fetching medication data for user', {
userId: user._id,
});
const [medsData, remindersData, takenDosesData, settingsData] =
await Promise.all([
@@ -249,9 +257,10 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => {
databaseService.getUserSettings(user._id),
]);
console.warn('Data fetched successfully:', {
medications: medsData.length,
reminders: remindersData.length,
logger.db.query('Fetched user data successfully', {
userId: user._id,
medicationCount: medsData.length,
reminderCount: remindersData.length,
hasTakenDoses: !!takenDosesData,
hasSettings: !!settingsData,
});
@@ -266,8 +275,9 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => {
}
} catch (e) {
setError('Failed to load your data. Please try again.');
console.error('Error loading user data:', e);
console.error('User object:', user);
logger.db.error('Error loading user data', normalizeError(e), {
userId: user._id,
});
} finally {
setIsLoading(false);
}
@@ -368,16 +378,35 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => {
async (doseId: string) => {
if (!takenDosesDoc) return;
const newDoses = { ...takenDosesDoc.doses };
if (newDoses[doseId]) {
const wasTaken = Boolean(newDoses[doseId]);
if (wasTaken) {
delete newDoses[doseId];
} else {
newDoses[doseId] = new Date().toISOString();
}
const updatedDoc = await databaseService.updateTakenDoses({
...takenDosesDoc,
doses: newDoses,
});
setTakenDosesDoc(updatedDoc);
if (!wasTaken) {
setSnoozedDoses(prev => {
if (!prev[doseId]) {
return prev;
}
const updated = { ...prev };
delete updated[doseId];
return updated;
});
if (notificationTimers.current[doseId]) {
clearTimeout(notificationTimers.current[doseId]);
delete notificationTimers.current[doseId];
}
}
},
[takenDosesDoc]
);
@@ -395,13 +424,13 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => {
}, []);
const getDoseStatus = useCallback(
(dose: Dose, doseTime: Date, now: Date): DoseStatus => {
if (takenDoses[dose.id]) return DoseStatus.TAKEN;
if (snoozedDoses[dose.id] && new Date(snoozedDoses[dose.id]) > now)
return DoseStatus.SNOOZED;
if (doseTime.getTime() < now.getTime()) return DoseStatus.MISSED;
return DoseStatus.UPCOMING;
},
(dose: Dose, doseTime: Date, now: Date): DoseStatus =>
determineDoseStatus({
takenAt: takenDoses[dose.id],
snoozedUntil: snoozedDoses[dose.id],
scheduledTime: doseTime,
now,
}),
[takenDoses, snoozedDoses]
);
@@ -413,15 +442,20 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => {
const medication = medications.find(m => m._id === item.medicationId);
if (!medication) return null;
const snoozeString = snoozedDoses[item.id];
const snoozeDate = snoozeString ? new Date(snoozeString) : undefined;
const validSnooze =
snoozeDate && !Number.isNaN(snoozeDate.getTime())
? snoozeDate
: undefined;
return {
...item,
type: 'dose' as const,
medication,
status: getDoseStatus(item, item.scheduledTime, currentTime),
takenAt: takenDoses[item.id],
snoozedUntil: snoozedDoses[item.id]
? new Date(snoozedDoses[item.id])
: undefined,
snoozedUntil: validSnooze,
};
} else {
// It's a Custom Reminder
@@ -460,9 +494,10 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => {
let timeToNotification = -1;
let notificationBody = '';
let notificationTitle = '';
let targetTime: Date | null = null;
if (item.type === 'dose' && item.status === DoseStatus.UPCOMING) {
timeToNotification = item.scheduledTime.getTime() - now.getTime();
targetTime = item.snoozedUntil ?? item.scheduledTime;
notificationTitle = 'Time for your medication!';
notificationBody = `${item.medication.name} (${item.medication.dosage})`;
} else if (
@@ -470,17 +505,21 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => {
item.status === DoseStatus.SNOOZED &&
item.snoozedUntil
) {
timeToNotification = item.snoozedUntil.getTime() - now.getTime();
targetTime = item.snoozedUntil;
notificationTitle = 'Snoozed Medication Reminder';
notificationBody = `${item.medication.name} (${item.medication.dosage})`;
} else if (item.type === 'reminder' && item.scheduledTime > now) {
timeToNotification = item.scheduledTime.getTime() - now.getTime();
targetTime = item.scheduledTime;
notificationTitle = 'Reminder';
notificationBody = item.title;
}
if (targetTime) {
timeToNotification = targetTime.getTime() - now.getTime();
}
if (timeToNotification > 0) {
activeTimers[itemId] = setTimeout(() => {
const timerId = window.setTimeout(() => {
new Notification(notificationTitle, {
body: notificationBody,
tag: itemId,
@@ -488,16 +527,23 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => {
if (item.type === 'dose' && item.status === DoseStatus.SNOOZED) {
setSnoozedDoses(prev => {
const newSnoozed = { ...prev };
delete newSnoozed[itemId];
newSnoozed[itemId] = new Date().toISOString();
return newSnoozed;
});
}
delete activeTimers[itemId];
}, timeToNotification) as unknown as number;
}, timeToNotification);
activeTimers[itemId] = timerId;
}
});
return () => Object.values(activeTimers).forEach(clearTimeout);
return () => {
Object.entries(activeTimers).forEach(([id, timer]) => {
clearTimeout(timer);
delete activeTimers[id];
});
};
}, [scheduleWithStatus, settings?.notificationsEnabled]);
const filteredSchedule = useMemo(
@@ -688,7 +734,11 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => {
setSettings(updatedSettings);
setOnboardingOpen(false);
} catch (error) {
console.error('Failed to update onboarding status', error);
logger.ui.error(
'Failed to update onboarding status',
normalizeError(error),
{ userId: user._id }
);
setOnboardingOpen(false);
}
}
@@ -910,10 +960,10 @@ const App: React.FC = () => {
useEffect(() => {
const runSeeding = async () => {
try {
console.warn('🌱 Initializing database seeding...');
logger.db.query('Initializing database seeding');
await databaseSeeder.seedDatabase();
} catch (error) {
console.error(' Database seeding failed:', error);
logger.db.error('Database seeding failed', normalizeError(error));
}
};
@@ -929,6 +979,12 @@ const App: React.FC = () => {
}
if (!user) {
if (
typeof window !== 'undefined' &&
window.location.pathname === '/reset-password'
) {
return <ResetPasswordPage />;
}
return <AuthPage />;
}
+7 -21
View File
@@ -1,34 +1,17 @@
# Multi-stage Dockerfile for Medication Reminder App
FROM node:20-slim AS base
# Install system dependencies
RUN apt-get update && apt-get install -y \
curl \
unzip \
&& rm -rf /var/lib/apt/lists/*
# Install Bun
RUN curl -fsSL https://bun.sh/install | bash
ENV PATH="/root/.bun/bin:$PATH"
FROM oven/bun:1 AS builder
# Set working directory
WORKDIR /app
# Create non-root user
RUN groupadd --gid 1001 nodeuser && \
useradd --uid 1001 --gid nodeuser --shell /bin/bash --create-home nodeuser
# Builder stage
FROM base AS builder
# Copy package files
COPY --chown=nodeuser:nodeuser package.json bun.lock* ./
COPY package.json bun.lock* ./
# Install dependencies
RUN bun install --frozen-lockfile
# Copy source code
COPY --chown=nodeuser:nodeuser . ./
COPY . ./
# Build arguments for environment configuration
# Build Environment - unified config will handle the rest
@@ -40,6 +23,8 @@ ARG NODE_ENV=production
ARG VITE_COUCHDB_URL
ARG VITE_COUCHDB_USER
ARG VITE_COUCHDB_PASSWORD
ARG VITE_ADMIN_EMAIL
ARG VITE_ADMIN_PASSWORD
# Set environment variables for build process
# Unified config handles defaults, only set essential runtime overrides
@@ -47,7 +32,8 @@ ENV NODE_ENV=$NODE_ENV
ENV VITE_COUCHDB_URL=$VITE_COUCHDB_URL
ENV VITE_COUCHDB_USER=$VITE_COUCHDB_USER
ENV VITE_COUCHDB_PASSWORD=$VITE_COUCHDB_PASSWORD
ENV NODE_ENV=$NODE_ENV
ENV VITE_ADMIN_EMAIL=$VITE_ADMIN_EMAIL
ENV VITE_ADMIN_PASSWORD=$VITE_ADMIN_PASSWORD
# Build the application
RUN bun run build
+54 -1
View File
@@ -14,7 +14,12 @@ DOCKER_IMAGE ?= $(APP_NAME):latest
export
.PHONY: help install clean dev build test docker-build docker-buildx docker-run docker-clean info couchdb-up couchdb-down
.PHONY: help install clean \
dev preview build \
test test-watch test-coverage test-fast test-services test-integration \
lint lint-fix format format-check type-check \
docker-build docker-buildx docker-run docker-clean \
seed couchdb-up couchdb-down info
# Default target
.DEFAULT_GOAL := help
@@ -53,6 +58,10 @@ dev: ## Start development server
@echo "Starting $(APP_NAME) development server..."
@bun run dev
preview: ## Serve production build locally
@echo "Starting $(APP_NAME) preview server..."
@bun run preview
build: ## Build the application
@echo "Building $(APP_NAME) application..."
@bun run build
@@ -67,6 +76,44 @@ test-watch: ## Run unit tests in watch mode
@echo "Running $(APP_NAME) tests in watch mode..."
@bun run test:watch
test-coverage: ## Run tests with coverage report
@echo "Running $(APP_NAME) tests with coverage..."
@bun run test:coverage
test-fast: ## Run fast unit test subset
@echo "Running fast unit test subset..."
@bun run test:fast
test-services: ## Run service layer tests only
@echo "Running service layer tests..."
@bun run test:services
test-integration: ## Run integration tests
@echo "Running integration tests..."
@bun run test:integration
##@ Quality
lint: ## Run ESLint
@echo "Linting $(APP_NAME)..."
@bun run lint
lint-fix: ## Run ESLint with autofix
@echo "Linting $(APP_NAME) with auto-fix..."
@bun run lint:fix
format: ## Format code with Prettier
@echo "Formatting $(APP_NAME) code..."
@bun run format
format-check: ## Check code formatting without writing changes
@echo "Checking $(APP_NAME) formatting..."
@bun run format:check
type-check: ## Run TypeScript type checking
@echo "Type-checking $(APP_NAME)..."
@bun run type-check
##@ Docker
docker-build: ## Build Docker image for local development
@@ -102,6 +149,12 @@ docker-clean: ## Clean Docker resources and containers
@docker image prune -f 2>/dev/null || true
@docker container prune -f 2>/dev/null || true
##@ Database
seed: ## Seed default admin user into CouchDB
@echo "Seeding default admin account..."
@bun run seed
##@ Test Services
couchdb-up: ## Start local CouchDB for integration tests
+21 -1
View File
@@ -39,6 +39,16 @@ A modern, secure web application for managing medication schedules and reminders
- **Progress Tracking** over time
- **Export Capabilities** for healthcare providers
## 🧪 Run Profiles
| Profile | Purpose | How to run | Configuration |
| --------------- | ------------------------------------------------------ | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Development** | Fast local iteration with hot reload and mock services | `bun run dev` | Copy `.env.example` to `.env.local` for local overrides. CouchDB can be mocked (`VITE_COUCHDB_URL=mock`) or pointed to a dev instance. |
| **Testing** | Unit and integration validation in CI/stage | `bun run test` · `bun run test:watch` · `bun run test:coverage` | Tests run against the mock database strategy by default. No extra environment variables required. |
| **Production** | Hardened build served via Docker/Reverse proxy | `bun run build && bun run preview` or `docker compose up --build` | Populate `.env` with production credentials (CouchDB, Mailgun, OAuth). Review [`docs/setup/ENVIRONMENT_VARIABLES.md`](docs/setup/ENVIRONMENT_VARIABLES.md) for required keys. |
> ️ **Tip:** `.env.example` enumerates every variable consumed by the app. For local development prefer `.env.local` (ignored by Git) to avoid accidentally committing secrets.
### 🎨 **User Experience**
- **Responsive Design** for mobile and desktop
@@ -226,7 +236,17 @@ The application automatically selects the appropriate database strategy:
## 🐳 Docker Development
### **Build and Run**
### **Docker Compose Quickstart**
```bash
docker compose up --build
```
- Serves the production build at [http://localhost:8080](http://localhost:8080)
- Spins up CouchDB at [http://localhost:5984](http://localhost:5984) using credentials from `.env`
- Applies CORS settings from `couchdb-config/cors.ini` (update the allowed `origins` for custom domains)
### **Manual Build and Run**
```bash
# Build Docker image
+26 -14
View File
@@ -1,31 +1,43 @@
module.exports = {
presets: [
['@babel/preset-env', {
targets: {
node: 'current'
}
}],
'@babel/preset-typescript'
[
'@babel/preset-env',
{
targets: {
node: 'current',
},
},
],
[
'@babel/preset-typescript',
{
isTSX: true,
allExtensions: true,
},
],
],
plugins: [
// Transform import.meta for Jest compatibility
function() {
function () {
return {
visitor: {
MetaProperty(path) {
if (path.node.meta.name === 'import' && path.node.property.name === 'meta') {
if (
path.node.meta.name === 'import' &&
path.node.property.name === 'meta'
) {
path.replaceWithSourceString('({ env: process.env })');
}
}
}
},
},
};
}
},
],
env: {
test: {
plugins: [
// Additional test-specific plugins can go here
]
}
}
],
},
},
};
+469 -220
View File
@@ -1,8 +1,9 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useMemo } from 'react';
import { User, UserRole } from '../../types';
import { AccountStatus } from '../../services/auth/auth.constants';
import { databaseService } from '../../services/database';
import { useUser } from '../../contexts/UserContext';
import { logger } from '../../services/logging';
interface AdminInterfaceProps {
onClose: () => void;
@@ -15,61 +16,114 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
const [error, setError] = useState('');
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [newPassword, setNewPassword] = useState('');
const [userPendingDeletion, setUserPendingDeletion] = useState<User | null>(
null
);
const [isDeletingUser, setIsDeletingUser] = useState(false);
const [toasts, setToasts] = useState<
Array<{ id: string; message: string; tone: 'success' | 'error' | 'info' }>
>([]);
const [searchTerm, setSearchTerm] = useState('');
const [sortField, setSortField] = useState<
'createdAt' | 'status' | 'role' | 'username'
>('createdAt');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const createToastId = () =>
typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
const removeToast = (id: string) => {
setToasts(prev => prev.filter(toast => toast.id !== id));
};
const pushToast = (
message: string,
tone: 'success' | 'error' | 'info' = 'info'
) => {
const id = createToastId();
setToasts(prev => [...prev, { id, message, tone }]);
window.setTimeout(() => removeToast(id), 4000);
};
const toastStyles: Record<'success' | 'error' | 'info', string> = {
success: 'bg-green-50 border border-green-200 text-green-800',
error: 'bg-red-50 border border-red-200 text-red-800',
info: 'bg-blue-50 border border-blue-200 text-blue-800',
};
useEffect(() => {
loadUsers();
}, []);
const loadUsers = async () => {
setLoading(true);
setError('');
try {
const users = await databaseService.getAllUsers();
setUsers(users);
} catch (error) {
setError('Failed to load users');
console.error('Error loading users:', error);
logger.ui.error('Error loading users', error as Error);
pushToast('Failed to load users', 'error');
} finally {
setLoading(false);
}
};
const handleSuspendUser = async (userId: string) => {
const handleSuspendUser = async (user: User) => {
try {
await databaseService.suspendUser(userId);
await databaseService.suspendUser(user._id);
pushToast(`${user.username} suspended`, 'info');
await loadUsers();
} catch (error) {
setError('Failed to suspend user');
console.error('Error suspending user:', error);
logger.ui.error('Error suspending user', error as Error);
pushToast('Failed to suspend user', 'error');
}
};
const handleActivateUser = async (userId: string) => {
const handleActivateUser = async (user: User) => {
try {
await databaseService.activateUser(userId);
await databaseService.activateUser(user._id);
pushToast(`${user.username} reactivated`, 'success');
await loadUsers();
} catch (error) {
setError('Failed to activate user');
console.error('Error activating user:', error);
logger.ui.error('Error activating user', error as Error);
pushToast('Failed to activate user', 'error');
}
};
const handleDeleteUser = async (userId: string) => {
if (
!confirm(
'Are you sure you want to delete this user? This action cannot be undone.'
)
) {
return;
}
const confirmDeleteUser = (user: User) => {
setUserPendingDeletion(user);
setError('');
};
const executeDeleteUser = async () => {
if (!userPendingDeletion) return;
setIsDeletingUser(true);
try {
await databaseService.deleteUser(userId);
await databaseService.deleteUser(userPendingDeletion._id);
pushToast(`${userPendingDeletion.username} deleted`, 'info');
await loadUsers();
} catch (error) {
setError('Failed to delete user');
console.error('Error deleting user:', error);
logger.ui.error('Error deleting user', error as Error);
pushToast('Failed to delete user', 'error');
} finally {
setIsDeletingUser(false);
setUserPendingDeletion(null);
}
};
const closeDeleteDialog = () => {
if (isDeletingUser) return;
setUserPendingDeletion(null);
};
const handleChangePassword = async (userId: string) => {
if (!newPassword || newPassword.length < 6) {
setError('Password must be at least 6 characters long');
@@ -81,10 +135,11 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
setNewPassword('');
setSelectedUser(null);
setError('');
alert('Password changed successfully');
pushToast('Password changed successfully', 'success');
} catch (error) {
setError('Failed to change password');
console.error('Error changing password:', error);
logger.ui.error('Error changing password', error as Error);
pushToast('Failed to change password', 'error');
}
};
@@ -107,6 +162,88 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
: 'text-blue-600 bg-blue-100';
};
const statusPriority: Record<AccountStatus, number> = {
[AccountStatus.ACTIVE]: 0,
[AccountStatus.PENDING]: 1,
[AccountStatus.SUSPENDED]: 2,
};
const rolePriority: Record<UserRole, number> = {
[UserRole.ADMIN]: 0,
[UserRole.USER]: 1,
};
const getStatusPriority = (status?: AccountStatus) =>
statusPriority[status as AccountStatus] ?? Number.MAX_SAFE_INTEGER;
const getRolePriority = (role?: UserRole) =>
rolePriority[role as UserRole] ?? Number.MAX_SAFE_INTEGER;
const sortedUsers = useMemo(() => {
const copy = [...users];
copy.sort((a, b) => {
let result = 0;
switch (sortField) {
case 'status':
result = getStatusPriority(a.status) - getStatusPriority(b.status);
break;
case 'role':
result = getRolePriority(a.role) - getRolePriority(b.role);
break;
case 'username':
result = (a.username || '').localeCompare(
b.username || '',
undefined,
{
sensitivity: 'base',
}
);
break;
case 'createdAt':
default: {
const timeA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
const timeB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
result = timeA - timeB;
break;
}
}
if (result === 0) {
result = (a.username || '').localeCompare(b.username || '', undefined, {
sensitivity: 'base',
});
}
if (result === 0) {
result = (a.email || '').localeCompare(b.email || '', undefined, {
sensitivity: 'base',
});
}
return sortDirection === 'asc' ? result : -result;
});
return copy;
}, [users, sortField, sortDirection]);
const filteredUsers = useMemo(() => {
if (!searchTerm.trim()) {
return sortedUsers;
}
const query = searchTerm.trim().toLowerCase();
return sortedUsers.filter(user => {
const username = user.username?.toLowerCase() ?? '';
const email = user.email?.toLowerCase() ?? '';
return username.includes(query) || email.includes(query);
});
}, [sortedUsers, searchTerm]);
const visibleUsersLabel = searchTerm.trim()
? `${filteredUsers.length} of ${users.length} users`
: `${filteredUsers.length} users`;
if (currentUser?.role !== UserRole.ADMIN) {
return (
<div className='fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50'>
@@ -127,234 +264,346 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
}
return (
<div className='fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4'>
<div className='bg-white dark:bg-slate-800 rounded-lg w-full max-w-6xl max-h-[90vh] overflow-hidden'>
<div className='p-6 border-b border-slate-200 dark:border-slate-600'>
<div className='flex justify-between items-center'>
<h2 className='text-2xl font-bold text-slate-800 dark:text-slate-100'>
Admin Interface
</h2>
<>
<div className='fixed top-4 right-4 z-[70] space-y-2'>
{toasts.map(toast => (
<div
key={toast.id}
className={`px-4 py-3 rounded-lg shadow-lg flex items-start justify-between gap-3 ${toastStyles[toast.tone]}`}
role='status'
aria-live='polite'
>
<span className='text-sm font-medium'>{toast.message}</span>
<button
onClick={onClose}
className='text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200'
onClick={() => removeToast(toast.id)}
className='text-sm font-semibold text-slate-600 hover:text-slate-800 dark:text-slate-200 dark:hover:text-slate-50'
aria-label='Dismiss notification'
>
<svg
className='w-6 h-6'
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M6 18L18 6M6 6l12 12'
/>
</svg>
×
</button>
</div>
</div>
<div className='p-6 overflow-y-auto max-h-[calc(90vh-120px)]'>
{error && (
<div className='bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4'>
{error}
</div>
)}
{loading ? (
<div className='text-center py-8'>
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600 mx-auto'></div>
<p className='mt-2 text-slate-600 dark:text-slate-300'>
Loading users...
</p>
</div>
) : (
<div className='space-y-4'>
<div className='flex justify-between items-center'>
<h3 className='text-lg font-semibold text-slate-800 dark:text-slate-100'>
User Management ({users.length} users)
</h3>
<button
onClick={loadUsers}
className='bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-md text-sm'
))}
</div>
<div className='fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4'>
<div className='bg-white dark:bg-slate-800 rounded-lg w-full max-w-6xl max-h-[90vh] overflow-hidden'>
<div className='p-6 border-b border-slate-200 dark:border-slate-600'>
<div className='flex justify-between items-center'>
<h2 className='text-2xl font-bold text-slate-800 dark:text-slate-100'>
Admin Interface
</h2>
<button
onClick={onClose}
className='text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200'
>
<svg
className='w-6 h-6'
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
>
Refresh
</button>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M6 18L18 6M6 6l12 12'
/>
</svg>
</button>
</div>
</div>
<div className='p-6 overflow-y-auto max-h-[calc(90vh-120px)]'>
{error && (
<div className='bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4'>
{error}
</div>
)}
<div className='overflow-x-auto'>
<table className='min-w-full bg-white dark:bg-slate-700 rounded-lg overflow-hidden'>
<thead className='bg-slate-50 dark:bg-slate-600'>
<tr>
<th className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider'>
User
</th>
<th className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider'>
Email
</th>
<th className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider'>
Status
</th>
<th className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider'>
Role
</th>
<th className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider'>
Created
</th>
<th className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider'>
Actions
</th>
</tr>
</thead>
<tbody className='divide-y divide-slate-200 dark:divide-slate-600'>
{users.map(user => (
<tr
key={user._id}
className='hover:bg-slate-50 dark:hover:bg-slate-600'
{loading ? (
<div className='text-center py-8'>
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600 mx-auto'></div>
<p className='mt-2 text-slate-600 dark:text-slate-300'>
Loading users...
</p>
</div>
) : (
<div className='space-y-4'>
<div className='flex justify-between items-center'>
<h3 className='text-lg font-semibold text-slate-800 dark:text-slate-100'>
User Management ({visibleUsersLabel})
</h3>
<button
onClick={loadUsers}
className='bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-md text-sm'
>
Refresh
</button>
</div>
<div className='overflow-x-auto'>
<div className='flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between mb-4'>
<div className='w-full sm:w-72'>
<label htmlFor='admin-search' className='sr-only'>
Search users
</label>
<input
id='admin-search'
type='search'
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
placeholder='Search by username or email'
className='w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 text-sm bg-white dark:bg-slate-700 dark:border-slate-600 dark:text-slate-100'
/>
</div>
<div className='flex items-center gap-2'>
<label
htmlFor='admin-sort'
className='text-sm text-slate-600 dark:text-slate-300'
>
<td className='px-4 py-4'>
<div className='flex items-center'>
{user.avatar ? (
<img
src={user.avatar}
alt={user.username}
className='w-8 h-8 rounded-full mr-3'
/>
) : (
<div className='w-8 h-8 bg-indigo-600 rounded-full flex items-center justify-center mr-3'>
<span className='text-white text-sm font-medium'>
{user.username.charAt(0).toUpperCase()}
</span>
</div>
)}
<div>
<div className='text-sm font-medium text-slate-900 dark:text-slate-100'>
{user.username}
</div>
<div className='text-sm text-slate-500 dark:text-slate-400'>
ID: {user._id.slice(-8)}
</div>
</div>
</div>
</td>
<td className='px-4 py-4 text-sm text-slate-900 dark:text-slate-100'>
{user.email}
{user.emailVerified && (
<span className='ml-2 text-green-600'>✓</span>
)}
</td>
<td className='px-4 py-4'>
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(user.status)}`}
Sort by
</label>
<select
id='admin-sort'
value={sortField}
onChange={e =>
setSortField(
e.target.value as
| 'createdAt'
| 'status'
| 'role'
| 'username'
)
}
className='px-3 py-2 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 bg-white dark:bg-slate-700 dark:border-slate-600 dark:text-slate-100'
>
<option value='createdAt'>Created Date</option>
<option value='status'>Status</option>
<option value='role'>Role</option>
<option value='username'>Name</option>
</select>
<button
type='button'
onClick={() =>
setSortDirection(prev =>
prev === 'asc' ? 'desc' : 'asc'
)
}
className='px-3 py-2 text-sm border border-slate-300 rounded-md bg-white hover:bg-slate-100 dark:bg-slate-700 dark:border-slate-600 dark:text-slate-100 dark:hover:bg-slate-600'
aria-label={`Toggle sort direction. Currently ${
sortDirection === 'asc' ? 'ascending' : 'descending'
}`}
>
{sortDirection === 'asc' ? 'Asc ' : 'Desc '}
</button>
</div>
</div>
{filteredUsers.length === 0 ? (
<p className='text-center text-sm text-slate-600 dark:text-slate-300 py-6'>
No users match the current filters.
</p>
) : (
<table className='min-w-full bg-white dark:bg-slate-700 rounded-lg overflow-hidden'>
<thead className='bg-slate-50 dark:bg-slate-600'>
<tr>
<th className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider'>
User
</th>
<th className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider'>
Email
</th>
<th className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider'>
Status
</th>
<th className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider'>
Role
</th>
<th className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider'>
Created
</th>
<th className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider'>
Actions
</th>
</tr>
</thead>
<tbody className='divide-y divide-slate-200 dark:divide-slate-600'>
{filteredUsers.map(user => (
<tr
key={user._id}
className='hover:bg-slate-50 dark:hover:bg-slate-600'
>
{user.status || 'Unknown'}
</span>
</td>
<td className='px-4 py-4'>
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getRoleColor(user.role)}`}
>
{user.role || 'USER'}
</span>
</td>
<td className='px-4 py-4 text-sm text-slate-500 dark:text-slate-400'>
{user.createdAt
? new Date(user.createdAt).toLocaleDateString()
: 'Unknown'}
</td>
<td className='px-4 py-4'>
<div className='flex space-x-2'>
{user.status === AccountStatus.ACTIVE ? (
<button
onClick={() => handleSuspendUser(user._id)}
className='text-red-600 hover:text-red-800 text-xs'
disabled={user._id === currentUser?._id}
<td className='px-4 py-4'>
<div className='flex items-center'>
{user.avatar ? (
<img
src={user.avatar}
alt={user.username}
className='w-8 h-8 rounded-full mr-3'
/>
) : (
<div className='w-8 h-8 bg-indigo-600 rounded-full flex items-center justify-center mr-3'>
<span className='text-white text-sm font-medium'>
{user.username.charAt(0).toUpperCase()}
</span>
</div>
)}
<div>
<div className='text-sm font-medium text-slate-900 dark:text-slate-100'>
{user.username}
</div>
<div className='text-sm text-slate-500 dark:text-slate-400'>
ID: {user._id.slice(-8)}
</div>
</div>
</div>
</td>
<td className='px-4 py-4 text-sm text-slate-900 dark:text-slate-100'>
{user.email}
{user.emailVerified && (
<span className='ml-2 text-green-600'>✓</span>
)}
</td>
<td className='px-4 py-4'>
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(user.status)}`}
>
Suspend
</button>
) : (
<button
onClick={() => handleActivateUser(user._id)}
className='text-green-600 hover:text-green-800 text-xs'
{user.status || 'Unknown'}
</span>
</td>
<td className='px-4 py-4'>
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getRoleColor(user.role)}`}
>
Activate
</button>
)}
{user.role || 'USER'}
</span>
</td>
<td className='px-4 py-4 text-sm text-slate-500 dark:text-slate-400'>
{user.createdAt
? new Date(user.createdAt).toLocaleDateString()
: 'Unknown'}
</td>
<td className='px-4 py-4'>
<div className='flex space-x-2'>
{user.status === AccountStatus.ACTIVE ? (
<button
onClick={() => handleSuspendUser(user)}
className='text-red-600 hover:text-red-800 text-xs'
disabled={user._id === currentUser?._id}
>
Suspend
</button>
) : (
<button
onClick={() => handleActivateUser(user)}
className='text-green-600 hover:text-green-800 text-xs'
>
Activate
</button>
)}
{user.password && (
<button
onClick={() => setSelectedUser(user)}
className='text-blue-600 hover:text-blue-800 text-xs'
>
Change Password
</button>
)}
{user.password && (
<button
onClick={() => setSelectedUser(user)}
className='text-blue-600 hover:text-blue-800 text-xs'
>
Change Password
</button>
)}
<button
onClick={() => handleDeleteUser(user._id)}
className='text-red-600 hover:text-red-800 text-xs'
disabled={
user._id === currentUser?._id ||
user.role === UserRole.ADMIN
}
>
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
<button
onClick={() => confirmDeleteUser(user)}
className='text-red-600 hover:text-red-800 text-xs'
disabled={
user._id === currentUser?._id ||
user.role === UserRole.ADMIN
}
>
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
)}
</div>
{/* Password Change Modal */}
{selectedUser && (
<div className='fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-60'>
<div className='bg-white dark:bg-slate-800 rounded-lg p-6 max-w-md w-full mx-4'>
<h3 className='text-lg font-semibold text-slate-800 dark:text-slate-100 mb-4'>
Change Password for {selectedUser.username}
</h3>
<div className='space-y-4'>
<div>
<label className='block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2'>
New Password
</label>
<input
type='password'
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
className='w-full px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 dark:bg-slate-700 dark:border-slate-600 dark:text-white'
placeholder='Enter new password (min 6 characters)'
/>
</div>
<div className='flex space-x-3'>
<button
onClick={() => handleChangePassword(selectedUser._id)}
className='flex-1 bg-indigo-600 hover:bg-indigo-700 text-white font-medium py-2 px-4 rounded-md'
>
Change Password
</button>
<button
onClick={() => {
setSelectedUser(null);
setNewPassword('');
}}
className='flex-1 bg-slate-300 hover:bg-slate-400 text-slate-700 font-medium py-2 px-4 rounded-md'
>
Cancel
</button>
</div>
</div>
</div>
</div>
)}
</div>
{/* Password Change Modal */}
{selectedUser && (
<div className='fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-60'>
<div className='bg-white dark:bg-slate-800 rounded-lg p-6 max-w-md w-full mx-4'>
<h3 className='text-lg font-semibold text-slate-800 dark:text-slate-100 mb-4'>
Change Password for {selectedUser.username}
</h3>
<div className='space-y-4'>
<div>
<label className='block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2'>
New Password
</label>
<input
type='password'
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
className='w-full px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 dark:bg-slate-700 dark:border-slate-600 dark:text-white'
placeholder='Enter new password (min 6 characters)'
/>
</div>
{userPendingDeletion && (
<div className='fixed inset-0 bg-black bg-opacity-40 flex items-center justify-center z-[65] p-4'>
<div className='bg-white dark:bg-slate-800 rounded-lg p-6 max-w-md w-full'>
<h3 className='text-lg font-semibold text-slate-800 dark:text-slate-100 mb-2'>
Delete {userPendingDeletion.username}?
</h3>
<p className='text-sm text-slate-600 dark:text-slate-300 mb-4'>
This action cannot be undone. The user's data will be
permanently removed.
</p>
<div className='flex space-x-3'>
<button
onClick={() => handleChangePassword(selectedUser._id)}
className='flex-1 bg-indigo-600 hover:bg-indigo-700 text-white font-medium py-2 px-4 rounded-md'
onClick={executeDeleteUser}
disabled={isDeletingUser}
className='flex-1 bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-4 rounded-md disabled:opacity-50'
>
Change Password
{isDeletingUser ? 'Deleting...' : 'Delete'}
</button>
<button
onClick={() => {
setSelectedUser(null);
setNewPassword('');
}}
className='flex-1 bg-slate-300 hover:bg-slate-400 text-slate-700 font-medium py-2 px-4 rounded-md'
onClick={closeDeleteDialog}
disabled={isDeletingUser}
className='flex-1 bg-slate-200 hover:bg-slate-300 text-slate-700 font-medium py-2 px-4 rounded-md dark:bg-slate-700 dark:text-slate-200 dark:hover:bg-slate-600 disabled:opacity-50'
>
Cancel
</button>
</div>
</div>
</div>
</div>
)}
)}
</div>
</div>
</div>
</>
);
};
+6
View File
@@ -38,6 +38,12 @@ const AvatarDropdown: React.FC<AvatarDropdownProps> = ({
<div className='relative' ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
onKeyDown={event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
setIsOpen(prev => !prev);
}
}}
className='w-10 h-10 rounded-full bg-slate-200 dark:bg-slate-700 flex items-center justify-center text-lg font-bold text-slate-600 dark:text-slate-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-slate-900'
aria-label='User menu'
>
+188
View File
@@ -0,0 +1,188 @@
import React, { useMemo, useState } from 'react';
import { authService } from '../../services/auth/auth.service';
import { PillIcon } from '../icons/Icons';
const MIN_PASSWORD_LENGTH = 6;
const ResetPasswordPage: React.FC = () => {
const token = useMemo(() => {
if (typeof window === 'undefined') return null;
const params = new URLSearchParams(window.location.search);
return params.get('token');
}, []);
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<'idle' | 'submitting' | 'success'>(
'idle'
);
if (!token) {
return (
<div className='min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 px-4'>
<div className='max-w-md w-full bg-white dark:bg-slate-800 rounded-lg shadow-lg p-8 text-center'>
<h1 className='text-2xl font-semibold text-slate-800 dark:text-slate-100 mb-4'>
Password Reset Link Invalid
</h1>
<p className='text-slate-600 dark:text-slate-300 mb-6'>
We could not find a valid reset token. Please return to the sign in
page and request a new password reset email.
</p>
<button
onClick={() => {
if (typeof window !== 'undefined') {
window.location.href = '/';
}
}}
className='w-full bg-indigo-600 hover:bg-indigo-700 text-white font-medium py-2 px-4 rounded-md'
>
Back to Sign In
</button>
</div>
</div>
);
}
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setError(null);
if (password.length < MIN_PASSWORD_LENGTH) {
setError('Password must be at least 6 characters long.');
return;
}
if (password !== confirmPassword) {
setError('Passwords do not match.');
return;
}
try {
setStatus('submitting');
await authService.resetPassword(token, password);
setStatus('success');
} catch (err) {
setError(
err instanceof Error
? err.message
: 'Unable to reset password. Please try again.'
);
setStatus('idle');
}
};
if (status === 'success') {
return (
<div className='min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 px-4'>
<div className='max-w-md w-full bg-white dark:bg-slate-800 rounded-lg shadow-lg p-8 text-center'>
<div className='inline-block bg-emerald-100 dark:bg-emerald-900/60 p-3 rounded-full mb-4'>
<PillIcon className='w-8 h-8 text-emerald-500 dark:text-emerald-300' />
</div>
<h1 className='text-2xl font-semibold text-slate-800 dark:text-slate-100 mb-2'>
Password Updated
</h1>
<p className='text-slate-600 dark:text-slate-300 mb-6'>
Your password has been reset successfully. You can now sign in with
your new credentials.
</p>
<button
onClick={() => {
if (typeof window !== 'undefined') {
window.location.href = '/';
}
}}
className='w-full bg-indigo-600 hover:bg-indigo-700 text-white font-medium py-2 px-4 rounded-md'
>
Go to Sign In
</button>
</div>
</div>
);
}
return (
<div className='min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 px-4'>
<div className='w-full max-w-md'>
<div className='text-center mb-8'>
<div className='inline-block bg-indigo-600 p-3 rounded-xl mb-4'>
<PillIcon className='w-8 h-8 text-white' />
</div>
<h1 className='text-3xl font-bold text-slate-800 dark:text-slate-100'>
Reset Password
</h1>
<p className='text-slate-500 dark:text-slate-400 mt-1'>
Choose a new password for your account.
</p>
</div>
<div className='bg-white dark:bg-slate-800 rounded-lg shadow-lg p-8'>
<form onSubmit={handleSubmit} className='space-y-4'>
<div>
<label
htmlFor='password'
className='block text-sm font-medium text-slate-700 dark:text-slate-300'
>
New Password
</label>
<input
id='password'
type='password'
value={password}
onChange={event => setPassword(event.target.value)}
className='mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600 dark:placeholder-slate-400 dark:text-white'
placeholder='Enter a new password'
/>
</div>
<div>
<label
htmlFor='confirmPassword'
className='block text-sm font-medium text-slate-700 dark:text-slate-300'
>
Confirm Password
</label>
<input
id='confirmPassword'
type='password'
value={confirmPassword}
onChange={event => setConfirmPassword(event.target.value)}
className='mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600 dark:placeholder-slate-400 dark:text-white'
placeholder='Re-enter your password'
/>
</div>
{error && (
<div className='text-sm text-red-600 bg-red-50 dark:bg-red-900/40 border border-red-200 dark:border-red-800 rounded-md px-3 py-2'>
{error}
</div>
)}
<button
type='submit'
disabled={status === 'submitting'}
className='w-full bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 text-white font-medium py-2 px-4 rounded-md transition-colors duration-200'
>
{status === 'submitting'
? 'Updating Password...'
: 'Update Password'}
</button>
</form>
<button
type='button'
onClick={() => {
if (typeof window !== 'undefined') {
window.location.href = '/';
}
}}
className='mt-6 w-full text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200'
>
Back to Sign In
</button>
</div>
</div>
</div>
);
};
export default ResetPasswordPage;
+98 -253
View File
@@ -5,7 +5,6 @@ import AvatarDropdown from '../AvatarDropdown';
import { User, UserRole } from '../../../types';
import { AccountStatus } from '../../../services/auth/auth.constants';
// Mock user data
const mockRegularUser: User = {
_id: '1',
_rev: '1-abc123',
@@ -33,7 +32,19 @@ const mockUserWithAvatar: User = {
const mockUserWithPassword: User = {
...mockRegularUser,
password: 'hashed-password',
password: '$2b$12$examplehashforpassword',
};
type DropdownProps = Partial<React.ComponentProps<typeof AvatarDropdown>>;
const renderDropdown = (props: DropdownProps = {}) => {
const defaultProps: React.ComponentProps<typeof AvatarDropdown> = {
user: mockRegularUser,
onLogout: jest.fn(),
};
const merged = { ...defaultProps, ...props };
return render(React.createElement(AvatarDropdown, merged));
};
describe('AvatarDropdown', () => {
@@ -47,7 +58,7 @@ describe('AvatarDropdown', () => {
describe('rendering', () => {
test('should render avatar button with user initials', () => {
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
renderDropdown({ user: mockRegularUser, onLogout: mockOnLogout });
const button = screen.getByRole('button', { name: /user menu/i });
expect(button).toBeInTheDocument();
@@ -55,9 +66,7 @@ describe('AvatarDropdown', () => {
});
test('should render avatar image when user has avatar', () => {
render(
<AvatarDropdown user={mockUserWithAvatar} onLogout={mockOnLogout} />
);
renderDropdown({ user: mockUserWithAvatar, onLogout: mockOnLogout });
const avatar = screen.getByAltText('User avatar');
expect(avatar).toBeInTheDocument();
@@ -66,16 +75,14 @@ describe('AvatarDropdown', () => {
test('should render fallback character for empty username', () => {
const userWithEmptyName = { ...mockRegularUser, username: '' };
render(
<AvatarDropdown user={userWithEmptyName} onLogout={mockOnLogout} />
);
renderDropdown({ user: userWithEmptyName, onLogout: mockOnLogout });
const button = screen.getByRole('button', { name: /user menu/i });
expect(button).toHaveTextContent('?');
});
test('should not render dropdown menu initially', () => {
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
renderDropdown({ user: mockRegularUser, onLogout: mockOnLogout });
expect(screen.queryByText('Signed in as')).not.toBeInTheDocument();
});
@@ -83,7 +90,7 @@ describe('AvatarDropdown', () => {
describe('dropdown functionality', () => {
test('should open dropdown when avatar button is clicked', () => {
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
renderDropdown({ user: mockRegularUser, onLogout: mockOnLogout });
const button = screen.getByRole('button', { name: /user menu/i });
fireEvent.click(button);
@@ -93,35 +100,40 @@ describe('AvatarDropdown', () => {
});
test('should close dropdown when avatar button is clicked again', () => {
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
renderDropdown({ user: mockRegularUser, onLogout: mockOnLogout });
const button = screen.getByRole('button', { name: /user menu/i });
// Open dropdown
fireEvent.click(button);
expect(screen.getByText('Signed in as')).toBeInTheDocument();
// Close dropdown
fireEvent.click(button);
expect(screen.queryByText('Signed in as')).not.toBeInTheDocument();
});
test('should close dropdown when clicking outside', async () => {
render(
<div>
<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />
<div data-testid='outside'>Outside element</div>
</div>
React.createElement(
'div',
null,
React.createElement(AvatarDropdown, {
user: mockRegularUser,
onLogout: mockOnLogout,
}),
React.createElement(
'div',
{ 'data-testid': 'outside' },
'Outside element'
)
)
);
const button = screen.getByRole('button', { name: /user menu/i });
const outside = screen.getByTestId('outside');
// Open dropdown
fireEvent.click(button);
expect(screen.getByText('Signed in as')).toBeInTheDocument();
// Click outside
fireEvent.mouseDown(outside);
await waitFor(() => {
@@ -132,21 +144,21 @@ describe('AvatarDropdown', () => {
describe('user information display', () => {
test('should display username in dropdown', () => {
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
renderDropdown({ user: mockRegularUser, onLogout: mockOnLogout });
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
test('should display administrator badge for admin users', () => {
render(<AvatarDropdown user={mockAdminUser} onLogout={mockOnLogout} />);
renderDropdown({ user: mockAdminUser, onLogout: mockOnLogout });
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
expect(screen.getByText('Administrator')).toBeInTheDocument();
});
test('should not display administrator badge for regular users', () => {
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
renderDropdown({ user: mockRegularUser, onLogout: mockOnLogout });
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
expect(screen.queryByText('Administrator')).not.toBeInTheDocument();
@@ -158,9 +170,7 @@ describe('AvatarDropdown', () => {
username: 'Very Long Username That Should Be Truncated',
};
render(
<AvatarDropdown user={userWithLongName} onLogout={mockOnLogout} />
);
renderDropdown({ user: userWithLongName, onLogout: mockOnLogout });
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
const usernameElement = screen.getByText(
@@ -172,7 +182,7 @@ describe('AvatarDropdown', () => {
describe('logout functionality', () => {
test('should call onLogout when logout button is clicked', () => {
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
renderDropdown({ user: mockRegularUser, onLogout: mockOnLogout });
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
fireEvent.click(screen.getByText('Logout'));
@@ -181,7 +191,7 @@ describe('AvatarDropdown', () => {
});
test('should close dropdown after logout is clicked', () => {
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
renderDropdown({ user: mockRegularUser, onLogout: mockOnLogout });
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
fireEvent.click(screen.getByText('Logout'));
@@ -190,7 +200,7 @@ describe('AvatarDropdown', () => {
});
test('should always display logout button', () => {
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
renderDropdown({ user: mockRegularUser, onLogout: mockOnLogout });
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
expect(screen.getByText('Logout')).toBeInTheDocument();
@@ -198,280 +208,115 @@ describe('AvatarDropdown', () => {
});
describe('admin functionality', () => {
test('should display admin interface button for admin users when onAdmin provided', () => {
render(
<AvatarDropdown
user={mockAdminUser}
onLogout={mockOnLogout}
onAdmin={mockOnAdmin}
/>
);
test('should render Admin Interface button for admin users', () => {
renderDropdown({
user: mockAdminUser,
onLogout: mockOnLogout,
onAdmin: mockOnAdmin,
});
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
expect(screen.getByText('Admin Interface')).toBeInTheDocument();
});
test('should not display admin interface button for regular users', () => {
render(
<AvatarDropdown
user={mockRegularUser}
onLogout={mockOnLogout}
onAdmin={mockOnAdmin}
/>
);
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
expect(screen.queryByText('Admin Interface')).not.toBeInTheDocument();
});
test('should not display admin interface button when onAdmin not provided', () => {
render(<AvatarDropdown user={mockAdminUser} onLogout={mockOnLogout} />);
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
expect(screen.queryByText('Admin Interface')).not.toBeInTheDocument();
});
test('should call onAdmin when admin interface button is clicked', () => {
render(
<AvatarDropdown
user={mockAdminUser}
onLogout={mockOnLogout}
onAdmin={mockOnAdmin}
/>
);
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
fireEvent.click(screen.getByText('Admin Interface'));
const adminButton = screen.getByText('Admin Interface');
expect(adminButton).toBeInTheDocument();
fireEvent.click(adminButton);
expect(mockOnAdmin).toHaveBeenCalledTimes(1);
});
test('should close dropdown after admin interface is clicked', () => {
render(
<AvatarDropdown
user={mockAdminUser}
onLogout={mockOnLogout}
onAdmin={mockOnAdmin}
/>
);
test('should not render Admin Interface button for regular users', () => {
renderDropdown({ user: mockRegularUser, onLogout: mockOnLogout });
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
fireEvent.click(screen.getByText('Admin Interface'));
expect(screen.queryByText('Signed in as')).not.toBeInTheDocument();
expect(screen.queryByText('Admin Interface')).not.toBeInTheDocument();
});
});
describe('change password functionality', () => {
test('should display change password button for users with password when onChangePassword provided', () => {
render(
<AvatarDropdown
user={mockUserWithPassword}
onLogout={mockOnLogout}
onChangePassword={mockOnChangePassword}
/>
);
describe('change password visibility', () => {
test('should show change password option when user has password', () => {
renderDropdown({
user: mockUserWithPassword,
onLogout: mockOnLogout,
onChangePassword: mockOnChangePassword,
});
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
expect(screen.getByText('Change Password')).toBeInTheDocument();
});
test('should not display change password button for users without password', () => {
render(
<AvatarDropdown
user={mockRegularUser}
onLogout={mockOnLogout}
onChangePassword={mockOnChangePassword}
/>
);
test('should hide change password option when user has no password', () => {
renderDropdown({
user: mockRegularUser,
onLogout: mockOnLogout,
onChangePassword: mockOnChangePassword,
});
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
expect(screen.queryByText('Change Password')).not.toBeInTheDocument();
});
test('should not display change password button when onChangePassword not provided', () => {
render(
<AvatarDropdown user={mockUserWithPassword} onLogout={mockOnLogout} />
);
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
expect(screen.queryByText('Change Password')).not.toBeInTheDocument();
});
test('should call onChangePassword when change password button is clicked', () => {
render(
<AvatarDropdown
user={mockUserWithPassword}
onLogout={mockOnLogout}
onChangePassword={mockOnChangePassword}
/>
);
test('should call onChangePassword when change password button clicked', () => {
renderDropdown({
user: mockUserWithPassword,
onLogout: mockOnLogout,
onChangePassword: mockOnChangePassword,
});
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
fireEvent.click(screen.getByText('Change Password'));
expect(mockOnChangePassword).toHaveBeenCalledTimes(1);
});
});
test('should close dropdown after change password is clicked', () => {
render(
<AvatarDropdown
user={mockUserWithPassword}
onLogout={mockOnLogout}
onChangePassword={mockOnChangePassword}
/>
);
describe('keyboard accessibility', () => {
test('should toggle dropdown with Enter key', () => {
renderDropdown({ user: mockRegularUser, onLogout: mockOnLogout });
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
fireEvent.click(screen.getByText('Change Password'));
const button = screen.getByRole('button', { name: /user menu/i });
fireEvent.keyDown(button, { key: 'Enter', code: 'Enter' });
fireEvent.keyUp(button, { key: 'Enter', code: 'Enter' });
expect(screen.getByText('Signed in as')).toBeInTheDocument();
});
test('should not toggle dropdown with unrelated key', () => {
renderDropdown({ user: mockRegularUser, onLogout: mockOnLogout });
const button = screen.getByRole('button', { name: /user menu/i });
fireEvent.keyDown(button, { key: 'Space', code: 'Space' });
expect(screen.queryByText('Signed in as')).not.toBeInTheDocument();
});
});
describe('getInitials function', () => {
test('should return first character uppercase for regular names', () => {
const userWithLowercase = { ...mockRegularUser, username: 'john doe' };
render(
<AvatarDropdown user={userWithLowercase} onLogout={mockOnLogout} />
);
describe('user initials generation', () => {
test('should handle lowercase usernames', () => {
const userWithLowercase = { ...mockRegularUser, username: 'john' };
renderDropdown({ user: userWithLowercase, onLogout: mockOnLogout });
const button = screen.getByRole('button', { name: /user menu/i });
expect(button).toHaveTextContent('J');
});
test('should return question mark for empty string', () => {
test('should handle empty username gracefully', () => {
const userWithEmptyName = { ...mockRegularUser, username: '' };
render(
<AvatarDropdown user={userWithEmptyName} onLogout={mockOnLogout} />
);
renderDropdown({ user: userWithEmptyName, onLogout: mockOnLogout });
const button = screen.getByRole('button', { name: /user menu/i });
expect(button).toHaveTextContent('?');
});
test('should handle single character names', () => {
const userWithSingleChar = { ...mockRegularUser, username: 'x' };
render(
<AvatarDropdown user={userWithSingleChar} onLogout={mockOnLogout} />
);
test('should handle single character username', () => {
const userWithSingleChar = { ...mockRegularUser, username: 'a' };
renderDropdown({ user: userWithSingleChar, onLogout: mockOnLogout });
const button = screen.getByRole('button', { name: /user menu/i });
expect(button).toHaveTextContent('X');
expect(button).toHaveTextContent('A');
});
test('should handle special characters', () => {
const userWithSpecialChar = { ...mockRegularUser, username: '@john' };
render(
<AvatarDropdown user={userWithSpecialChar} onLogout={mockOnLogout} />
);
test('should handle usernames with special characters', () => {
const userWithSpecialChar = { ...mockRegularUser, username: '!john' };
renderDropdown({ user: userWithSpecialChar, onLogout: mockOnLogout });
const button = screen.getByRole('button', { name: /user menu/i });
expect(button).toHaveTextContent('@');
});
});
describe('accessibility', () => {
test('should have proper aria-label for avatar button', () => {
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
const button = screen.getByRole('button', { name: /user menu/i });
expect(button).toHaveAttribute('aria-label', 'User menu');
});
test('should be keyboard accessible', () => {
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
const button = screen.getByRole('button', { name: /user menu/i });
button.focus();
expect(button).toHaveFocus();
});
test('should have proper focus styles', () => {
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
const button = screen.getByRole('button', { name: /user menu/i });
expect(button).toHaveClass('focus:outline-none', 'focus:ring-2');
});
});
describe('styling and theming', () => {
test('should apply dark mode classes', () => {
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
const button = screen.getByRole('button', { name: /user menu/i });
expect(button).toHaveClass('dark:bg-slate-700', 'dark:text-slate-300');
fireEvent.click(button);
const dropdown = screen
.getByText('Signed in as')
.closest('div')?.parentElement;
expect(dropdown).toHaveClass(
'dark:bg-slate-800',
'dark:border-slate-700'
);
});
test('should apply hover styles to menu items', () => {
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
fireEvent.click(screen.getByRole('button', { name: /user menu/i }));
const logoutButton = screen.getByText('Logout');
expect(logoutButton).toHaveClass(
'hover:bg-slate-100',
'dark:hover:bg-slate-700'
);
});
});
describe('edge cases', () => {
test('should handle clicking outside when dropdown is closed', async () => {
render(
<div>
<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />
<div data-testid='outside'>Outside element</div>
</div>
);
const outside = screen.getByTestId('outside');
fireEvent.mouseDown(outside);
// Should not throw any errors
expect(screen.queryByText('Signed in as')).not.toBeInTheDocument();
});
test('should handle rapid clicking', () => {
render(<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />);
const button = screen.getByRole('button', { name: /user menu/i });
// Rapid clicks - odd number should end up open
fireEvent.click(button);
fireEvent.click(button);
fireEvent.click(button);
// Should end up open (3 clicks = open)
expect(screen.getByText('Signed in as')).toBeInTheDocument();
});
test('should cleanup event listeners on unmount', () => {
const removeEventListenerSpy = jest.spyOn(
document,
'removeEventListener'
);
const { unmount } = render(
<AvatarDropdown user={mockRegularUser} onLogout={mockOnLogout} />
);
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'mousedown',
expect.any(Function)
);
removeEventListenerSpy.mockRestore();
expect(button).toHaveTextContent('!');
});
});
});
@@ -0,0 +1,90 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import ResetPasswordPage from '../ResetPasswordPage';
import { authService } from '../../../services/auth/auth.service';
jest.mock('../../../services/auth/auth.service', () => ({
authService: {
resetPassword: jest.fn(),
},
}));
const mockedAuthService = authService as jest.Mocked<typeof authService>;
const mockedResetPassword = mockedAuthService.resetPassword;
const setLocation = (url: string) => {
window.history.replaceState({}, 'Test', url);
};
describe('ResetPasswordPage', () => {
beforeEach(() => {
mockedResetPassword.mockReset();
});
test('renders invalid token state when no token provided', () => {
setLocation('http://localhost/reset-password');
render(React.createElement(ResetPasswordPage));
expect(screen.getByText('Password Reset Link Invalid')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /back to sign in/i })
).toBeInTheDocument();
});
test('shows validation error when passwords do not match', async () => {
setLocation('http://localhost/reset-password?token=abc123');
render(React.createElement(ResetPasswordPage));
fireEvent.change(screen.getByLabelText('New Password'), {
target: { value: 'Password1!' },
});
fireEvent.change(screen.getByLabelText('Confirm Password'), {
target: { value: 'SomethingElse' },
});
fireEvent.click(screen.getByRole('button', { name: /update password/i }));
expect(
await screen.findByText('Passwords do not match.')
).toBeInTheDocument();
expect(mockedResetPassword).not.toHaveBeenCalled();
});
test('submits password reset and displays success state', async () => {
setLocation('http://localhost/reset-password?token=token123');
mockedResetPassword.mockResolvedValue({
user: {
_id: 'user-1',
_rev: '1',
username: 'Reset User',
} as any,
message: 'Password reset successfully',
});
render(React.createElement(ResetPasswordPage));
fireEvent.change(screen.getByLabelText('New Password'), {
target: { value: 'Password1!' },
});
fireEvent.change(screen.getByLabelText('Confirm Password'), {
target: { value: 'Password1!' },
});
fireEvent.click(screen.getByRole('button', { name: /update password/i }));
await waitFor(() => {
expect(mockedResetPassword).toHaveBeenCalledWith(
'token123',
'Password1!'
);
});
expect(await screen.findByText('Password Updated')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /go to sign in/i })
).toBeInTheDocument();
});
});
+1
View File
@@ -2,3 +2,4 @@
export { default as AuthPage } from './AuthPage';
export { default as AvatarDropdown } from './AvatarDropdown';
export { default as ChangePasswordModal } from './ChangePasswordModal';
export { default as ResetPasswordPage } from './ResetPasswordPage';
+2 -1
View File
@@ -1,6 +1,7 @@
import React, { useState, useEffect, useRef } from 'react';
import { Medication, Frequency } from '../../types';
import { medicationIcons } from '../icons/Icons';
import { logger } from '../../services/logging';
interface AddMedicationModalProps {
isOpen: boolean;
@@ -67,7 +68,7 @@ const AddMedicationModal: React.FC<AddMedicationModalProps> = ({
icon,
});
} catch (error) {
console.error('Failed to add medication', error);
logger.ui.error('Failed to add medication', error as Error);
alert('There was an error saving your medication. Please try again.');
setIsSaving(false);
}
+46 -2
View File
@@ -59,6 +59,13 @@ const statusStyles = {
},
};
const statusLabels: Record<DoseStatus, string> = {
[DoseStatus.UPCOMING]: 'Upcoming dose',
[DoseStatus.TAKEN]: 'Dose taken',
[DoseStatus.MISSED]: 'Dose missed',
[DoseStatus.SNOOZED]: 'Dose snoozed',
};
const DoseCard: React.FC<DoseCardProps> = ({
dose,
medication,
@@ -68,6 +75,7 @@ const DoseCard: React.FC<DoseCardProps> = ({
snoozedUntil,
}) => {
const styles = statusStyles[status];
const statusLabel = statusLabels[status];
const timeString = dose.scheduledTime.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
@@ -85,17 +93,48 @@ const DoseCard: React.FC<DoseCardProps> = ({
})
: '';
const MedicationIcon = getMedicationIcon(medication.icon);
const cardTitleId = `dose-${dose.id}-title`;
const statusId = `dose-${dose.id}-status`;
const handleCardKeyDown = (
event: React.KeyboardEvent<HTMLLIElement>
): void => {
if (event.target !== event.currentTarget) {
return;
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onToggleDose(dose.id);
}
if (
(event.key.toLowerCase() === 's' || event.key === 'S') &&
status === DoseStatus.UPCOMING
) {
event.preventDefault();
onSnooze(dose.id);
}
};
return (
<li
className={`shadow-md rounded-lg p-4 flex flex-col justify-between transition-all duration-300 ${styles.bg} ${styles.ring} ring-4 ring-transparent border border-slate-200 dark:border-slate-700`}
role='group'
tabIndex={0}
onKeyDown={handleCardKeyDown}
aria-labelledby={cardTitleId}
aria-describedby={statusId}
className={`shadow-md rounded-lg p-4 flex flex-col justify-between transition-all duration-300 ${styles.bg} ${styles.ring} ring-4 ring-transparent border border-slate-200 dark:border-slate-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-400 dark:focus:ring-offset-slate-900`}
>
<div>
<div className='flex justify-between items-start'>
<div className='flex items-center space-x-3'>
<MedicationIcon className='w-7 h-7 text-indigo-500 dark:text-indigo-400 flex-shrink-0' />
<div>
<h4 className='font-bold text-lg text-slate-800 dark:text-slate-100'>
<h4
id={cardTitleId}
className='font-bold text-lg text-slate-800 dark:text-slate-100'
>
{medication.name}
</h4>
<p className='text-slate-600 dark:text-slate-300'>
@@ -106,9 +145,14 @@ const DoseCard: React.FC<DoseCardProps> = ({
{styles.icon}
</div>
<div
id={statusId}
role='status'
aria-live='polite'
aria-atomic='true'
className={`flex items-center space-x-2 mt-4 font-semibold text-lg ${styles.text}`}
>
<ClockIcon className='w-5 h-5' />
<span className='sr-only'>{statusLabel}</span>
<span>{timeString}</span>
</div>
@@ -1,6 +1,7 @@
import React, { useState, useEffect, useRef } from 'react';
import { Medication, Frequency } from '../../types';
import { medicationIcons } from '../icons/Icons';
import { logger } from '../../services/logging';
interface EditMedicationModalProps {
isOpen: boolean;
@@ -72,7 +73,7 @@ const EditMedicationModal: React.FC<EditMedicationModalProps> = ({
icon,
});
} catch (error) {
console.error('Failed to update medication', error);
logger.ui.error('Failed to update medication', error as Error);
alert('There was an error updating your medication. Please try again.');
setIsSaving(false);
}
+71 -4
View File
@@ -1,6 +1,12 @@
import React, { useState, useEffect, useRef } from 'react';
import { CustomReminder } from '../../types';
import { reminderIcons } from '../icons/Icons';
import {
MIN_REMINDER_FREQUENCY_MINUTES,
MAX_REMINDER_FREQUENCY_MINUTES,
validateReminderInputs,
} from './reminderValidation';
import { logger } from '../../services/logging';
interface AddReminderModalProps {
isOpen: boolean;
@@ -19,9 +25,21 @@ const AddReminderModal: React.FC<AddReminderModalProps> = ({
const [startTime, setStartTime] = useState('09:00');
const [endTime, setEndTime] = useState('17:00');
const [isSaving, setIsSaving] = useState(false);
const [errors, setErrors] = useState<{
frequency?: string;
timeRange?: string;
}>({});
const titleInputRef = useRef<HTMLInputElement>(null);
const validate = () => {
return validateReminderInputs({
frequencyMinutes,
startTime,
endTime,
});
};
useEffect(() => {
if (isOpen) {
setTitle('');
@@ -30,14 +48,26 @@ const AddReminderModal: React.FC<AddReminderModalProps> = ({
setStartTime('09:00');
setEndTime('17:00');
setIsSaving(false);
setErrors({});
setTimeout(() => titleInputRef.current?.focus(), 100);
}
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
setErrors(validate());
}, [isOpen, frequencyMinutes, startTime, endTime]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title || isSaving) return;
const validation = validate();
setErrors(validation);
if (Object.keys(validation).length > 0) {
return;
}
setIsSaving(true);
try {
await onAdd({
@@ -48,7 +78,7 @@ const AddReminderModal: React.FC<AddReminderModalProps> = ({
endTime,
});
} catch (error) {
console.error('Failed to add reminder', error);
logger.ui.error('Failed to add reminder', error as Error);
alert('There was an error saving your reminder. Please try again.');
} finally {
setIsSaving(false);
@@ -125,11 +155,30 @@ const AddReminderModal: React.FC<AddReminderModalProps> = ({
id='rem-frequency'
value={frequencyMinutes}
onChange={e =>
setFrequencyMinutes(parseInt(e.target.value, 10))
setFrequencyMinutes(
Number.isNaN(parseInt(e.target.value, 10))
? 0
: parseInt(e.target.value, 10)
)
}
min='1'
min={MIN_REMINDER_FREQUENCY_MINUTES}
max={MAX_REMINDER_FREQUENCY_MINUTES}
aria-invalid={Boolean(errors.frequency)}
aria-describedby='rem-frequency-help'
className='mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600'
/>
<p
id='rem-frequency-help'
className={`mt-1 text-sm ${
errors.frequency
? 'text-red-600 dark:text-red-400'
: 'text-slate-500 dark:text-slate-400'
}`}
>
{errors.frequency
? errors.frequency
: `Minimum ${MIN_REMINDER_FREQUENCY_MINUTES} minutes, maximum ${MAX_REMINDER_FREQUENCY_MINUTES} minutes.`}
</p>
</div>
<div className='grid grid-cols-2 gap-4'>
@@ -146,6 +195,10 @@ const AddReminderModal: React.FC<AddReminderModalProps> = ({
value={startTime}
onChange={e => setStartTime(e.target.value)}
required
aria-invalid={Boolean(errors.timeRange)}
aria-describedby={
errors.timeRange ? 'rem-time-help' : undefined
}
className='mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600'
/>
</div>
@@ -162,10 +215,22 @@ const AddReminderModal: React.FC<AddReminderModalProps> = ({
value={endTime}
onChange={e => setEndTime(e.target.value)}
required
aria-invalid={Boolean(errors.timeRange)}
aria-describedby={
errors.timeRange ? 'rem-time-help' : undefined
}
className='mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600'
/>
</div>
</div>
{errors.timeRange && (
<p
id='rem-time-help'
className='text-sm text-red-600 dark:text-red-400'
>
{errors.timeRange}
</p>
)}
</div>
<div className='px-6 py-4 bg-slate-50 dark:bg-slate-700/50 flex justify-end space-x-3 rounded-b-lg border-t border-slate-200 dark:border-slate-700'>
<button
@@ -178,7 +243,9 @@ const AddReminderModal: React.FC<AddReminderModalProps> = ({
</button>
<button
type='submit'
disabled={isSaving}
disabled={
isSaving || Boolean(errors.frequency || errors.timeRange)
}
className='px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center dark:focus:ring-offset-slate-800'
>
{isSaving ? 'Adding...' : 'Add Reminder'}
+69 -6
View File
@@ -1,6 +1,12 @@
import React, { useState, useEffect, useRef } from 'react';
import { CustomReminder } from '../../types';
import { reminderIcons } from '../icons/Icons';
import {
MIN_REMINDER_FREQUENCY_MINUTES,
MAX_REMINDER_FREQUENCY_MINUTES,
validateReminderInputs,
} from './reminderValidation';
import { logger } from '../../services/logging';
interface EditReminderModalProps {
isOpen: boolean;
@@ -21,9 +27,20 @@ const EditReminderModal: React.FC<EditReminderModalProps> = ({
const [startTime, setStartTime] = useState('09:00');
const [endTime, setEndTime] = useState('17:00');
const [isSaving, setIsSaving] = useState(false);
const [errors, setErrors] = useState<{
frequency?: string;
timeRange?: string;
}>({});
const titleInputRef = useRef<HTMLInputElement>(null);
const validate = () =>
validateReminderInputs({
frequencyMinutes,
startTime,
endTime,
});
useEffect(() => {
if (isOpen && reminder) {
setTitle(reminder.title);
@@ -32,14 +49,26 @@ const EditReminderModal: React.FC<EditReminderModalProps> = ({
setStartTime(reminder.startTime);
setEndTime(reminder.endTime);
setIsSaving(false);
setErrors({});
setTimeout(() => titleInputRef.current?.focus(), 100);
}
}, [isOpen, reminder]);
useEffect(() => {
if (!isOpen) return;
setErrors(validate());
}, [isOpen, frequencyMinutes, startTime, endTime]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title || !reminder || isSaving) return;
const validation = validate();
setErrors(validation);
if (Object.keys(validation).length > 0) {
return;
}
setIsSaving(true);
try {
await onUpdate({
@@ -51,7 +80,7 @@ const EditReminderModal: React.FC<EditReminderModalProps> = ({
endTime,
});
} catch (error) {
console.error('Failed to update reminder', error);
logger.ui.error('Failed to update reminder', error as Error);
alert('There was an error updating your reminder. Please try again.');
} finally {
setIsSaving(false);
@@ -126,12 +155,28 @@ const EditReminderModal: React.FC<EditReminderModalProps> = ({
type='number'
id='rem-edit-frequency'
value={frequencyMinutes}
onChange={e =>
setFrequencyMinutes(parseInt(e.target.value, 10))
}
min='1'
onChange={e => {
const value = parseInt(e.target.value, 10);
setFrequencyMinutes(Number.isNaN(value) ? 0 : value);
}}
min={MIN_REMINDER_FREQUENCY_MINUTES}
max={MAX_REMINDER_FREQUENCY_MINUTES}
aria-invalid={Boolean(errors.frequency)}
aria-describedby='rem-edit-frequency-help'
className='mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600'
/>
<p
id='rem-edit-frequency-help'
className={`mt-1 text-sm ${
errors.frequency
? 'text-red-600 dark:text-red-400'
: 'text-slate-500 dark:text-slate-400'
}`}
>
{errors.frequency
? errors.frequency
: `Minimum ${MIN_REMINDER_FREQUENCY_MINUTES} minutes, maximum ${MAX_REMINDER_FREQUENCY_MINUTES} minutes.`}
</p>
</div>
<div className='grid grid-cols-2 gap-4'>
@@ -148,6 +193,10 @@ const EditReminderModal: React.FC<EditReminderModalProps> = ({
value={startTime}
onChange={e => setStartTime(e.target.value)}
required
aria-invalid={Boolean(errors.timeRange)}
aria-describedby={
errors.timeRange ? 'rem-edit-time-help' : undefined
}
className='mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600'
/>
</div>
@@ -164,10 +213,22 @@ const EditReminderModal: React.FC<EditReminderModalProps> = ({
value={endTime}
onChange={e => setEndTime(e.target.value)}
required
aria-invalid={Boolean(errors.timeRange)}
aria-describedby={
errors.timeRange ? 'rem-edit-time-help' : undefined
}
className='mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600'
/>
</div>
</div>
{errors.timeRange && (
<p
id='rem-edit-time-help'
className='text-sm text-red-600 dark:text-red-400'
>
{errors.timeRange}
</p>
)}
</div>
<div className='px-6 py-4 bg-slate-50 dark:bg-slate-700/50 flex justify-end space-x-3 rounded-b-lg border-t border-slate-200 dark:border-slate-700'>
<button
@@ -180,7 +241,9 @@ const EditReminderModal: React.FC<EditReminderModalProps> = ({
</button>
<button
type='submit'
disabled={isSaving}
disabled={
isSaving || Boolean(errors.frequency || errors.timeRange)
}
className='px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center dark:focus:ring-offset-slate-800'
>
{isSaving ? 'Saving...' : 'Save Changes'}
@@ -0,0 +1,72 @@
import React from 'react';
import '@testing-library/jest-dom';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import AddReminderModal from '../AddReminderModal';
describe('AddReminderModal validation', () => {
const onClose = jest.fn();
const onAdd = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
it('displays an error when frequency is below the minimum', () => {
render(<AddReminderModal isOpen onClose={onClose} onAdd={onAdd} />);
const frequencyInput = screen.getByLabelText('Remind me every (minutes)');
fireEvent.change(frequencyInput, { target: { value: '1' } });
expect(
screen.getByText(/Choose a value between 5 and 720 minutes\./i)
).toBeInTheDocument();
const submitButton = screen.getByRole('button', { name: /add reminder/i });
expect(submitButton).toBeDisabled();
});
it('disables submit when end time is earlier than start time', () => {
render(<AddReminderModal isOpen onClose={onClose} onAdd={onAdd} />);
const startInput = screen.getByLabelText('From');
const endInput = screen.getByLabelText('Until');
fireEvent.change(startInput, { target: { value: '10:00' } });
fireEvent.change(endInput, { target: { value: '09:00' } });
expect(
screen.getByText(/End time must be later than start time\./i)
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /add reminder/i })
).toBeDisabled();
});
it('calls onAdd with valid data', async () => {
const resolveAdd = jest.fn().mockResolvedValue(undefined);
render(<AddReminderModal isOpen onClose={onClose} onAdd={resolveAdd} />);
fireEvent.change(screen.getByLabelText('Title'), {
target: { value: 'Hydrate' },
});
fireEvent.change(screen.getByLabelText('Remind me every (minutes)'), {
target: { value: '30' },
});
fireEvent.change(screen.getByLabelText('From'), {
target: { value: '08:00' },
});
fireEvent.change(screen.getByLabelText('Until'), {
target: { value: '12:00' },
});
fireEvent.click(screen.getByRole('button', { name: /add reminder/i }));
await waitFor(() => expect(resolveAdd).toHaveBeenCalledTimes(1));
expect(resolveAdd).toHaveBeenCalledWith({
title: 'Hydrate',
icon: 'bell',
frequencyMinutes: 30,
startTime: '08:00',
endTime: '12:00',
});
});
});
+54
View File
@@ -0,0 +1,54 @@
export const MIN_REMINDER_FREQUENCY_MINUTES = 5;
export const MAX_REMINDER_FREQUENCY_MINUTES = 720;
export const parseTimeToMinutes = (time: string): number | null => {
const [hours, minutes] = time.split(':').map(Number);
if (
Number.isNaN(hours) ||
Number.isNaN(minutes) ||
hours < 0 ||
hours > 23 ||
minutes < 0 ||
minutes > 59
) {
return null;
}
return hours * 60 + minutes;
};
export interface ReminderValidationParams {
frequencyMinutes: number;
startTime: string;
endTime: string;
}
export const validateReminderInputs = ({
frequencyMinutes,
startTime,
endTime,
}: ReminderValidationParams): { frequency?: string; timeRange?: string } => {
const errors: { frequency?: string; timeRange?: string } = {};
const frequency = Number(frequencyMinutes);
if (
!Number.isInteger(frequency) ||
frequency < MIN_REMINDER_FREQUENCY_MINUTES ||
frequency > MAX_REMINDER_FREQUENCY_MINUTES
) {
errors.frequency = `Choose a value between ${MIN_REMINDER_FREQUENCY_MINUTES} and ${MAX_REMINDER_FREQUENCY_MINUTES} minutes.`;
}
const startMinutes = parseTimeToMinutes(startTime);
const endMinutes = parseTimeToMinutes(endTime);
if (
startMinutes === null ||
endMinutes === null ||
endMinutes <= startMinutes
) {
errors.timeRange = 'End time must be later than start time.';
}
return errors;
};
+10 -2
View File
@@ -12,15 +12,23 @@ const ReminderCard: React.FC<ReminderCardProps> = ({ reminder }) => {
minute: '2-digit',
});
const ReminderIcon = getReminderIcon(reminder.icon);
const titleId = `reminder-${reminder.id}-title`;
return (
<li className='shadow-md rounded-lg p-4 flex flex-col justify-between transition-all duration-300 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700'>
<li
tabIndex={0}
aria-labelledby={titleId}
className='shadow-md rounded-lg p-4 flex flex-col justify-between transition-all duration-300 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-sky-400 dark:focus:ring-offset-slate-900'
>
<div>
<div className='flex justify-between items-start'>
<div className='flex items-center space-x-3'>
<ReminderIcon className='w-7 h-7 text-sky-500 dark:text-sky-400 flex-shrink-0' />
<div>
<h4 className='font-bold text-lg text-slate-800 dark:text-slate-100'>
<h4
id={titleId}
className='font-bold text-lg text-slate-800 dark:text-slate-100'
>
{reminder.title}
</h4>
</div>
+32 -6
View File
@@ -1,3 +1,21 @@
type LoggerInstance = (typeof import('../services/logging'))['logger'];
let loggerPromise: Promise<LoggerInstance | null> | null = null;
function withLogger(callback: (logger: LoggerInstance) => void): void {
if (!loggerPromise) {
loggerPromise = import('../services/logging')
.then(module => module.logger)
.catch(() => null);
}
void loggerPromise.then(logger => {
if (logger) {
callback(logger);
}
});
}
/**
* Unified Application Configuration System
*
@@ -754,10 +772,14 @@ function validateConfig(config: UnifiedConfig): void {
// Log warnings and throw errors
if (warnings.length > 0) {
console.warn('⚠️ Configuration warnings:', warnings);
withLogger(logger =>
logger.warn('Configuration warnings', 'CONFIG', warnings)
);
}
if (errors.length > 0) {
console.error('❌ Configuration errors:', errors);
withLogger(logger =>
logger.error('Configuration errors', 'CONFIG', errors)
);
throw new Error(`Configuration validation failed: ${errors.join(', ')}`);
}
}
@@ -955,8 +977,12 @@ export function exportAsEnvVars(
* Debug helper to log current configuration
*/
export function logConfig(): void {
if (unifiedConfig.features.debugMode) {
console.warn('🔧 Unified Configuration (Single Source of Truth):', {
if (!unifiedConfig.features.debugMode) {
return;
}
withLogger(logger =>
logger.info('Unified Configuration (Single Source of Truth)', 'CONFIG', {
environment: unifiedConfig.app.environment,
app: unifiedConfig.app.name,
version: unifiedConfig.app.version,
@@ -972,8 +998,8 @@ export function logConfig(): void {
},
features: unifiedConfig.features,
configSource: '.env file overrides applied',
});
}
})
);
}
// Auto-log in development
+43 -25
View File
@@ -8,8 +8,12 @@ import React, {
import { User } from '../types';
import { databaseService } from '../services/database';
import { authService } from '../services/auth/auth.service';
import { tokenStorage } from '../utils/token';
import { logger } from '../services/logging';
import { normalizeError } from '../utils/error';
const SESSION_KEY = 'medication_app_session';
const AUTH_CONTEXT = 'USER_CONTEXT';
interface UserContextType {
user: User | null;
@@ -68,25 +72,23 @@ export const UserProvider: React.FC<{ children: ReactNode }> = ({
// Use auth service for password-based login
const result = await authService.login({ email, password });
console.warn('Login result received:', result);
console.warn('User from login:', result.user);
console.warn('User _id:', result.user._id);
// Update last login time
const updatedUser = { ...result.user, lastLoginAt: new Date() };
await databaseService.updateUser(updatedUser);
console.warn('Updated user with last login:', updatedUser);
// Store access token for subsequent API calls.
localStorage.setItem('access_token', result.accessToken);
tokenStorage.save({
accessToken: result.accessToken,
refreshToken: result.refreshToken,
});
// Set the user from the login result
setUser(updatedUser);
console.warn('User set in context');
logger.auth.login('User authenticated with email/password', {
userId: updatedUser._id,
email: updatedUser.email,
});
return true;
} catch (error) {
console.error('Login error:', error);
logger.auth.error('Login error', normalizeError(error), { email });
return false;
}
};
@@ -101,7 +103,10 @@ export const UserProvider: React.FC<{ children: ReactNode }> = ({
// Don't auto-login after registration, require email verification
return true;
} catch (error) {
console.error('Registration error:', error);
logger.auth.error('Registration error', normalizeError(error), {
email,
username,
});
return false;
}
};
@@ -113,23 +118,26 @@ export const UserProvider: React.FC<{ children: ReactNode }> = ({
try {
const result = await authService.loginWithOAuth(provider, userData);
console.warn('OAuth login result received:', result);
console.warn('OAuth user:', result.user);
console.warn('OAuth user _id:', result.user._id);
// Update last login time
const updatedUser = { ...result.user, lastLoginAt: new Date() };
await databaseService.updateUser(updatedUser);
console.warn('Updated OAuth user with last login:', updatedUser);
localStorage.setItem('access_token', result.accessToken);
tokenStorage.save({
accessToken: result.accessToken,
refreshToken: result.refreshToken,
});
setUser(updatedUser);
console.warn('OAuth user set in context');
logger.auth.login('User authenticated via OAuth', {
userId: updatedUser._id,
provider,
email: updatedUser.email,
});
return true;
} catch (error) {
console.error('OAuth login error:', error);
logger.auth.error('OAuth login error', normalizeError(error), {
provider,
email: userData.email,
});
return false;
}
};
@@ -144,15 +152,21 @@ export const UserProvider: React.FC<{ children: ReactNode }> = ({
}
await authService.changePassword(user._id, currentPassword, newPassword);
logger.auth.login('User changed password', { userId: user._id });
return true;
} catch (error) {
console.error('Password change error:', error);
logger.auth.error('Password change error', normalizeError(error), {
userId: user?._id,
});
return false;
}
};
const logout = () => {
const currentUserId = user?._id;
tokenStorage.clear();
setUser(null);
logger.auth.logout('User logged out', { userId: currentUserId });
};
const updateUser = async (updatedUser: User) => {
@@ -160,8 +174,12 @@ export const UserProvider: React.FC<{ children: ReactNode }> = ({
const savedUser = await databaseService.updateUser(updatedUser);
setUser(savedUser);
} catch (error) {
console.error('Failed to update user', error);
// Optionally revert state or show error
logger.error(
'Failed to update user profile',
AUTH_CONTEXT,
{ userId: updatedUser._id },
normalizeError(error)
);
}
};
+8
View File
@@ -0,0 +1,8 @@
[chttpd]
enable_cors = true
[cors]
origins = http://localhost:8080, http://localhost:5173
credentials = true
methods = GET, PUT, POST, HEAD, DELETE, OPTIONS
headers = accept, authorization, content-type, origin, referer, cache-control, x-requested-with
+16
View File
@@ -0,0 +1,16 @@
[admins]
admin = -pbkdf2:sha256-c9a393efac86b8a234ad91c5f7dd5a3d057ea7b76aad8b0194b41ff64ee80ec5,cab6f942a2c7d4ff7e5d54010475b7a2,600000
[couchdb]
uuid = 2083849204f5378942a1abfff8ef20cf
[chttpd_auth]
secret = 4e4abcc9cae38e179910098ad7f2f2e4
[chttpd]
bind_address = 0.0.0.0
port = 5984
[cluster]
n = 1
+40
View File
@@ -0,0 +1,40 @@
version: '3.8'
services:
couchdb:
image: couchdb:3
container_name: meds-couchdb
env_file:
- .env
environment:
COUCHDB_USER: ${COUCHDB_USER:-admin}
COUCHDB_PASSWORD: ${COUCHDB_PASSWORD:-change-this-secure-password}
ports:
- '5984:5984'
volumes:
- couchdb-data:/opt/couchdb/data
- ./couchdb-config:/opt/couchdb/etc/local.d
restart: unless-stopped
frontend:
build:
context: .
dockerfile: Dockerfile
args:
NODE_ENV: ${NODE_ENV:-production}
VITE_COUCHDB_URL: ${VITE_COUCHDB_URL:-http://localhost:5984}
VITE_COUCHDB_USER: ${VITE_COUCHDB_USER:-admin}
VITE_COUCHDB_PASSWORD: ${VITE_COUCHDB_PASSWORD:-change-this-secure-password}
VITE_ADMIN_EMAIL: ${VITE_ADMIN_EMAIL:-admin@localhost}
VITE_ADMIN_PASSWORD: ${VITE_ADMIN_PASSWORD:-admin123!}
env_file:
- .env
depends_on:
- couchdb
ports:
- '8080:80'
restart: unless-stopped
volumes:
couchdb-data:
name: meds-couchdb-data
+38
View File
@@ -10,6 +10,16 @@ The rxminder application supports multiple ways to configure deployments using e
2. **Dynamic Configuration**: Runtime environment variable injection
3. **Hybrid Approach**: Combination of both methods
### Run Profiles at a Glance
| Profile | Typical Usage | Key Files |
| ----------- | --------------------------------------------------------------- | ------------------------------------------- |
| Development | Vite dev server with hot reload and optional mock CouchDB | `.env.local`, `bun run dev` |
| Testing | Jest unit/integration suites against the mock database strategy | No additional env required (`bun run test`) |
| Production | Hardened build served by Docker/Kubernetes with real services | `.env`, Docker/compose manifests |
See the [Run Profiles section](../../README.md#-run-profiles) in the project README for commands and best practices.
## Environment Variable Sources
Variables are loaded in the following priority order (last wins):
@@ -193,6 +203,18 @@ DEV_API_URL=http://localhost:5984
| `COUCHDB_USERNAME` | `admin` | Database username |
| `COUCHDB_PASSWORD` | - | Database password (required) |
### Email (Mailgun) Variables
| Variable | Default | Description |
| ------------------------- | ---------------------------- | ------------------------------------------------------------------------------ |
| `VITE_MAILGUN_API_KEY` | _required_ | Mailgun API key used for authenticated requests |
| `VITE_MAILGUN_DOMAIN` | _required_ | Mailgun sending domain (e.g. `mg.yourdomain.com`) |
| `VITE_MAILGUN_BASE_URL` | `https://api.mailgun.net/v3` | Mailgun REST API base URL |
| `VITE_MAILGUN_FROM_NAME` | `Medication Reminder` | Friendly name used in the `from` header |
| `VITE_MAILGUN_FROM_EMAIL` | _required_ | Email address used in the `from` header (must belong to the configured domain) |
> **Tip:** When any required Mailgun variables are missing, the application falls back to a development mode that logs email previews instead of sending real messages. Configure the variables above in `.env.local` (git ignored) before testing real email flows.
### Network & Ingress Variables
| Variable | Default | Description |
@@ -203,6 +225,8 @@ DEV_API_URL=http://localhost:5984
| `CERT_MANAGER_ISSUER` | `letsencrypt-prod` | Certificate issuer |
| `CORS_ORIGIN` | `*` | CORS allowed origins |
> When running via `docker compose up --build`, CouchDB CORS settings are sourced from `couchdb-config/cors.ini`. Update the `origins` list in that file to add additional frontend domains.
### Performance Variables
| Variable | Default | Description |
@@ -243,6 +267,20 @@ DEV_API_URL=http://localhost:5984
| `API_SECRET_KEY` | - | API secret key |
| `JWT_SECRET` | - | JWT signing secret |
### Bootstrap Admin Variables
These variables control the default admin account created/updated at app startup by the frontend seeder. They are read at build-time (Vite), so changing them requires rebuilding the frontend image.
| Variable | Default | Description |
| --------------------- | ----------------- | ----------------------------------- |
| `VITE_ADMIN_EMAIL` | `admin@localhost` | Email of the default admin user |
| `VITE_ADMIN_PASSWORD` | `admin123!` | Password for the default admin user |
Notes:
- To change these in Docker, set build args in `docker-compose.yaml` or define them in `.env` and rebuild: `docker compose build frontend && docker compose up -d`.
- The seeder is idempotent: if a user with this email exists, it updates role/status and keeps the latest password you set.
## Usage Examples
### Basic Development Setup
+9 -1
View File
@@ -7,6 +7,7 @@
"<rootDir>/types/**/__tests__/**/*.test.ts",
"<rootDir>/components/**/__tests__/**/*.test.tsx",
"<rootDir>/tests/**/*.test.ts",
"<rootDir>/tests/**/*.test.tsx",
"<rootDir>/tests/**/*.test.js"
],
"collectCoverageFrom": [
@@ -25,7 +26,14 @@
"^node-fetch$": "<rootDir>/tests/__mocks__/node-fetch.js"
},
"transform": {
"^.+\\.tsx?$": "babel-jest",
"^.+\\.tsx?$": [
"ts-jest",
{
"tsconfig": "tsconfig.json",
"babelConfig": "babel.config.cjs",
"diagnostics": false
}
],
"^.+\\.jsx?$": "babel-jest"
},
"transformIgnorePatterns": ["node_modules/(?!(@jest/transform|uuid|node-fetch)/)"],
+1
View File
@@ -18,6 +18,7 @@
"test:unit": "jest --testPathPatterns='(utils|types).*test\\.(ts|js)$'",
"test:services": "jest --testPathPatterns='services.*test\\.(ts|js)$'",
"test:integration": "jest --testPathPatterns='(tests/integration/.*\\.test\\.(ts|js)|services/.*/__tests__/integration/.*\\.test\\.(ts|js))$'",
"seed": "bun run scripts/seed.ts",
"lint:markdown": "markdownlint-cli2 \"**/*.md\"",
"lint:markdown:fix": "markdownlint-cli2 --fix \"**/*.md\"",
"check:secrets": "secretlint \"**/*\"",
+20
View File
@@ -0,0 +1,20 @@
import { databaseSeeder } from '../services/database.seeder';
import { logger } from '../services/logging';
const run = async () => {
try {
await databaseSeeder.seedDatabase();
logger.info('Database seeding complete', 'SEEDER');
process.exit(0);
} catch (error) {
logger.error(
'Database seeding failed',
'SEEDER',
undefined,
error as Error
);
process.exit(1);
}
};
run();
+139 -25
View File
@@ -1,15 +1,17 @@
// Mock the mailgun config before any imports
const mockGetMailgunConfig = jest.fn().mockReturnValue({
apiKey: 'test-api-key',
domain: 'test.mailgun.org',
baseUrl: 'https://api.mailgun.net/v3',
fromName: 'Test App',
fromEmail: 'test@example.com',
});
jest.mock('../mailgun.config', () => {
const defaultConfig = {
apiKey: 'test-api-key',
domain: 'test.mailgun.org',
baseUrl: 'https://api.mailgun.net/v3',
fromName: 'Test App',
fromEmail: 'test@example.com',
};
jest.mock('../mailgun.config', () => ({
getMailgunConfig: mockGetMailgunConfig,
}));
return {
getMailgunConfig: jest.fn(() => defaultConfig),
};
});
// Mock the app config
jest.mock('../../config/unified.config', () => ({
@@ -18,6 +20,7 @@ jest.mock('../../config/unified.config', () => ({
baseUrl: 'http://localhost:3000',
},
},
getAppConfig: jest.fn(() => ({ baseUrl: 'http://localhost:3000' })),
}));
// Mock global fetch and related APIs
@@ -32,10 +35,28 @@ global.btoa = jest
// Import the service after mocks are set up
import { MailgunService } from '../mailgun.service';
import { getMailgunConfig } from '../mailgun.config';
import { logger } from '../logging';
const mockGetMailgunConfig = getMailgunConfig as jest.MockedFunction<
typeof getMailgunConfig
>;
mockGetMailgunConfig.mockReturnValue({
apiKey: 'test-api-key',
domain: 'test.mailgun.org',
baseUrl: 'https://api.mailgun.net/v3',
fromName: 'Test App',
fromEmail: 'test@example.com',
});
describe('MailgunService', () => {
let mockFetch: jest.MockedFunction<typeof fetch>;
let mockFormData: jest.MockedFunction<any>;
let warnSpy: jest.SpyInstance;
let infoSpy: jest.SpyInstance;
let errorSpy: jest.SpyInstance;
let debugSpy: jest.SpyInstance;
const mockConfig = {
apiKey: 'test-api-key',
@@ -47,10 +68,13 @@ describe('MailgunService', () => {
beforeEach(() => {
jest.clearAllMocks();
console.warn = jest.fn();
console.error = jest.fn();
mockFetch = fetch as jest.MockedFunction<typeof fetch>;
mockFormData = MockFormData;
warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
infoSpy = jest.spyOn(logger, 'info').mockImplementation(() => undefined);
errorSpy = jest.spyOn(logger, 'error').mockImplementation(() => undefined);
debugSpy = jest.spyOn(logger, 'debug').mockImplementation(() => undefined);
});
describe('constructor', () => {
@@ -67,11 +91,28 @@ describe('MailgunService', () => {
new MailgunService();
expect(console.warn).toHaveBeenCalledWith(
'📧 Mailgun Service: Running in development mode (emails will be logged only)'
expect(warnSpy).toHaveBeenCalledWith(
'Mailgun running in development mode; emails will not be delivered',
'MAILGUN',
{
missingFields: [
'VITE_MAILGUN_API_KEY',
'VITE_MAILGUN_DOMAIN',
'VITE_MAILGUN_FROM_EMAIL',
],
domain: undefined,
}
);
expect(console.warn).toHaveBeenCalledWith(
'💡 To enable real emails, configure Mailgun credentials in .env.local'
expect(infoSpy).toHaveBeenCalledWith(
'To enable email delivery, configure Mailgun environment variables',
'MAILGUN',
{
requiredVariables: [
'VITE_MAILGUN_API_KEY',
'VITE_MAILGUN_DOMAIN',
'VITE_MAILGUN_FROM_EMAIL',
],
}
);
});
@@ -80,10 +121,15 @@ describe('MailgunService', () => {
new MailgunService();
expect(console.warn).toHaveBeenCalledWith(
'📧 Mailgun Service: Configured for production with domain:',
'test.mailgun.org'
expect(infoSpy).toHaveBeenCalledWith(
'Mailgun configured for delivery',
'MAILGUN',
{
domain: 'test.mailgun.org',
fromEmail: 'test@example.com',
}
);
expect(warnSpy).not.toHaveBeenCalled();
});
});
@@ -122,8 +168,9 @@ describe('MailgunService', () => {
}),
})
);
expect(console.warn).toHaveBeenCalledWith(
'📧 Email sent successfully via Mailgun:',
expect(infoSpy).toHaveBeenCalledWith(
'Email sent via Mailgun',
'MAILGUN',
{
to: 'test@example.com',
subject: 'Test Subject',
@@ -148,8 +195,10 @@ describe('MailgunService', () => {
const result = await service.sendEmail('test@example.com', template);
expect(result).toBe(false);
expect(console.error).toHaveBeenCalledWith(
'Email sending failed:',
expect(errorSpy).toHaveBeenCalledWith(
'Mailgun email send failed',
'MAILGUN',
{ domain: 'test.mailgun.org' },
expect.any(Error)
);
});
@@ -165,8 +214,10 @@ describe('MailgunService', () => {
const result = await service.sendEmail('test@example.com', template);
expect(result).toBe(false);
expect(console.error).toHaveBeenCalledWith(
'Email sending failed:',
expect(errorSpy).toHaveBeenCalledWith(
'Mailgun email send failed',
'MAILGUN',
{ domain: 'test.mailgun.org' },
expect.any(Error)
);
});
@@ -237,6 +288,56 @@ describe('MailgunService', () => {
expect.anything()
);
});
test('logs preview and skips send when configuration is missing', async () => {
const unconfiguredConfig = {
apiKey: undefined,
domain: undefined,
baseUrl: 'https://api.mailgun.net/v3',
fromName: 'Test App',
fromEmail: undefined,
};
mockGetMailgunConfig.mockReturnValue(unconfiguredConfig);
const unconfiguredService = new MailgunService();
const template = {
subject: 'Test Subject',
html: '<p>Test HTML</p>',
};
const result = await unconfiguredService.sendEmail(
'test@example.com',
template
);
expect(result).toBe(false);
expect(mockFetch).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledWith(
'Skipping email send; Mailgun is not configured',
'MAILGUN',
expect.objectContaining({
to: 'test@example.com',
missingFields: [
'VITE_MAILGUN_API_KEY',
'VITE_MAILGUN_DOMAIN',
'VITE_MAILGUN_FROM_EMAIL',
],
preview: true,
})
);
expect(debugSpy).toHaveBeenCalledWith(
'Mailgun email preview',
'MAILGUN',
expect.objectContaining({
to: 'test@example.com',
subject: 'Test Subject',
html: '<p>Test HTML</p>',
})
);
mockGetMailgunConfig.mockReturnValue(mockConfig);
});
});
describe('sendVerificationEmail', () => {
@@ -360,6 +461,7 @@ describe('MailgunService', () => {
mode: 'production',
domain: 'test.mailgun.org',
fromEmail: 'test@example.com',
missingFields: [],
});
});
@@ -382,6 +484,11 @@ describe('MailgunService', () => {
mode: 'development',
domain: undefined,
fromEmail: undefined,
missingFields: [
'VITE_MAILGUN_API_KEY',
'VITE_MAILGUN_DOMAIN',
'VITE_MAILGUN_FROM_EMAIL',
],
});
});
@@ -404,6 +511,13 @@ describe('MailgunService', () => {
mode: 'development',
domain: '',
fromEmail: '',
missingFields: [
'VITE_MAILGUN_API_KEY',
'VITE_MAILGUN_DOMAIN',
'VITE_MAILGUN_FROM_EMAIL',
'VITE_MAILGUN_BASE_URL',
'VITE_MAILGUN_FROM_NAME',
],
});
});
});
@@ -1,8 +1,10 @@
import bcrypt from 'bcryptjs';
import { authService } from '../auth.service';
import { AccountStatus } from '../auth.constants';
import { AuthenticatedUser } from '../auth.types';
import { isBcryptHash } from '../password.service';
// Mock the new database service
// Mock the database service used by authService
jest.mock('../../database', () => ({
databaseService: {
findUserByEmail: jest.fn(),
@@ -14,7 +16,7 @@ jest.mock('../../database', () => ({
},
}));
// Mock the emailVerification service
// Mock the email verification service
jest.mock('../emailVerification.service', () => ({
EmailVerificationService: jest.fn().mockImplementation(() => ({
generateVerificationToken: jest.fn().mockResolvedValue({
@@ -38,31 +40,36 @@ describe('Authentication Integration Tests', () => {
let mockUser: AuthenticatedUser;
let mockDatabaseService: any;
let hashedPassword: string;
beforeEach(async () => {
localStorage.clear();
jest.clearAllMocks();
// Get the mocked database service
const { databaseService } = await import('../../database');
mockDatabaseService = databaseService;
// Setup default mock user
hashedPassword = await bcrypt.hash(testCredentials.password, 10);
mockUser = {
_id: 'user1',
_rev: 'mock-rev-1',
email: testCredentials.email,
username: testCredentials.username,
password: testCredentials.password,
password: hashedPassword,
emailVerified: false,
status: AccountStatus.PENDING,
};
mockDatabaseService.createUserWithPassword.mockResolvedValue(mockUser);
mockDatabaseService.updateUser.mockImplementation(
async (user: any) => user
);
});
describe('User Registration', () => {
test('should create a pending account for new user', async () => {
test('should hash password before persisting new user', async () => {
mockDatabaseService.findUserByEmail.mockResolvedValue(null);
mockDatabaseService.createUserWithPassword.mockResolvedValue(mockUser);
const result = await authService.register(
testCredentials.email,
@@ -72,10 +79,7 @@ describe('Authentication Integration Tests', () => {
expect(result).toBeDefined();
expect(result.user.username).toBe(testCredentials.username);
expect(result.user.email).toBe(testCredentials.email);
expect(result.user.status).toBe(AccountStatus.PENDING);
expect(result.user.emailVerified).toBe(false);
expect(result.verificationToken).toBeDefined();
expect(result.verificationToken.token).toBe('mock-verification-token');
expect(mockDatabaseService.findUserByEmail).toHaveBeenCalledWith(
@@ -83,9 +87,14 @@ describe('Authentication Integration Tests', () => {
);
expect(mockDatabaseService.createUserWithPassword).toHaveBeenCalledWith(
testCredentials.email,
testCredentials.password,
expect.any(String),
testCredentials.username
);
const persistedPassword =
mockDatabaseService.createUserWithPassword.mock.calls[0][1];
expect(isBcryptHash(persistedPassword)).toBe(true);
expect(persistedPassword).not.toBe(testCredentials.password);
});
test('should fail when user already exists', async () => {
@@ -115,7 +124,7 @@ describe('Authentication Integration Tests', () => {
).rejects.toThrow('Email verification required');
});
test('should succeed after email verification', async () => {
test('should succeed with correct bcrypt hashed password', async () => {
const verifiedUser = {
...mockUser,
emailVerified: true,
@@ -128,10 +137,8 @@ describe('Authentication Integration Tests', () => {
password: testCredentials.password,
});
expect(tokens).toBeDefined();
expect(tokens.accessToken).toBeTruthy();
expect(tokens.refreshToken).toBeTruthy();
expect(tokens.user).toBeDefined();
expect(tokens.user.status).toBe(AccountStatus.ACTIVE);
});
@@ -151,6 +158,23 @@ describe('Authentication Integration Tests', () => {
).rejects.toThrow('Invalid credentials');
});
test('should reject legacy accounts with plaintext passwords', async () => {
const legacyUser = {
...mockUser,
emailVerified: true,
status: AccountStatus.ACTIVE,
password: testCredentials.password,
};
mockDatabaseService.findUserByEmail.mockResolvedValue(legacyUser);
await expect(
authService.login({
email: testCredentials.email,
password: testCredentials.password,
})
).rejects.toThrow('Invalid credentials');
});
test('should fail for non-existent user', async () => {
mockDatabaseService.findUserByEmail.mockResolvedValue(null);
@@ -185,19 +209,9 @@ describe('Authentication Integration Tests', () => {
const result = await authService.loginWithOAuth('google', oauthUserData);
expect(result).toBeDefined();
expect(result.user.email).toBe(oauthUserData.email);
expect(result.user.username).toBe(oauthUserData.username);
expect(result.user.status).toBe(AccountStatus.ACTIVE);
expect(result.user.emailVerified).toBe(true);
expect(result.accessToken).toBeTruthy();
expect(result.refreshToken).toBeTruthy();
expect(mockDatabaseService.createUserFromOAuth).toHaveBeenCalledWith(
oauthUserData.email,
oauthUserData.username,
'google'
);
});
test('should login existing OAuth user', async () => {
@@ -215,74 +229,70 @@ describe('Authentication Integration Tests', () => {
const result = await authService.loginWithOAuth('google', oauthUserData);
expect(result).toBeDefined();
expect(result.user.email).toBe(oauthUserData.email);
expect(result.user._id).toBe('existing-user1');
expect(result.accessToken).toBeTruthy();
expect(result.refreshToken).toBeTruthy();
expect(mockDatabaseService.createUserFromOAuth).not.toHaveBeenCalled();
});
test('should handle OAuth login errors gracefully', async () => {
mockDatabaseService.findUserByEmail.mockRejectedValue(
new Error('Database error')
);
await expect(
authService.loginWithOAuth('google', oauthUserData)
).rejects.toThrow('OAuth login failed: Database error');
});
});
describe('Password Management', () => {
test('should change password with valid current password', async () => {
const userId = 'user1';
const currentPassword = 'currentPassword';
const newPassword = 'newPassword123';
const userWithPassword = {
const activeUser = {
...mockUser,
password: currentPassword,
};
const updatedUser = {
...userWithPassword,
password: newPassword,
emailVerified: true,
status: AccountStatus.ACTIVE,
};
mockDatabaseService.getUserById.mockResolvedValue(userWithPassword);
mockDatabaseService.updateUser.mockResolvedValue(updatedUser);
mockDatabaseService.getUserById.mockResolvedValue(activeUser);
const result = await authService.changePassword(
userId,
currentPassword,
testCredentials.password,
newPassword
);
expect(result).toBeDefined();
expect(result.user.password).toBe(newPassword);
const updatedUser = mockDatabaseService.updateUser.mock.calls[0][0];
expect(isBcryptHash(updatedUser.password)).toBe(true);
expect(await bcrypt.compare(newPassword, updatedUser.password)).toBe(
true
);
expect(result.message).toBe('Password changed successfully');
expect(mockDatabaseService.updateUser).toHaveBeenCalledWith({
...userWithPassword,
password: newPassword,
});
});
test('should fail password change with incorrect current password', async () => {
const userId = 'user1';
const currentPassword = 'wrongPassword';
const newPassword = 'newPassword123';
const userWithPassword = {
const hashed = await bcrypt.hash('correctPassword', 10);
const activeUser = {
...mockUser,
password: 'correctPassword',
emailVerified: true,
status: AccountStatus.ACTIVE,
password: hashed,
};
mockDatabaseService.getUserById.mockResolvedValue(userWithPassword);
mockDatabaseService.getUserById.mockResolvedValue(activeUser);
await expect(
authService.changePassword(userId, currentPassword, newPassword)
authService.changePassword(userId, 'wrongPassword', 'newPassword123')
).rejects.toThrow('Current password is incorrect');
});
test('should fail password change when legacy password is detected', async () => {
const userId = 'user1';
const legacyUser = {
...mockUser,
emailVerified: true,
status: AccountStatus.ACTIVE,
password: 'legacyPassword',
};
mockDatabaseService.getUserById.mockResolvedValue(legacyUser);
await expect(
authService.changePassword(userId, 'legacyPassword', 'newPassword123')
).rejects.toThrow('Password needs to be reset before it can be changed');
});
test('should fail password change for OAuth users', async () => {
const userId = 'user1';
const oauthUser = {
@@ -302,7 +312,8 @@ describe('Authentication Integration Tests', () => {
test('should request password reset for existing user', async () => {
const userWithPassword = {
...mockUser,
password: 'hasPassword',
emailVerified: true,
status: AccountStatus.ACTIVE,
};
mockDatabaseService.findUserByEmail.mockResolvedValue(userWithPassword);
@@ -310,7 +321,6 @@ describe('Authentication Integration Tests', () => {
testCredentials.email
);
expect(result).toBeDefined();
expect(result.message).toContain('password reset link has been sent');
});
@@ -321,7 +331,6 @@ describe('Authentication Integration Tests', () => {
'nonexistent@example.com'
);
expect(result).toBeDefined();
expect(result.message).toContain('password reset link has been sent');
});
@@ -0,0 +1,365 @@
import type { EmailVerificationToken } from '../auth.types';
import type { PasswordResetToken } from '../token.service';
import { logger } from '../../logging';
jest.mock('../../../config/unified.config', () => {
const actual = jest.requireActual('../../../config/unified.config');
return {
...actual,
getDatabaseConfig: jest.fn(),
};
});
const { getDatabaseConfig } = jest.requireMock(
'../../../config/unified.config'
) as {
getDatabaseConfig: jest.MockedFunction<
() => {
url: string;
username: string;
password: string;
useMock?: boolean;
}
>;
};
getDatabaseConfig.mockReturnValue({
url: '',
username: '',
password: '',
useMock: true,
});
let TokenServiceClass: typeof import('../token.service').TokenService;
beforeAll(async () => {
({ TokenService: TokenServiceClass } = await import('../token.service'));
});
describe('TokenService (localStorage fallback)', () => {
let service: TokenService;
beforeEach(() => {
getDatabaseConfig.mockReturnValue({
url: '',
username: '',
password: '',
useMock: true,
});
service = new TokenServiceClass();
localStorage.clear();
});
it('stores and retrieves verification tokens with Date instances', async () => {
const expiresAt = new Date('2025-01-01T00:00:00.000Z');
const token: EmailVerificationToken = {
userId: 'user-1',
email: 'user@example.com',
token: 'verify-123',
expiresAt,
};
await service.saveVerificationToken(token);
const stored = await service.findVerificationToken('verify-123');
expect(stored).not.toBeNull();
expect(stored?.expiresAt).toBeInstanceOf(Date);
expect(stored?.expiresAt.getTime()).toBe(expiresAt.getTime());
});
it('removes verification tokens only for the targeted user', async () => {
const expiry = new Date(Date.now() + 60_000);
await service.saveVerificationToken({
userId: 'user-a',
email: 'a@example.com',
token: 'token-a',
expiresAt: expiry,
});
await service.saveVerificationToken({
userId: 'user-b',
email: 'b@example.com',
token: 'token-b',
expiresAt: expiry,
});
await service.deleteVerificationTokensForUser('user-a');
expect(await service.findVerificationToken('token-a')).toBeNull();
expect(await service.findVerificationToken('token-b')).not.toBeNull();
});
it('supports password reset token lifecycle (save, find, delete)', async () => {
const expiresAt = new Date(Date.now() + 120_000);
const token: PasswordResetToken = {
userId: 'reset-user',
email: 'reset@example.com',
token: 'reset-123',
expiresAt,
};
await service.savePasswordResetToken(token);
const stored = await service.findPasswordResetToken('reset-123');
expect(stored).not.toBeNull();
expect(stored?.expiresAt).toBeInstanceOf(Date);
await service.deletePasswordResetToken('reset-123');
expect(await service.findPasswordResetToken('reset-123')).toBeNull();
});
it('removes password reset tokens by user id', async () => {
const expiresAt = new Date(Date.now() + 120_000);
await service.savePasswordResetToken({
userId: 'reset-a',
email: 'a@example.com',
token: 'reset-a-token',
expiresAt,
});
await service.savePasswordResetToken({
userId: 'reset-b',
email: 'b@example.com',
token: 'reset-b-token',
expiresAt,
});
await service.deletePasswordResetTokensForUser('reset-a');
expect(await service.findPasswordResetToken('reset-a-token')).toBeNull();
expect(
await service.findPasswordResetToken('reset-b-token')
).not.toBeNull();
});
it('cleans up expired tokens from both verification and reset stores', async () => {
const past = new Date(Date.now() - 60_000);
const future = new Date(Date.now() + 60_000);
await service.saveVerificationToken({
userId: 'expired',
email: 'expired@example.com',
token: 'expired-ver',
expiresAt: past,
});
await service.saveVerificationToken({
userId: 'valid',
email: 'valid@example.com',
token: 'valid-ver',
expiresAt: future,
});
await service.savePasswordResetToken({
userId: 'expired',
email: 'expired@example.com',
token: 'expired-reset',
expiresAt: past,
});
await service.savePasswordResetToken({
userId: 'valid',
email: 'valid@example.com',
token: 'valid-reset',
expiresAt: future,
});
const removed = await service.cleanupExpiredTokens();
expect(removed).toBe(2);
expect(await service.findVerificationToken('expired-ver')).toBeNull();
expect(await service.findVerificationToken('valid-ver')).not.toBeNull();
expect(await service.findPasswordResetToken('expired-reset')).toBeNull();
expect(await service.findPasswordResetToken('valid-reset')).not.toBeNull();
});
});
describe('TokenService (CouchDB mode)', () => {
let service: TokenService;
let docs: Record<string, any>;
let revCounter: number;
beforeEach(() => {
getDatabaseConfig.mockReturnValue({
url: 'http://couch.local',
username: 'admin',
password: 'secret',
useMock: false,
});
service = new TokenServiceClass();
docs = {};
revCounter = 1;
jest.spyOn(service as any, 'ensureDatabase').mockResolvedValue(undefined);
(service as any).usingCouch = true;
(service as any).initialized = true;
(service as any).couchBaseUrl = 'http://couch.local';
(service as any).couchAuthHeader = 'Basic test';
jest
.spyOn(service as any, 'makeCouchRequest')
.mockImplementation(async (method: string, path: string, body?: any) => {
const [resourcePath] = path.split('?');
const segments = resourcePath.split('/');
const docId = segments[2];
if (method === 'GET' && resourcePath === '/auth_tokens/_all_docs') {
return {
rows: Object.values(docs).map((doc: any) => ({
id: doc._id,
value: {},
doc,
})),
};
}
if (method === 'POST' && resourcePath === '/auth_tokens/_find') {
const selector = body.selector || {};
const matching = Object.values(docs).filter((doc: any) => {
return Object.entries(selector).every(
([key, value]) => doc[key] === value
);
});
return { docs: matching };
}
if (method === 'GET') {
const doc = docs[docId];
if (!doc) {
throw new Error('not found');
}
return doc;
}
if (method === 'PUT') {
const newRev = `rev-${revCounter++}`;
const doc = { ...body, _rev: newRev };
docs[doc._id] = doc;
return { id: doc._id, rev: newRev };
}
if (method === 'DELETE') {
if (!docs[docId]) {
throw new Error('missing');
}
delete docs[docId];
return { ok: true };
}
throw new Error(`Unhandled request: ${method} ${path}`);
});
jest.spyOn(logger.db, 'error').mockImplementation(() => undefined);
jest.spyOn(logger.db, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('persists and retrieves verification tokens via Couch paths', async () => {
const expiresAt = new Date('2025-01-02T00:00:00.000Z');
await service.saveVerificationToken({
userId: 'user-1',
email: 'user@example.com',
token: 'ver-1',
expiresAt,
});
const found = await service.findVerificationToken('ver-1');
expect(found).not.toBeNull();
expect(found?.expiresAt).toBeInstanceOf(Date);
expect(found?.expiresAt.getTime()).toBe(expiresAt.getTime());
expect(Object.keys(docs)).toContain('ver-ver-1');
});
it('deletes verification tokens for a user using Mango query results', async () => {
const future = new Date(Date.now() + 60_000).toISOString();
docs['ver-keep'] = {
_id: 'ver-keep',
_rev: 'rev-1',
tokenType: 'verification',
token: 'keep',
userId: 'user-keep',
email: 'keep@example.com',
expiresAt: future,
createdAt: future,
};
docs['ver-drop'] = {
_id: 'ver-drop',
_rev: 'rev-2',
tokenType: 'verification',
token: 'drop',
userId: 'user-drop',
email: 'drop@example.com',
expiresAt: future,
createdAt: future,
};
await service.deleteVerificationTokensForUser('user-drop');
expect(docs['ver-drop']).toBeUndefined();
expect(docs['ver-keep']).toBeDefined();
});
it('handles password reset token lifecycle via Couch calls', async () => {
const expiresAt = new Date('2025-01-03T00:00:00.000Z');
await service.savePasswordResetToken({
userId: 'reset-user',
email: 'reset@example.com',
token: 'rst-1',
expiresAt,
});
const found = await service.findPasswordResetToken('rst-1');
expect(found).not.toBeNull();
expect(found?.expiresAt.getTime()).toBe(expiresAt.getTime());
await service.deletePasswordResetToken('rst-1');
expect(docs['rst-rst-1']).toBeUndefined();
});
it('cleans up expired Couch documents and keeps valid ones', async () => {
const past = new Date(Date.now() - 60_000).toISOString();
const future = new Date(Date.now() + 60_000).toISOString();
docs['ver-expired'] = {
_id: 'ver-expired',
_rev: 'rev-5',
tokenType: 'verification',
token: 'expired',
userId: 'user-expired',
email: 'expired@example.com',
expiresAt: past,
createdAt: past,
};
docs['rst-expired'] = {
_id: 'rst-expired',
_rev: 'rev-6',
tokenType: 'reset',
token: 'rst-expired',
userId: 'user-expired',
email: 'expired@example.com',
expiresAt: past,
createdAt: past,
};
docs['ver-valid'] = {
_id: 'ver-valid',
_rev: 'rev-7',
tokenType: 'verification',
token: 'valid',
userId: 'user-valid',
email: 'valid@example.com',
expiresAt: future,
createdAt: future,
};
const removed = await service.cleanupExpiredTokens();
expect(removed).toBe(2);
expect(docs['ver-expired']).toBeUndefined();
expect(docs['rst-expired']).toBeUndefined();
expect(docs['ver-valid']).toBeDefined();
});
});
+31 -12
View File
@@ -4,6 +4,7 @@ import { EmailVerificationService } from './emailVerification.service';
import { databaseService } from '../database';
import { logger } from '../logging';
import { tokenService } from './token.service';
import { hashPassword, verifyPassword, isBcryptHash } from './password.service';
const emailVerificationService = new EmailVerificationService();
@@ -22,9 +23,11 @@ const authService = {
}
// Create user with password
const passwordToPersist = await hashPassword(password);
const user = await databaseService.createUserWithPassword(
email,
password,
passwordToPersist,
username
);
@@ -80,14 +83,21 @@ const authService = {
throw new Error('Email verification required');
}
// Simple password verification (in production, use bcrypt)
logger.auth.login('Comparing passwords', {
inputPassword: input.password,
storedPassword: user.password,
match: user.password === input.password,
if (!isBcryptHash(user.password)) {
logger.auth.error('Stored password is not hashed; rejecting login');
throw new Error('Invalid credentials');
}
const hasValidPassword = await verifyPassword(
input.password,
user.password
);
logger.auth.login('Password comparison result', {
hasValidPassword,
});
if (user.password !== input.password) {
if (!hasValidPassword) {
logger.auth.error('Password mismatch');
throw new Error('Invalid credentials');
}
@@ -162,8 +172,16 @@ const authService = {
throw new Error('Cannot change password for OAuth accounts');
}
// Verify current password
if (user.password !== currentPassword) {
if (!isBcryptHash(user.password)) {
throw new Error('Password needs to be reset before it can be changed');
}
const currentPasswordMatches = await verifyPassword(
currentPassword,
user.password
);
if (!currentPasswordMatches) {
throw new Error('Current password is incorrect');
}
@@ -175,7 +193,7 @@ const authService = {
// Update user with new password (this should be hashed before calling)
const updatedUser = await databaseService.updateUser({
...user,
password: newPassword,
password: await hashPassword(newPassword),
});
return {
@@ -249,10 +267,11 @@ const authService = {
throw new Error('User not found');
}
// Update user with new password (this should be hashed before calling)
const hashedPassword = await hashPassword(newPassword);
const updatedUser = await databaseService.updateUser({
...user,
password: newPassword,
password: hashedPassword,
});
// Remove used token
+2 -1
View File
@@ -4,6 +4,7 @@ import { mailgunService } from '../mailgun.service';
import { AccountStatus } from './auth.constants';
import { databaseService } from '../database';
import { tokenService } from './token.service';
import { logger } from '../logging';
const TOKEN_EXPIRY_HOURS = 24;
@@ -32,7 +33,7 @@ export class EmailVerificationService {
token
);
if (!emailSent) {
console.warn('Failed to send verification email');
logger.auth.warn('Failed to send verification email');
}
}
+39
View File
@@ -0,0 +1,39 @@
import bcrypt from 'bcryptjs';
import { getAuthConfig } from '../../config/unified.config';
const DEFAULT_ROUNDS = 10;
/**
* Hash a plaintext password using bcrypt.
* Falls back to a sane default if auth config is unavailable.
*/
export async function hashPassword(plainPassword: string): Promise<string> {
const rounds = getAuthConfig()?.bcryptRounds ?? DEFAULT_ROUNDS;
return bcrypt.hash(plainPassword, rounds);
}
/**
* Compare a plaintext password against a stored bcrypt hash.
*/
export async function verifyPassword(
plainPassword: string,
hashedPassword?: string | null
): Promise<boolean> {
if (!hashedPassword) {
return false;
}
return bcrypt.compare(plainPassword, hashedPassword);
}
/**
* Convenience helper to decide whether a password needs hashing.
* Useful when dealing with legacy or seeded data.
*/
export function isBcryptHash(value?: string | null): boolean {
if (!value) return false;
return (
value.startsWith('$2a$') ||
value.startsWith('$2b$') ||
value.startsWith('$2y$')
);
}
+3 -8
View File
@@ -9,6 +9,7 @@ import { EmailVerificationToken } from './auth.types';
import type { CouchDBDocument } from '../../types';
import { getDatabaseConfig } from '../../config/unified.config';
import { logger } from '../logging';
import { encodeBase64 } from '../../utils/base64';
export interface PasswordResetToken {
userId: string;
@@ -42,14 +43,8 @@ function fromISO(date: string | Date): Date {
return date instanceof Date ? date : new Date(date);
}
function base64Auth(user: string, pass: string): string {
// btoa may not exist in some environments (e.g., Node). Fallback to Buffer.
if (typeof btoa !== 'undefined') {
return btoa(`${user}:${pass}`);
}
return Buffer.from(`${user}:${pass}`).toString('base64');
}
const base64Auth = (user: string, pass: string): string =>
encodeBase64(`${user}:${pass}`);
export class TokenService {
private couchBaseUrl: string | null = null;
+34 -22
View File
@@ -1,32 +1,36 @@
import { databaseService } from './database';
import { AccountStatus } from './auth/auth.constants';
import { UserRole } from '../types';
import { hashPassword, isBcryptHash } from './auth/password.service';
import { logger } from './logging';
export class DatabaseSeeder {
private static seedingInProgress = false;
private static seedingCompleted = false;
async seedDefaultAdmin(): Promise<void> {
const adminEmail = 'admin@localhost';
const adminPassword = 'admin123!';
const adminEmail =
(import.meta as any)?.env?.VITE_ADMIN_EMAIL || 'admin@localhost';
const adminPassword =
(import.meta as any)?.env?.VITE_ADMIN_PASSWORD || 'admin123!';
console.warn('🌱 Starting admin user seeding...');
console.warn('📧 Admin email:', adminEmail);
logger.db.info('🌱 Starting admin user seeding...');
logger.db.info('📧 Admin email:', adminEmail);
try {
// Check if admin already exists
const existingAdmin = await databaseService.findUserByEmail(adminEmail);
if (existingAdmin) {
console.warn('✅ Default admin user already exists');
console.warn('👤 Existing admin:', existingAdmin);
logger.db.info('✅ Default admin user already exists');
logger.db.info('👤 Existing admin:', existingAdmin);
// Check if admin needs to be updated to correct role/status
if (
existingAdmin.role !== UserRole.ADMIN ||
existingAdmin.status !== AccountStatus.ACTIVE
) {
console.warn('🔧 Updating admin user role and status...');
logger.db.info('🔧 Updating admin user role and status...');
const updatedAdmin = {
...existingAdmin,
role: UserRole.ADMIN,
@@ -34,21 +38,25 @@ export class DatabaseSeeder {
emailVerified: true,
};
await databaseService.updateUser(updatedAdmin);
console.warn('✅ Admin user updated successfully');
console.warn('👤 Updated admin:', updatedAdmin);
logger.db.info('✅ Admin user updated successfully');
logger.db.info('👤 Updated admin:', updatedAdmin);
}
return;
}
console.warn('🚀 Creating new admin user...');
logger.db.info('🚀 Creating new admin user...');
// Create default admin user
const passwordToUse = isBcryptHash(adminPassword)
? adminPassword
: await hashPassword(adminPassword);
const adminUser = await databaseService.createUserWithPassword(
adminEmail,
adminPassword,
passwordToUse,
'admin'
);
console.warn('👤 Admin user created:', adminUser);
logger.db.info('👤 Admin user created:', adminUser);
// Update user to admin role and active status
const updatedAdmin = {
@@ -62,13 +70,15 @@ export class DatabaseSeeder {
await databaseService.updateUser(updatedAdmin);
console.warn('✅ Admin user created successfully');
console.warn('👤 Final admin user:', updatedAdmin);
console.warn('📧 Email:', adminEmail);
console.warn('🔑 Password:', adminPassword);
console.warn('⚠️ Please change the default password after first login!');
logger.db.info('✅ Admin user created successfully');
logger.db.info('👤 Final admin user:', updatedAdmin);
logger.db.info('📧 Email:', adminEmail);
logger.db.info('🔑 Password:', adminPassword);
logger.db.info(
'⚠️ Please change the default password after first login!'
);
} catch (error) {
console.error('❌ Failed to create default admin user:', error);
logger.db.error('❌ Failed to create default admin user:', error);
throw error;
}
}
@@ -76,19 +86,21 @@ export class DatabaseSeeder {
async seedDatabase(): Promise<void> {
// Prevent multiple seeding attempts
if (DatabaseSeeder.seedingInProgress || DatabaseSeeder.seedingCompleted) {
console.warn('🔄 Seeding already in progress or completed, skipping...');
logger.db.info(
'🔄 Seeding already in progress or completed, skipping...'
);
return;
}
DatabaseSeeder.seedingInProgress = true;
console.warn('🌱 Starting database seeding...');
logger.db.info('🌱 Starting database seeding...');
try {
await this.seedDefaultAdmin();
DatabaseSeeder.seedingCompleted = true;
console.warn('🎯 Admin seeding completed successfully');
logger.db.info('🎯 Admin seeding completed successfully');
} catch (error) {
console.error('💥 Database seeding failed:', error);
logger.db.error('💥 Database seeding failed:', error);
throw error;
} finally {
DatabaseSeeder.seedingInProgress = false;
+7 -4
View File
@@ -3,6 +3,8 @@ import { MockDatabaseStrategy } from './MockDatabaseStrategy';
import { ProductionDatabaseStrategy } from './ProductionDatabaseStrategy';
import { DatabaseStrategy } from './types';
import { AccountStatus } from '../auth/auth.constants';
import { hashPassword } from '../auth/password.service';
import { logger } from '../logging';
/**
* Consolidated Database Service
@@ -30,9 +32,9 @@ export class DatabaseService implements DatabaseStrategy {
try {
return new ProductionDatabaseStrategy();
} catch (error) {
console.warn(
'Production CouchDB service not available, falling back to mock:',
error
logger.db.warn(
'Production CouchDB service not available, falling back to mock',
error as Error
);
return new MockDatabaseStrategy();
}
@@ -188,9 +190,10 @@ export class DatabaseService implements DatabaseStrategy {
async changeUserPassword(userId: string, newPassword: string) {
const user = await this.strategy.getUserById(userId);
if (!user) throw new Error('User not found');
const hashedPassword = await hashPassword(newPassword);
return this.strategy.updateUser({
...user,
password: newPassword,
password: hashedPassword,
});
}
+80 -13
View File
@@ -12,6 +12,7 @@ import { AccountStatus } from '../auth/auth.constants';
import { DatabaseStrategy, DatabaseError } from './types';
import { getDatabaseConfig } from '../../config/unified.config';
import { logger } from '../logging';
import { encodeBase64 } from '../../utils/base64';
export class ProductionDatabaseStrategy implements DatabaseStrategy {
private baseUrl: string;
@@ -22,7 +23,7 @@ export class ProductionDatabaseStrategy implements DatabaseStrategy {
const dbConfig = getDatabaseConfig();
this.baseUrl = dbConfig.url;
this.auth = btoa(`${dbConfig.username}:${dbConfig.password}`);
this.auth = encodeBase64(`${dbConfig.username}:${dbConfig.password}`);
logger.db.query('Initializing production database strategy', {
url: dbConfig.url,
@@ -47,6 +48,9 @@ export class ProductionDatabaseStrategy implements DatabaseStrategy {
for (const dbName of databases) {
try {
await this.createDatabaseIfNotExists(dbName);
if (dbName === 'users') {
await this.ensureUserEmailIndex();
}
} catch (error) {
logger.db.error(
`Failed to initialize database ${dbName}`,
@@ -56,6 +60,40 @@ export class ProductionDatabaseStrategy implements DatabaseStrategy {
}
}
private async ensureUserEmailIndex(): Promise<void> {
try {
const indexes = await this.makeRequest<{
indexes: Array<{
name: string;
def: { fields: Array<Record<string, string>> };
}>;
}>('GET', '/users/_index');
const hasEmailIndex = indexes.indexes.some(index => {
if (index.name === 'email-index') {
return true;
}
return index.def.fields.some(
field => Object.keys(field)[0] === 'email'
);
});
if (hasEmailIndex) {
return;
}
await this.makeRequest('POST', '/users/_index', {
index: { fields: ['email'] },
name: 'email-index',
type: 'json',
});
logger.db.query('Created email index for users database');
} catch (error) {
logger.db.error('Failed to ensure user email index', error as Error);
}
}
private async createDatabaseIfNotExists(dbName: string): Promise<void> {
try {
// Check if database exists
@@ -150,18 +188,46 @@ export class ProductionDatabaseStrategy implements DatabaseStrategy {
private async putDoc<T extends CouchDBDocument>(
dbName: string,
doc: T
doc: T,
retries = 2
): Promise<T> {
const response = await this.makeRequest<{ id: string; rev: string }>(
'PUT',
`/${dbName}/${doc._id}`,
doc
);
try {
const response = await this.makeRequest<{ id: string; rev: string }>(
'PUT',
`/${dbName}/${doc._id}`,
doc
);
return {
...doc,
_rev: response.rev,
};
return {
...doc,
_rev: response.rev,
};
} catch (error) {
if (
error instanceof DatabaseError &&
error.status === 409 &&
retries > 0 &&
doc._rev
) {
logger.db.warn('Document conflict detected, retrying update', {
dbName,
id: doc._id,
retriesRemaining: retries,
});
const latest = await this.getDoc<T>(dbName, doc._id);
if (latest) {
const mergedDoc = {
...latest,
...doc,
_rev: latest._rev,
};
return this.putDoc(dbName, mergedDoc, retries - 1);
}
}
throw error;
}
}
private async deleteDoc(
@@ -225,13 +291,14 @@ export class ProductionDatabaseStrategy implements DatabaseStrategy {
async findUserByEmail(email: string): Promise<User | null> {
const response = await this.makeRequest<{
rows: Array<{ doc: User }>;
docs: User[];
warning?: string;
}>('POST', '/users/_find', {
selector: { email },
limit: 1,
});
return response.rows[0]?.doc || null;
return response.docs[0] || null;
}
async deleteUser(id: string): Promise<boolean> {
@@ -12,10 +12,14 @@ jest.mock('../../../config/unified.config', () => ({
baseUrl: 'http://localhost:3000',
},
},
getAuthConfig: jest.fn(() => ({ bcryptRounds: 4 })),
}));
const strategyMocks: Record<string, jest.Mock> = {};
// Create mock strategy methods object
const mockStrategyMethods = {
const mockStrategyMethods = strategyMocks as Record<string, jest.Mock>;
Object.assign(mockStrategyMethods, {
createUser: jest.fn(),
updateUser: jest.fn(),
getUserById: jest.fn(),
@@ -36,17 +40,15 @@ const mockStrategyMethods = {
updateCustomReminder: jest.fn(),
getCustomReminders: jest.fn(),
deleteCustomReminder: jest.fn(),
};
});
// Mock the strategies
jest.mock('../MockDatabaseStrategy', () => ({
MockDatabaseStrategy: jest.fn().mockImplementation(() => mockStrategyMethods),
MockDatabaseStrategy: jest.fn().mockImplementation(() => strategyMocks),
}));
jest.mock('../ProductionDatabaseStrategy', () => ({
ProductionDatabaseStrategy: jest
.fn()
.mockImplementation(() => mockStrategyMethods),
ProductionDatabaseStrategy: jest.fn().mockImplementation(() => strategyMocks),
}));
// Import after mocks are set up
@@ -390,18 +392,19 @@ describe('DatabaseService', () => {
test('should support changeUserPassword method', async () => {
const user = createMockUser();
const updatedUser = { ...user, password: 'newPassword' };
mockStrategyMethods.getUserById.mockResolvedValue(user);
mockStrategyMethods.updateUser.mockResolvedValue(updatedUser);
mockStrategyMethods.updateUser.mockImplementation(
async updated => updated
);
const result = await service.changeUserPassword('user1', 'newPassword');
expect(mockStrategyMethods.getUserById).toHaveBeenCalledWith('user1');
expect(mockStrategyMethods.updateUser).toHaveBeenCalledWith({
...user,
password: 'newPassword',
});
expect(result).toBe(updatedUser);
const updateCallArg = mockStrategyMethods.updateUser.mock.calls[0][0];
expect(updateCallArg._id).toBe(user._id);
expect(updateCallArg.password).not.toBe('newPassword');
expect(updateCallArg.password.startsWith('$2')).toBe(true);
expect(result.password).toBe(updateCallArg.password);
});
test('should support deleteAllUserData method', async () => {
+6 -3
View File
@@ -1,6 +1,8 @@
/**
* Mock email service for sending verification emails
*/
import { logger } from './logging';
export class EmailService {
/**
* Simulates sending a verification email with a link to /verify-email?token=${token}
@@ -10,10 +12,11 @@ export class EmailService {
async sendVerificationEmail(email: string, token: string): Promise<void> {
// In a real implementation, this would send an actual email
// For this demo, we'll just log the action
console.warn(
`📧 Sending verification email to ${email} with token: ${token}`
logger.info(
`Sending verification email to ${email} with token: ${token}`,
'EMAIL'
);
console.warn(`🔗 Verification link: /verify-email?token=${token}`);
logger.info(`Verification link: /verify-email?token=${token}`, 'EMAIL');
// Simulate network delay
await new Promise(resolve => setTimeout(resolve, 500));
+83 -18
View File
@@ -5,6 +5,9 @@
import { getMailgunConfig, type MailgunConfig } from './mailgun.config';
import { getAppConfig } from '../config/unified.config';
import { logger } from './logging';
import { normalizeError } from '../utils/error';
import { encodeBase64 } from '../utils/base64';
interface EmailTemplate {
subject: string;
@@ -14,24 +17,34 @@ interface EmailTemplate {
export class MailgunService {
private config: MailgunConfig;
private readonly context = 'MAILGUN';
constructor() {
this.config = getMailgunConfig();
// Log configuration status on startup
const status = this.getConfigurationStatus();
if (status.mode === 'development') {
console.warn(
'📧 Mailgun Service: Running in development mode (emails will be logged only)'
if (!status.configured) {
logger.warn(
'Mailgun running in development mode; emails will not be delivered',
this.context,
{
missingFields: status.missingFields,
domain: status.domain,
}
);
console.warn(
'💡 To enable real emails, configure Mailgun credentials in .env.local'
logger.info(
'To enable email delivery, configure Mailgun environment variables',
this.context,
{
requiredVariables: status.missingFields,
}
);
} else {
console.warn(
'📧 Mailgun Service: Configured for production with domain:',
status.domain
);
logger.info('Mailgun configured for delivery', this.context, {
domain: status.domain,
fromEmail: status.fromEmail,
});
}
}
@@ -95,6 +108,27 @@ export class MailgunService {
async sendEmail(to: string, template: EmailTemplate): Promise<boolean> {
try {
const status = this.getConfigurationStatus();
if (!status.configured) {
logger.warn(
'Skipping email send; Mailgun is not configured',
this.context,
{
to,
subject: template.subject,
missingFields: status.missingFields,
preview: status.mode === 'development',
}
);
logger.debug('Mailgun email preview', this.context, {
to,
subject: template.subject,
html: template.html,
text: template.text,
});
return false;
}
// Production Mailgun API call
const formData = new FormData();
formData.append(
@@ -113,7 +147,7 @@ export class MailgunService {
{
method: 'POST',
headers: {
Authorization: `Basic ${btoa(`api:${this.config.apiKey}`)}`,
Authorization: `Basic ${encodeBase64(`api:${this.config.apiKey}`)}`,
},
body: formData,
}
@@ -125,7 +159,7 @@ export class MailgunService {
}
const result = await response.json();
console.warn('📧 Email sent successfully via Mailgun:', {
logger.info('Email sent via Mailgun', this.context, {
to,
subject: template.subject,
messageId: result.id,
@@ -133,7 +167,14 @@ export class MailgunService {
return true;
} catch (error: unknown) {
console.error('Email sending failed:', error);
logger.error(
'Mailgun email send failed',
this.context,
{
domain: this.config.domain,
},
normalizeError(error)
);
return false;
}
}
@@ -156,13 +197,10 @@ export class MailgunService {
mode: 'development' | 'production';
domain: string;
fromEmail: string;
missingFields: string[];
} {
const configured =
!!this.config.apiKey &&
!!this.config.domain &&
!!this.config.baseUrl &&
!!this.config.fromEmail &&
!!this.config.fromName;
const missingFields = this.getMissingFields();
const configured = missingFields.length === 0;
const mode: 'development' | 'production' = configured
? 'production'
: 'development';
@@ -171,8 +209,35 @@ export class MailgunService {
mode,
domain: this.config.domain,
fromEmail: this.config.fromEmail,
missingFields,
};
}
private getMissingFields(): string[] {
const missing: string[] = [];
if (!this.config.apiKey) {
missing.push('VITE_MAILGUN_API_KEY');
}
if (!this.config.domain) {
missing.push('VITE_MAILGUN_DOMAIN');
}
if (!this.config.fromEmail) {
missing.push('VITE_MAILGUN_FROM_EMAIL');
}
if (!this.config.baseUrl) {
missing.push('VITE_MAILGUN_BASE_URL');
}
if (!this.config.fromName) {
missing.push('VITE_MAILGUN_FROM_NAME');
}
return missing;
}
}
export const mailgunService = new MailgunService();
@@ -0,0 +1,31 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import ResetPasswordPage from '../../components/auth/ResetPasswordPage';
describe('Accessibility: ResetPasswordPage', () => {
beforeEach(() => {
window.history.replaceState({}, 'Test', '/reset-password?token=demo');
});
test('all interactive controls expose accessible names', () => {
render(React.createElement(ResetPasswordPage));
const buttons = screen.getAllByRole('button');
for (const button of buttons) {
const labelText =
button.getAttribute('aria-label') ?? button.textContent?.trim();
expect(labelText).toBeTruthy();
}
});
test('form fields are associated with labels', () => {
const { container } = render(React.createElement(ResetPasswordPage));
const labelElements = Array.from(container.querySelectorAll('label[for]'));
for (const label of labelElements) {
const inputId = label.getAttribute('for');
const field = inputId ? container.querySelector(`#${inputId}`) : null;
expect(field).not.toBeNull();
}
});
});
@@ -0,0 +1,26 @@
import { generateSchedule } from '../../utils/schedule';
import { Frequency, Medication } from '../../types';
describe('Performance: schedule generation', () => {
const createMedication = (index: number): Medication => ({
_id: `med-${index}`,
_rev: `1-${index}`,
name: `Medication ${index}`,
dosage: '10mg',
frequency: Frequency.Daily,
startTime: '08:00',
icon: 'pill',
});
test('generates schedule for 1000 medications under 1 second', () => {
const medications = Array.from({ length: 1000 }, (_, idx) =>
createMedication(idx)
);
const start = performance.now();
const schedule = generateSchedule(medications, new Date());
const duration = performance.now() - start;
expect(schedule.length).toBe(medications.length);
expect(duration).toBeLessThan(1000);
});
});
@@ -0,0 +1,14 @@
import React from 'react';
import { render } from '@testing-library/react';
import ResetPasswordPage from '../../components/auth/ResetPasswordPage';
describe('Visual Baseline: ResetPasswordPage', () => {
beforeEach(() => {
window.history.replaceState({}, 'Test', '/reset-password?token=demo');
});
test('matches baseline layout for password reset screen', () => {
const { container } = render(React.createElement(ResetPasswordPage));
expect(container.firstChild).toMatchSnapshot();
});
});
@@ -0,0 +1,99 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Visual Baseline: ResetPasswordPage matches baseline layout for password reset screen 1`] = `
<div
class="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 px-4"
>
<div
class="w-full max-w-md"
>
<div
class="text-center mb-8"
>
<div
class="inline-block bg-indigo-600 p-3 rounded-xl mb-4"
>
<svg
class="w-8 h-8 text-white"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m10.5 20.5 10-10a4.95 4.95 0 1 0-7-7l-10 10a4.95 4.95 0 1 0 7 7Z"
/>
<path
d="m8.5 8.5 7 7"
/>
</svg>
</div>
<h1
class="text-3xl font-bold text-slate-800 dark:text-slate-100"
>
Reset Password
</h1>
<p
class="text-slate-500 dark:text-slate-400 mt-1"
>
Choose a new password for your account.
</p>
</div>
<div
class="bg-white dark:bg-slate-800 rounded-lg shadow-lg p-8"
>
<form
class="space-y-4"
>
<div>
<label
class="block text-sm font-medium text-slate-700 dark:text-slate-300"
for="password"
>
New Password
</label>
<input
class="mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600 dark:placeholder-slate-400 dark:text-white"
id="password"
placeholder="Enter a new password"
type="password"
value=""
/>
</div>
<div>
<label
class="block text-sm font-medium text-slate-700 dark:text-slate-300"
for="confirmPassword"
>
Confirm Password
</label>
<input
class="mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600 dark:placeholder-slate-400 dark:text-white"
id="confirmPassword"
placeholder="Re-enter your password"
type="password"
value=""
/>
</div>
<button
class="w-full bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 text-white font-medium py-2 px-4 rounded-md transition-colors duration-200"
type="submit"
>
Update Password
</button>
</form>
<button
class="mt-6 w-full text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200"
type="button"
>
Back to Sign In
</button>
</div>
</div>
</div>
`;
+63
View File
@@ -0,0 +1,63 @@
import { determineDoseStatus } from '../doseStatus';
import { DoseStatus } from '../../types';
describe('determineDoseStatus', () => {
const scheduledTime = new Date('2024-05-10T08:00:00.000Z');
const now = new Date('2024-05-10T07:00:00.000Z');
it('returns TAKEN when dose has been recorded as taken', () => {
const status = determineDoseStatus({
takenAt: new Date().toISOString(),
snoozedUntil: undefined,
scheduledTime,
now,
});
expect(status).toBe(DoseStatus.TAKEN);
});
it('returns SNOOZED when snooze time is in the future', () => {
const status = determineDoseStatus({
takenAt: undefined,
snoozedUntil: new Date(now.getTime() + 5 * 60 * 1000).toISOString(),
scheduledTime,
now,
});
expect(status).toBe(DoseStatus.SNOOZED);
});
it('returns UPCOMING when snooze time has expired', () => {
const pastSnooze = new Date(now.getTime() - 5 * 60 * 1000).toISOString();
const status = determineDoseStatus({
takenAt: undefined,
snoozedUntil: pastSnooze,
scheduledTime: new Date(now.getTime() - 60 * 60 * 1000),
now,
});
expect(status).toBe(DoseStatus.UPCOMING);
});
it('returns MISSED when scheduled time is in the past without snooze', () => {
const status = determineDoseStatus({
takenAt: undefined,
snoozedUntil: undefined,
scheduledTime: new Date(now.getTime() - 60 * 60 * 1000),
now,
});
expect(status).toBe(DoseStatus.MISSED);
});
it('returns UPCOMING when scheduled time is in the future without snooze', () => {
const status = determineDoseStatus({
takenAt: undefined,
snoozedUntil: undefined,
scheduledTime: new Date(now.getTime() + 60 * 60 * 1000),
now,
});
expect(status).toBe(DoseStatus.UPCOMING);
});
});
+27
View File
@@ -0,0 +1,27 @@
import { normalizeError } from '../error';
describe('normalizeError', () => {
it('returns the same error instance when provided', () => {
const error = new Error('test');
expect(normalizeError(error)).toBe(error);
});
it('converts strings into Error instances', () => {
const result = normalizeError('failure');
expect(result).toBeInstanceOf(Error);
expect(result.message).toBe('failure');
});
it('stringifies objects when creating the error message', () => {
const result = normalizeError({ reason: 'timeout', code: 504 });
expect(result.message).toBe('{"reason":"timeout","code":504}');
});
it('falls back to a generic error for unserializable input', () => {
const circular: Record<string, unknown> = {};
circular.self = circular;
const result = normalizeError(circular);
expect(result.message).toBe('Unknown error');
});
});
+112
View File
@@ -99,6 +99,74 @@ describe('Schedule Utilities', () => {
const schedule = generateSchedule([medication], baseDate);
expect(schedule).toEqual([]);
});
describe('DST boundaries', () => {
test('maintains morning doses during spring forward transition', () => {
const medication = createMockMedication({
frequency: Frequency.Daily,
startTime: '08:00',
});
const beforeTransition = generateSchedule(
[medication],
new Date('2024-03-09T12:00:00.000Z')
);
const duringTransition = generateSchedule(
[medication],
new Date('2024-03-10T12:00:00.000Z')
);
expect(beforeTransition).toHaveLength(1);
expect(duringTransition).toHaveLength(1);
const diffHours =
(duringTransition[0].scheduledTime.getTime() -
beforeTransition[0].scheduledTime.getTime()) /
3600000;
expect(diffHours).toBe(23);
});
test('shifts to next valid slot when scheduled time is skipped by DST', () => {
const medication = createMockMedication({
frequency: Frequency.Daily,
startTime: '02:30',
});
const schedule = generateSchedule(
[medication],
new Date('2024-03-10T12:00:00.000Z')
);
expect(schedule).toHaveLength(1);
expect(schedule[0].scheduledTime.getHours()).toBe(3);
expect(schedule[0].scheduledTime.getMinutes()).toBe(30);
});
test('extends interval by one hour during fall back transition', () => {
const medication = createMockMedication({
frequency: Frequency.Daily,
startTime: '08:00',
});
const beforeTransition = generateSchedule(
[medication],
new Date('2024-11-02T12:00:00.000Z')
);
const duringTransition = generateSchedule(
[medication],
new Date('2024-11-03T12:00:00.000Z')
);
expect(beforeTransition).toHaveLength(1);
expect(duringTransition).toHaveLength(1);
const diffHours =
(duringTransition[0].scheduledTime.getTime() -
beforeTransition[0].scheduledTime.getTime()) /
3600000;
expect(diffHours).toBe(25);
expect(duringTransition[0].scheduledTime.getHours()).toBe(8);
expect(duringTransition[0].scheduledTime.getMinutes()).toBe(0);
});
});
});
describe('generateReminderSchedule', () => {
@@ -186,6 +254,50 @@ describe('Schedule Utilities', () => {
const uniqueIds = new Set(ids);
expect(uniqueIds.size).toBe(ids.length);
});
test('should include reminders at end of window when aligned with frequency', () => {
const reminder = createMockReminder({
startTime: '09:00',
endTime: '10:00',
frequencyMinutes: 30,
});
const schedule = generateReminderSchedule([reminder], baseDate);
const times = schedule.map(instance =>
instance.scheduledTime.toTimeString().slice(0, 5)
);
expect(times).toEqual(['09:00', '09:30', '10:00']);
});
test('should not exceed the time window when frequency does not divide evenly', () => {
const reminder = createMockReminder({
startTime: '09:00',
endTime: '09:40',
frequencyMinutes: 25,
});
const schedule = generateReminderSchedule([reminder], baseDate);
const times = schedule.map(instance =>
instance.scheduledTime.toTimeString().slice(0, 5)
);
expect(times).toEqual(['09:00', '09:25']);
});
test('should handle frequency larger than window by returning a single reminder', () => {
const reminder = createMockReminder({
startTime: '14:00',
endTime: '14:10',
frequencyMinutes: 30,
});
const schedule = generateReminderSchedule([reminder], baseDate);
expect(schedule).toHaveLength(1);
expect(schedule[0].scheduledTime.toTimeString().startsWith('14:00')).toBe(
true
);
});
});
describe('error handling', () => {
+103
View File
@@ -0,0 +1,103 @@
import { tokenStorage } from '../token';
import { logger } from '../../services/logging';
describe('tokenStorage', () => {
const STORAGE_KEY = 'meds_auth_tokens';
const originalLocalStorageDescriptor = Object.getOwnPropertyDescriptor(
window,
'localStorage'
);
beforeEach(() => {
jest.restoreAllMocks();
tokenStorage.clear();
window.localStorage.clear();
});
afterAll(() => {
if (originalLocalStorageDescriptor) {
Object.defineProperty(
window,
'localStorage',
originalLocalStorageDescriptor
);
}
});
it('persists tokens with a single storage key', () => {
const tokens = { accessToken: 'access-123', refreshToken: 'refresh-789' };
tokenStorage.save(tokens);
expect(window.localStorage.getItem(STORAGE_KEY)).toEqual(
JSON.stringify({
accessToken: 'access-123',
refreshToken: 'refresh-789',
})
);
expect(tokenStorage.getAccessToken()).toBe('access-123');
});
it('clears tokens from storage and cache', () => {
tokenStorage.save({
accessToken: 'access-123',
refreshToken: 'refresh-789',
});
tokenStorage.clear();
expect(window.localStorage.getItem(STORAGE_KEY)).toBeNull();
expect(tokenStorage.getTokens()).toBeNull();
expect(tokenStorage.getAccessToken()).toBeNull();
});
it('handles corrupted storage values by clearing and logging', () => {
window.localStorage.setItem(STORAGE_KEY, 'not json');
const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {});
expect(tokenStorage.getTokens()).toBeNull();
expect(window.localStorage.getItem(STORAGE_KEY)).toBeNull();
expect(warnSpy).toHaveBeenCalledWith(
'Failed to parse stored tokens, clearing cache',
'AUTH_TOKENS',
expect.any(Error)
);
});
it('falls back to in-memory storage when localStorage is unavailable', () => {
const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {});
const error = new Error('blocked');
Object.defineProperty(window, 'localStorage', {
configurable: true,
get: () => {
throw error;
},
});
tokenStorage.clear();
tokenStorage.save({ accessToken: 'access-only' });
expect(tokenStorage.getAccessToken()).toBe('access-only');
expect(warnSpy).toHaveBeenCalledWith(
'Token storage fallback to memory',
'AUTH_TOKENS',
error
);
if (originalLocalStorageDescriptor) {
Object.defineProperty(
window,
'localStorage',
originalLocalStorageDescriptor
);
}
});
it('throws when attempting to save without an access token', () => {
expect(() => tokenStorage.save({ accessToken: '' })).toThrow(
'Token payload must include an access token'
);
});
});
+7
View File
@@ -0,0 +1,7 @@
export const encodeBase64 = (value: string): string => {
if (typeof btoa !== 'undefined') {
return btoa(value);
}
return Buffer.from(value, 'utf-8').toString('base64');
};
+35
View File
@@ -0,0 +1,35 @@
import { DoseStatus } from '../types';
export interface DoseStatusParams {
takenAt?: string;
snoozedUntil?: string;
scheduledTime: Date;
now: Date;
}
export const determineDoseStatus = ({
takenAt,
snoozedUntil,
scheduledTime,
now,
}: DoseStatusParams): DoseStatus => {
if (takenAt) {
return DoseStatus.TAKEN;
}
if (snoozedUntil) {
const snoozeTime = new Date(snoozedUntil);
if (!Number.isNaN(snoozeTime.getTime())) {
if (snoozeTime.getTime() > now.getTime()) {
return DoseStatus.SNOOZED;
}
return DoseStatus.UPCOMING;
}
}
if (scheduledTime.getTime() < now.getTime()) {
return DoseStatus.MISSED;
}
return DoseStatus.UPCOMING;
};
+15
View File
@@ -0,0 +1,15 @@
export const normalizeError = (error: unknown): Error => {
if (error instanceof Error) {
return error;
}
if (typeof error === 'string') {
return new Error(error);
}
try {
return new Error(JSON.stringify(error));
} catch {
return new Error('Unknown error');
}
};
+101
View File
@@ -0,0 +1,101 @@
import { logger } from '../services/logging';
export interface AuthTokens {
accessToken: string;
refreshToken?: string | null;
}
const TOKEN_STORAGE_KEY = 'meds_auth_tokens';
let memoryTokens: AuthTokens | null = null;
function getStorage(): Storage | null {
if (typeof window === 'undefined') {
return null;
}
try {
return window.localStorage;
} catch (error) {
// LocalStorage may be unavailable (e.g. Safari private mode); gracefully degrade.
logger.warn('Token storage fallback to memory', 'AUTH_TOKENS', error);
return null;
}
}
function persist(tokens: AuthTokens | null): void {
const storage = getStorage();
if (!storage) {
memoryTokens = tokens;
return;
}
if (!tokens) {
storage.removeItem(TOKEN_STORAGE_KEY);
memoryTokens = null;
return;
}
const payload: AuthTokens = {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken ?? null,
};
storage.setItem(TOKEN_STORAGE_KEY, JSON.stringify(payload));
memoryTokens = payload;
}
function readTokens(): AuthTokens | null {
const storage = getStorage();
if (!storage) {
return memoryTokens;
}
try {
const raw = storage.getItem(TOKEN_STORAGE_KEY);
if (!raw) {
memoryTokens = null;
return null;
}
const parsed = JSON.parse(raw) as AuthTokens;
memoryTokens = parsed;
return parsed;
} catch (error) {
logger.warn(
'Failed to parse stored tokens, clearing cache',
'AUTH_TOKENS',
error
);
storage.removeItem(TOKEN_STORAGE_KEY);
memoryTokens = null;
return null;
}
}
export const tokenStorage = {
save(tokens: AuthTokens): void {
if (!tokens || !tokens.accessToken) {
throw new Error('Token payload must include an access token');
}
persist(tokens);
},
getTokens(): AuthTokens | null {
return readTokens();
},
getAccessToken(): string | null {
const tokens = readTokens();
return tokens?.accessToken ?? null;
},
clear(): void {
persist(null);
},
};
export default tokenStorage;