Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 10d1de91fe | |||
| 16bd4a8b20 | |||
| dec8c7b42e | |||
| eb43766b21 | |||
| de237fd997 | |||
| e3a924c0c6 | |||
| f9ccb50222 | |||
| fcfe2a38e2 | |||
| e9a662d1e2 | |||
| 7c712ae84b | |||
| 9bed793997 | |||
| 35dcae07e5 | |||
| 2cb56d5f5f | |||
| 9b4ee116e6 | |||
| e7dbe30763 | |||
| 71c37f4b7b | |||
| c1c8e28f01 | |||
| 6b6a44acef |
@@ -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 />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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,5 @@
|
||||
import { logger } from '../services/logging';
|
||||
|
||||
/**
|
||||
* Unified Application Configuration System
|
||||
*
|
||||
@@ -754,10 +756,10 @@ function validateConfig(config: UnifiedConfig): void {
|
||||
|
||||
// Log warnings and throw errors
|
||||
if (warnings.length > 0) {
|
||||
console.warn('⚠️ Configuration warnings:', warnings);
|
||||
logger.warn('Configuration warnings', 'CONFIG', warnings);
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
console.error('❌ Configuration errors:', errors);
|
||||
logger.error('Configuration errors', 'CONFIG', errors);
|
||||
throw new Error(`Configuration validation failed: ${errors.join(', ')}`);
|
||||
}
|
||||
}
|
||||
@@ -956,7 +958,7 @@ export function exportAsEnvVars(
|
||||
*/
|
||||
export function logConfig(): void {
|
||||
if (unifiedConfig.features.debugMode) {
|
||||
console.warn('🔧 Unified Configuration (Single Source of Truth):', {
|
||||
logger.info('Unified Configuration (Single Source of Truth)', 'CONFIG', {
|
||||
environment: unifiedConfig.app.environment,
|
||||
app: unifiedConfig.app.name,
|
||||
version: unifiedConfig.app.version,
|
||||
|
||||
+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
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 { 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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
+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,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