Issue #7: Profile validation logic was embedded in page component. Changes: - Create utils/validation.ts with shared validation functions: - validateEmail: email format validation - validateTelegram: handle format with @ prefix - validateSignal: username length validation - validateNostrNpub: bech32 format validation - validateProfileFields: combined validation - Update profile/page.tsx to use shared validation - Both frontend and backend now read validation rules from shared/constants.json for consistency
134 lines
4.3 KiB
TypeScript
134 lines
4.3 KiB
TypeScript
/**
|
|
* Validation utilities for user profile fields.
|
|
*
|
|
* These validation functions mirror the backend validation logic in
|
|
* backend/validation.py. Both use shared rules from shared/constants.json
|
|
* to ensure consistent validation across frontend and backend.
|
|
*/
|
|
|
|
import { bech32 } from "bech32";
|
|
|
|
import constants from "../../../shared/constants.json";
|
|
|
|
const { telegram: telegramRules, signal: signalRules, nostrNpub: npubRules } = constants.validation;
|
|
|
|
/**
|
|
* Validate contact email format.
|
|
* Returns undefined if valid, error message if invalid.
|
|
* Empty values are valid (field is optional).
|
|
*/
|
|
export function validateEmail(value: string): string | undefined {
|
|
if (!value) return undefined;
|
|
// Comprehensive email regex that matches email-validator behavior
|
|
// Checks for: local part, @, domain with at least one dot, valid TLD
|
|
const emailRegex =
|
|
/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/;
|
|
if (!emailRegex.test(value)) {
|
|
return "Please enter a valid email address";
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Validate Telegram handle.
|
|
* Must start with @ if provided, with characters after @ within max length.
|
|
* Returns undefined if valid, error message if invalid.
|
|
* Empty values are valid (field is optional).
|
|
*/
|
|
export function validateTelegram(value: string): string | undefined {
|
|
if (!value) return undefined;
|
|
if (!value.startsWith(telegramRules.mustStartWith)) {
|
|
return `Telegram handle must start with ${telegramRules.mustStartWith}`;
|
|
}
|
|
const handle = value.slice(1);
|
|
if (handle.length < 1) {
|
|
return `Telegram handle must have at least one character after ${telegramRules.mustStartWith}`;
|
|
}
|
|
if (handle.length > telegramRules.maxLengthAfterAt) {
|
|
return `Telegram handle must be at most ${telegramRules.maxLengthAfterAt} characters (after ${telegramRules.mustStartWith})`;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Validate Signal username.
|
|
* Any non-empty string within max length is valid.
|
|
* Returns undefined if valid, error message if invalid.
|
|
* Empty values are valid (field is optional).
|
|
*/
|
|
export function validateSignal(value: string): string | undefined {
|
|
if (!value) return undefined;
|
|
if (value.trim().length === 0) {
|
|
return "Signal username cannot be empty";
|
|
}
|
|
if (value.length > signalRules.maxLength) {
|
|
return `Signal username must be at most ${signalRules.maxLength} characters`;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Validate Nostr npub (public key in bech32 format).
|
|
* Must be valid bech32 with 'npub' prefix.
|
|
* Returns undefined if valid, error message if invalid.
|
|
* Empty values are valid (field is optional).
|
|
*/
|
|
export function validateNostrNpub(value: string): string | undefined {
|
|
if (!value) return undefined;
|
|
if (!value.startsWith(npubRules.prefix)) {
|
|
return `Nostr npub must start with '${npubRules.prefix}'`;
|
|
}
|
|
|
|
try {
|
|
const decoded = bech32.decode(value);
|
|
if (decoded.prefix !== "npub") {
|
|
return "Nostr npub must have 'npub' prefix";
|
|
}
|
|
// npub should decode to 32 bytes (256 bits) for a public key
|
|
// In bech32, each character encodes 5 bits, so 32 bytes = 52 characters of data
|
|
if (decoded.words.length !== npubRules.bech32Words) {
|
|
return "Invalid Nostr npub: incorrect length";
|
|
}
|
|
return undefined;
|
|
} catch {
|
|
return "Invalid Nostr npub: bech32 checksum failed";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Field errors object type.
|
|
*/
|
|
export interface FieldErrors {
|
|
contact_email?: string;
|
|
telegram?: string;
|
|
signal?: string;
|
|
nostr_npub?: string;
|
|
}
|
|
|
|
/**
|
|
* Validate all profile fields at once.
|
|
* Returns an object with field_name -> error_message for any invalid fields.
|
|
* Empty object means all fields are valid.
|
|
*/
|
|
export function validateProfileFields(data: {
|
|
contact_email?: string;
|
|
telegram?: string;
|
|
signal?: string;
|
|
nostr_npub?: string;
|
|
}): FieldErrors {
|
|
const errors: FieldErrors = {};
|
|
|
|
const emailError = validateEmail(data.contact_email || "");
|
|
if (emailError) errors.contact_email = emailError;
|
|
|
|
const telegramError = validateTelegram(data.telegram || "");
|
|
if (telegramError) errors.telegram = telegramError;
|
|
|
|
const signalError = validateSignal(data.signal || "");
|
|
if (signalError) errors.signal = signalError;
|
|
|
|
const npubError = validateNostrNpub(data.nostr_npub || "");
|
|
if (npubError) errors.nostr_npub = npubError;
|
|
|
|
return errors;
|
|
}
|