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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
`;
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user