From fdab4a5dacd3de8e2a14810f6928bb672a22d977 Mon Sep 17 00:00:00 2001 From: counterweight Date: Mon, 22 Dec 2025 09:13:03 +0100 Subject: [PATCH] refactor(frontend): extract validation utilities to shared module 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 --- frontend/app/profile/page.tsx | 97 ++-------------------- frontend/app/utils/validation.ts | 134 +++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 90 deletions(-) create mode 100644 frontend/app/utils/validation.ts diff --git a/frontend/app/profile/page.tsx b/frontend/app/profile/page.tsx index 667eb53..f8b112d 100644 --- a/frontend/app/profile/page.tsx +++ b/frontend/app/profile/page.tsx @@ -1,13 +1,12 @@ "use client"; import { useEffect, useState, useCallback, useRef } from "react"; -import { bech32 } from "bech32"; + import { api, ApiError } from "../api"; -import { Header } from "../components/Header"; -import { useRequireAuth } from "../hooks/useRequireAuth"; -import { components } from "../generated/api"; -import constants from "../../../shared/constants.json"; import { Permission } from "../auth-context"; +import { Header } from "../components/Header"; +import { components } from "../generated/api"; +import { useRequireAuth } from "../hooks/useRequireAuth"; import { layoutStyles, cardStyles, @@ -16,6 +15,7 @@ import { toastStyles, utilityStyles, } from "../styles/shared"; +import { FieldErrors, validateProfileFields } from "../utils/validation"; // Use generated type from OpenAPI schema type ProfileData = components["schemas"]["ProfileResponse"]; @@ -28,89 +28,6 @@ interface FormData { nostr_npub: string; } -interface FieldErrors { - contact_email?: string; - telegram?: string; - signal?: string; - nostr_npub?: string; -} - -// Client-side validation using shared rules from constants -const { telegram: telegramRules, signal: signalRules, nostrNpub: npubRules } = constants.validation; - -function validateEmail(value: string): string | undefined { - if (!value) return undefined; - // More 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; -} - -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; -} - -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; -} - -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"; - } -} - -function validateForm(data: FormData): 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; -} - function toFormData(data: ProfileData): FormData { return { contact_email: data.contact_email || "", @@ -214,7 +131,7 @@ export default function ProfilePage() { // Debounce validation - wait 500ms after user stops typing validationTimeoutRef.current = setTimeout(() => { const newFormData = { ...formData, [field]: value }; - const newErrors = validateForm(newFormData); + const newErrors = validateProfileFields(newFormData); setErrors(newErrors); }, 500); }; @@ -223,7 +140,7 @@ export default function ProfilePage() { e.preventDefault(); // Validate all fields - const validationErrors = validateForm(formData); + const validationErrors = validateProfileFields(formData); setErrors(validationErrors); if (Object.keys(validationErrors).length > 0) { diff --git a/frontend/app/utils/validation.ts b/frontend/app/utils/validation.ts new file mode 100644 index 0000000..625e3aa --- /dev/null +++ b/frontend/app/utils/validation.ts @@ -0,0 +1,134 @@ +/** + * 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; +}