"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 { layoutStyles, cardStyles, formStyles, buttonStyles, toastStyles, utilityStyles, } from "../styles/shared"; // Use generated type from OpenAPI schema type ProfileData = components["schemas"]["ProfileResponse"]; // UI-specific types (not from API) interface FormData { contact_email: string; telegram: string; signal: 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 { return { contact_email: data.contact_email || "", telegram: data.telegram || "", signal: data.signal || "", nostr_npub: data.nostr_npub || "", }; } export default function ProfilePage() { const { user, isLoading, isAuthorized } = useRequireAuth({ requiredPermission: Permission.MANAGE_OWN_PROFILE, fallbackRedirect: "/audit", }); const [originalData, setOriginalData] = useState(null); const [formData, setFormData] = useState({ contact_email: "", telegram: "", signal: "", nostr_npub: "", }); const [godfatherEmail, setGodfatherEmail] = useState(null); const [errors, setErrors] = useState({}); const [isLoadingProfile, setIsLoadingProfile] = useState(true); const [isSubmitting, setIsSubmitting] = useState(false); const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null); const validationTimeoutRef = useRef(null); // Check if form has changes const hasChanges = useCallback(() => { if (!originalData) return false; return ( formData.contact_email !== originalData.contact_email || formData.telegram !== originalData.telegram || formData.signal !== originalData.signal || formData.nostr_npub !== originalData.nostr_npub ); }, [formData, originalData]); // Check if form is valid const isValid = useCallback(() => { return Object.keys(errors).length === 0; }, [errors]); const fetchProfile = useCallback(async () => { try { const data = await api.get("/api/profile"); const formValues = toFormData(data); setFormData(formValues); setOriginalData(formValues); setGodfatherEmail(data.godfather_email ?? null); } catch (err) { console.error("Profile load error:", err); setToast({ message: "Failed to load profile", type: "error" }); } finally { setIsLoadingProfile(false); } }, []); useEffect(() => { if (user && isAuthorized) { fetchProfile(); } }, [user, isAuthorized, fetchProfile]); // Auto-dismiss toast after 3 seconds useEffect(() => { if (toast) { const timer = setTimeout(() => setToast(null), 3000); return () => clearTimeout(timer); } }, [toast]); // Cleanup validation timeout on unmount useEffect(() => { return () => { if (validationTimeoutRef.current) { clearTimeout(validationTimeoutRef.current); } }; }, []); const handleInputChange = (field: keyof FormData) => (e: React.ChangeEvent) => { let value = e.target.value; // For telegram: auto-prepend @ if user starts with a valid letter if (field === "telegram" && value && !value.startsWith("@")) { // Check if first char is a valid telegram handle start (letter) if (/^[a-zA-Z]/.test(value)) { value = "@" + value; } } setFormData((prev) => ({ ...prev, [field]: value })); // Clear any pending validation timeout if (validationTimeoutRef.current) { clearTimeout(validationTimeoutRef.current); } // Debounce validation - wait 500ms after user stops typing validationTimeoutRef.current = setTimeout(() => { const newFormData = { ...formData, [field]: value }; const newErrors = validateForm(newFormData); setErrors(newErrors); }, 500); }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); // Validate all fields const validationErrors = validateForm(formData); setErrors(validationErrors); if (Object.keys(validationErrors).length > 0) { return; } setIsSubmitting(true); try { const data = await api.put("/api/profile", { contact_email: formData.contact_email || null, telegram: formData.telegram || null, signal: formData.signal || null, nostr_npub: formData.nostr_npub || null, }); const formValues = toFormData(data); setFormData(formValues); setOriginalData(formValues); setToast({ message: "Profile saved successfully!", type: "success" }); } catch (err) { console.error("Profile save error:", err); if (err instanceof ApiError && err.status === 422) { const errorData = err.data as { detail?: { field_errors?: FieldErrors } }; if (errorData?.detail?.field_errors) { setErrors(errorData.detail.field_errors); } setToast({ message: "Please fix the errors below", type: "error" }); } else { setToast({ message: "Network error. Please try again.", type: "error" }); } } finally { setIsSubmitting(false); } }; if (isLoading || isLoadingProfile) { return (
Loading...
); } if (!user || !isAuthorized) { return null; } const canSubmit = hasChanges() && isValid() && !isSubmitting; return (
{/* Toast notification */} {toast && (
{toast.message}
)}

My Profile

Manage your contact information

{/* Login email - read only */}
This is your login email and cannot be changed here.
{/* Godfather - shown if user was invited */} {godfatherEmail && (
{godfatherEmail}
The user who invited you to join.
)}

Contact Details

These are for communication purposes only — they won't affect your login.

{/* Contact email */}
{errors.contact_email && ( {errors.contact_email} )}
{/* Telegram */}
{errors.telegram && {errors.telegram}}
{/* Signal */}
{errors.signal && {errors.signal}}
{/* Nostr npub */}
{errors.nostr_npub && {errors.nostr_npub}}
); } // Page-specific styles const styles: Record = { profileCard: { ...cardStyles.card, width: "100%", maxWidth: "480px", }, labelWithBadge: { fontFamily: "'DM Sans', system-ui, sans-serif", color: "rgba(255, 255, 255, 0.7)", fontSize: "0.875rem", fontWeight: 500, display: "flex", alignItems: "center", gap: "0.5rem", }, godfatherBox: { padding: "0.875rem 1rem", background: "rgba(99, 102, 241, 0.08)", border: "1px solid rgba(99, 102, 241, 0.2)", borderRadius: "12px", }, godfatherEmail: { fontFamily: "'DM Sans', system-ui, sans-serif", fontSize: "1rem", color: "rgba(129, 140, 248, 0.9)", }, sectionLabel: { fontFamily: "'DM Sans', system-ui, sans-serif", fontSize: "0.875rem", fontWeight: 600, color: "rgba(255, 255, 255, 0.8)", margin: 0, textTransform: "uppercase", letterSpacing: "0.05em", }, sectionHint: { fontFamily: "'DM Sans', system-ui, sans-serif", fontSize: "0.8rem", color: "rgba(255, 255, 255, 0.4)", margin: 0, marginBottom: "0.5rem", }, };