feat: add Terms & Conditions checkbox to signup (#4) #5
@@ -26,6 +26,7 @@ const MIGRATION_ORDER = [
|
|||||||
'features/backup', // Admin backup feature; depends on update_updated_at_column()
|
'features/backup', // Admin backup feature; depends on update_updated_at_column()
|
||||||
'features/notifications', // Depends on maintenance and documents
|
'features/notifications', // Depends on maintenance and documents
|
||||||
'features/user-profile', // User profile management; independent
|
'features/user-profile', // User profile management; independent
|
||||||
|
'features/terms-agreement', // Terms & Conditions acceptance audit trail
|
||||||
];
|
];
|
||||||
|
|
||||||
// Base directory where migrations are copied inside the image (set by Dockerfile)
|
// Base directory where migrations are copied inside the image (set by Dockerfile)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ Feature capsule directory. Each feature is 100% self-contained with api/, domain
|
|||||||
| `onboarding/` | User onboarding flow | First-time user setup |
|
| `onboarding/` | User onboarding flow | First-time user setup |
|
||||||
| `platform/` | Vehicle data and VIN decoding | Make/model lookup, VIN validation |
|
| `platform/` | Vehicle data and VIN decoding | Make/model lookup, VIN validation |
|
||||||
| `stations/` | Gas station search and favorites | Google Maps integration, station data |
|
| `stations/` | Gas station search and favorites | Google Maps integration, station data |
|
||||||
|
| `terms-agreement/` | Terms & Conditions acceptance audit | Signup T&C, legal compliance |
|
||||||
| `user-export/` | User data export | GDPR compliance, data portability |
|
| `user-export/` | User data export | GDPR compliance, data portability |
|
||||||
| `user-preferences/` | User preference management | User settings API |
|
| `user-preferences/` | User preference management | User settings API |
|
||||||
| `user-profile/` | User profile management | Profile CRUD, avatar handling |
|
| `user-profile/` | User profile management | Profile CRUD, avatar handling |
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||||
import { AuthService } from '../domain/auth.service';
|
import { AuthService } from '../domain/auth.service';
|
||||||
import { UserProfileRepository } from '../../user-profile/data/user-profile.repository';
|
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 { pool } from '../../../core/config/database';
|
||||||
import { logger } from '../../../core/logging/logger';
|
import { logger } from '../../../core/logging/logger';
|
||||||
import { signupSchema, resendVerificationPublicSchema } from './auth.validation';
|
import { signupSchema, resendVerificationPublicSchema } from './auth.validation';
|
||||||
@@ -15,7 +17,22 @@ export class AuthController {
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const userProfileRepository = new UserProfileRepository(pool);
|
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 });
|
logger.info('User signup successful', { email, userId: result.userId });
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ const passwordSchema = z
|
|||||||
export const signupSchema = z.object({
|
export const signupSchema = z.object({
|
||||||
email: z.string().email('Invalid email format'),
|
email: z.string().email('Invalid email format'),
|
||||||
password: passwordSchema,
|
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>;
|
export type SignupInput = z.infer<typeof signupSchema>;
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
import { auth0ManagementClient } from '../../../core/auth/auth0-management.client';
|
import { auth0ManagementClient } from '../../../core/auth/auth0-management.client';
|
||||||
import { UserProfileRepository } from '../../user-profile/data/user-profile.repository';
|
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 { logger } from '../../../core/logging/logger';
|
||||||
import {
|
import {
|
||||||
SignupRequest,
|
SignupRequest,
|
||||||
@@ -13,17 +15,21 @@ import {
|
|||||||
ResendVerificationResponse,
|
ResendVerificationResponse,
|
||||||
SecurityStatusResponse,
|
SecurityStatusResponse,
|
||||||
PasswordResetResponse,
|
PasswordResetResponse,
|
||||||
|
TermsData,
|
||||||
} from './auth.types';
|
} from './auth.types';
|
||||||
|
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
constructor(private userProfileRepository: UserProfileRepository) {}
|
constructor(
|
||||||
|
private userProfileRepository: UserProfileRepository,
|
||||||
|
private termsAgreementRepository: TermsAgreementRepository
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new user account
|
* Create a new user account
|
||||||
* 1. Create user in Auth0 (which automatically sends verification email)
|
* 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;
|
const { email, password } = request;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -36,14 +42,42 @@ export class AuthService {
|
|||||||
|
|
||||||
logger.info('Auth0 user created', { auth0UserId, email });
|
logger.info('Auth0 user created', { auth0UserId, email });
|
||||||
|
|
||||||
// Create local user profile
|
// Create local user profile and terms agreement in a transaction
|
||||||
const userProfile = await this.userProfileRepository.create(
|
const client = await pool.connect();
|
||||||
auth0UserId,
|
try {
|
||||||
email,
|
await client.query('BEGIN');
|
||||||
undefined // displayName is optional
|
|
||||||
);
|
|
||||||
|
|
||||||
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 {
|
return {
|
||||||
userId: auth0UserId,
|
userId: auth0UserId,
|
||||||
|
|||||||
@@ -7,6 +7,16 @@
|
|||||||
export interface SignupRequest {
|
export interface SignupRequest {
|
||||||
email: string;
|
email: string;
|
||||||
password: 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
|
// Response from signup endpoint
|
||||||
|
|||||||
46
backend/src/features/terms-agreement/README.md
Normal file
46
backend/src/features/terms-agreement/README.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Terms Agreement Feature
|
||||||
|
|
||||||
|
Stores legal audit trail for Terms & Conditions acceptance at user signup.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Provides comprehensive legal compliance by capturing:
|
||||||
|
- **User consent**: Proof that user agreed to T&C before account creation
|
||||||
|
- **Audit fields**: IP address, user agent, timestamp, terms version, content hash
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
1. User checks T&C checkbox on signup form
|
||||||
|
2. Frontend sends `termsAccepted: true` with signup request
|
||||||
|
3. Controller extracts IP (from X-Forwarded-For header), User-Agent
|
||||||
|
4. Service creates user profile and terms agreement atomically in transaction
|
||||||
|
5. Terms agreement record stores audit fields for legal compliance
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| id | UUID | Primary key |
|
||||||
|
| user_id | VARCHAR | Auth0 user ID |
|
||||||
|
| agreed_at | TIMESTAMPTZ | UTC timestamp of agreement |
|
||||||
|
| ip_address | VARCHAR(45) | Client IP (supports IPv6) |
|
||||||
|
| user_agent | TEXT | Browser/client user agent |
|
||||||
|
| terms_version | VARCHAR | Version string (e.g., v2026-01-03) |
|
||||||
|
| terms_url | VARCHAR | URL path to PDF |
|
||||||
|
| terms_content_hash | VARCHAR(64) | SHA-256 hash of PDF content |
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `migrations/001_create_terms_agreements.sql` | Database table creation |
|
||||||
|
| `data/terms-agreement.repository.ts` | Data access layer |
|
||||||
|
| `domain/terms-agreement.types.ts` | TypeScript interfaces |
|
||||||
|
| `domain/terms-config.ts` | Static terms version and hash |
|
||||||
|
| `index.ts` | Feature exports |
|
||||||
|
|
||||||
|
## Invariants
|
||||||
|
|
||||||
|
- Every user in `user_profiles` has exactly one record in `terms_agreements`
|
||||||
|
- `agreed_at` is always stored in UTC
|
||||||
|
- `terms_content_hash` matches SHA-256 of PDF at signup time
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Terms agreement data access layer
|
||||||
|
* @ai-context Provides parameterized SQL queries for terms agreement operations
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Pool, PoolClient } from 'pg';
|
||||||
|
import { TermsAgreement, CreateTermsAgreementRequest } from '../domain/terms-agreement.types';
|
||||||
|
import { logger } from '../../../core/logging/logger';
|
||||||
|
|
||||||
|
const TERMS_AGREEMENT_COLUMNS = `
|
||||||
|
id, user_id, agreed_at, ip_address, user_agent,
|
||||||
|
terms_version, terms_url, terms_content_hash,
|
||||||
|
created_at, updated_at
|
||||||
|
`;
|
||||||
|
|
||||||
|
export class TermsAgreementRepository {
|
||||||
|
constructor(private pool: Pool) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new terms agreement record
|
||||||
|
* Can accept a PoolClient for transaction support
|
||||||
|
*/
|
||||||
|
async create(
|
||||||
|
request: CreateTermsAgreementRequest,
|
||||||
|
client?: PoolClient
|
||||||
|
): Promise<TermsAgreement> {
|
||||||
|
const query = `
|
||||||
|
INSERT INTO terms_agreements (
|
||||||
|
user_id, ip_address, user_agent,
|
||||||
|
terms_version, terms_url, terms_content_hash
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING ${TERMS_AGREEMENT_COLUMNS}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const values = [
|
||||||
|
request.userId,
|
||||||
|
request.ipAddress,
|
||||||
|
request.userAgent,
|
||||||
|
request.termsVersion,
|
||||||
|
request.termsUrl,
|
||||||
|
request.termsContentHash,
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const executor = client || this.pool;
|
||||||
|
const result = await executor.query(query, values);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error('Failed to create terms agreement');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.mapRow(result.rows[0]);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error creating terms agreement', { error, userId: request.userId });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get terms agreement by user ID
|
||||||
|
*/
|
||||||
|
async getByUserId(userId: string): Promise<TermsAgreement | null> {
|
||||||
|
const query = `
|
||||||
|
SELECT ${TERMS_AGREEMENT_COLUMNS}
|
||||||
|
FROM terms_agreements
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY agreed_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.pool.query(query, [userId]);
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.mapRow(result.rows[0]);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching terms agreement by user_id', { error, userId });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapRow(row: any): TermsAgreement {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
userId: row.user_id,
|
||||||
|
agreedAt: new Date(row.agreed_at),
|
||||||
|
ipAddress: row.ip_address,
|
||||||
|
userAgent: row.user_agent,
|
||||||
|
termsVersion: row.terms_version,
|
||||||
|
termsUrl: row.terms_url,
|
||||||
|
termsContentHash: row.terms_content_hash,
|
||||||
|
createdAt: new Date(row.created_at),
|
||||||
|
updatedAt: new Date(row.updated_at),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary TypeScript types for terms agreement feature
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface TermsAgreement {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
agreedAt: Date;
|
||||||
|
ipAddress: string;
|
||||||
|
userAgent: string;
|
||||||
|
termsVersion: string;
|
||||||
|
termsUrl: string;
|
||||||
|
termsContentHash: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTermsAgreementRequest {
|
||||||
|
userId: string;
|
||||||
|
ipAddress: string;
|
||||||
|
userAgent: string;
|
||||||
|
termsVersion: string;
|
||||||
|
termsUrl: string;
|
||||||
|
termsContentHash: string;
|
||||||
|
}
|
||||||
63
backend/src/features/terms-agreement/domain/terms-config.ts
Normal file
63
backend/src/features/terms-agreement/domain/terms-config.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Terms & Conditions configuration
|
||||||
|
* @ai-context Static configuration for current T&C version, computed once at startup
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
import { readFileSync, existsSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { logger } from '../../../core/logging/logger';
|
||||||
|
|
||||||
|
// Current terms version and URL (update when PDF changes)
|
||||||
|
const TERMS_VERSION = 'v2026-01-03';
|
||||||
|
const TERMS_URL = '/docs/v2026-01-03.pdf';
|
||||||
|
|
||||||
|
// Compute hash once at module load (startup)
|
||||||
|
let termsContentHash: string | null = null;
|
||||||
|
|
||||||
|
function computeTermsHash(): string {
|
||||||
|
if (termsContentHash) {
|
||||||
|
return termsContentHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In production, PDF is served by frontend; backend may not have access
|
||||||
|
// For development, try to read from frontend/public/docs
|
||||||
|
const possiblePaths = [
|
||||||
|
join(__dirname, '../../../../../frontend/public/docs/v2026-01-03.pdf'),
|
||||||
|
join(process.cwd(), 'frontend/public/docs/v2026-01-03.pdf'),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pdfPath of possiblePaths) {
|
||||||
|
if (existsSync(pdfPath)) {
|
||||||
|
try {
|
||||||
|
const pdfContent = readFileSync(pdfPath);
|
||||||
|
termsContentHash = createHash('sha256').update(pdfContent).digest('hex');
|
||||||
|
logger.info('Terms content hash computed', {
|
||||||
|
version: TERMS_VERSION,
|
||||||
|
hash: termsContentHash.substring(0, 16) + '...'
|
||||||
|
});
|
||||||
|
return termsContentHash;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to read terms PDF for hashing', { path: pdfPath, error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: use a placeholder hash (should be updated in production)
|
||||||
|
// In production, this should be pre-computed and set as an environment variable
|
||||||
|
termsContentHash = process.env['TERMS_CONTENT_HASH'] ||
|
||||||
|
'placeholder-hash-update-in-production';
|
||||||
|
|
||||||
|
logger.warn('Using fallback terms hash', {
|
||||||
|
hash: termsContentHash.substring(0, 16) + '...',
|
||||||
|
reason: 'PDF not found in development paths'
|
||||||
|
});
|
||||||
|
|
||||||
|
return termsContentHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const termsConfig = {
|
||||||
|
version: TERMS_VERSION,
|
||||||
|
url: TERMS_URL,
|
||||||
|
getContentHash: computeTermsHash,
|
||||||
|
};
|
||||||
6
backend/src/features/terms-agreement/index.ts
Normal file
6
backend/src/features/terms-agreement/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Terms agreement feature exports
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { TermsAgreementRepository } from './data/terms-agreement.repository';
|
||||||
|
export { TermsAgreement, CreateTermsAgreementRequest } from './domain/terms-agreement.types';
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
-- Terms Agreements Table
|
||||||
|
-- Stores legal audit trail for Terms & Conditions acceptance at signup
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS terms_agreements (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id VARCHAR(255) NOT NULL,
|
||||||
|
agreed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
ip_address VARCHAR(45) NOT NULL,
|
||||||
|
user_agent TEXT NOT NULL,
|
||||||
|
terms_version VARCHAR(50) NOT NULL,
|
||||||
|
terms_url VARCHAR(255) NOT NULL,
|
||||||
|
terms_content_hash VARCHAR(64) NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for user lookup
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_terms_agreements_user_id ON terms_agreements(user_id);
|
||||||
|
|
||||||
|
-- Trigger for updated_at
|
||||||
|
CREATE OR REPLACE FUNCTION update_terms_agreements_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS terms_agreements_updated_at ON terms_agreements;
|
||||||
|
CREATE TRIGGER terms_agreements_updated_at
|
||||||
|
BEFORE UPDATE ON terms_agreements
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_terms_agreements_updated_at();
|
||||||
@@ -18,6 +18,9 @@ const signupSchema = z
|
|||||||
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
|
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
|
||||||
.regex(/[0-9]/, 'Password must contain at least one number'),
|
.regex(/[0-9]/, 'Password must contain at least one number'),
|
||||||
confirmPassword: z.string(),
|
confirmPassword: z.string(),
|
||||||
|
termsAccepted: z.literal(true, {
|
||||||
|
errorMap: () => ({ message: 'You must agree to the Terms & Conditions to create an account' }),
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
.refine((data) => data.password === data.confirmPassword, {
|
.refine((data) => data.password === data.confirmPassword, {
|
||||||
message: 'Passwords do not match',
|
message: 'Passwords do not match',
|
||||||
@@ -42,8 +45,8 @@ export const SignupForm: React.FC<SignupFormProps> = ({ onSubmit, loading }) =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleFormSubmit = (data: SignupRequest & { confirmPassword: string }) => {
|
const handleFormSubmit = (data: SignupRequest & { confirmPassword: string }) => {
|
||||||
const { email, password } = data;
|
const { email, password, termsAccepted } = data;
|
||||||
onSubmit({ email, password });
|
onSubmit({ email, password, termsAccepted });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -138,6 +141,31 @@ export const SignupForm: React.FC<SignupFormProps> = ({ onSubmit, loading }) =>
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start min-h-[44px]">
|
||||||
|
<label className="flex items-start cursor-pointer">
|
||||||
|
<input
|
||||||
|
{...register('termsAccepted')}
|
||||||
|
type="checkbox"
|
||||||
|
className="w-5 h-5 mt-0.5 rounded border-silverstone text-primary-600 focus:ring-abudhabi dark:border-silverstone dark:focus:ring-abudhabi"
|
||||||
|
aria-label="I agree to the Terms and Conditions"
|
||||||
|
/>
|
||||||
|
<span className="ml-2 text-sm text-avus">
|
||||||
|
I agree to the{' '}
|
||||||
|
<a
|
||||||
|
href="/docs/v2026-01-03.pdf"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-abudhabi hover:underline"
|
||||||
|
>
|
||||||
|
Terms & Conditions
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{errors.termsAccepted && (
|
||||||
|
<p className="text-sm text-red-400">{errors.termsAccepted.message}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="pt-4">
|
<div className="pt-4">
|
||||||
<Button type="submit" loading={loading} className="w-full min-h-[44px]">
|
<Button type="submit" loading={loading} className="w-full min-h-[44px]">
|
||||||
Create Account
|
Create Account
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
export interface SignupRequest {
|
export interface SignupRequest {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
termsAccepted: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SignupResponse {
|
export interface SignupResponse {
|
||||||
|
|||||||
Reference in New Issue
Block a user