- Add dbGet/dbAll helper functions for database-agnostic queries - Add toDbBool/convertBooleansForDb for boolean type conversion - Add toDbDate/getNow for timestamp type handling - Add generateId that returns UUID for Postgres, nanoid for SQLite - Update all routes to use compatibility helpers - Add normalizeEvent to return clean number types from Postgres decimal - Add formatPrice utility for consistent price display - Add legal pages admin interface with RichTextEditor - Update carousel images - Add drizzle migration files for PostgreSQL
673 lines
20 KiB
TypeScript
673 lines
20 KiB
TypeScript
import { Hono } from 'hono';
|
|
import { zValidator } from '@hono/zod-validator';
|
|
import { z } from 'zod';
|
|
import { db, dbGet, users, magicLinkTokens, User } from '../db/index.js';
|
|
import { eq } from 'drizzle-orm';
|
|
import {
|
|
hashPassword,
|
|
verifyPassword,
|
|
createToken,
|
|
createRefreshToken,
|
|
isFirstUser,
|
|
getAuthUser,
|
|
validatePassword,
|
|
createMagicLinkToken,
|
|
verifyMagicLinkToken,
|
|
invalidateAllUserSessions,
|
|
requireAuth,
|
|
} from '../lib/auth.js';
|
|
import { generateId, getNow, toDbBool } from '../lib/utils.js';
|
|
import { sendEmail } from '../lib/email.js';
|
|
|
|
// User type that includes all fields (some added in schema updates)
|
|
type AuthUser = User & {
|
|
isClaimed: boolean;
|
|
googleId: string | null;
|
|
rucNumber: string | null;
|
|
accountStatus: string;
|
|
};
|
|
|
|
const auth = new Hono();
|
|
|
|
// Rate limiting store (in production, use Redis)
|
|
const loginAttempts = new Map<string, { count: number; resetAt: number }>();
|
|
const MAX_LOGIN_ATTEMPTS = 5;
|
|
const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 minutes
|
|
|
|
function checkRateLimit(email: string): { allowed: boolean; retryAfter?: number } {
|
|
const now = Date.now();
|
|
const attempts = loginAttempts.get(email);
|
|
|
|
if (!attempts) {
|
|
return { allowed: true };
|
|
}
|
|
|
|
if (now > attempts.resetAt) {
|
|
loginAttempts.delete(email);
|
|
return { allowed: true };
|
|
}
|
|
|
|
if (attempts.count >= MAX_LOGIN_ATTEMPTS) {
|
|
return { allowed: false, retryAfter: Math.ceil((attempts.resetAt - now) / 1000) };
|
|
}
|
|
|
|
return { allowed: true };
|
|
}
|
|
|
|
function recordFailedAttempt(email: string): void {
|
|
const now = Date.now();
|
|
const attempts = loginAttempts.get(email) || { count: 0, resetAt: now + LOCKOUT_DURATION };
|
|
attempts.count++;
|
|
loginAttempts.set(email, attempts);
|
|
}
|
|
|
|
function clearFailedAttempts(email: string): void {
|
|
loginAttempts.delete(email);
|
|
}
|
|
|
|
const registerSchema = z.object({
|
|
email: z.string().email(),
|
|
password: z.string().min(10, 'Password must be at least 10 characters'),
|
|
name: z.string().min(2),
|
|
phone: z.string().optional(),
|
|
languagePreference: z.enum(['en', 'es']).optional(),
|
|
});
|
|
|
|
const loginSchema = z.object({
|
|
email: z.string().email(),
|
|
password: z.string(),
|
|
});
|
|
|
|
const magicLinkRequestSchema = z.object({
|
|
email: z.string().email(),
|
|
});
|
|
|
|
const magicLinkVerifySchema = z.object({
|
|
token: z.string(),
|
|
});
|
|
|
|
const passwordResetRequestSchema = z.object({
|
|
email: z.string().email(),
|
|
});
|
|
|
|
const passwordResetSchema = z.object({
|
|
token: z.string(),
|
|
password: z.string().min(10, 'Password must be at least 10 characters'),
|
|
});
|
|
|
|
const claimAccountSchema = z.object({
|
|
token: z.string(),
|
|
password: z.string().min(10, 'Password must be at least 10 characters').optional(),
|
|
googleId: z.string().optional(),
|
|
});
|
|
|
|
const changePasswordSchema = z.object({
|
|
currentPassword: z.string(),
|
|
newPassword: z.string().min(10, 'Password must be at least 10 characters'),
|
|
});
|
|
|
|
const googleAuthSchema = z.object({
|
|
credential: z.string(), // Google ID token
|
|
});
|
|
|
|
// Register
|
|
auth.post('/register', zValidator('json', registerSchema), async (c) => {
|
|
const data = c.req.valid('json');
|
|
|
|
// Validate password strength
|
|
const passwordValidation = validatePassword(data.password);
|
|
if (!passwordValidation.valid) {
|
|
return c.json({ error: passwordValidation.error }, 400);
|
|
}
|
|
|
|
// Check if email exists
|
|
const existing = await dbGet<any>(
|
|
(db as any).select().from(users).where(eq((users as any).email, data.email))
|
|
);
|
|
if (existing) {
|
|
// If user exists but is unclaimed, allow claiming
|
|
if (!existing.isClaimed || existing.accountStatus === 'unclaimed') {
|
|
return c.json({
|
|
error: 'Email already registered',
|
|
canClaim: true,
|
|
message: 'This email has an unclaimed account. Please check your email for the claim link or request a new one.'
|
|
}, 400);
|
|
}
|
|
return c.json({ error: 'Email already registered' }, 400);
|
|
}
|
|
|
|
// Check if first user (becomes admin)
|
|
const firstUser = await isFirstUser();
|
|
|
|
const hashedPassword = await hashPassword(data.password);
|
|
const now = getNow();
|
|
const id = generateId();
|
|
|
|
const newUser = {
|
|
id,
|
|
email: data.email,
|
|
password: hashedPassword,
|
|
name: data.name,
|
|
phone: data.phone || null,
|
|
role: firstUser ? 'admin' : 'user',
|
|
languagePreference: data.languagePreference || null,
|
|
isClaimed: toDbBool(true),
|
|
googleId: null,
|
|
rucNumber: null,
|
|
accountStatus: 'active',
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
|
|
await (db as any).insert(users).values(newUser);
|
|
|
|
const token = await createToken(id, data.email, newUser.role);
|
|
const refreshToken = await createRefreshToken(id);
|
|
|
|
return c.json({
|
|
user: {
|
|
id,
|
|
email: data.email,
|
|
name: data.name,
|
|
role: newUser.role,
|
|
isClaimed: true,
|
|
},
|
|
token,
|
|
refreshToken,
|
|
message: firstUser ? 'Admin account created successfully' : 'Account created successfully',
|
|
}, 201);
|
|
});
|
|
|
|
// Login with email/password
|
|
auth.post('/login', zValidator('json', loginSchema), async (c) => {
|
|
const data = c.req.valid('json');
|
|
|
|
// Check rate limit
|
|
const rateLimit = checkRateLimit(data.email);
|
|
if (!rateLimit.allowed) {
|
|
return c.json({
|
|
error: 'Too many login attempts. Please try again later.',
|
|
retryAfter: rateLimit.retryAfter
|
|
}, 429);
|
|
}
|
|
|
|
const user = await dbGet<any>(
|
|
(db as any).select().from(users).where(eq((users as any).email, data.email))
|
|
);
|
|
if (!user) {
|
|
recordFailedAttempt(data.email);
|
|
return c.json({ error: 'Invalid credentials' }, 401);
|
|
}
|
|
|
|
// Check if account is suspended
|
|
if (user.accountStatus === 'suspended') {
|
|
return c.json({ error: 'Account is suspended. Please contact support.' }, 403);
|
|
}
|
|
|
|
// Check if user has a password set
|
|
if (!user.password) {
|
|
return c.json({
|
|
error: 'No password set for this account',
|
|
needsClaim: !user.isClaimed,
|
|
message: user.isClaimed
|
|
? 'Please use Google login or request a password reset.'
|
|
: 'Please claim your account first.'
|
|
}, 400);
|
|
}
|
|
|
|
const validPassword = await verifyPassword(data.password, user.password);
|
|
if (!validPassword) {
|
|
recordFailedAttempt(data.email);
|
|
return c.json({ error: 'Invalid credentials' }, 401);
|
|
}
|
|
|
|
// Clear failed attempts on successful login
|
|
clearFailedAttempts(data.email);
|
|
|
|
const token = await createToken(user.id, user.email, user.role);
|
|
const refreshToken = await createRefreshToken(user.id);
|
|
|
|
return c.json({
|
|
user: {
|
|
id: user.id,
|
|
email: user.email,
|
|
name: user.name,
|
|
role: user.role,
|
|
isClaimed: user.isClaimed,
|
|
phone: user.phone,
|
|
rucNumber: user.rucNumber,
|
|
languagePreference: user.languagePreference,
|
|
},
|
|
token,
|
|
refreshToken,
|
|
});
|
|
});
|
|
|
|
// Request magic link login
|
|
auth.post('/magic-link/request', zValidator('json', magicLinkRequestSchema), async (c) => {
|
|
const { email } = c.req.valid('json');
|
|
|
|
const user = await dbGet<any>(
|
|
(db as any).select().from(users).where(eq((users as any).email, email))
|
|
);
|
|
|
|
if (!user) {
|
|
// Don't reveal if email exists
|
|
return c.json({ message: 'If an account exists with this email, a login link has been sent.' });
|
|
}
|
|
|
|
if (user.accountStatus === 'suspended') {
|
|
return c.json({ message: 'If an account exists with this email, a login link has been sent.' });
|
|
}
|
|
|
|
// Create magic link token (expires in 10 minutes)
|
|
const token = await createMagicLinkToken(user.id, 'login', 10);
|
|
const magicLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/magic-link?token=${token}`;
|
|
|
|
// Send email
|
|
try {
|
|
await sendEmail({
|
|
to: email,
|
|
subject: 'Your Spanglish Login Link',
|
|
html: `
|
|
<h2>Login to Spanglish</h2>
|
|
<p>Click the link below to log in. This link expires in 10 minutes.</p>
|
|
<p><a href="${magicLink}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Log In</a></p>
|
|
<p>Or copy this link: ${magicLink}</p>
|
|
<p>If you didn't request this, you can safely ignore this email.</p>
|
|
`,
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to send magic link email:', error);
|
|
}
|
|
|
|
return c.json({ message: 'If an account exists with this email, a login link has been sent.' });
|
|
});
|
|
|
|
// Verify magic link and login
|
|
auth.post('/magic-link/verify', zValidator('json', magicLinkVerifySchema), async (c) => {
|
|
const { token } = c.req.valid('json');
|
|
|
|
const verification = await verifyMagicLinkToken(token, 'login');
|
|
|
|
if (!verification.valid) {
|
|
return c.json({ error: verification.error }, 400);
|
|
}
|
|
|
|
const user = await dbGet<any>(
|
|
(db as any).select().from(users).where(eq((users as any).id, verification.userId))
|
|
);
|
|
|
|
if (!user || user.accountStatus === 'suspended') {
|
|
return c.json({ error: 'Invalid token' }, 400);
|
|
}
|
|
|
|
const authToken = await createToken(user.id, user.email, user.role);
|
|
const refreshToken = await createRefreshToken(user.id);
|
|
|
|
return c.json({
|
|
user: {
|
|
id: user.id,
|
|
email: user.email,
|
|
name: user.name,
|
|
role: user.role,
|
|
isClaimed: user.isClaimed,
|
|
phone: user.phone,
|
|
rucNumber: user.rucNumber,
|
|
languagePreference: user.languagePreference,
|
|
},
|
|
token: authToken,
|
|
refreshToken,
|
|
});
|
|
});
|
|
|
|
// Request password reset
|
|
auth.post('/password-reset/request', zValidator('json', passwordResetRequestSchema), async (c) => {
|
|
const { email } = c.req.valid('json');
|
|
|
|
const user = await dbGet<any>(
|
|
(db as any).select().from(users).where(eq((users as any).email, email))
|
|
);
|
|
|
|
if (!user) {
|
|
// Don't reveal if email exists
|
|
return c.json({ message: 'If an account exists with this email, a password reset link has been sent.' });
|
|
}
|
|
|
|
if (user.accountStatus === 'suspended') {
|
|
return c.json({ message: 'If an account exists with this email, a password reset link has been sent.' });
|
|
}
|
|
|
|
// Create reset token (expires in 30 minutes)
|
|
const token = await createMagicLinkToken(user.id, 'reset_password', 30);
|
|
const resetLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/reset-password?token=${token}`;
|
|
|
|
// Send email
|
|
try {
|
|
await sendEmail({
|
|
to: email,
|
|
subject: 'Reset Your Spanglish Password',
|
|
html: `
|
|
<h2>Reset Your Password</h2>
|
|
<p>Click the link below to reset your password. This link expires in 30 minutes.</p>
|
|
<p><a href="${resetLink}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Reset Password</a></p>
|
|
<p>Or copy this link: ${resetLink}</p>
|
|
<p>If you didn't request this, you can safely ignore this email.</p>
|
|
`,
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to send password reset email:', error);
|
|
}
|
|
|
|
return c.json({ message: 'If an account exists with this email, a password reset link has been sent.' });
|
|
});
|
|
|
|
// Reset password
|
|
auth.post('/password-reset/confirm', zValidator('json', passwordResetSchema), async (c) => {
|
|
const { token, password } = c.req.valid('json');
|
|
|
|
// Validate password strength
|
|
const passwordValidation = validatePassword(password);
|
|
if (!passwordValidation.valid) {
|
|
return c.json({ error: passwordValidation.error }, 400);
|
|
}
|
|
|
|
const verification = await verifyMagicLinkToken(token, 'reset_password');
|
|
|
|
if (!verification.valid) {
|
|
return c.json({ error: verification.error }, 400);
|
|
}
|
|
|
|
const hashedPassword = await hashPassword(password);
|
|
const now = getNow();
|
|
|
|
await (db as any)
|
|
.update(users)
|
|
.set({
|
|
password: hashedPassword,
|
|
updatedAt: now,
|
|
})
|
|
.where(eq((users as any).id, verification.userId));
|
|
|
|
// Invalidate all existing sessions for security
|
|
await invalidateAllUserSessions(verification.userId!);
|
|
|
|
return c.json({ message: 'Password reset successfully. Please log in with your new password.' });
|
|
});
|
|
|
|
// Claim unclaimed account
|
|
auth.post('/claim-account/request', zValidator('json', magicLinkRequestSchema), async (c) => {
|
|
const { email } = c.req.valid('json');
|
|
|
|
const user = await dbGet<any>(
|
|
(db as any).select().from(users).where(eq((users as any).email, email))
|
|
);
|
|
|
|
if (!user) {
|
|
return c.json({ message: 'If an unclaimed account exists with this email, a claim link has been sent.' });
|
|
}
|
|
|
|
if (user.isClaimed && user.accountStatus !== 'unclaimed') {
|
|
return c.json({ error: 'Account is already claimed' }, 400);
|
|
}
|
|
|
|
// Create claim token (expires in 24 hours)
|
|
const token = await createMagicLinkToken(user.id, 'claim_account', 24 * 60);
|
|
const claimLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/claim-account?token=${token}`;
|
|
|
|
// Send email
|
|
try {
|
|
await sendEmail({
|
|
to: email,
|
|
subject: 'Claim Your Spanglish Account',
|
|
html: `
|
|
<h2>Claim Your Account</h2>
|
|
<p>An account was created for you during booking. Click below to set up your login credentials.</p>
|
|
<p><a href="${claimLink}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Claim Account</a></p>
|
|
<p>Or copy this link: ${claimLink}</p>
|
|
<p>This link expires in 24 hours.</p>
|
|
`,
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to send claim account email:', error);
|
|
}
|
|
|
|
return c.json({ message: 'If an unclaimed account exists with this email, a claim link has been sent.' });
|
|
});
|
|
|
|
// Complete account claim
|
|
auth.post('/claim-account/confirm', zValidator('json', claimAccountSchema), async (c) => {
|
|
const { token, password, googleId } = c.req.valid('json');
|
|
|
|
if (!password && !googleId) {
|
|
return c.json({ error: 'Please provide either a password or link a Google account' }, 400);
|
|
}
|
|
|
|
const verification = await verifyMagicLinkToken(token, 'claim_account');
|
|
|
|
if (!verification.valid) {
|
|
return c.json({ error: verification.error }, 400);
|
|
}
|
|
|
|
const now = getNow();
|
|
const updates: Record<string, any> = {
|
|
isClaimed: toDbBool(true),
|
|
accountStatus: 'active',
|
|
updatedAt: now,
|
|
};
|
|
|
|
if (password) {
|
|
const passwordValidation = validatePassword(password);
|
|
if (!passwordValidation.valid) {
|
|
return c.json({ error: passwordValidation.error }, 400);
|
|
}
|
|
updates.password = await hashPassword(password);
|
|
}
|
|
|
|
if (googleId) {
|
|
updates.googleId = googleId;
|
|
}
|
|
|
|
await (db as any)
|
|
.update(users)
|
|
.set(updates)
|
|
.where(eq((users as any).id, verification.userId));
|
|
|
|
const user = await dbGet<any>(
|
|
(db as any).select().from(users).where(eq((users as any).id, verification.userId))
|
|
);
|
|
|
|
const authToken = await createToken(user.id, user.email, user.role);
|
|
const refreshToken = await createRefreshToken(user.id);
|
|
|
|
return c.json({
|
|
user: {
|
|
id: user.id,
|
|
email: user.email,
|
|
name: user.name,
|
|
role: user.role,
|
|
isClaimed: user.isClaimed,
|
|
phone: user.phone,
|
|
rucNumber: user.rucNumber,
|
|
languagePreference: user.languagePreference,
|
|
},
|
|
token: authToken,
|
|
refreshToken,
|
|
message: 'Account claimed successfully!',
|
|
});
|
|
});
|
|
|
|
// Google OAuth login/register
|
|
auth.post('/google', zValidator('json', googleAuthSchema), async (c) => {
|
|
const { credential } = c.req.valid('json');
|
|
|
|
try {
|
|
// Verify Google token
|
|
// In production, use Google's library to verify: https://developers.google.com/identity/gsi/web/guides/verify-google-id-token
|
|
const response = await fetch(`https://oauth2.googleapis.com/tokeninfo?id_token=${credential}`);
|
|
|
|
if (!response.ok) {
|
|
return c.json({ error: 'Invalid Google token' }, 400);
|
|
}
|
|
|
|
const googleData = await response.json() as {
|
|
sub: string;
|
|
email: string;
|
|
name: string;
|
|
email_verified: string;
|
|
};
|
|
|
|
if (googleData.email_verified !== 'true') {
|
|
return c.json({ error: 'Google email not verified' }, 400);
|
|
}
|
|
|
|
const { sub: googleId, email, name } = googleData;
|
|
|
|
// Check if user exists by email or google_id
|
|
let user = await dbGet<any>(
|
|
(db as any).select().from(users).where(eq((users as any).email, email))
|
|
);
|
|
|
|
if (!user) {
|
|
// Check by google_id
|
|
user = await dbGet<any>(
|
|
(db as any).select().from(users).where(eq((users as any).googleId, googleId))
|
|
);
|
|
}
|
|
|
|
const now = getNow();
|
|
|
|
if (user) {
|
|
// User exists - link Google account if not already linked
|
|
if (user.accountStatus === 'suspended') {
|
|
return c.json({ error: 'Account is suspended. Please contact support.' }, 403);
|
|
}
|
|
|
|
if (!user.googleId) {
|
|
await (db as any)
|
|
.update(users)
|
|
.set({
|
|
googleId,
|
|
isClaimed: toDbBool(true),
|
|
accountStatus: 'active',
|
|
updatedAt: now,
|
|
})
|
|
.where(eq((users as any).id, user.id));
|
|
}
|
|
|
|
// Refresh user data
|
|
user = await dbGet<any>(
|
|
(db as any).select().from(users).where(eq((users as any).id, user.id))
|
|
);
|
|
} else {
|
|
// Create new user
|
|
const firstUser = await isFirstUser();
|
|
const id = generateId();
|
|
|
|
const newUser = {
|
|
id,
|
|
email,
|
|
password: null,
|
|
name,
|
|
phone: null,
|
|
role: firstUser ? 'admin' : 'user',
|
|
languagePreference: null,
|
|
isClaimed: toDbBool(true),
|
|
googleId,
|
|
rucNumber: null,
|
|
accountStatus: 'active',
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
|
|
await (db as any).insert(users).values(newUser);
|
|
user = newUser;
|
|
}
|
|
|
|
const authToken = await createToken(user.id, user.email, user.role);
|
|
const refreshToken = await createRefreshToken(user.id);
|
|
|
|
return c.json({
|
|
user: {
|
|
id: user.id,
|
|
email: user.email,
|
|
name: user.name,
|
|
role: user.role,
|
|
isClaimed: user.isClaimed,
|
|
phone: user.phone,
|
|
rucNumber: user.rucNumber,
|
|
languagePreference: user.languagePreference,
|
|
},
|
|
token: authToken,
|
|
refreshToken,
|
|
});
|
|
} catch (error) {
|
|
console.error('Google auth error:', error);
|
|
return c.json({ error: 'Failed to authenticate with Google' }, 500);
|
|
}
|
|
});
|
|
|
|
// Get current user
|
|
auth.get('/me', async (c) => {
|
|
const user = await getAuthUser(c);
|
|
|
|
if (!user) {
|
|
return c.json({ error: 'Unauthorized' }, 401);
|
|
}
|
|
|
|
return c.json({
|
|
user: {
|
|
id: user.id,
|
|
email: user.email,
|
|
name: user.name,
|
|
role: user.role,
|
|
phone: user.phone,
|
|
isClaimed: user.isClaimed,
|
|
rucNumber: user.rucNumber,
|
|
languagePreference: user.languagePreference,
|
|
accountStatus: user.accountStatus,
|
|
createdAt: user.createdAt,
|
|
},
|
|
});
|
|
});
|
|
|
|
// Change password (authenticated users)
|
|
auth.post('/change-password', requireAuth(), zValidator('json', changePasswordSchema), async (c) => {
|
|
const user = (c as any).get('user') as AuthUser;
|
|
const { currentPassword, newPassword } = c.req.valid('json');
|
|
|
|
// Validate new password
|
|
const passwordValidation = validatePassword(newPassword);
|
|
if (!passwordValidation.valid) {
|
|
return c.json({ error: passwordValidation.error }, 400);
|
|
}
|
|
|
|
// Verify current password if user has one
|
|
if (user.password) {
|
|
const validPassword = await verifyPassword(currentPassword, user.password);
|
|
if (!validPassword) {
|
|
return c.json({ error: 'Current password is incorrect' }, 400);
|
|
}
|
|
}
|
|
|
|
const hashedPassword = await hashPassword(newPassword);
|
|
const now = getNow();
|
|
|
|
await (db as any)
|
|
.update(users)
|
|
.set({
|
|
password: hashedPassword,
|
|
updatedAt: now,
|
|
})
|
|
.where(eq((users as any).id, user.id));
|
|
|
|
return c.json({ message: 'Password changed successfully' });
|
|
});
|
|
|
|
// Logout (client-side token removal, but we can log the action)
|
|
auth.post('/logout', async (c) => {
|
|
return c.json({ message: 'Logged out successfully' });
|
|
});
|
|
|
|
export default auth;
|