arbret/frontend/app/profile/page.tsx
counterweight 21698203fe
refactor(auth): unify authorization patterns with MANAGE_OWN_PROFILE permission
Issue #2: The profile route used a custom role-based check instead
of the permission-based pattern used everywhere else.

Changes:
- Add MANAGE_OWN_PROFILE permission to backend Permission enum
- Add permission to ROLE_REGULAR role definition
- Update profile routes to use require_permission(MANAGE_OWN_PROFILE)
- Remove custom require_regular_user dependency
- Update frontend Permission constant and profile page
- Update invites page to use permission instead of role check
- Update profile tests with proper permission mocking

This ensures consistent authorization patterns across all routes.
2025-12-21 23:50:06 +01:00

477 lines
15 KiB
TypeScript

"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<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={layoutStyles.main}>
<div style={layoutStyles.loader}>Loading...</div>
</main>
);
}
if (!user || !isAuthorized) {
return null;
}
const canSubmit = hasChanges() && isValid() && !isSubmitting;
return (
<main style={layoutStyles.main}>
{/* Toast notification */}
{toast && (
<div
style={{
...toastStyles.toast,
...(toast.type === "success" ? toastStyles.toastSuccess : toastStyles.toastError),
}}
>
{toast.message}
</div>
)}
<Header currentPage="profile" />
<div style={layoutStyles.contentCentered}>
<div style={styles.profileCard}>
<div style={cardStyles.cardHeader}>
<h1 style={cardStyles.cardTitle}>My Profile</h1>
<p style={cardStyles.cardSubtitle}>Manage your contact information</p>
</div>
<form onSubmit={handleSubmit} style={formStyles.form}>
{/* Login email - read only */}
<div style={formStyles.field}>
<label style={styles.labelWithBadge}>
Login Email
<span style={utilityStyles.readOnlyBadge}>Read only</span>
</label>
<input
type="email"
value={user.email}
style={{ ...formStyles.input, ...formStyles.inputReadOnly }}
disabled
/>
<span style={formStyles.hint}>
This is your login email and cannot be changed here.
</span>
</div>
{/* Godfather - shown if user was invited */}
{godfatherEmail && (
<div style={formStyles.field}>
<label style={styles.labelWithBadge}>
Invited By
<span style={utilityStyles.readOnlyBadge}>Read only</span>
</label>
<div style={styles.godfatherBox}>
<span style={styles.godfatherEmail}>{godfatherEmail}</span>
</div>
<span style={formStyles.hint}>The user who invited you to join.</span>
</div>
)}
<div style={utilityStyles.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={formStyles.field}>
<label htmlFor="contact_email" style={formStyles.label}>
Contact Email
</label>
<input
id="contact_email"
type="email"
value={formData.contact_email}
onChange={handleInputChange("contact_email")}
style={{
...formStyles.input,
...(errors.contact_email ? formStyles.inputError : {}),
}}
placeholder="alternate@example.com"
/>
{errors.contact_email && (
<span style={formStyles.errorText}>{errors.contact_email}</span>
)}
</div>
{/* Telegram */}
<div style={formStyles.field}>
<label htmlFor="telegram" style={formStyles.label}>
Telegram
</label>
<input
id="telegram"
type="text"
value={formData.telegram}
onChange={handleInputChange("telegram")}
style={{
...formStyles.input,
...(errors.telegram ? formStyles.inputError : {}),
}}
placeholder="@username"
/>
{errors.telegram && <span style={formStyles.errorText}>{errors.telegram}</span>}
</div>
{/* Signal */}
<div style={formStyles.field}>
<label htmlFor="signal" style={formStyles.label}>
Signal
</label>
<input
id="signal"
type="text"
value={formData.signal}
onChange={handleInputChange("signal")}
style={{
...formStyles.input,
...(errors.signal ? formStyles.inputError : {}),
}}
placeholder="username.01"
/>
{errors.signal && <span style={formStyles.errorText}>{errors.signal}</span>}
</div>
{/* Nostr npub */}
<div style={formStyles.field}>
<label htmlFor="nostr_npub" style={formStyles.label}>
Nostr (npub)
</label>
<input
id="nostr_npub"
type="text"
value={formData.nostr_npub}
onChange={handleInputChange("nostr_npub")}
style={{
...formStyles.input,
...(errors.nostr_npub ? formStyles.inputError : {}),
}}
placeholder="npub1..."
/>
{errors.nostr_npub && <span style={formStyles.errorText}>{errors.nostr_npub}</span>}
</div>
<button
type="submit"
style={{
...buttonStyles.primaryButton,
marginTop: "1rem",
...(!canSubmit ? buttonStyles.buttonDisabled : {}),
}}
disabled={!canSubmit}
>
{isSubmitting ? "Saving..." : "Save Changes"}
</button>
</form>
</div>
</div>
</main>
);
}
// Page-specific styles
const styles: Record<string, React.CSSProperties> = {
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",
},
};