implemented
This commit is contained in:
parent
40ca82bb45
commit
409e0df9a6
16 changed files with 2451 additions and 4 deletions
594
frontend/app/profile/page.test.tsx
Normal file
594
frontend/app/profile/page.test.tsx
Normal file
|
|
@ -0,0 +1,594 @@
|
|||
import { render, screen, waitFor, cleanup, fireEvent } from "@testing-library/react";
|
||||
import { expect, test, vi, beforeEach, afterEach, describe } from "vitest";
|
||||
import ProfilePage from "./page";
|
||||
|
||||
// Mock next/navigation
|
||||
const mockPush = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Default mock values
|
||||
let mockUser: { id: number; email: string; roles: string[]; permissions: string[] } | null = {
|
||||
id: 1,
|
||||
email: "test@example.com",
|
||||
roles: ["regular"],
|
||||
permissions: ["view_counter", "increment_counter", "use_sum"],
|
||||
};
|
||||
let mockIsLoading = false;
|
||||
const mockLogout = vi.fn();
|
||||
const mockHasRole = vi.fn((role: string) => mockUser?.roles.includes(role) ?? false);
|
||||
|
||||
vi.mock("../auth-context", () => ({
|
||||
useAuth: () => ({
|
||||
user: mockUser,
|
||||
isLoading: mockIsLoading,
|
||||
logout: mockLogout,
|
||||
hasRole: mockHasRole,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock profile data
|
||||
const mockProfileData = {
|
||||
contact_email: "contact@example.com",
|
||||
telegram: "@testuser",
|
||||
signal: "signal.42",
|
||||
nostr_npub: "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqzqujme",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset to authenticated regular user
|
||||
mockUser = {
|
||||
id: 1,
|
||||
email: "test@example.com",
|
||||
roles: ["regular"],
|
||||
permissions: ["view_counter", "increment_counter", "use_sum"],
|
||||
};
|
||||
mockIsLoading = false;
|
||||
mockHasRole.mockImplementation((role: string) => mockUser?.roles.includes(role) ?? false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("ProfilePage - Display", () => {
|
||||
test("renders loading state initially", () => {
|
||||
mockIsLoading = true;
|
||||
vi.spyOn(global, "fetch").mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
render(<ProfilePage />);
|
||||
expect(screen.getByText("Loading...")).toBeDefined();
|
||||
});
|
||||
|
||||
test("renders profile page title", async () => {
|
||||
vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockProfileData),
|
||||
} as Response);
|
||||
|
||||
render(<ProfilePage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("displays login email as read-only", async () => {
|
||||
vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockProfileData),
|
||||
} as Response);
|
||||
|
||||
render(<ProfilePage />);
|
||||
await waitFor(() => {
|
||||
const loginEmailInput = screen.getByDisplayValue("test@example.com");
|
||||
expect(loginEmailInput).toBeDefined();
|
||||
expect(loginEmailInput).toHaveProperty("disabled", true);
|
||||
});
|
||||
});
|
||||
|
||||
test("shows read-only badge for login email", async () => {
|
||||
vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockProfileData),
|
||||
} as Response);
|
||||
|
||||
render(<ProfilePage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Read only")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("shows hint about login email", async () => {
|
||||
vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockProfileData),
|
||||
} as Response);
|
||||
|
||||
render(<ProfilePage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/cannot be changed/i)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("displays contact details section hint", async () => {
|
||||
vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockProfileData),
|
||||
} as Response);
|
||||
|
||||
render(<ProfilePage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/communication purposes only/i)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("displays fetched profile data", async () => {
|
||||
vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockProfileData),
|
||||
} as Response);
|
||||
|
||||
render(<ProfilePage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("contact@example.com")).toBeDefined();
|
||||
expect(screen.getByDisplayValue("@testuser")).toBeDefined();
|
||||
expect(screen.getByDisplayValue("signal.42")).toBeDefined();
|
||||
expect(screen.getByDisplayValue(mockProfileData.nostr_npub)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("fetches profile with credentials", async () => {
|
||||
const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockProfileData),
|
||||
} as Response);
|
||||
|
||||
render(<ProfilePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
"http://localhost:8000/api/profile",
|
||||
expect.objectContaining({
|
||||
credentials: "include",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("displays empty fields when profile has null values", async () => {
|
||||
vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
contact_email: null,
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
render(<ProfilePage />);
|
||||
await waitFor(() => {
|
||||
// Check that inputs exist with empty values (placeholders shown)
|
||||
const telegramInput = document.getElementById("telegram") as HTMLInputElement;
|
||||
expect(telegramInput.value).toBe("");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProfilePage - Navigation", () => {
|
||||
test("shows nav links for regular user", async () => {
|
||||
vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockProfileData),
|
||||
} as Response);
|
||||
|
||||
render(<ProfilePage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Counter")).toBeDefined();
|
||||
expect(screen.getByText("Sum")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("highlights My Profile in nav", async () => {
|
||||
vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockProfileData),
|
||||
} as Response);
|
||||
|
||||
render(<ProfilePage />);
|
||||
await waitFor(() => {
|
||||
// My Profile should be visible (as current page indicator)
|
||||
const navItems = screen.getAllByText("My Profile");
|
||||
expect(navItems.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProfilePage - Access Control", () => {
|
||||
test("redirects to login when not authenticated", async () => {
|
||||
mockUser = null;
|
||||
|
||||
render(<ProfilePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith("/login");
|
||||
});
|
||||
});
|
||||
|
||||
test("redirects admin to audit page", async () => {
|
||||
mockUser = {
|
||||
id: 1,
|
||||
email: "admin@example.com",
|
||||
roles: ["admin"],
|
||||
permissions: ["view_audit"],
|
||||
};
|
||||
|
||||
render(<ProfilePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith("/audit");
|
||||
});
|
||||
});
|
||||
|
||||
test("does not fetch profile for admin user", async () => {
|
||||
mockUser = {
|
||||
id: 1,
|
||||
email: "admin@example.com",
|
||||
roles: ["admin"],
|
||||
permissions: ["view_audit"],
|
||||
};
|
||||
const fetchSpy = vi.spyOn(global, "fetch");
|
||||
|
||||
render(<ProfilePage />);
|
||||
|
||||
// Give it a moment to potentially fetch
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProfilePage - Loading State", () => {
|
||||
test("does not redirect while loading", () => {
|
||||
mockIsLoading = true;
|
||||
mockUser = null;
|
||||
|
||||
render(<ProfilePage />);
|
||||
|
||||
expect(mockPush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("shows loading indicator while loading", () => {
|
||||
mockIsLoading = true;
|
||||
|
||||
render(<ProfilePage />);
|
||||
|
||||
expect(screen.getByText("Loading...")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProfilePage - Form Behavior", () => {
|
||||
test("submit button is disabled when no changes", async () => {
|
||||
vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockProfileData),
|
||||
} as Response);
|
||||
|
||||
render(<ProfilePage />);
|
||||
await waitFor(() => {
|
||||
const submitButton = screen.getByRole("button", { name: /save changes/i });
|
||||
expect(submitButton).toHaveProperty("disabled", true);
|
||||
});
|
||||
});
|
||||
|
||||
test("submit button is enabled after field changes", async () => {
|
||||
vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockProfileData),
|
||||
} as Response);
|
||||
|
||||
render(<ProfilePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("@testuser")).toBeDefined();
|
||||
});
|
||||
|
||||
const telegramInput = screen.getByDisplayValue("@testuser");
|
||||
fireEvent.change(telegramInput, { target: { value: "@newhandle" } });
|
||||
|
||||
await waitFor(() => {
|
||||
const submitButton = screen.getByRole("button", { name: /save changes/i });
|
||||
expect(submitButton).toHaveProperty("disabled", false);
|
||||
});
|
||||
});
|
||||
|
||||
test("shows inline error for invalid telegram handle", async () => {
|
||||
vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
contact_email: null,
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
render(<ProfilePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
|
||||
});
|
||||
|
||||
const telegramInput = document.getElementById("telegram") as HTMLInputElement;
|
||||
fireEvent.change(telegramInput, { target: { value: "noatsign" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/must start with @/i)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("shows inline error for invalid npub", async () => {
|
||||
vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
contact_email: null,
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
render(<ProfilePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
|
||||
});
|
||||
|
||||
const npubInput = document.getElementById("nostr_npub") as HTMLInputElement;
|
||||
fireEvent.change(npubInput, { target: { value: "invalidnpub" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/must start with 'npub1'/i)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("submit button is disabled when form has validation errors", async () => {
|
||||
vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
contact_email: null,
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
render(<ProfilePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
|
||||
});
|
||||
|
||||
// Enter invalid telegram (no @)
|
||||
const telegramInput = document.getElementById("telegram") as HTMLInputElement;
|
||||
fireEvent.change(telegramInput, { target: { value: "noatsign" } });
|
||||
|
||||
await waitFor(() => {
|
||||
const submitButton = screen.getByRole("button", { name: /save changes/i });
|
||||
expect(submitButton).toHaveProperty("disabled", true);
|
||||
});
|
||||
});
|
||||
|
||||
test("clears error when field becomes valid", async () => {
|
||||
vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
contact_email: null,
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
render(<ProfilePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
|
||||
});
|
||||
|
||||
const telegramInput = document.getElementById("telegram") as HTMLInputElement;
|
||||
|
||||
// First enter invalid value
|
||||
fireEvent.change(telegramInput, { target: { value: "noat" } });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/must start with @/i)).toBeDefined();
|
||||
});
|
||||
|
||||
// Then fix it
|
||||
fireEvent.change(telegramInput, { target: { value: "@validhandle" } });
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/must start with @/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProfilePage - Form Submission", () => {
|
||||
test("shows success toast after successful save", async () => {
|
||||
const fetchSpy = vi.spyOn(global, "fetch")
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
contact_email: null,
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
}),
|
||||
} as Response)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
contact_email: "new@example.com",
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
render(<ProfilePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
|
||||
});
|
||||
|
||||
// Change email
|
||||
const emailInput = document.getElementById("contact_email") as HTMLInputElement;
|
||||
fireEvent.change(emailInput, { target: { value: "new@example.com" } });
|
||||
|
||||
// Submit
|
||||
const submitButton = screen.getByRole("button", { name: /save changes/i });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/saved successfully/i)).toBeDefined();
|
||||
});
|
||||
|
||||
// Verify PUT was called
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
"http://localhost:8000/api/profile",
|
||||
expect.objectContaining({
|
||||
method: "PUT",
|
||||
credentials: "include",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("shows inline errors from backend validation", async () => {
|
||||
vi.spyOn(global, "fetch")
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
contact_email: null,
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
}),
|
||||
} as Response)
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 422,
|
||||
json: () => Promise.resolve({
|
||||
detail: {
|
||||
field_errors: {
|
||||
telegram: "Backend error: invalid handle",
|
||||
},
|
||||
},
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
render(<ProfilePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
|
||||
});
|
||||
|
||||
// Enter a value that passes frontend validation but fails backend
|
||||
const telegramInput = document.getElementById("telegram") as HTMLInputElement;
|
||||
fireEvent.change(telegramInput, { target: { value: "@validfrontend" } });
|
||||
|
||||
// Submit
|
||||
const submitButton = screen.getByRole("button", { name: /save changes/i });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/backend error/i)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("shows error toast on network failure", async () => {
|
||||
vi.spyOn(global, "fetch")
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
contact_email: null,
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
}),
|
||||
} as Response)
|
||||
.mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
render(<ProfilePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
|
||||
});
|
||||
|
||||
// Change something
|
||||
const emailInput = document.getElementById("contact_email") as HTMLInputElement;
|
||||
fireEvent.change(emailInput, { target: { value: "new@example.com" } });
|
||||
|
||||
// Submit
|
||||
const submitButton = screen.getByRole("button", { name: /save changes/i });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/network error/i)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("submit button shows 'Saving...' while submitting", async () => {
|
||||
let resolveSubmit: (value: Response) => void;
|
||||
const submitPromise = new Promise<Response>((resolve) => {
|
||||
resolveSubmit = resolve;
|
||||
});
|
||||
|
||||
vi.spyOn(global, "fetch")
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
contact_email: null,
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
}),
|
||||
} as Response)
|
||||
.mockReturnValueOnce(submitPromise as Promise<Response>);
|
||||
|
||||
render(<ProfilePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
|
||||
});
|
||||
|
||||
// Change something
|
||||
const emailInput = document.getElementById("contact_email") as HTMLInputElement;
|
||||
fireEvent.change(emailInput, { target: { value: "new@example.com" } });
|
||||
|
||||
// Submit
|
||||
const submitButton = screen.getByRole("button", { name: /save changes/i });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Saving...")).toBeDefined();
|
||||
});
|
||||
|
||||
// Resolve the promise
|
||||
resolveSubmit!({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
contact_email: "new@example.com",
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /save changes/i })).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
575
frontend/app/profile/page.tsx
Normal file
575
frontend/app/profile/page.tsx
Normal file
|
|
@ -0,0 +1,575 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
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;
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
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 < 5) {
|
||||
return "Telegram handle must be at least 5 characters (after @)";
|
||||
}
|
||||
if (handle.length > 32) {
|
||||
return "Telegram handle must be at most 32 characters (after @)";
|
||||
}
|
||||
if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(handle)) {
|
||||
return "Telegram handle must start with a letter and contain only letters, numbers, and underscores";
|
||||
}
|
||||
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'";
|
||||
}
|
||||
// Basic length check (valid npubs are 63 characters)
|
||||
if (value.length !== 63) {
|
||||
return "Invalid Nostr npub format";
|
||||
}
|
||||
// Check for valid bech32 characters
|
||||
if (!/^npub1[023456789acdefghjklmnpqrstuvwxyz]+$/.test(value)) {
|
||||
return "Invalid Nostr npub: contains invalid characters";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
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<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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user && isRegularUser) {
|
||||
fetchProfile();
|
||||
}
|
||||
}, [user, isRegularUser]);
|
||||
|
||||
// Auto-dismiss toast after 3 seconds
|
||||
useEffect(() => {
|
||||
if (toast) {
|
||||
const timer = setTimeout(() => setToast(null), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
const fetchProfile = 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);
|
||||
}
|
||||
} catch {
|
||||
// Handle error silently for now
|
||||
} finally {
|
||||
setIsLoadingProfile(false);
|
||||
}
|
||||
};
|
||||
|
||||
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: 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 (
|
||||
<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'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 };
|
||||
Loading…
Add table
Add a link
Reference in a new issue