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:
parent
53aa54d6c9
commit
fdab4a5dac
2 changed files with 141 additions and 90 deletions
|
|
@ -1,13 +1,12 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, useRef } from "react";
|
import { useEffect, useState, useCallback, useRef } from "react";
|
||||||
import { bech32 } from "bech32";
|
|
||||||
import { api, ApiError } from "../api";
|
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 { Permission } from "../auth-context";
|
||||||
|
import { Header } from "../components/Header";
|
||||||
|
import { components } from "../generated/api";
|
||||||
|
import { useRequireAuth } from "../hooks/useRequireAuth";
|
||||||
import {
|
import {
|
||||||
layoutStyles,
|
layoutStyles,
|
||||||
cardStyles,
|
cardStyles,
|
||||||
|
|
@ -16,6 +15,7 @@ import {
|
||||||
toastStyles,
|
toastStyles,
|
||||||
utilityStyles,
|
utilityStyles,
|
||||||
} from "../styles/shared";
|
} from "../styles/shared";
|
||||||
|
import { FieldErrors, validateProfileFields } from "../utils/validation";
|
||||||
|
|
||||||
// Use generated type from OpenAPI schema
|
// Use generated type from OpenAPI schema
|
||||||
type ProfileData = components["schemas"]["ProfileResponse"];
|
type ProfileData = components["schemas"]["ProfileResponse"];
|
||||||
|
|
@ -28,89 +28,6 @@ interface FormData {
|
||||||
nostr_npub: string;
|
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 {
|
function toFormData(data: ProfileData): FormData {
|
||||||
return {
|
return {
|
||||||
contact_email: data.contact_email || "",
|
contact_email: data.contact_email || "",
|
||||||
|
|
@ -214,7 +131,7 @@ export default function ProfilePage() {
|
||||||
// Debounce validation - wait 500ms after user stops typing
|
// Debounce validation - wait 500ms after user stops typing
|
||||||
validationTimeoutRef.current = setTimeout(() => {
|
validationTimeoutRef.current = setTimeout(() => {
|
||||||
const newFormData = { ...formData, [field]: value };
|
const newFormData = { ...formData, [field]: value };
|
||||||
const newErrors = validateForm(newFormData);
|
const newErrors = validateProfileFields(newFormData);
|
||||||
setErrors(newErrors);
|
setErrors(newErrors);
|
||||||
}, 500);
|
}, 500);
|
||||||
};
|
};
|
||||||
|
|
@ -223,7 +140,7 @@ export default function ProfilePage() {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
// Validate all fields
|
// Validate all fields
|
||||||
const validationErrors = validateForm(formData);
|
const validationErrors = validateProfileFields(formData);
|
||||||
setErrors(validationErrors);
|
setErrors(validationErrors);
|
||||||
|
|
||||||
if (Object.keys(validationErrors).length > 0) {
|
if (Object.keys(validationErrors).length > 0) {
|
||||||
|
|
|
||||||
134
frontend/app/utils/validation.ts
Normal file
134
frontend/app/utils/validation.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue