feat: add Terms & Conditions checkbox to signup (refs #4)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 4m38s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 28s
Deploy to Staging / Verify Staging (pull_request) Successful in 6s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 6s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 4m38s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 28s
Deploy to Staging / Verify Staging (pull_request) Successful in 6s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 6s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
- Add terms_agreements table for legal audit trail - Create terms-agreement feature capsule with repository - Modify signup to create terms agreement atomically - Add checkbox with PDF link to SignupForm - Capture IP, User-Agent, terms version, content hash - Update CLAUDE.md documentation index 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,8 @@
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { AuthService } from '../domain/auth.service';
|
||||
import { UserProfileRepository } from '../../user-profile/data/user-profile.repository';
|
||||
import { TermsAgreementRepository } from '../../terms-agreement/data/terms-agreement.repository';
|
||||
import { termsConfig } from '../../terms-agreement/domain/terms-config';
|
||||
import { pool } from '../../../core/config/database';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import { signupSchema, resendVerificationPublicSchema } from './auth.validation';
|
||||
@@ -15,7 +17,22 @@ export class AuthController {
|
||||
|
||||
constructor() {
|
||||
const userProfileRepository = new UserProfileRepository(pool);
|
||||
this.authService = new AuthService(userProfileRepository);
|
||||
const termsAgreementRepository = new TermsAgreementRepository(pool);
|
||||
this.authService = new AuthService(userProfileRepository, termsAgreementRepository);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract client IP address from request
|
||||
* Checks X-Forwarded-For header for proxy scenarios (Traefik)
|
||||
*/
|
||||
private getClientIp(request: FastifyRequest): string {
|
||||
const forwardedFor = request.headers['x-forwarded-for'];
|
||||
if (forwardedFor) {
|
||||
// X-Forwarded-For can be comma-separated list; first IP is the client
|
||||
const ips = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor;
|
||||
return ips.split(',')[0].trim();
|
||||
}
|
||||
return request.ip || 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,9 +51,18 @@ export class AuthController {
|
||||
});
|
||||
}
|
||||
|
||||
const { email, password } = validation.data;
|
||||
const { email, password, termsAccepted } = validation.data;
|
||||
|
||||
const result = await this.authService.signup({ email, password });
|
||||
// Extract terms data for audit trail
|
||||
const termsData = {
|
||||
ipAddress: this.getClientIp(request),
|
||||
userAgent: (request.headers['user-agent'] as string) || 'unknown',
|
||||
termsVersion: termsConfig.version,
|
||||
termsUrl: termsConfig.url,
|
||||
termsContentHash: termsConfig.getContentHash(),
|
||||
};
|
||||
|
||||
const result = await this.authService.signup({ email, password, termsAccepted }, termsData);
|
||||
|
||||
logger.info('User signup successful', { email, userId: result.userId });
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@ const passwordSchema = z
|
||||
export const signupSchema = z.object({
|
||||
email: z.string().email('Invalid email format'),
|
||||
password: passwordSchema,
|
||||
termsAccepted: z.literal(true, {
|
||||
errorMap: () => ({ message: 'You must agree to the Terms & Conditions to create an account' }),
|
||||
}),
|
||||
});
|
||||
|
||||
export type SignupInput = z.infer<typeof signupSchema>;
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
import { auth0ManagementClient } from '../../../core/auth/auth0-management.client';
|
||||
import { UserProfileRepository } from '../../user-profile/data/user-profile.repository';
|
||||
import { TermsAgreementRepository } from '../../terms-agreement/data/terms-agreement.repository';
|
||||
import { pool } from '../../../core/config/database';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import {
|
||||
SignupRequest,
|
||||
@@ -13,17 +15,21 @@ import {
|
||||
ResendVerificationResponse,
|
||||
SecurityStatusResponse,
|
||||
PasswordResetResponse,
|
||||
TermsData,
|
||||
} from './auth.types';
|
||||
|
||||
export class AuthService {
|
||||
constructor(private userProfileRepository: UserProfileRepository) {}
|
||||
constructor(
|
||||
private userProfileRepository: UserProfileRepository,
|
||||
private termsAgreementRepository: TermsAgreementRepository
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a new user account
|
||||
* 1. Create user in Auth0 (which automatically sends verification email)
|
||||
* 2. Create local user profile with emailVerified=false
|
||||
* 2. Create local user profile and terms agreement atomically
|
||||
*/
|
||||
async signup(request: SignupRequest): Promise<SignupResponse> {
|
||||
async signup(request: SignupRequest, termsData: TermsData): Promise<SignupResponse> {
|
||||
const { email, password } = request;
|
||||
|
||||
try {
|
||||
@@ -36,14 +42,42 @@ export class AuthService {
|
||||
|
||||
logger.info('Auth0 user created', { auth0UserId, email });
|
||||
|
||||
// Create local user profile
|
||||
const userProfile = await this.userProfileRepository.create(
|
||||
auth0UserId,
|
||||
email,
|
||||
undefined // displayName is optional
|
||||
);
|
||||
// Create local user profile and terms agreement in a transaction
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
logger.info('User profile created', { userId: userProfile.id, email });
|
||||
// Create user profile
|
||||
const profileQuery = `
|
||||
INSERT INTO user_profiles (auth0_sub, email, subscription_tier)
|
||||
VALUES ($1, $2, 'free')
|
||||
RETURNING id
|
||||
`;
|
||||
const profileResult = await client.query(profileQuery, [auth0UserId, email]);
|
||||
const profileId = profileResult.rows[0].id;
|
||||
|
||||
logger.info('User profile created', { userId: profileId, email });
|
||||
|
||||
// Create terms agreement
|
||||
await this.termsAgreementRepository.create({
|
||||
userId: auth0UserId,
|
||||
ipAddress: termsData.ipAddress,
|
||||
userAgent: termsData.userAgent,
|
||||
termsVersion: termsData.termsVersion,
|
||||
termsUrl: termsData.termsUrl,
|
||||
termsContentHash: termsData.termsContentHash,
|
||||
}, client);
|
||||
|
||||
logger.info('Terms agreement created', { userId: auth0UserId });
|
||||
|
||||
await client.query('COMMIT');
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
logger.error('Transaction failed, rolling back', { error, email });
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
return {
|
||||
userId: auth0UserId,
|
||||
|
||||
@@ -7,6 +7,16 @@
|
||||
export interface SignupRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
termsAccepted: boolean;
|
||||
}
|
||||
|
||||
// Terms data captured during signup for audit trail
|
||||
export interface TermsData {
|
||||
ipAddress: string;
|
||||
userAgent: string;
|
||||
termsVersion: string;
|
||||
termsUrl: string;
|
||||
termsContentHash: string;
|
||||
}
|
||||
|
||||
// Response from signup endpoint
|
||||
|
||||
Reference in New Issue
Block a user