arbret/frontend/app/profile/page.tsx

582 lines
17 KiB
TypeScript

"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;
}
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, logout, hasRole } = useAuth();
const router = useRouter();
const [originalData, setOriginalData] = useState<FormData | null>(null);
const [formData, setFormData] = useState<FormData>({
contact_email: "",
telegram: "",
signal: "",
nostr_npub: "",
});
const [errors, setErrors] = useState<FieldErrors>({});
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 = toFormData(data);
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<HTMLInputElement>) => {
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 = toFormData(data);
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 (
<main style={styles.main}>
<div style={styles.loader}>Loading...</div>
</main>
);
}
if (!user || !isRegularUser) {
return null;
}
const canSubmit = hasChanges() && isValid() && !isSubmitting;
return (
<main style={styles.main}>
{/* Toast notification */}
{toast && (
<div
style={{
...styles.toast,
...(toast.type === "success" ? styles.toastSuccess : styles.toastError),
}}
>
{toast.message}
</div>
)}
<div style={styles.header}>
<div style={styles.nav}>
<a href="/" style={styles.navLink}>Counter</a>
<span style={styles.navDivider}></span>
<a href="/sum" style={styles.navLink}>Sum</a>
<span style={styles.navDivider}></span>
<span style={styles.navCurrent}>My Profile</span>
</div>
<div style={styles.userInfo}>
<span style={styles.userEmail}>{user.email}</span>
<button onClick={handleLogout} style={styles.logoutBtn}>
Sign out
</button>
</div>
</div>
<div style={styles.content}>
<div style={styles.profileCard}>
<div style={styles.cardHeader}>
<h1 style={styles.cardTitle}>My Profile</h1>
<p style={styles.cardSubtitle}>Manage your contact information</p>
</div>
<form onSubmit={handleSubmit} style={styles.form}>
{/* Login email - read only */}
<div style={styles.field}>
<label style={styles.label}>
Login Email
<span style={styles.readOnlyBadge}>Read only</span>
</label>
<input
type="email"
value={user.email}
style={{ ...styles.input, ...styles.inputReadOnly }}
disabled
/>
<span style={styles.hint}>
This is your login email and cannot be changed here.
</span>
</div>
<div style={styles.divider} />
<p style={styles.sectionLabel}>Contact Details</p>
<p style={styles.sectionHint}>
These are for communication purposes only they won&apos;t affect your login.
</p>
{/* Contact email */}
<div style={styles.field}>
<label htmlFor="contact_email" style={styles.label}>
Contact Email
</label>
<input
id="contact_email"
type="email"
value={formData.contact_email}
onChange={handleInputChange("contact_email")}
style={{
...styles.input,
...(errors.contact_email ? styles.inputError : {}),
}}
placeholder="alternate@example.com"
/>
{errors.contact_email && (
<span style={styles.errorText}>{errors.contact_email}</span>
)}
</div>
{/* Telegram */}
<div style={styles.field}>
<label htmlFor="telegram" style={styles.label}>
Telegram
</label>
<input
id="telegram"
type="text"
value={formData.telegram}
onChange={handleInputChange("telegram")}
style={{
...styles.input,
...(errors.telegram ? styles.inputError : {}),
}}
placeholder="@username"
/>
{errors.telegram && (
<span style={styles.errorText}>{errors.telegram}</span>
)}
</div>
{/* Signal */}
<div style={styles.field}>
<label htmlFor="signal" style={styles.label}>
Signal
</label>
<input
id="signal"
type="text"
value={formData.signal}
onChange={handleInputChange("signal")}
style={{
...styles.input,
...(errors.signal ? styles.inputError : {}),
}}
placeholder="username.01"
/>
{errors.signal && (
<span style={styles.errorText}>{errors.signal}</span>
)}
</div>
{/* Nostr npub */}
<div style={styles.field}>
<label htmlFor="nostr_npub" style={styles.label}>
Nostr (npub)
</label>
<input
id="nostr_npub"
type="text"
value={formData.nostr_npub}
onChange={handleInputChange("nostr_npub")}
style={{
...styles.input,
...(errors.nostr_npub ? styles.inputError : {}),
}}
placeholder="npub1..."
/>
{errors.nostr_npub && (
<span style={styles.errorText}>{errors.nostr_npub}</span>
)}
</div>
<button
type="submit"
style={{
...styles.button,
...(!canSubmit ? styles.buttonDisabled : {}),
}}
disabled={!canSubmit}
>
{isSubmitting ? "Saving..." : "Save Changes"}
</button>
</form>
</div>
</div>
</main>
);
}
const pageStyles: Record<string, React.CSSProperties> = {
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 };