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
This commit is contained in:
counterweight 2025-12-22 09:13:03 +01:00
parent 53aa54d6c9
commit fdab4a5dac
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
2 changed files with 141 additions and 90 deletions

View file

@ -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) {

View file

@ -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;
}