Initial commit: Complete NodeJS-native setup

- Migrated from Python pre-commit to NodeJS-native solution
- Reorganized documentation structure
- Set up Husky + lint-staged for efficient pre-commit hooks
- Fixed Dockerfile healthcheck issue
- Added comprehensive documentation index
This commit is contained in:
William Valentin
2025-09-06 01:42:48 -07:00
commit e48adbcb00
159 changed files with 24405 additions and 0 deletions
@@ -0,0 +1,59 @@
import { authService } from '../auth.service';
import { AccountStatus } from '../auth.constants';
import { User } from '../../../types';
// Helper to clear localStorage and reset the mock DB before each test
beforeEach(() => {
// Clear all localStorage keys used by dbService and authService
Object.keys(localStorage).forEach(key => localStorage.removeItem(key));
});
describe('Authentication Integration Tests', () => {
const username = 'testuser';
const password = 'Passw0rd!';
const email = 'testuser@example.com';
test('User registration creates a pending account', async () => {
const result = await authService.register(email, password, username);
expect(result).toBeDefined();
expect(result.user.username).toBe(username);
expect(result.user.email).toBe(email);
expect(result.user.status).toBe(AccountStatus.PENDING);
expect(result.user.emailVerified).toBeFalsy();
});
test('Login fails for unverified (pending) account', async () => {
await expect(authService.login({ email, password })).rejects.toThrow();
});
test('Email verification activates the account', async () => {
// Register a user first to get the verification token
const result = await authService.register(email, password, username);
const verificationToken = result.verificationToken.token;
const verifiedUser = await authService.verifyEmail(verificationToken);
expect(verifiedUser.status).toBe(AccountStatus.ACTIVE);
expect(verifiedUser.emailVerified).toBeTruthy();
});
test('Login succeeds after email verification', async () => {
const tokens = await authService.login({ email, password });
expect(tokens).toBeDefined();
expect(tokens.accessToken).toBeTruthy();
expect(tokens.refreshToken).toBeTruthy();
});
test('OAuth flow registers or logs in a user', async () => {
const oauthEmail = 'oauthuser@example.com';
const oauthName = 'OAuth User';
const result = await authService.loginWithOAuth('google', {
email: oauthEmail,
username: oauthName,
});
expect(result).toBeDefined();
expect(result.user.email).toBe(oauthEmail);
// OAuth users should be active immediately
expect(result.user.status).toBe(AccountStatus.ACTIVE);
expect(result.user.emailVerified).toBeTruthy();
});
});
@@ -0,0 +1,59 @@
import { EmailVerificationService } from '../emailVerification.service';
import { dbService } from '../../couchdb.factory';
jest.mock('../../couchdb.factory');
jest.mock('../../email');
describe('EmailVerificationService', () => {
let emailVerificationService: EmailVerificationService;
beforeEach(() => {
emailVerificationService = new EmailVerificationService();
});
test('should generate and validate verification token', async () => {
const user = {
_id: 'user1',
email: 'test@example.com',
username: 'testuser',
password: 'password',
};
const verificationToken =
await emailVerificationService.generateVerificationToken(user as any);
expect(verificationToken).toBeDefined();
expect(verificationToken.token).toBeDefined();
expect(verificationToken.expiresAt).toBeDefined();
const validatedUser =
await emailVerificationService.validateVerificationToken(
verificationToken.token
);
expect(validatedUser).toBeDefined();
expect(validatedUser!._id).toBe(user._id);
});
test('should not validate expired token', async () => {
const user = {
_id: 'user2',
email: 'test2@example.com',
username: 'testuser2',
password: 'password2',
};
const verificationToken =
await emailVerificationService.generateVerificationToken(user as any);
// Set expiresAt to past date
verificationToken.expiresAt = new Date(Date.now() - 1000 * 60 * 60 * 24);
const validatedUser =
await emailVerificationService.validateVerificationToken(
verificationToken.token
);
expect(validatedUser).toBeNull();
});
});
+25
View File
@@ -0,0 +1,25 @@
// Client-side auth constants for demo/development purposes
// In production, these would be handled by a secure backend service
export const JWT_EXPIRES_IN = '1h';
export const REFRESH_TOKEN_EXPIRES_IN = '7d';
export const EMAIL_VERIFICATION_EXPIRES_IN = '24h';
// Mock secrets for frontend-only demo (NOT for production use)
export const JWT_SECRET = 'demo_jwt_secret_for_frontend_only';
export const REFRESH_TOKEN_SECRET = 'demo_refresh_secret_for_frontend_only';
export const EMAIL_VERIFICATION_SECRET =
'demo_email_verification_secret_for_frontend_only';
export enum AccountStatus {
PENDING = 'PENDING',
ACTIVE = 'ACTIVE',
SUSPENDED = 'SUSPENDED',
}
export interface AuthConfig {
jwtSecret: string;
jwtExpiresIn: string;
refreshTokenExpiresIn: string;
emailVerificationExpiresIn: string;
}
+43
View File
@@ -0,0 +1,43 @@
import { NextFunction, Request, Response } from 'express';
/**
* Custom AuthError class that extends Error with HTTP status code
* Security: Provides consistent error handling for authentication issues
*/
export class AuthError extends Error {
statusCode: number;
constructor(message: string, statusCode: number = 401) {
super(message);
this.statusCode = statusCode;
this.name = 'AuthError';
}
}
/**
* Middleware to handle AuthError exceptions
* Security: Centralized error handling for authentication errors
*/
export const handleAuthError = (
err: Error,
req: Request,
res: Response,
next: NextFunction
) => {
if (err instanceof AuthError) {
return res.status(err.statusCode).json({
error: err.message,
statusCode: err.statusCode,
});
}
// Handle JWT verification errors
if (err.name === 'JsonWebTokenError' || err.name === 'TokenExpiredError') {
return res.status(401).json({
error: 'Invalid or expired token',
statusCode: 401,
});
}
next(err);
};
+48
View File
@@ -0,0 +1,48 @@
import { Request, Response, NextFunction } from 'express';
import * as jwt from 'jsonwebtoken';
import { JWT_SECRET } from './auth.constants';
import { AuthError, handleAuthError } from './auth.error';
// Security: JWT authentication middleware
export const authenticate = (
req: Request,
res: Response,
next: NextFunction
) => {
try {
// Security: Get token from Authorization header
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new AuthError('No authentication token provided', 401);
}
const token = authHeader.split(' ')[1];
// Security: Verify JWT token
const decoded = jwt.verify(token, JWT_SECRET);
// Add user information to request
req.user = decoded;
next();
} catch (error) {
handleAuthError(error, req, res, next);
}
};
// Security: Role-based authorization middleware
export const authorize = (...allowedRoles: string[]) => {
return (req: Request, res: Response, next: NextFunction) => {
try {
// Security: Check if user exists in request
if (!req.user) {
throw new AuthError('Authentication required', 401);
}
// In a full implementation, we would check user roles
next();
} catch (error) {
handleAuthError(error, req, res, next);
}
};
};
+244
View File
@@ -0,0 +1,244 @@
import { v4 as uuidv4 } from 'uuid';
import { dbService } from '../../services/couchdb.factory';
import { AccountStatus } from './auth.constants';
import { User } from '../../types';
import { AuthenticatedUser } from './auth.types';
import { EmailVerificationService } from './emailVerification.service';
const emailVerificationService = new EmailVerificationService();
const authService = {
async register(email: string, password: string, username?: string) {
try {
// Create user with password
const user = await dbService.createUserWithPassword(
email,
password,
username
);
// Generate and send verification token (in production)
const verificationToken =
await emailVerificationService.generateVerificationToken(
user as AuthenticatedUser
);
return { user, verificationToken };
} catch (error) {
if (error.message.includes('already exists')) {
throw new Error('An account with this email already exists');
}
throw error;
}
},
async login(input: { email: string; password: string }) {
console.log('🔐 Login attempt for:', input.email);
// Find user by email
const user = await dbService.findUserByEmail(input.email);
if (!user) {
console.log('❌ User not found for email:', input.email);
throw new Error('User not found');
}
console.log('👤 User found:', {
email: user.email,
hasPassword: !!user.password,
role: user.role,
status: user.status,
emailVerified: user.emailVerified,
});
// Check if user has a password (email-based account)
if (!user.password) {
console.log('❌ No password found - OAuth account');
throw new Error(
'This account was created with OAuth. Please use Google or GitHub to sign in.'
);
}
// Simple password verification (in production, use bcrypt)
console.log('🔍 Comparing passwords:', {
inputPassword: input.password,
storedPassword: user.password,
match: user.password === input.password,
});
if (user.password !== input.password) {
console.log('❌ Password mismatch');
throw new Error('Invalid password');
}
console.log('✅ Login successful for:', user.email);
// Return mock tokens for frontend compatibility
return {
user,
accessToken: 'mock_access_token_' + Date.now(),
refreshToken: 'mock_refresh_token_' + Date.now(),
};
},
async loginWithOAuth(
provider: 'google' | 'github',
userData: { email: string; username: string; avatar?: string }
) {
try {
// Try to find existing user by email
let user = await dbService.findUserByEmail(userData.email);
if (!user) {
// Create new user from OAuth data
user = await dbService.createUserFromOAuth(userData);
}
// Generate access tokens
return {
user,
accessToken: `oauth_${provider}_token_` + Date.now(),
refreshToken: `oauth_${provider}_refresh_` + Date.now(),
};
} catch (error) {
throw new Error(`OAuth login failed: ${error.message}`);
}
},
async verifyEmail(token: string) {
const user =
await emailVerificationService.validateVerificationToken(token);
if (!user) {
throw new Error('Invalid or expired verification token');
}
await emailVerificationService.markEmailVerified(user);
return user;
},
async changePassword(
userId: string,
currentPassword: string,
newPassword: string
) {
// Get user by ID
const user = await dbService.getUserById(userId);
if (!user) {
throw new Error('User not found');
}
// Check if user has a password (not OAuth user)
if (!user.password) {
throw new Error('Cannot change password for OAuth accounts');
}
// Verify current password
if (user.password !== currentPassword) {
throw new Error('Current password is incorrect');
}
// Validate new password
if (newPassword.length < 6) {
throw new Error('New password must be at least 6 characters long');
}
// Update password
const updatedUser = await dbService.changeUserPassword(userId, newPassword);
return {
user: updatedUser,
message: 'Password changed successfully',
};
},
async requestPasswordReset(email: string) {
const user = await dbService.findUserByEmail(email);
if (!user) {
// Don't reveal if email exists or not for security
return {
message:
'If an account with this email exists, a password reset link has been sent.',
};
}
if (!user.password) {
throw new Error('Cannot reset password for OAuth accounts');
}
// Generate reset token (similar to verification token)
const resetToken = uuidv4().replace(/-/g, '');
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 1); // 1 hour expiry
// Store reset token (in production, save to database)
const resetTokens = JSON.parse(
localStorage.getItem('password_reset_tokens') || '[]'
);
resetTokens.push({
userId: user._id,
email: user.email,
token: resetToken,
expiresAt,
});
localStorage.setItem('password_reset_tokens', JSON.stringify(resetTokens));
// Send reset email
const emailSent = await emailVerificationService.sendPasswordResetEmail(
user.email!,
resetToken
);
return {
message:
'If an account with this email exists, a password reset link has been sent.',
emailSent,
};
},
async resetPassword(token: string, newPassword: string) {
// Get reset tokens
const resetTokens = JSON.parse(
localStorage.getItem('password_reset_tokens') || '[]'
);
const resetToken = resetTokens.find((t: any) => t.token === token);
if (!resetToken) {
throw new Error('Invalid or expired reset token');
}
// Check if token is expired
if (new Date() > new Date(resetToken.expiresAt)) {
throw new Error('Reset token has expired');
}
// Validate new password
if (newPassword.length < 6) {
throw new Error('Password must be at least 6 characters long');
}
// Update password
const updatedUser = await dbService.changeUserPassword(
resetToken.userId,
newPassword
);
// Remove used token
const filteredTokens = resetTokens.filter((t: any) => t.token !== token);
localStorage.setItem(
'password_reset_tokens',
JSON.stringify(filteredTokens)
);
return {
user: updatedUser,
message: 'Password reset successfully',
};
},
};
export { authService };
export default authService;
+42
View File
@@ -0,0 +1,42 @@
import { User } from '../../types';
import { AccountStatus } from './auth.constants';
export interface RegisterInput {
username: string;
email: string;
password: string;
}
export interface LoginInput {
email: string;
password: string;
}
export interface AuthResponse {
user: User;
accessToken: string;
refreshToken: string;
}
export interface TokenPayload {
userId: string;
username: string;
}
export interface EmailVerificationToken {
userId: string;
email: string;
token: string;
expiresAt: Date;
}
export interface RefreshTokenPayload {
userId: string;
refreshToken: string;
}
export interface AuthenticatedUser extends User {
status: AccountStatus;
email?: string;
emailVerified?: boolean;
}
@@ -0,0 +1,95 @@
import { v4 as uuidv4 } from 'uuid';
import { EmailVerificationToken, AuthenticatedUser } from './auth.types';
import { mailgunService } from '../mailgun.service';
import { AccountStatus } from './auth.constants';
const TOKEN_EXPIRY_HOURS = 24;
export class EmailVerificationService {
async generateVerificationToken(
user: AuthenticatedUser
): Promise<EmailVerificationToken> {
const token = uuidv4().replace(/-/g, ''); // Generate a random token using UUID
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + TOKEN_EXPIRY_HOURS);
const verificationToken: EmailVerificationToken = {
userId: user._id,
email: user.email || '',
token,
expiresAt,
};
// Store token in localStorage for demo (in production, save to database)
const tokens = JSON.parse(
localStorage.getItem('verification_tokens') || '[]'
);
tokens.push(verificationToken);
localStorage.setItem('verification_tokens', JSON.stringify(tokens));
// Send verification email via Mailgun
if (user.email) {
const emailSent = await mailgunService.sendVerificationEmail(
user.email,
token
);
if (!emailSent) {
console.warn('Failed to send verification email');
}
}
return verificationToken;
}
async validateVerificationToken(
token: string
): Promise<AuthenticatedUser | null> {
// Get tokens from localStorage
const tokens = JSON.parse(
localStorage.getItem('verification_tokens') || '[]'
);
const verificationToken = tokens.find(
(t: EmailVerificationToken) => t.token === token
);
if (!verificationToken) {
return null;
}
// Check if token is expired
if (new Date() > new Date(verificationToken.expiresAt)) {
return null;
}
// Find the user (in production, this would be a proper database lookup)
const { dbService } = await import('../couchdb');
const user = await dbService.findUserByEmail(verificationToken.email);
return user as AuthenticatedUser;
}
async markEmailVerified(user: AuthenticatedUser): Promise<void> {
// Update user in database
const { dbService } = await import('../couchdb');
const updatedUser = {
...user,
emailVerified: true,
status: AccountStatus.ACTIVE,
};
await dbService.updateUser(updatedUser);
// Remove used token
const tokens = JSON.parse(
localStorage.getItem('verification_tokens') || '[]'
);
const filteredTokens = tokens.filter(
(t: EmailVerificationToken) => t.userId !== user._id
);
localStorage.setItem('verification_tokens', JSON.stringify(filteredTokens));
}
async sendPasswordResetEmail(email: string, token: string): Promise<boolean> {
return mailgunService.sendPasswordResetEmail(email, token);
}
}
@@ -0,0 +1,17 @@
import { EmailVerificationToken } from '../auth.types';
export const verificationEmailTemplate = (token: EmailVerificationToken) => {
const baseUrl = process.env.APP_BASE_URL || 'http://localhost:5173';
const verificationLink = `${baseUrl}/verify-email?token=${token.token}`;
return `
<html>
<body>
<h1>Email Verification</h1>
<p>Please verify your email address by clicking the link below:</p>
<p><a href="${verificationLink}">${verificationLink}</a></p>
<p>This link will expire in 24 hours.</p>
</body>
</html>
`;
};
+44
View File
@@ -0,0 +1,44 @@
// Production CouchDB Service Configuration
// This file determines whether to use mock localStorage or real CouchDB
import { CouchDBService as MockCouchDBService } from './couchdb';
// Environment detection
const isProduction = () => {
// Check if we're in a Docker environment or if CouchDB URL is configured
const env = (import.meta as any).env || {};
const couchdbUrl =
env.VITE_COUCHDB_URL ||
(typeof process !== 'undefined' ? process.env.VITE_COUCHDB_URL : null) ||
(typeof process !== 'undefined' ? process.env.COUCHDB_URL : null);
return !!couchdbUrl && couchdbUrl !== 'mock';
};
// Create the database service based on environment
const createDbService = () => {
if (isProduction()) {
try {
// Use dynamic require to avoid TypeScript resolution issues
// eslint-disable-next-line @typescript-eslint/no-var-requires
const {
CouchDBService: RealCouchDBService,
} = require('./couchdb.production');
return new RealCouchDBService();
} catch (error) {
console.warn(
'Production CouchDB service not available, falling back to mock:',
error
);
return new MockCouchDBService();
}
} else {
return new MockCouchDBService();
}
};
// Export the database service instance
export const dbService = createDbService();
// Re-export the error class
export { CouchDBError } from './couchdb';
+392
View File
@@ -0,0 +1,392 @@
import { v4 as uuidv4 } from 'uuid';
import {
User,
Medication,
UserSettings,
TakenDoses,
CustomReminder,
CouchDBDocument,
UserRole,
} from '../types';
import { AccountStatus } from './auth/auth.constants';
import { CouchDBError } from './couchdb';
// Production CouchDB Service that connects to a real CouchDB instance
export class CouchDBService {
private baseUrl: string;
private auth: string;
constructor() {
// Get CouchDB configuration from environment
const couchdbUrl =
(import.meta as any).env?.VITE_COUCHDB_URL || 'http://localhost:5984';
const couchdbUser = (import.meta as any).env?.VITE_COUCHDB_USER || 'admin';
const couchdbPassword =
(import.meta as any).env?.VITE_COUCHDB_PASSWORD || 'password';
this.baseUrl = couchdbUrl;
this.auth = btoa(`${couchdbUser}:${couchdbPassword}`);
// Initialize databases
this.initializeDatabases();
}
private async initializeDatabases(): Promise<void> {
const databases = [
'users',
'medications',
'settings',
'taken_doses',
'reminders',
];
for (const dbName of databases) {
try {
await this.createDatabaseIfNotExists(dbName);
} catch (error) {
console.error(`Failed to initialize database ${dbName}:`, error);
}
}
}
private async createDatabaseIfNotExists(dbName: string): Promise<void> {
try {
const response = await fetch(`${this.baseUrl}/${dbName}`, {
method: 'HEAD',
headers: {
Authorization: `Basic ${this.auth}`,
},
});
if (response.status === 404) {
// Database doesn't exist, create it
const createResponse = await fetch(`${this.baseUrl}/${dbName}`, {
method: 'PUT',
headers: {
Authorization: `Basic ${this.auth}`,
'Content-Type': 'application/json',
},
});
if (!createResponse.ok) {
throw new Error(`Failed to create database ${dbName}`);
}
console.log(`✅ Created CouchDB database: ${dbName}`);
}
} catch (error) {
console.error(`Error checking/creating database ${dbName}:`, error);
throw error;
}
}
private async makeRequest(
method: string,
path: string,
body?: any
): Promise<any> {
const url = `${this.baseUrl}${path}`;
const options: RequestInit = {
method,
headers: {
Authorization: `Basic ${this.auth}`,
'Content-Type': 'application/json',
},
};
if (body) {
options.body = JSON.stringify(body);
}
const response = await fetch(url, options);
if (!response.ok) {
const errorText = await response.text();
throw new CouchDBError(`CouchDB error: ${errorText}`, response.status);
}
return response.json();
}
private async getDoc<T extends CouchDBDocument>(
dbName: string,
id: string
): Promise<T | null> {
try {
const doc = await this.makeRequest('GET', `/${dbName}/${id}`);
return doc;
} catch (error) {
if (error instanceof CouchDBError && error.status === 404) {
return null;
}
throw error;
}
}
private async putDoc<T extends CouchDBDocument>(
dbName: string,
doc: Omit<T, '_rev'> & { _rev?: string }
): Promise<T> {
const response = await this.makeRequest(
'PUT',
`/${dbName}/${doc._id}`,
doc
);
return { ...doc, _rev: response.rev } as T;
}
private async query<T>(dbName: string, selector: any): Promise<T[]> {
const response = await this.makeRequest('POST', `/${dbName}/_find`, {
selector,
limit: 1000,
});
return response.docs;
}
// User Management Methods
async findUserByUsername(username: string): Promise<User | null> {
const users = await this.query<User>('users', { username });
return users[0] || null;
}
async findUserByEmail(email: string): Promise<User | null> {
const users = await this.query<User>('users', { email });
return users[0] || null;
}
async createUser(username: string): Promise<User> {
const existingUser = await this.findUserByUsername(username);
if (existingUser) {
throw new CouchDBError('User already exists', 409);
}
const newUser: Omit<User, '_rev'> = { _id: uuidv4(), username };
return this.putDoc<User>('users', newUser);
}
async createUserWithPassword(
email: string,
password: string,
username?: string
): Promise<User> {
const existingUser = await this.findUserByEmail(email);
if (existingUser) {
throw new CouchDBError('User already exists', 409);
}
const newUser: Omit<User, '_rev'> = {
_id: uuidv4(),
username: username || email.split('@')[0],
email,
password,
emailVerified: false,
status: AccountStatus.PENDING,
role: UserRole.USER,
createdAt: new Date(),
lastLoginAt: new Date(),
};
return this.putDoc<User>('users', newUser);
}
async createUserFromOAuth(userData: {
email: string;
username: string;
avatar?: string;
}): Promise<User> {
const existingUser = await this.findUserByEmail(userData.email);
if (existingUser) {
throw new CouchDBError('User already exists', 409);
}
const newUser: Omit<User, '_rev'> = {
_id: uuidv4(),
username: userData.username,
email: userData.email,
avatar: userData.avatar,
emailVerified: true,
status: AccountStatus.ACTIVE,
role: UserRole.USER,
createdAt: new Date(),
lastLoginAt: new Date(),
};
return this.putDoc<User>('users', newUser);
}
async getUserById(id: string): Promise<User | null> {
return this.getDoc<User>('users', id);
}
async updateUser(user: User): Promise<User> {
return this.putDoc<User>('users', user);
}
async deleteUser(id: string): Promise<void> {
const user = await this.getDoc<User>('users', id);
if (!user) {
throw new CouchDBError('User not found', 404);
}
await this.makeRequest('DELETE', `/users/${id}?rev=${user._rev}`);
}
// Medication Methods
async getMedications(userId: string): Promise<Medication[]> {
return this.query<Medication>('medications', { userId });
}
async createMedication(
medication: Omit<Medication, '_id' | '_rev'>
): Promise<Medication> {
const newMedication = { ...medication, _id: uuidv4() };
return this.putDoc<Medication>('medications', newMedication);
}
async updateMedication(medication: Medication): Promise<Medication> {
return this.putDoc<Medication>('medications', medication);
}
async deleteMedication(id: string): Promise<void> {
const medication = await this.getDoc<Medication>('medications', id);
if (!medication) {
throw new CouchDBError('Medication not found', 404);
}
await this.makeRequest(
'DELETE',
`/medications/${id}?rev=${medication._rev}`
);
}
// Settings Methods
async getSettings(userId: string): Promise<UserSettings> {
const settings = await this.getDoc<UserSettings>('settings', userId);
if (!settings) {
const defaultSettings: Omit<UserSettings, '_rev'> = {
_id: userId,
notificationsEnabled: true,
hasCompletedOnboarding: false,
};
return this.putDoc<UserSettings>('settings', defaultSettings);
}
return settings;
}
async updateSettings(settings: UserSettings): Promise<UserSettings> {
return this.putDoc<UserSettings>('settings', settings);
}
// Taken Doses Methods
async getTakenDoses(userId: string): Promise<TakenDoses> {
const doses = await this.getDoc<TakenDoses>('taken_doses', userId);
if (!doses) {
const defaultDoses: Omit<TakenDoses, '_rev'> = {
_id: userId,
doses: {},
};
return this.putDoc<TakenDoses>('taken_doses', defaultDoses);
}
return doses;
}
async updateTakenDoses(takenDoses: TakenDoses): Promise<TakenDoses> {
return this.putDoc<TakenDoses>('taken_doses', takenDoses);
}
// Reminder Methods
async getReminders(userId: string): Promise<CustomReminder[]> {
return this.query<CustomReminder>('reminders', { userId });
}
async createReminder(
reminder: Omit<CustomReminder, '_id' | '_rev'>
): Promise<CustomReminder> {
const newReminder = { ...reminder, _id: uuidv4() };
return this.putDoc<CustomReminder>('reminders', newReminder);
}
async updateReminder(reminder: CustomReminder): Promise<CustomReminder> {
return this.putDoc<CustomReminder>('reminders', reminder);
}
async deleteReminder(id: string): Promise<void> {
const reminder = await this.getDoc<CustomReminder>('reminders', id);
if (!reminder) {
throw new CouchDBError('Reminder not found', 404);
}
await this.makeRequest('DELETE', `/reminders/${id}?rev=${reminder._rev}`);
}
// Admin Methods
async getAllUsers(): Promise<User[]> {
return this.query<User>('users', {});
}
async updateUserStatus(userId: string, status: AccountStatus): Promise<User> {
const user = await this.getUserById(userId);
if (!user) {
throw new CouchDBError('User not found', 404);
}
const updatedUser = { ...user, status };
return this.updateUser(updatedUser);
}
async changeUserPassword(userId: string, newPassword: string): Promise<User> {
const user = await this.getUserById(userId);
if (!user) {
throw new CouchDBError('User not found', 404);
}
const updatedUser = { ...user, password: newPassword };
return this.updateUser(updatedUser);
}
// Cleanup Methods
async deleteAllUserData(userId: string): Promise<void> {
// Delete user medications, settings, doses, and reminders
const [medications, reminders] = await Promise.all([
this.getMedications(userId),
this.getReminders(userId),
]);
// Delete all user data
const deletePromises = [
...medications.map(med => this.deleteMedication(med._id)),
...reminders.map(rem => this.deleteReminder(rem._id)),
];
// Delete settings and taken doses
try {
const settings = await this.getDoc('settings', userId);
if (settings) {
deletePromises.push(
this.makeRequest('DELETE', `/settings/${userId}?rev=${settings._rev}`)
);
}
} catch (error) {
// Settings might not exist
}
try {
const takenDoses = await this.getDoc('taken_doses', userId);
if (takenDoses) {
deletePromises.push(
this.makeRequest(
'DELETE',
`/taken_doses/${userId}?rev=${takenDoses._rev}`
)
);
}
} catch (error) {
// Taken doses might not exist
}
await Promise.all(deletePromises);
// Finally delete the user
await this.deleteUser(userId);
}
}
+402
View File
@@ -0,0 +1,402 @@
import { v4 as uuidv4 } from 'uuid';
import {
User,
Medication,
UserSettings,
TakenDoses,
CustomReminder,
CouchDBDocument,
UserRole,
} from '../types';
import { AccountStatus } from './auth/auth.constants';
// This is a mock CouchDB service that uses localStorage for persistence.
// It mimics the async nature of a real database API and includes robust error handling and conflict resolution.
const latency = () =>
new Promise(res => setTimeout(res, Math.random() * 200 + 50));
export class CouchDBError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'CouchDBError';
this.status = status;
}
}
class CouchDBService {
private async getDb<T>(dbName: string): Promise<T[]> {
await latency();
const db = localStorage.getItem(dbName);
return db ? JSON.parse(db) : [];
}
private async saveDb<T>(dbName: string, data: T[]): Promise<void> {
await latency();
localStorage.setItem(dbName, JSON.stringify(data));
}
private async getDoc<T extends CouchDBDocument>(
dbName: string,
id: string
): Promise<T | null> {
const allDocs = await this.getDb<T>(dbName);
return allDocs.find(doc => doc._id === id) || null;
}
private async query<T>(
dbName: string,
predicate: (doc: T) => boolean
): Promise<T[]> {
const allDocs = await this.getDb<T>(dbName);
return allDocs.filter(predicate);
}
private async putDoc<T extends CouchDBDocument>(
dbName: string,
doc: Omit<T, '_rev'> & { _rev?: string }
): Promise<T> {
const allDocs = await this.getDb<T>(dbName);
const docIndex = allDocs.findIndex(d => d._id === doc._id);
if (docIndex > -1) {
// Update
const existingDoc = allDocs[docIndex];
if (existingDoc._rev !== doc._rev) {
throw new CouchDBError('Document update conflict', 409);
}
const newRev = parseInt(existingDoc._rev.split('-')[0], 10) + 1;
const updatedDoc = {
...doc,
_rev: `${newRev}-${Math.random().toString(36).substr(2, 9)}`,
} as T;
allDocs[docIndex] = updatedDoc;
await this.saveDb(dbName, allDocs);
return updatedDoc;
} else {
// Create
const newDoc = {
...doc,
_rev: `1-${Math.random().toString(36).substr(2, 9)}`,
} as T;
allDocs.push(newDoc);
await this.saveDb(dbName, allDocs);
return newDoc;
}
}
private async deleteDoc<T extends CouchDBDocument>(
dbName: string,
doc: T
): Promise<void> {
let docs = await this.getDb<T>(dbName);
const docIndex = docs.findIndex(d => d._id === doc._id);
if (docIndex > -1) {
if (docs[docIndex]._rev !== doc._rev) {
throw new CouchDBError('Document update conflict', 409);
}
docs = docs.filter(m => m._id !== doc._id);
await this.saveDb(dbName, docs);
} else {
throw new CouchDBError('Document not found', 404);
}
}
// Generic update function with conflict resolution
private async updateDocWithConflictResolution<T extends CouchDBDocument>(
dbName: string,
doc: T,
mergeFn?: (latest: T, incoming: T) => T
): Promise<T> {
try {
return await this.putDoc<T>(dbName, doc);
} catch (error) {
if (error instanceof CouchDBError && error.status === 409) {
console.warn(
`Conflict detected for doc ${doc._id}. Attempting to resolve.`
);
const latestDoc = await this.getDoc<T>(dbName, doc._id);
if (latestDoc) {
// Default merge: incoming changes overwrite latest
const defaultMerge = { ...latestDoc, ...doc, _rev: latestDoc._rev };
const mergedDoc = mergeFn ? mergeFn(latestDoc, doc) : defaultMerge;
// Retry the update with the latest revision and merged data
return this.putDoc<T>(dbName, mergedDoc);
}
}
// Re-throw if it's not a resolvable conflict or fetching the latest doc fails
throw error;
}
}
// --- User Management ---
async findUserByUsername(username: string): Promise<User | null> {
const users = await this.query<User>(
'users',
u => u.username.toLowerCase() === username.toLowerCase()
);
return users[0] || null;
}
async findUserByEmail(email: string): Promise<User | null> {
const users = await this.query<User>(
'users',
u => u.email?.toLowerCase() === email.toLowerCase()
);
return users[0] || null;
}
async createUser(username: string): Promise<User> {
if (await this.findUserByUsername(username)) {
throw new CouchDBError('User already exists', 409);
}
const newUser: Omit<User, '_rev'> = { _id: uuidv4(), username };
return this.putDoc<User>('users', newUser);
}
async createUserWithPassword(
email: string,
password: string,
username?: string
): Promise<User> {
// Check if user already exists by email
const existingUser = await this.findUserByEmail(email);
if (existingUser) {
throw new CouchDBError('User already exists', 409);
}
const newUser: Omit<User, '_rev'> = {
_id: uuidv4(),
username: username || email.split('@')[0], // Default username from email
email,
password, // In production, this should be hashed with bcrypt
emailVerified: false, // Require email verification for password accounts
status: AccountStatus.PENDING,
role: UserRole.USER, // Default role is USER
createdAt: new Date(),
lastLoginAt: new Date(),
};
return this.putDoc<User>('users', newUser);
}
async createUserFromOAuth(userData: {
email: string;
username: string;
avatar?: string;
}): Promise<User> {
// Check if user already exists by email
const existingUser = await this.findUserByEmail(userData.email);
if (existingUser) {
throw new CouchDBError('User already exists', 409);
}
const newUser: Omit<User, '_rev'> = {
_id: uuidv4(),
username: userData.username,
email: userData.email,
avatar: userData.avatar,
emailVerified: true, // OAuth users have verified emails
status: AccountStatus.ACTIVE,
role: UserRole.USER, // Default role is USER
createdAt: new Date(),
lastLoginAt: new Date(),
};
return this.putDoc<User>('users', newUser);
}
async updateUser(user: User): Promise<User> {
return this.updateDocWithConflictResolution<User>('users', user);
}
// --- Admin User Management ---
async getAllUsers(): Promise<User[]> {
return this.getDb<User>('users');
}
async getUserById(userId: string): Promise<User | null> {
return this.getDoc<User>('users', userId);
}
async suspendUser(userId: string): Promise<User> {
const user = await this.getUserById(userId);
if (!user) {
throw new CouchDBError('User not found', 404);
}
const updatedUser = { ...user, status: AccountStatus.SUSPENDED };
return this.updateUser(updatedUser);
}
async activateUser(userId: string): Promise<User> {
const user = await this.getUserById(userId);
if (!user) {
throw new CouchDBError('User not found', 404);
}
const updatedUser = { ...user, status: AccountStatus.ACTIVE };
return this.updateUser(updatedUser);
}
async deleteUser(userId: string): Promise<void> {
const user = await this.getUserById(userId);
if (!user) {
throw new CouchDBError('User not found', 404);
}
// Delete user data
await this.deleteDoc<User>('users', user);
// Delete user's associated data
const userMeds = this.getUserDbName('meds', userId);
const userSettings = this.getUserDbName('settings', userId);
const userTaken = this.getUserDbName('taken', userId);
const userReminders = this.getUserDbName('reminders', userId);
localStorage.removeItem(userMeds);
localStorage.removeItem(userSettings);
localStorage.removeItem(userTaken);
localStorage.removeItem(userReminders);
}
async changeUserPassword(userId: string, newPassword: string): Promise<User> {
const user = await this.getUserById(userId);
if (!user) {
throw new CouchDBError('User not found', 404);
}
// In production, hash the password with bcrypt
const updatedUser = { ...user, password: newPassword };
return this.updateUser(updatedUser);
}
// --- User Data Management ---
private getUserDbName = (
type: 'meds' | 'settings' | 'taken' | 'reminders',
userId: string
) => `${type}_${userId}`;
async getMedications(userId: string): Promise<Medication[]> {
return this.getDb<Medication>(this.getUserDbName('meds', userId));
}
async addMedication(
userId: string,
med: Omit<Medication, '_id' | '_rev'>
): Promise<Medication> {
const newMed = { ...med, _id: uuidv4() };
return this.putDoc<Medication>(this.getUserDbName('meds', userId), newMed);
}
async updateMedication(userId: string, med: Medication): Promise<Medication> {
return this.updateDocWithConflictResolution<Medication>(
this.getUserDbName('meds', userId),
med
);
}
async deleteMedication(userId: string, med: Medication): Promise<void> {
return this.deleteDoc<Medication>(this.getUserDbName('meds', userId), med);
}
async getCustomReminders(userId: string): Promise<CustomReminder[]> {
return this.getDb<CustomReminder>(this.getUserDbName('reminders', userId));
}
async addCustomReminder(
userId: string,
reminder: Omit<CustomReminder, '_id' | '_rev'>
): Promise<CustomReminder> {
const newReminder = { ...reminder, _id: uuidv4() };
return this.putDoc<CustomReminder>(
this.getUserDbName('reminders', userId),
newReminder
);
}
async updateCustomReminder(
userId: string,
reminder: CustomReminder
): Promise<CustomReminder> {
return this.updateDocWithConflictResolution<CustomReminder>(
this.getUserDbName('reminders', userId),
reminder
);
}
async deleteCustomReminder(
userId: string,
reminder: CustomReminder
): Promise<void> {
return this.deleteDoc<CustomReminder>(
this.getUserDbName('reminders', userId),
reminder
);
}
async getSettings(userId: string): Promise<UserSettings> {
const dbName = this.getUserDbName('settings', userId);
let settings = await this.getDoc<UserSettings>(dbName, userId);
if (!settings) {
settings = await this.putDoc<UserSettings>(dbName, {
_id: userId,
notificationsEnabled: true,
hasCompletedOnboarding: false,
});
}
return settings;
}
async updateSettings(
userId: string,
settings: UserSettings
): Promise<UserSettings> {
return this.updateDocWithConflictResolution<UserSettings>(
this.getUserDbName('settings', userId),
settings
);
}
async getTakenDoses(userId: string): Promise<TakenDoses> {
const dbName = this.getUserDbName('taken', userId);
let takenDoses = await this.getDoc<TakenDoses>(dbName, userId);
if (!takenDoses) {
takenDoses = await this.putDoc<TakenDoses>(dbName, {
_id: userId,
doses: {},
});
}
return takenDoses;
}
async updateTakenDoses(
userId: string,
takenDoses: TakenDoses
): Promise<TakenDoses> {
// Custom merge logic for taken doses to avoid overwriting recent updates
const mergeFn = (latest: TakenDoses, incoming: TakenDoses): TakenDoses => {
return {
...latest, // Use latest doc as the base
...incoming, // Apply incoming changes
doses: { ...latest.doses, ...incoming.doses }, // Specifically merge the doses object
_rev: latest._rev, // IMPORTANT: Use the latest revision for the update attempt
};
};
return this.updateDocWithConflictResolution<TakenDoses>(
this.getUserDbName('taken', userId),
takenDoses,
mergeFn
);
}
async deleteAllUserData(userId: string): Promise<void> {
await latency();
localStorage.removeItem(this.getUserDbName('meds', userId));
localStorage.removeItem(this.getUserDbName('settings', userId));
localStorage.removeItem(this.getUserDbName('taken', userId));
localStorage.removeItem(this.getUserDbName('reminders', userId));
}
}
export { CouchDBService };
export const dbService = new CouchDBService();
+101
View File
@@ -0,0 +1,101 @@
import { dbService } from './couchdb.factory';
import { AccountStatus } from './auth/auth.constants';
import { UserRole } from '../types';
export class DatabaseSeeder {
private static seedingInProgress = false;
private static seedingCompleted = false;
async seedDefaultAdmin(): Promise<void> {
const adminEmail = 'admin@localhost';
const adminPassword = 'admin123!';
console.log('🌱 Starting admin user seeding...');
console.log('📧 Admin email:', adminEmail);
try {
// Check if admin already exists
const existingAdmin = await dbService.findUserByEmail(adminEmail);
if (existingAdmin) {
console.log('✅ Default admin user already exists');
console.log('👤 Existing admin:', existingAdmin);
// Check if admin needs to be updated to correct role/status
if (
existingAdmin.role !== UserRole.ADMIN ||
existingAdmin.status !== AccountStatus.ACTIVE
) {
console.log('🔧 Updating admin user role and status...');
const updatedAdmin = {
...existingAdmin,
role: UserRole.ADMIN,
status: AccountStatus.ACTIVE,
emailVerified: true,
};
await dbService.updateUser(updatedAdmin);
console.log('✅ Admin user updated successfully');
console.log('👤 Updated admin:', updatedAdmin);
}
return;
}
console.log('🔨 Creating new admin user...');
// Create default admin user
const adminUser = await dbService.createUserWithPassword(
adminEmail,
adminPassword,
'admin'
);
console.log('👤 Admin user created:', adminUser);
// Update user to admin role and active status
const updatedAdmin = {
...adminUser,
role: UserRole.ADMIN,
status: AccountStatus.ACTIVE,
emailVerified: true,
createdAt: new Date(),
lastLoginAt: new Date(),
};
await dbService.updateUser(updatedAdmin);
console.log('✅ Default admin user created successfully');
console.log('👤 Final admin user:', updatedAdmin);
console.log('📧 Email:', adminEmail);
console.log('🔑 Password:', adminPassword);
console.log('⚠️ Please change the default password after first login!');
} catch (error) {
console.error('❌ Failed to create default admin user:', error);
throw error;
}
}
async seedDatabase(): Promise<void> {
// Prevent multiple seeding attempts
if (DatabaseSeeder.seedingInProgress || DatabaseSeeder.seedingCompleted) {
console.log('🔄 Seeding already in progress or completed, skipping...');
return;
}
DatabaseSeeder.seedingInProgress = true;
console.log('🌱 Starting database seeding...');
try {
await this.seedDefaultAdmin();
DatabaseSeeder.seedingCompleted = true;
console.log('🎉 Database seeding completed successfully!');
} catch (error) {
console.error('💥 Database seeding failed:', error);
throw error;
} finally {
DatabaseSeeder.seedingInProgress = false;
}
}
}
export const databaseSeeder = new DatabaseSeeder();
// The seeding will be called explicitly from App.tsx
+26
View File
@@ -0,0 +1,26 @@
/**
* Mock email service for sending verification emails
*/
export class EmailService {
/**
* Simulates sending a verification email with a link to /verify-email?token=${token}
* @param email - The recipient's email address
* @param token - The verification token
*/
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.log(
`📧 Sending verification email to ${email} with token: ${token}`
);
console.log(`🔗 Verification link: /verify-email?token=${token}`);
// Simulate network delay
await new Promise(resolve => setTimeout(resolve, 500));
// In a real implementation, we would make an HTTP request to an email service
// Example: await fetch('/api/send-email', { method: 'POST', body: JSON.stringify({ email, token }) });
}
}
export const emailService = new EmailService();
+62
View File
@@ -0,0 +1,62 @@
// Mailgun Configuration
// This file handles Mailgun credentials and configuration
export interface MailgunConfig {
apiKey: string;
domain: string;
baseUrl: string;
fromName: string;
fromEmail: string;
}
// Default configuration for development
const defaultConfig: MailgunConfig = {
apiKey: 'demo-key',
domain: 'demo.mailgun.org',
baseUrl: 'https://api.mailgun.net/v3',
fromName: 'Medication Reminder',
fromEmail: 'noreply@demo.mailgun.org',
};
// Load configuration from environment variables or use defaults
export const getMailgunConfig = (): MailgunConfig => {
// Check if running in browser environment
const isClient = typeof window !== 'undefined';
if (isClient) {
// In browser, use Vite environment variables
// Note: Vite environment variables are available at build time
const env = (import.meta as any).env || {};
return {
apiKey: env.VITE_MAILGUN_API_KEY || defaultConfig.apiKey,
domain: env.VITE_MAILGUN_DOMAIN || defaultConfig.domain,
baseUrl: env.VITE_MAILGUN_BASE_URL || defaultConfig.baseUrl,
fromName: env.VITE_MAILGUN_FROM_NAME || defaultConfig.fromName,
fromEmail: env.VITE_MAILGUN_FROM_EMAIL || defaultConfig.fromEmail,
};
} else {
// In Node.js environment (if needed for SSR)
return {
apiKey: process.env.MAILGUN_API_KEY || defaultConfig.apiKey,
domain: process.env.MAILGUN_DOMAIN || defaultConfig.domain,
baseUrl: process.env.MAILGUN_BASE_URL || defaultConfig.baseUrl,
fromName: process.env.MAILGUN_FROM_NAME || defaultConfig.fromName,
fromEmail: process.env.MAILGUN_FROM_EMAIL || defaultConfig.fromEmail,
};
}
};
// Check if Mailgun is properly configured (not using demo values)
export const isMailgunConfigured = (): boolean => {
const config = getMailgunConfig();
return (
config.apiKey !== 'demo-key' &&
config.domain !== 'demo.mailgun.org' &&
config.apiKey.length > 0
);
};
// Development mode check
export const isDevelopmentMode = (): boolean => {
return !isMailgunConfigured();
};
+191
View File
@@ -0,0 +1,191 @@
// Mailgun Email Service
// This service handles email sending via Mailgun API
import {
getMailgunConfig,
isMailgunConfigured,
isDevelopmentMode,
type MailgunConfig,
} from './mailgun.config';
interface EmailTemplate {
subject: string;
html: string;
text?: string;
}
export class MailgunService {
private config: MailgunConfig;
constructor() {
this.config = getMailgunConfig();
// Log configuration status on startup
const status = this.getConfigurationStatus();
if (status.mode === 'development') {
console.log(
'📧 Mailgun Service: Running in development mode (emails will be logged only)'
);
console.log(
'💡 To enable real emails, configure Mailgun credentials in .env.local'
);
} else {
console.log(
'📧 Mailgun Service: Configured for production with domain:',
status.domain
);
}
}
private getVerificationEmailTemplate(verificationUrl: string): EmailTemplate {
return {
subject: 'Verify Your Email - Medication Reminder',
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #4f46e5;">Verify Your Email Address</h2>
<p>Thank you for signing up for Medication Reminder! Please click the button below to verify your email address:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${verificationUrl}"
style="background-color: #4f46e5; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">
Verify Email Address
</a>
</div>
<p>Or copy and paste this link into your browser:</p>
<p style="word-break: break-all; color: #6b7280;">${verificationUrl}</p>
<p style="color: #6b7280; font-size: 14px;">This link will expire in 24 hours.</p>
</div>
`,
text: `
Verify Your Email - Medication Reminder
Thank you for signing up! Please verify your email by visiting:
${verificationUrl}
This link will expire in 24 hours.
`,
};
}
private getPasswordResetEmailTemplate(resetUrl: string): EmailTemplate {
return {
subject: 'Reset Your Password - Medication Reminder',
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #4f46e5;">Reset Your Password</h2>
<p>You requested to reset your password. Click the button below to set a new password:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${resetUrl}"
style="background-color: #4f46e5; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">
Reset Password
</a>
</div>
<p>Or copy and paste this link into your browser:</p>
<p style="word-break: break-all; color: #6b7280;">${resetUrl}</p>
<p style="color: #6b7280; font-size: 14px;">This link will expire in 1 hour. If you didn't request this, please ignore this email.</p>
</div>
`,
text: `
Reset Your Password - Medication Reminder
You requested to reset your password. Visit this link to set a new password:
${resetUrl}
This link will expire in 1 hour. If you didn't request this, please ignore this email.
`,
};
}
async sendEmail(to: string, template: EmailTemplate): Promise<boolean> {
try {
// In development mode or when Mailgun is not configured, just log the email
if (isDevelopmentMode()) {
console.log('📧 Mock Email Sent (Development Mode):', {
to,
subject: template.subject,
from: `${this.config.fromName} <${this.config.fromEmail}>`,
html: template.html,
text: template.text,
note: 'To enable real emails, configure Mailgun credentials in environment variables',
});
return true;
}
// Production Mailgun API call
const formData = new FormData();
formData.append(
'from',
`${this.config.fromName} <${this.config.fromEmail}>`
);
formData.append('to', to);
formData.append('subject', template.subject);
formData.append('html', template.html);
if (template.text) {
formData.append('text', template.text);
}
const response = await fetch(
`${this.config.baseUrl}/${this.config.domain}/messages`,
{
method: 'POST',
headers: {
Authorization: `Basic ${btoa(`api:${this.config.apiKey}`)}`,
},
body: formData,
}
);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Mailgun API error: ${response.status} - ${errorText}`);
}
const result = await response.json();
console.log('📧 Email sent successfully via Mailgun:', {
to,
subject: template.subject,
messageId: result.id,
});
return true;
} catch (error) {
console.error('Email sending failed:', error);
return false;
}
}
async sendVerificationEmail(email: string, token: string): Promise<boolean> {
const baseUrl = process.env.APP_BASE_URL || 'http://localhost:5173';
const verificationUrl = `${baseUrl}/verify-email?token=${token}`;
const template = this.getVerificationEmailTemplate(verificationUrl);
return this.sendEmail(email, template);
}
async sendPasswordResetEmail(email: string, token: string): Promise<boolean> {
const baseUrl = process.env.APP_BASE_URL || 'http://localhost:5173';
const resetUrl = `${baseUrl}/reset-password?token=${token}`;
const template = this.getPasswordResetEmailTemplate(resetUrl);
return this.sendEmail(email, template);
}
// Utility method to check if Mailgun is properly configured
isConfigured(): boolean {
return isMailgunConfigured();
}
// Get configuration status for debugging
getConfigurationStatus(): {
configured: boolean;
mode: 'development' | 'production';
domain: string;
fromEmail: string;
} {
return {
configured: isMailgunConfigured(),
mode: isDevelopmentMode() ? 'development' : 'production',
domain: this.config.domain,
fromEmail: this.config.fromEmail,
};
}
}
export const mailgunService = new MailgunService();
+139
View File
@@ -0,0 +1,139 @@
import { authService } from './auth/auth.service';
import { OAuthProvider, OAuthState, User } from '../types';
import { dbService } from './couchdb.factory';
import { AccountStatus } from './auth/auth.constants';
// Mock OAuth configuration
const GOOGLE_CLIENT_ID = 'mock_google_client_id';
const GITHUB_CLIENT_ID = 'mock_github_client_id';
// Mock OAuth endpoints
const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize';
// Mock token exchange endpoints
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token';
// Mock OAuth scopes
const GOOGLE_SCOPES = 'openid email profile';
const GITHUB_SCOPES = 'user:email';
// Mock redirect URI
const REDIRECT_URI = 'http://localhost:3000/auth/callback';
// Mock OAuth state generation
const generateState = () => crypto.randomUUID();
export const googleAuth = () => {
const state = generateState();
const url = new URL(GOOGLE_AUTH_URL);
url.searchParams.append('client_id', GOOGLE_CLIENT_ID);
url.searchParams.append('response_type', 'code');
url.searchParams.append('scope', GOOGLE_SCOPES);
url.searchParams.append('redirect_uri', REDIRECT_URI);
url.searchParams.append('state', state);
// In a real implementation, we would store the state in the session or localStorage
localStorage.setItem('oauth_state', state);
// Redirect to Google's auth endpoint
window.location.href = url.toString();
};
export const githubAuth = () => {
const state = generateState();
const url = new URL(GITHUB_AUTH_URL);
url.searchParams.append('client_id', GITHUB_CLIENT_ID);
url.searchParams.append('response_type', 'code');
url.searchParams.append('scope', GITHUB_SCOPES);
url.searchParams.append('redirect_uri', REDIRECT_URI);
url.searchParams.append('state', state);
// In a real implementation, we would store the state in the session or localStorage
localStorage.setItem('oauth_state', state);
// Redirect to GitHub's auth endpoint
window.location.href = url.toString();
};
// Mock token exchange
const mockExchangeCodeForToken = async (
provider: 'google' | 'github',
code: string
): Promise<string> => {
// In a real implementation, we would make a POST request to the token endpoint
// with the code, client_id, client_secret, and redirect_uri
// For this mock, we'll just return a mock access token
return `mock_${provider}_access_token_${crypto.randomUUID()}`;
};
// Mock user info retrieval
const mockGetUserInfo = async (
provider: 'google' | 'github',
accessToken: string
): Promise<{ email: string; name: string }> => {
// In a real implementation, we would make a GET request to the user info endpoint
// with the access token
// For this mock, we'll return mock user info
return {
email: `mock_${provider}_user_${crypto.randomUUID()}@example.com`,
name: `Mock ${provider.charAt(0).toUpperCase() + provider.slice(1)} User`,
};
};
export const handleGoogleCallback = async () => {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const state = params.get('state');
const storedState = localStorage.getItem('oauth_state');
// Verify state to prevent CSRF attacks
if (state !== storedState) {
throw new Error('Invalid OAuth state');
}
// Clear stored state
localStorage.removeItem('oauth_state');
// Exchange code for token
const accessToken = await mockExchangeCodeForToken('google', code);
// Get user info
const userInfo = await mockGetUserInfo('google', accessToken);
// Register or login the user
return authService.loginWithOAuth('google', {
email: userInfo.email,
username: userInfo.name,
});
};
export const handleGithubCallback = async () => {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const state = params.get('state');
const storedState = localStorage.getItem('oauth_state');
// Verify state to prevent CSRF attacks
if (state !== storedState) {
throw new Error('Invalid OAuth state');
}
// Clear stored state
localStorage.removeItem('oauth_state');
// Exchange code for token
const accessToken = await mockExchangeCodeForToken('github', code);
// Get user info
const userInfo = await mockGetUserInfo('github', accessToken);
// Register or login the user
return authService.loginWithOAuth('github', {
email: userInfo.email,
username: userInfo.name,
});
};