Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed8cbca1da | |||
| f44ec57c62 | |||
| bf36f14eab | |||
| 7f5cf7a9e5 | |||
| 6a6b48cbc5 | |||
| 7317616032 | |||
| 25e25d92bc | |||
| a183aca4d8 | |||
| 50a352fb27 | |||
| 35d6a48802 | |||
| 10d1de91fe | |||
| 16bd4a8b20 | |||
| dec8c7b42e | |||
| eb43766b21 | |||
| de237fd997 | |||
| e3a924c0c6 | |||
| f9ccb50222 | |||
| fcfe2a38e2 | |||
| e9a662d1e2 | |||
| 7c712ae84b | |||
| 9bed793997 | |||
| 35dcae07e5 | |||
| 2cb56d5f5f | |||
| 9b4ee116e6 | |||
| e7dbe30763 | |||
| 71c37f4b7b | |||
| c1c8e28f01 | |||
| 6b6a44acef |
+7
-1
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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'
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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)/)"],
|
||||
|
||||
@@ -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 \"**/*\"",
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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$')
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
`;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
@@ -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;
|
||||
Reference in New Issue
Block a user