- Install prettier - Configure .prettierrc.json and .prettierignore - Add npm scripts: format, format:check - Add Makefile target: format-frontend - Format all frontend files
583 lines
18 KiB
TypeScript
583 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState, useCallback, useRef } from "react";
|
|
import { bech32 } from "bech32";
|
|
import { api, ApiError } from "../api";
|
|
import { sharedStyles } from "../styles/shared";
|
|
import { Header } from "../components/Header";
|
|
import { useRequireAuth } from "../hooks/useRequireAuth";
|
|
import { components } from "../generated/api";
|
|
import constants from "../../../shared/constants.json";
|
|
|
|
// 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({
|
|
requiredRole: constants.roles.REGULAR,
|
|
fallbackRedirect: "/audit",
|
|
});
|
|
const [originalData, setOriginalData] = useState<FormData | null>(null);
|
|
const [formData, setFormData] = useState<FormData>({
|
|
contact_email: "",
|
|
telegram: "",
|
|
signal: "",
|
|
nostr_npub: "",
|
|
});
|
|
const [godfatherEmail, setGodfatherEmail] = useState<string | null>(null);
|
|
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 validationTimeoutRef = useRef<NodeJS.Timeout | null>(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<ProfileData>("/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<HTMLInputElement>) => {
|
|
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<ProfileData>("/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 (
|
|
<main style={styles.main}>
|
|
<div style={styles.loader}>Loading...</div>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
if (!user || !isAuthorized) {
|
|
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>
|
|
)}
|
|
|
|
<Header currentPage="profile" />
|
|
|
|
<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>
|
|
|
|
{/* Godfather - shown if user was invited */}
|
|
{godfatherEmail && (
|
|
<div style={styles.field}>
|
|
<label style={styles.label}>
|
|
Invited By
|
|
<span style={styles.readOnlyBadge}>Read only</span>
|
|
</label>
|
|
<div style={styles.godfatherBox}>
|
|
<span style={styles.godfatherEmail}>{godfatherEmail}</span>
|
|
</div>
|
|
<span style={styles.hint}>The user who invited you to join.</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'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",
|
|
},
|
|
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)",
|
|
},
|
|
inputError: {
|
|
border: "1px solid 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 };
|