"use client"; import { useEffect, useState, useCallback } from "react"; import { useRouter } from "next/navigation"; import { bech32 } from "bech32"; import { useAuth } from "../auth-context"; import { API_URL } from "../config"; import { sharedStyles } from "../styles/shared"; interface ProfileData { contact_email: string | null; telegram: string | null; signal: string | null; nostr_npub: string | null; } 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 matching backend rules 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("@")) { return "Telegram handle must start with @"; } const handle = value.slice(1); if (handle.length < 1) { return "Telegram handle must have at least one character after @"; } if (handle.length > 32) { return "Telegram handle must be at most 32 characters (after @)"; } 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 > 64) { return "Signal username must be at most 64 characters"; } return undefined; } function validateNostrNpub(value: string): string | undefined { if (!value) return undefined; if (!value.startsWith("npub1")) { return "Nostr npub must start with 'npub1'"; } 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 !== 52) { 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; } export default function ProfilePage() { const { user, isLoading, logout, hasRole } = useAuth(); const router = useRouter(); const [originalData, setOriginalData] = useState(null); const [formData, setFormData] = useState({ contact_email: "", telegram: "", signal: "", nostr_npub: "", }); 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 isRegularUser = hasRole("regular"); // 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]); useEffect(() => { if (!isLoading) { if (!user) { router.push("/login"); } else if (!isRegularUser) { router.push("/audit"); } } }, [isLoading, user, router, isRegularUser]); const fetchProfile = useCallback(async () => { try { const res = await fetch(`${API_URL}/api/profile`, { credentials: "include", }); if (res.ok) { const data: ProfileData = await res.json(); const formValues: FormData = { contact_email: data.contact_email || "", telegram: data.telegram || "", signal: data.signal || "", nostr_npub: data.nostr_npub || "", }; setFormData(formValues); setOriginalData(formValues); } else { setToast({ message: "Failed to load profile", type: "error" }); } } catch { setToast({ message: "Network error. Please try again.", type: "error" }); } finally { setIsLoadingProfile(false); } }, []); useEffect(() => { if (user && isRegularUser) { fetchProfile(); } }, [user, isRegularUser, fetchProfile]); // Auto-dismiss toast after 3 seconds useEffect(() => { if (toast) { const timer = setTimeout(() => setToast(null), 3000); return () => clearTimeout(timer); } }, [toast]); const handleInputChange = (field: keyof FormData) => (e: React.ChangeEvent) => { const value = e.target.value; setFormData((prev) => ({ ...prev, [field]: value })); // Validate on change and clear error if valid const newFormData = { ...formData, [field]: value }; const newErrors = validateForm(newFormData); setErrors(newErrors); }; 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 res = await fetch(`${API_URL}/api/profile`, { method: "PUT", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ contact_email: formData.contact_email || null, telegram: formData.telegram || null, signal: formData.signal || null, nostr_npub: formData.nostr_npub || null, }), }); if (res.ok) { const data: ProfileData = await res.json(); const formValues: FormData = { contact_email: data.contact_email || "", telegram: data.telegram || "", signal: data.signal || "", nostr_npub: data.nostr_npub || "", }; setFormData(formValues); setOriginalData(formValues); setToast({ message: "Profile saved successfully!", type: "success" }); } else if (res.status === 422) { // Handle validation errors from backend const errorData = await res.json(); if (errorData.detail?.field_errors) { setErrors(errorData.detail.field_errors); } setToast({ message: "Please fix the errors below", type: "error" }); } else { setToast({ message: "Failed to save profile", type: "error" }); } } catch { setToast({ message: "Network error. Please try again.", type: "error" }); } finally { setIsSubmitting(false); } }; const handleLogout = async () => { await logout(); router.push("/login"); }; if (isLoading || isLoadingProfile) { return (
Loading...
); } if (!user || !isRegularUser) { return null; } const canSubmit = hasChanges() && isValid() && !isSubmitting; return (
{/* Toast notification */} {toast && (
{toast.message}
)}
Counter Sum My Profile
{user.email}

My Profile

Manage your contact information

{/* Login email - read only */}
This is your login email and cannot be changed here.

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} )}
); } const pageStyles: Record = { profileCard: { background: "rgba(255, 255, 255, 0.03)", backdropFilter: "blur(10px)", border: "1px solid rgba(255, 255, 255, 0.08)", borderRadius: "24px", padding: "2.5rem", width: "100%", maxWidth: "480px", boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5)", }, cardHeader: { marginBottom: "2rem", }, cardTitle: { fontFamily: "'Instrument Serif', Georgia, serif", fontSize: "2rem", fontWeight: 400, color: "#fff", margin: 0, letterSpacing: "-0.02em", }, cardSubtitle: { fontFamily: "'DM Sans', system-ui, sans-serif", color: "rgba(255, 255, 255, 0.5)", marginTop: "0.5rem", fontSize: "0.95rem", }, form: { display: "flex", flexDirection: "column", gap: "1.25rem", }, field: { display: "flex", flexDirection: "column", gap: "0.5rem", }, label: { 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", }, readOnlyBadge: { fontSize: "0.7rem", fontWeight: 500, color: "rgba(255, 255, 255, 0.4)", background: "rgba(255, 255, 255, 0.08)", padding: "0.15rem 0.5rem", borderRadius: "4px", textTransform: "uppercase", letterSpacing: "0.05em", }, input: { fontFamily: "'DM Sans', system-ui, sans-serif", padding: "0.875rem 1rem", fontSize: "1rem", background: "rgba(255, 255, 255, 0.05)", border: "1px solid rgba(255, 255, 255, 0.1)", borderRadius: "12px", color: "#fff", outline: "none", transition: "border-color 0.2s, box-shadow 0.2s", }, inputReadOnly: { background: "rgba(255, 255, 255, 0.02)", color: "rgba(255, 255, 255, 0.5)", cursor: "not-allowed", }, inputError: { borderColor: "rgba(239, 68, 68, 0.5)", boxShadow: "0 0 0 2px rgba(239, 68, 68, 0.1)", }, hint: { fontFamily: "'DM Sans', system-ui, sans-serif", fontSize: "0.75rem", color: "rgba(255, 255, 255, 0.4)", fontStyle: "italic", }, errorText: { fontFamily: "'DM Sans', system-ui, sans-serif", fontSize: "0.75rem", color: "#fca5a5", }, divider: { height: "1px", background: "rgba(255, 255, 255, 0.08)", margin: "0.75rem 0", }, 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", }, button: { fontFamily: "'DM Sans', system-ui, sans-serif", marginTop: "1rem", padding: "1rem", fontSize: "1rem", fontWeight: 600, background: "linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)", color: "#fff", border: "none", borderRadius: "12px", cursor: "pointer", transition: "transform 0.2s, box-shadow 0.2s", boxShadow: "0 4px 14px rgba(99, 102, 241, 0.4)", }, buttonDisabled: { opacity: 0.5, cursor: "not-allowed", boxShadow: "none", }, toast: { position: "fixed", top: "1.5rem", right: "1.5rem", padding: "1rem 1.5rem", borderRadius: "12px", fontFamily: "'DM Sans', system-ui, sans-serif", fontSize: "0.875rem", fontWeight: 500, zIndex: 1000, animation: "slideIn 0.3s ease-out", boxShadow: "0 10px 25px rgba(0, 0, 0, 0.3)", }, toastSuccess: { background: "rgba(34, 197, 94, 0.9)", color: "#fff", }, toastError: { background: "rgba(239, 68, 68, 0.9)", color: "#fff", }, }; const styles = { ...sharedStyles, ...pageStyles };