feat: User onboarding finished
This commit is contained in:
@@ -8,7 +8,7 @@ import { AuthService } from '../domain/auth.service';
|
||||
import { UserProfileRepository } from '../../user-profile/data/user-profile.repository';
|
||||
import { pool } from '../../../core/config/database';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import { signupSchema } from './auth.validation';
|
||||
import { signupSchema, resendVerificationPublicSchema } from './auth.validation';
|
||||
|
||||
export class AuthController {
|
||||
private authService: AuthService;
|
||||
@@ -119,4 +119,67 @@ export class AuthController {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/auth/resend-verification-public
|
||||
* Resend verification email by email address
|
||||
* Public endpoint - no JWT required (for pre-login verification page)
|
||||
*/
|
||||
async resendVerificationPublic(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const validation = resendVerificationPublicSchema.safeParse(request.body);
|
||||
if (!validation.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
message: validation.error.errors[0]?.message || 'Invalid input',
|
||||
});
|
||||
}
|
||||
|
||||
const { email } = validation.data;
|
||||
|
||||
const result = await this.authService.resendVerificationByEmail(email);
|
||||
|
||||
logger.info('Public resend verification requested', { email: email.substring(0, 3) + '***' });
|
||||
|
||||
return reply.code(200).send(result);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to resend verification email (public)', { error });
|
||||
|
||||
// Always return success for security (don't reveal if email exists)
|
||||
return reply.code(200).send({
|
||||
message: 'If an account exists with this email, a verification link will be sent.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/auth/user-status
|
||||
* Get user status for routing decisions
|
||||
* Protected endpoint - requires JWT
|
||||
*/
|
||||
async getUserStatus(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
|
||||
const result = await this.authService.getUserStatus(userId);
|
||||
|
||||
logger.info('User status retrieved', {
|
||||
userId: userId.substring(0, 8) + '...',
|
||||
emailVerified: result.emailVerified,
|
||||
onboardingCompleted: result.onboardingCompleted,
|
||||
});
|
||||
|
||||
return reply.code(200).send(result);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to get user status', {
|
||||
error,
|
||||
userId: (request as any).user?.sub,
|
||||
});
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to get user status',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,4 +27,13 @@ export const authRoutes: FastifyPluginAsync = async (
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: authController.resendVerification.bind(authController),
|
||||
});
|
||||
|
||||
// POST /api/auth/resend-verification-public - Resend verification by email (public, no JWT)
|
||||
fastify.post('/auth/resend-verification-public', authController.resendVerificationPublic.bind(authController));
|
||||
|
||||
// GET /api/auth/user-status - Get user status for routing (requires JWT, verification exempt)
|
||||
fastify.get('/auth/user-status', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: authController.getUserStatus.bind(authController),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -21,3 +21,10 @@ export const signupSchema = z.object({
|
||||
});
|
||||
|
||||
export type SignupInput = z.infer<typeof signupSchema>;
|
||||
|
||||
// Schema for public resend verification endpoint (no JWT required)
|
||||
export const resendVerificationPublicSchema = z.object({
|
||||
email: z.string().email('Invalid email format'),
|
||||
});
|
||||
|
||||
export type ResendVerificationPublicInput = z.infer<typeof resendVerificationPublicSchema>;
|
||||
|
||||
@@ -127,4 +127,87 @@ export class AuthService {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend verification email by email address (public endpoint)
|
||||
* Looks up user by email and sends verification if not verified
|
||||
*/
|
||||
async resendVerificationByEmail(email: string): Promise<ResendVerificationResponse> {
|
||||
try {
|
||||
// Look up user by email in our database
|
||||
const userProfile = await this.userProfileRepository.getByEmail(email);
|
||||
|
||||
if (!userProfile) {
|
||||
// Don't reveal if email exists - return success message regardless
|
||||
logger.info('Resend verification requested for unknown email', {
|
||||
email: email.substring(0, 3) + '***',
|
||||
});
|
||||
return {
|
||||
message: 'If an account exists with this email, a verification link will be sent.',
|
||||
};
|
||||
}
|
||||
|
||||
// Check if already verified via Auth0
|
||||
const verified = await auth0ManagementClient.checkEmailVerified(userProfile.auth0Sub);
|
||||
|
||||
if (verified) {
|
||||
logger.info('Email already verified, skipping resend', { email: email.substring(0, 3) + '***' });
|
||||
return {
|
||||
message: 'If an account exists with this email, a verification link will be sent.',
|
||||
};
|
||||
}
|
||||
|
||||
// Request Auth0 to resend verification email
|
||||
await auth0ManagementClient.resendVerificationEmail(userProfile.auth0Sub);
|
||||
|
||||
logger.info('Verification email resent via public endpoint', {
|
||||
email: email.substring(0, 3) + '***',
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'If an account exists with this email, a verification link will be sent.',
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to resend verification email by email', {
|
||||
email: email.substring(0, 3) + '***',
|
||||
error,
|
||||
});
|
||||
// Don't reveal error details - return generic success for security
|
||||
return {
|
||||
message: 'If an account exists with this email, a verification link will be sent.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user status for routing decisions
|
||||
* Returns email verification and onboarding completion status
|
||||
*/
|
||||
async getUserStatus(auth0Sub: string): Promise<{
|
||||
emailVerified: boolean;
|
||||
onboardingCompleted: boolean;
|
||||
email: string;
|
||||
}> {
|
||||
try {
|
||||
// Get user details from Auth0
|
||||
const auth0User = await auth0ManagementClient.getUser(auth0Sub);
|
||||
|
||||
// Get local profile for onboarding status
|
||||
const localProfile = await this.userProfileRepository.getByAuth0Sub(auth0Sub);
|
||||
|
||||
// Sync email verification status if needed
|
||||
if (localProfile && localProfile.emailVerified !== auth0User.emailVerified) {
|
||||
await this.userProfileRepository.updateEmailVerified(auth0Sub, auth0User.emailVerified);
|
||||
}
|
||||
|
||||
return {
|
||||
emailVerified: auth0User.emailVerified,
|
||||
onboardingCompleted: localProfile?.onboardingCompletedAt !== null,
|
||||
email: auth0User.email,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get user status', { auth0Sub, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,26 @@ export class UserProfileRepository {
|
||||
}
|
||||
}
|
||||
|
||||
async getByEmail(email: string): Promise<UserProfile | null> {
|
||||
const query = `
|
||||
SELECT ${USER_PROFILE_COLUMNS}
|
||||
FROM user_profiles
|
||||
WHERE email = $1
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [email]);
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return this.mapRowToUserProfile(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching user profile by email', { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async create(
|
||||
auth0Sub: string,
|
||||
email: string,
|
||||
|
||||
Reference in New Issue
Block a user